feat: add DeskPaw memory service

This commit is contained in:
18962558917 2026-09-22 23:33:47 +08:00
commit 792c30ce68
10 changed files with 508 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.sqlite
*.sqlite-shm
*.sqlite-wal
runtime/

7
AGENTS.md Normal file
View File

@ -0,0 +1,7 @@
# DeskPaw Memory Development Rules
- Keep this module independent from `deskpaw-agent`; integration uses the local JSON-line socket protocol.
- SQLite is the authority. Markdown bundle files are human-readable projections, not a second source of truth.
- Never store credentials, hidden reasoning, or unbounded raw tool output as memory.
- Memory service failure must not stop the device agent from accepting or executing tasks.
- Run `npm test` after code changes. Do not publish or push unless the user asks.

54
README.md Normal file
View File

@ -0,0 +1,54 @@
# deskpaw-memory
DeskPaw 的本地记忆与上下文整理模块。它作为 `deskpaw-agent` 的平级服务运行,通过本地 socket 提供:
- 任务卡和执行结果的持久化;
- 新旧任务关联度提示;
- 日程附属资料夹;
- 每轮执行前的有限上下文包;
- 主动整理记录与可观测事件。
模块不保存隐藏思考,不接管日程执行,不直接操作电脑或发送消息。Agent 不依赖本服务才能运行;服务离线时自动降级。
`deploy/deskpaw-memory.service` 是当前 KickPi 部署模板。`bin/run-memory` 会读取设备配置中的 `DESKPAW_NODE_BIN`,避免 systemd 找不到 NVM 安装的 Node。服务应先于 Agent 启动;它故障重启时不会要求 Agent 一起重启。
## 启动
Linux 默认 socket 为 `~/.deskpaw/runtime/memory.sock`:
```bash
npm start
```
正式状态统一保存在 `~/.deskpaw/memory/`:SQLite 数据库位于
`data/memory.sqlite3`,日程和项目资料位于 `bundles/`。旧版 Python
记忆服务的数据应迁到 `~/.deskpaw/memory-legacy/`,不要与本模块共用数据库。
可配置:
```text
DESKPAW_STATE_DIR=/path/to/state
DESKPAW_MEMORY_SOCKET=/path/to/memory.sock
DESKPAW_MEMORY_DB=/path/to/memory.sqlite
DESKPAW_MEMORY_BUNDLES=/path/to/bundles
```
协议为单请求连接的 JSON Lines。请求包含 `id、method、params`,响应包含 `id、ok、result/error`。
首版接口:`health`、`transition.assess`、`context.prepare`、`task.complete`、`task.list`、`consolidation.record`、`bundle.get`、`bundle.list`、`bundle.document.put`。修改资料使用 `expected_revision`,防止猫窝和桌喵同时编辑时旧内容覆盖新内容。
## 资料夹
日程第一次运行时会按稳定 `schedule_id` 建立:
```text
bundles/schedule/<schedule_id>/
INDEX.md
requirements.md
preferences.md
lessons.md
state.json
runs/<run_id>/summary.md
```
数据库保存权威状态,以上文件用于猫窝展示和按需阅读。

17
bin/run-memory Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
memory_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
device_config="${DESKPAW_DEVICE_CONFIG:-/home/kickpi/.config/deskpaw/device.env}"
if [[ -f "$device_config" ]]; then
set -a
source "$device_config"
set +a
fi
node_bin="${DESKPAW_NODE_BIN:-$(command -v node || true)}"
if [[ -z "$node_bin" || ! -x "$node_bin" ]]; then
echo "DeskPaw memory cannot find Node.js" >&2
exit 1
fi
cd "$memory_root"
exec "$node_bin" src/server.js

View File

@ -0,0 +1,23 @@
[Unit]
Description=DeskPaw Memory and Context Service
After=local-fs.target
Before=deskpaw-agent.service
[Service]
Type=simple
User=kickpi
Group=kickpi
WorkingDirectory=/home/kickpi/deskpaw-memory
Environment=NODE_NO_WARNINGS=1
Environment=DESKPAW_STATE_DIR=/home/kickpi/.deskpaw
ExecStart=/usr/bin/node /home/kickpi/deskpaw-memory/src/server.js
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/kickpi/.deskpaw
[Install]
WantedBy=deskpaw-device.target

