From 792c30ce681c375c85c4a1145b7b2c75a1b4f5e8 Mon Sep 17 00:00:00 2001 From: 18962558917 <18962558917@189.cn> Date: Tue, 22 Sep 2026 23:33:47 +0800 Subject: [PATCH] feat: add DeskPaw memory service --- .gitignore | 4 + AGENTS.md | 7 + README.md | 54 ++++++++ bin/run-memory | 17 +++ deploy/deskpaw-memory.service | 23 ++++ package.json | 13 ++ src/policy.js | 33 +++++ src/server.js | 67 ++++++++++ src/store.js | 239 ++++++++++++++++++++++++++++++++++ test/store.test.js | 51 ++++++++ 10 files changed, 508 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100755 bin/run-memory create mode 100644 deploy/deskpaw-memory.service create mode 100644 package.json create mode 100644 src/policy.js create mode 100644 src/server.js create mode 100644 src/store.js create mode 100644 test/store.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0424f2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.sqlite +*.sqlite-shm +*.sqlite-wal +runtime/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a7648fd --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b745ee --- /dev/null +++ b/README.md @@ -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// + INDEX.md + requirements.md + preferences.md + lessons.md + state.json + runs//summary.md +``` + +数据库保存权威状态,以上文件用于猫窝展示和按需阅读。 diff --git a/bin/run-memory b/bin/run-memory new file mode 100755 index 0000000..9384534 --- /dev/null +++ b/bin/run-memory @@ -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 diff --git a/deploy/deskpaw-memory.service b/deploy/deskpaw-memory.service new file mode 100644 index 0000000..3ed6dd4 --- /dev/null +++ b/deploy/deskpaw-memory.service @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..1c7148e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/policy.js b/src/policy.js new file mode 100644 index 0000000..9d0a0af --- /dev/null +++ b/src/policy.js @@ -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); +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..92dac00 --- /dev/null +++ b/src/server.js @@ -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); diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..af149ab --- /dev/null +++ b/src/store.js @@ -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); + } +} diff --git a/test/store.test.js b/test/store.test.js new file mode 100644 index 0000000..4860e79 --- /dev/null +++ b/test/store.test.js @@ -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 }); + } +});