feat: add DeskPaw memory service
This commit is contained in:
commit
792c30ce68
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
runtime/
|
||||
7
AGENTS.md
Normal file
7
AGENTS.md
Normal 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
54
README.md
Normal 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
17
bin/run-memory
Executable 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
|
||||
23
deploy/deskpaw-memory.service
Normal file
23
deploy/deskpaw-memory.service
Normal 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
13
package.json
Normal 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
33
src/policy.js
Normal 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
67
src/server.js
Normal 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
239
src/store.js
Normal 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
51
test/store.test.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user