13
package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "deskpaw-memory",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.19.0"
},
"scripts": {
"start": "node src/server.js",
"test": "node --test test/*.test.js"
}
}

33
src/policy.js Normal file
View File

@ -0,0 +1,33 @@
const SECRET_PATTERNS = [
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i,
/\b(?:password|passwd|api[_ -]?key|access[_ -]?token|refresh[_ -]?token|bearer|client[_ -]?secret)\b/i,
/(?:密码|口令|令牌|密钥|私钥|验证码)/,
];
export function boundedText(value, maxLength) {
if (typeof value !== "string") return "";
return value.trim().slice(0, maxLength);
}
export function safeMemoryText(value, maxLength = 8_000) {
const text = boundedText(value, maxLength);
if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) return "[敏感内容未写入记忆]";
return text;
}
export function words(value) {
const lowered = boundedText(value, 16_000).toLowerCase();
const terms = new Set(lowered.match(/[a-z0-9_-]{2,}|[\u3400-\u9fff]{2}/g) ?? []);
const cjk = [...lowered.matchAll(/[\u3400-\u9fff]/g)].map((match) => match[0]).join("");
for (let index = 0; index + 1 < cjk.length; index++) terms.add(cjk.slice(index, index + 2));
return terms;
}
export function similarity(left, right) {
const a = words(left);
const b = words(right);
if (!a.size || !b.size) return 0;
let overlap = 0;
for (const term of a) if (b.has(term)) overlap++;
return overlap / Math.sqrt(a.size * b.size);
}

67
src/server.js Normal file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env node
import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { createServer } from "node:net";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { MemoryStore } from "./store.js";
const stateDir = resolve(process.env.DESKPAW_STATE_DIR ?? join(homedir(), ".deskpaw"));
const runtimeDir = join(stateDir, "runtime");
const memoryDir = join(stateDir, "memory");
const socketPath = process.env.DESKPAW_MEMORY_SOCKET ?? (process.platform === "win32" ? "\\\\.\\pipe\\deskpaw-memory" : join(runtimeDir, "memory.sock"));
const databasePath = resolve(process.env.DESKPAW_MEMORY_DB ?? join(memoryDir, "data", "memory.sqlite3"));
const bundlesDir = resolve(process.env.DESKPAW_MEMORY_BUNDLES ?? join(memoryDir, "bundles"));
process.umask(0o077);
mkdirSync(dirname(databasePath), { recursive: true, mode: 0o700 });
if (process.platform !== "win32" && existsSync(socketPath)) rmSync(socketPath);
const store = new MemoryStore(databasePath, bundlesDir);
function call(method, params) {
if (method === "health") return { status: "ok", version: "0.1.0", ...store.status() };
if (method === "transition.assess") return store.assessTransition(params);
if (method === "context.prepare") return store.prepareContext(params);
if (method === "task.complete") return store.completeTask(params);
if (method === "consolidation.record") return store.recordConsolidation(params);
if (method === "bundle.get") return store.bundle(String(params.scope ?? ""), String(params.object_id ?? ""));
if (method === "bundle.list") return store.listBundles(params.scope === undefined ? undefined : String(params.scope));
if (method === "bundle.document.put") return store.updateBundleDocument(params);
if (method === "task.list") return store.listTasks(Number(params.limit ?? 20));
throw new Error(`Unknown method: ${method}`);
}
const server = createServer((socket) => {
socket.setEncoding("utf8");
let input = "";
socket.on("error", () => {});
socket.on("data", (chunk) => {
input += chunk;
if (input.length > 1_000_000) socket.destroy(new Error("Request too large"));
const newline = input.indexOf("\n");
if (newline < 0) return;
const line = input.slice(0, newline);
try {
const request = JSON.parse(line);
const result = call(String(request.method ?? ""), request.params ?? {});
socket.end(JSON.stringify({ id: request.id ?? null, ok: true, result }) + "\n");
} catch (error) {
socket.end(JSON.stringify({ id: null, ok: false, error: error instanceof Error ? error.message : String(error) }) + "\n");
}
});
});
server.listen(socketPath, () => {
if (process.platform !== "win32") chmodSync(socketPath, 0o600);
console.log(`DeskPaw memory ready at ${socketPath}`);
});
let stopping = false;
function shutdown() {
if (stopping) return;
stopping = true;
server.close(() => {
store.close();
if (process.platform !== "win32" && existsSync(socketPath)) rmSync(socketPath);
process.exit(0);
});
}
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);

239
src/store.js Normal file
View File

@ -0,0 +1,239 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { boundedText, safeMemoryText, similarity } from "./policy.js";
function json(value) {
return JSON.stringify(value);
}
function parse(value, fallback) {
try {
return JSON.parse(String(value));
} catch {
return fallback;
}
}
function markdown(value) {
return String(value).replaceAll("\r", "").trim() + "\n";
}
function identifier(value, name) {
const text = boundedText(value, 200);
if (!/^[a-z0-9_.-]+$/i.test(text)) throw new Error(`Invalid ${name}`);
return text;
}
export class MemoryStore {
constructor(path, bundlesDir) {
this.bundlesDir = bundlesDir;
mkdirSync(bundlesDir, { recursive: true, mode: 0o700 });
this.db = new DatabaseSync(path);
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS events (
seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, at INTEGER NOT NULL,
task_id TEXT, scope TEXT, object_id TEXT, data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS task_cards (
task_id TEXT PRIMARY KEY, objective TEXT NOT NULL, status TEXT NOT NULL,
result TEXT NOT NULL, schedule_id TEXT, context_epoch TEXT NOT NULL,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS bundles (
bundle_id TEXT PRIMARY KEY, scope TEXT NOT NULL, object_id TEXT NOT NULL,
title TEXT NOT NULL, revision INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, UNIQUE(scope, object_id)
);
CREATE TABLE IF NOT EXISTS bundle_documents (
bundle_id TEXT NOT NULL, name TEXT NOT NULL, body TEXT NOT NULL,
revision INTEGER NOT NULL, updated_at INTEGER NOT NULL,
PRIMARY KEY(bundle_id, name)
);
CREATE TABLE IF NOT EXISTS consolidations (
id TEXT PRIMARY KEY, before_task_id TEXT, after_task_id TEXT,
reason TEXT NOT NULL, status TEXT NOT NULL, details TEXT NOT NULL,
created_at INTEGER NOT NULL, completed_at INTEGER
);
CREATE INDEX IF NOT EXISTS events_task ON events(task_id, seq);
CREATE INDEX IF NOT EXISTS task_cards_updated ON task_cards(updated_at DESC);`);
}
close() {
this.db.close();
}
event(type, data, taskId = null, scope = null, objectId = null) {
this.db.prepare("INSERT INTO events(type,at,task_id,scope,object_id,data) VALUES (?,?,?,?,?,?)")
.run(type, Date.now(), taskId, scope, objectId, json(data));
}
latestTask() {
return this.db.prepare("SELECT * FROM task_cards ORDER BY updated_at DESC LIMIT 1").get();
}
assessTransition(params) {
const objective = boundedText(params.objective, 16_000);
const scheduleId = boundedText(params.schedule_id, 200) || null;
const previous = this.latestTask();
if (!previous) return { relationship: "new", score: 0, previous_task: null, recommended_action: "keep" };
const score = similarity(previous.objective, objective);
const sameSchedule = Boolean(scheduleId && previous.schedule_id === scheduleId);
const ageMs = Math.max(0, Date.now() - Number(previous.updated_at));
const relationship = sameSchedule || score >= 0.42 ? "same" : score >= 0.18 ? "related" : "new";
const recommendedAction = relationship === "new" && previous.status !== "running" ? "consolidate" : "keep";
return {
relationship,
score: Number(score.toFixed(3)),
previous_task: {
task_id: previous.task_id,
objective: previous.objective.slice(0, 500),
status: previous.status,
result: previous.result.slice(0, 800),
age_ms: ageMs,
},
recommended_action: recommendedAction,
};
}
ensureBundle(scope, objectId, title, initialRequirements = "") {
scope = identifier(scope, "bundle scope");
objectId = identifier(objectId, "bundle object id");
const existing = this.db.prepare("SELECT * FROM bundles WHERE scope=? AND object_id=?").get(scope, objectId);
if (existing) return existing;
const now = Date.now();
const bundle = { bundle_id: randomUUID(), scope, object_id: objectId, title, revision: 1, created_at: now, updated_at: now };
this.db.prepare("INSERT INTO bundles VALUES (?,?,?,?,?,?,?)")
.run(bundle.bundle_id, scope, objectId, title, 1, now, now);
this.putDocument(bundle.bundle_id, "INDEX.md", `# ${title}\n\n此资料夹属于 ${scope}:${objectId}。\n`, false);
this.putDocument(bundle.bundle_id, "requirements.md", `# 执行要求\n\n${safeMemoryText(initialRequirements) || "尚未设置。"}\n`, false);
this.putDocument(bundle.bundle_id, "preferences.md", "# 偏好\n\n尚无。\n", false);
this.putDocument(bundle.bundle_id, "lessons.md", "# 已验证经验\n\n尚无。\n", false);
this.projectBundle(bundle.bundle_id);
this.event("bundle.created", { title }, null, scope, objectId);
return this.db.prepare("SELECT * FROM bundles WHERE bundle_id=?").get(bundle.bundle_id);
}
putDocument(bundleId, name, body, bumpBundle = true) {
if (!/^[a-z0-9_.-]+$/i.test(name)) throw new Error("Invalid document name");
const current = this.db.prepare("SELECT revision FROM bundle_documents WHERE bundle_id=? AND name=?").get(bundleId, name);
const revision = Number(current?.revision ?? 0) + 1;
this.db.prepare("INSERT OR REPLACE INTO bundle_documents VALUES (?,?,?,?,?)")
.run(bundleId, name, safeMemoryText(body, 32_000), revision, Date.now());
if (bumpBundle) this.db.prepare("UPDATE bundles SET revision=revision+1,updated_at=? WHERE bundle_id=?").run(Date.now(), bundleId);
}
bundle(scope, objectId) {
scope = identifier(scope, "bundle scope");
objectId = identifier(objectId, "bundle object id");
const bundle = this.db.prepare("SELECT * FROM bundles WHERE scope=? AND object_id=?").get(scope, objectId);
if (!bundle) return null;
const documents = this.db.prepare("SELECT name,body,revision,updated_at FROM bundle_documents WHERE bundle_id=? ORDER BY name").all(bundle.bundle_id);
return { ...bundle, documents };
}
listBundles(scope) {
const rows = scope
? this.db.prepare("SELECT * FROM bundles WHERE scope=? ORDER BY updated_at DESC").all(identifier(scope, "bundle scope"))
: this.db.prepare("SELECT * FROM bundles ORDER BY updated_at DESC").all();
return rows.map((row) => ({ ...row, documents: this.db.prepare("SELECT name,revision,updated_at FROM bundle_documents WHERE bundle_id=? ORDER BY name").all(row.bundle_id) }));
}
updateBundleDocument(params) {
const scope = identifier(params.scope, "bundle scope");
const objectId = identifier(params.object_id, "bundle object id");
const name = identifier(params.name, "document name");
const bundle = this.db.prepare("SELECT * FROM bundles WHERE scope=? AND object_id=?").get(scope, objectId);
if (!bundle) throw new Error("Bundle not found");
if (params.expected_revision !== undefined && Number(params.expected_revision) !== Number(bundle.revision))
throw new Error(`Bundle revision conflict: expected ${String(params.expected_revision)}, current ${String(bundle.revision)}`);
this.putDocument(bundle.bundle_id, name, params.body, true);
this.projectBundle(bundle.bundle_id);
const updated = this.bundle(scope, objectId);
this.event("bundle.document_updated", { name, revision: updated.revision }, null, scope, objectId);
return updated;
}
projectBundle(bundleId) {
const bundle = this.db.prepare("SELECT * FROM bundles WHERE bundle_id=?").get(bundleId);
if (!bundle) return;
const directory = join(this.bundlesDir, bundle.scope, bundle.object_id);
mkdirSync(join(directory, "runs"), { recursive: true, mode: 0o700 });
const documents = this.db.prepare("SELECT name,body FROM bundle_documents WHERE bundle_id=?").all(bundleId);
for (const document of documents) writeFileSync(join(directory, document.name), markdown(document.body), { mode: 0o600 });
writeFileSync(join(directory, "state.json"), json({ scope: bundle.scope, object_id: bundle.object_id, revision: bundle.revision, updated_at: bundle.updated_at }) + "\n", { mode: 0o600 });
}
prepareContext(params) {
const taskId = boundedText(params.task_id, 200);
const objective = boundedText(params.objective, 16_000);
const scheduleId = boundedText(params.schedule_id, 200) || null;
const contextEpoch = randomUUID();
let bundle = null;
if (scheduleId) {
this.ensureBundle("schedule", scheduleId, boundedText(params.schedule_name, 200) || "日程资料", objective);
bundle = this.bundle("schedule", scheduleId);
}
const recent = this.db
.prepare("SELECT task_id,objective,status,result,schedule_id,updated_at FROM task_cards ORDER BY updated_at DESC LIMIT 20")
.all()
.filter((row) => (scheduleId ? row.schedule_id === scheduleId : similarity(row.objective, objective) >= 0.18))
.slice(0, 3);
const selectedDocs = bundle?.documents.filter((document) => ["INDEX.md", "requirements.md", "preferences.md", "lessons.md"].includes(document.name)) ?? [];
const pack = {
context_epoch: contextEpoch,
task: { task_id: taskId, objective },
schedule_bundle: bundle ? { bundle_id: bundle.bundle_id, revision: bundle.revision, documents: selectedDocs.map((document) => ({ name: document.name, body: document.body.slice(0, 2500), revision: document.revision })) } : null,
recent_task_cards: recent.map((row) => ({ task_id: row.task_id, objective: row.objective.slice(0, 500), status: row.status, result: row.result.slice(0, 800), updated_at: row.updated_at })),
};
this.event("context.prepared", { context_epoch: contextEpoch, schedule_id: scheduleId }, taskId, scheduleId ? "schedule" : "task", scheduleId ?? taskId);
return pack;
}
completeTask(params) {
const taskId = boundedText(params.task_id, 200);
const objective = boundedText(params.objective, 16_000);
const status = boundedText(params.status, 40) || "completed";
const result = safeMemoryText(params.result, 4_000);
const scheduleId = boundedText(params.schedule_id, 200) || null;
const contextEpoch = boundedText(params.context_epoch, 200) || randomUUID();
const now = Date.now();
const existing = this.db.prepare("SELECT created_at FROM task_cards WHERE task_id=?").get(taskId);
this.db.prepare("INSERT OR REPLACE INTO task_cards VALUES (?,?,?,?,?,?,?,?)")
.run(taskId, objective, status, result, scheduleId, contextEpoch, Number(existing?.created_at ?? now), now);
if (scheduleId) {
const bundle = this.ensureBundle("schedule", scheduleId, boundedText(params.schedule_name, 200) || "日程资料", objective);
const directory = join(this.bundlesDir, "schedule", scheduleId, "runs", taskId);
mkdirSync(directory, { recursive: true, mode: 0o700 });
writeFileSync(join(directory, "summary.md"), markdown(`# ${status}\n\n${result}`), { mode: 0o600 });
this.putDocument(bundle.bundle_id, "INDEX.md", `# ${bundle.title}\n\n最近一次运行:${new Date(now).toISOString()}\n\n状态:${status}\n`, true);
this.projectBundle(bundle.bundle_id);
}
this.event("task.completed", { status, context_epoch: contextEpoch }, taskId, scheduleId ? "schedule" : "task", scheduleId ?? taskId);
return { task_id: taskId, stored: true };
}
recordConsolidation(params) {
const id = randomUUID();
const status = boundedText(params.status, 40) || "completed";
this.db.prepare("INSERT INTO consolidations VALUES (?,?,?,?,?,?,?,?)")
.run(id, boundedText(params.before_task_id, 200) || null, boundedText(params.after_task_id, 200) || null, boundedText(params.reason, 500), status, json(params.details ?? {}), Date.now(), status === "started" ? null : Date.now());
this.event("context.consolidated", { id, status, reason: boundedText(params.reason, 500) }, boundedText(params.after_task_id, 200) || null);
return { consolidation_id: id, status };
}
status() {
return {
tasks: Number(this.db.prepare("SELECT count(*) AS count FROM task_cards").get().count),
bundles: Number(this.db.prepare("SELECT count(*) AS count FROM bundles").get().count),
consolidations: Number(this.db.prepare("SELECT count(*) AS count FROM consolidations").get().count),
last_event_seq: Number(this.db.prepare("SELECT max(seq) AS seq FROM events").get().seq ?? 0),
};
}
listTasks(limit = 20) {
const safeLimit = Number.isSafeInteger(limit) ? Math.max(1, Math.min(100, limit)) : 20;
return this.db.prepare("SELECT * FROM task_cards ORDER BY updated_at DESC LIMIT ?").all(safeLimit);
}
}

51
test/store.test.js Normal file
View File

@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { MemoryStore } from "../src/store.js";
test("task transition recommends consolidation for an unrelated completed task", () => {
const directory = mkdtempSync(join(tmpdir(), "deskpaw-memory-"));
const store = new MemoryStore(join(directory, "memory.sqlite"), join(directory, "bundles"));
try {
store.completeTask({ task_id: "one", objective: "打开微信并发送日报", status: "completed", result: "已发送", context_epoch: "e1" });
const related = store.assessTransition({ objective: "继续发送刚才的日报" });
const unrelated = store.assessTransition({ objective: "分析项目中的 TypeScript 架构" });
assert.notEqual(related.relationship, "new");
assert.equal(unrelated.relationship, "new");
assert.equal(unrelated.recommended_action, "consolidate");
} finally {
store.close();
rmSync(directory, { recursive: true, force: true });
}
});
test("schedule context creates and projects a stable bundle", () => {
const directory = mkdtempSync(join(tmpdir(), "deskpaw-memory-"));
const store = new MemoryStore(join(directory, "memory.sqlite"), join(directory, "bundles"));
try {
const context = store.prepareContext({ task_id: "run-1", objective: "每天发送 AI 简报", schedule_id: "daily-ai", schedule_name: "AI 简报" });
assert.equal(context.schedule_bundle.documents.some((document) => document.name === "requirements.md"), true);
store.completeTask({ task_id: "run-1", objective: "每天发送 AI 简报", status: "completed", result: "今日已发送 5 条", schedule_id: "daily-ai", schedule_name: "AI 简报", context_epoch: context.context_epoch });
assert.match(readFileSync(join(directory, "bundles", "schedule", "daily-ai", "runs", "run-1", "summary.md"), "utf8"), /今日已发送/);
} finally {
store.close();
rmSync(directory, { recursive: true, force: true });
}
});
test("bundle document updates use optimistic revisions", () => {
const directory = mkdtempSync(join(tmpdir(), "deskpaw-memory-"));
const store = new MemoryStore(join(directory, "memory.sqlite"), join(directory, "bundles"));
try {
store.prepareContext({ task_id: "run-1", objective: "每天发送 AI 简报", schedule_id: "daily-ai", schedule_name: "AI 简报" });
const bundle = store.bundle("schedule", "daily-ai");
const updated = store.updateBundleDocument({ scope: "schedule", object_id: "daily-ai", name: "preferences.md", body: "多放开源项目。", expected_revision: bundle.revision });
assert.equal(updated.revision, bundle.revision + 1);
assert.throws(() => store.updateBundleDocument({ scope: "schedule", object_id: "daily-ai", name: "preferences.md", body: "旧版本覆盖", expected_revision: bundle.revision }), /revision conflict/);
} finally {
store.close();
rmSync(directory, { recursive: true, force: true });
}
});