dsh-continual-evolve 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,179 +1,139 @@
1
1
  /**
2
- * Read-only projection of one workspace memory store for the plugin
3
- * management card. Everything here is computed from disk at call time — the
4
- * card is a viewer, never a second store: no caching, no writes, no bootstrap
5
- * (unlike the injection path, a missing store is reported, not created).
2
+ * Read-only projection of the central memory store for the plugin
3
+ * management card. Everything here is computed from the database at call
4
+ * time — the card is a viewer, never a second store: no caching, no writes.
5
+ * A workspace reads as its `workspace_id` partition; the only filesystem
6
+ * contact is the existence check behind the workspace list (rows whose
7
+ * directory vanished stay in the database but are not listed — 失联不阻塞).
6
8
  */
7
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
- import { basename, join, resolve } from "node:path";
9
- import { memoryDirFor } from "./memory-section.js";
10
- /** The index file name (same store contract as the injection path). */
11
- export const MEMORY_SNAPSHOT_INDEX_FILE = "MEMORY.md";
9
+ import { existsSync } from "node:fs";
10
+ import { basename } from "node:path";
11
+ import { MEMORY_FILE_PREVIEW_LIMIT } from "./store.js";
12
+ export { MEMORY_FILE_PREVIEW_LIMIT };
12
13
  /**
13
- * Preview size cap for the file content endpoint — files above it answer
14
- * `too-large` instead of their body (ZCode's memory viewer uses the same 5 MiB).
14
+ * The read-only snapshot for one workspace partition. A workspace without
15
+ * rows reports `exists: false` (the card shows the empty state); a failing
16
+ * query reports `readError` instead of throwing — the card must observe,
17
+ * never break.
15
18
  */
16
- export const MEMORY_FILE_PREVIEW_LIMIT = 5 * 1024 * 1024;
17
- /**
18
- * Parse `- [title](file.md) …` rows out of an index text. Anything else
19
- * (headings, the bootstrap hint comment, blank lines) is ignored.
20
- */
21
- export function parseMemoryIndexRows(indexText) {
22
- const rows = [];
23
- for (const line of indexText.split("\n")) {
24
- const match = /^-\s+\[(.+?)\]\(([^()\s]+\.md)\)/.exec(line.trim());
25
- if (match?.[1] !== undefined && match?.[2] !== undefined) {
26
- rows.push({ title: match[1], file: match[2] });
27
- }
28
- }
29
- return rows;
30
- }
31
- /**
32
- * Read the card-relevant frontmatter fields (`name`, `description`, `type`)
33
- * from a leading `---` block. A missing or malformed block yields "" fields —
34
- * the body text remains the source of truth the user opens in the editor.
35
- */
36
- export function parseMemoryFrontmatter(text) {
37
- const fields = { name: "", description: "", type: "" };
38
- if (!text.startsWith("---")) {
39
- return fields;
40
- }
41
- const blockEnd = text.indexOf("\n---", 3);
42
- if (blockEnd < 0) {
43
- return fields;
44
- }
45
- for (const line of text.slice(3, blockEnd).split("\n")) {
46
- const match = /^(name|description|type):\s*(.*)$/.exec(line.trim());
47
- if (match?.[1] === undefined || match?.[2] === undefined) {
48
- continue;
49
- }
50
- const value = match[2].trim().replace(/^["']|["']$/g, "");
51
- if (match[1] === "name") {
52
- fields.name = value;
53
- }
54
- else if (match[1] === "description") {
55
- fields.description = value;
56
- }
57
- else {
58
- fields.type = value;
59
- }
60
- }
61
- return fields;
62
- }
63
- /** Store files the card inspects: every .md except the index, sorted. */
64
- function listStoreFiles(memoryDir) {
65
- return readdirSync(memoryDir)
66
- .filter((file) => file.endsWith(".md") && file !== MEMORY_SNAPSHOT_INDEX_FILE)
67
- .sort();
68
- }
69
- /** Single read pass over the store; throws on any filesystem failure. */
70
- function readStoreState(memoryDir) {
71
- const indexText = readFileSync(join(memoryDir, MEMORY_SNAPSHOT_INDEX_FILE), "utf8");
72
- const diskFiles = listStoreFiles(memoryDir);
73
- const files = diskFiles.map((file) => {
74
- const frontmatter = parseMemoryFrontmatter(readFileSync(join(memoryDir, file), "utf8"));
75
- const updatedAt = statSync(join(memoryDir, file)).mtimeMs;
76
- return { file, name: frontmatter.name, description: frontmatter.description, type: frontmatter.type, updatedAt };
77
- });
78
- return { indexRows: parseMemoryIndexRows(indexText), diskFiles, files };
79
- }
80
- /**
81
- * The read-only memory snapshot for one workspace root. A missing store
82
- * reports `exists: false` (the card shows the empty state); a store that
83
- * exists but fails to read reports `readError` instead of throwing — the
84
- * card must observe, never break.
85
- */
86
- export function memorySnapshot(root) {
87
- const workspaceRoot = resolve(root);
88
- const memoryDir = memoryDirFor(workspaceRoot);
19
+ export function memorySnapshot(store, root) {
20
+ const workspaceRoot = root;
89
21
  const absent = {
90
22
  root: workspaceRoot,
91
- memoryDir,
92
23
  exists: false,
24
+ readError: null,
93
25
  fileCount: 0,
94
- indexEntryCount: 0,
95
26
  files: [],
96
- missingFiles: [],
97
- unindexedFiles: [],
98
- readError: null,
27
+ quarantined: [],
28
+ lastPatrol: null,
99
29
  };
100
- if (!existsSync(memoryDir)) {
101
- return absent;
102
- }
103
- let state;
30
+ let all;
104
31
  try {
105
- state = readStoreState(memoryDir);
32
+ all = store.listAll(workspaceRoot);
33
+ if (all.length === 0) {
34
+ return absent;
35
+ }
106
36
  }
107
37
  catch (error) {
108
- // Filesystem read failure degrades to an error field: the card shows
109
- // what went wrong instead of taking the host down with it.
110
- return {
111
- ...absent,
112
- exists: true,
113
- readError: error instanceof Error ? error.message : String(error),
114
- };
38
+ return { ...absent, exists: true, readError: error instanceof Error ? error.message : String(error) };
39
+ }
40
+ const lastPatrolRaw = store.state("", "patrol:last");
41
+ let lastPatrol = null;
42
+ if (typeof lastPatrolRaw === "string") {
43
+ try {
44
+ const parsed = JSON.parse(lastPatrolRaw);
45
+ if (typeof parsed.ts === "string") {
46
+ lastPatrol = {
47
+ ts: parsed.ts,
48
+ orphanFtsRows: typeof parsed.orphanFtsRows === "number" ? parsed.orphanFtsRows : 0,
49
+ quarantined: Array.isArray(parsed.quarantined) ? parsed.quarantined.length : 0,
50
+ };
51
+ }
52
+ }
53
+ catch {
54
+ lastPatrol = null;
55
+ }
115
56
  }
116
- const referenced = new Set(state.indexRows.map((row) => row.file));
117
57
  return {
118
58
  root: workspaceRoot,
119
- memoryDir,
120
59
  exists: true,
121
- fileCount: state.diskFiles.length,
122
- indexEntryCount: state.indexRows.length,
123
- files: state.files,
124
- missingFiles: state.indexRows
125
- .map((row) => row.file)
126
- .filter((file) => !state.diskFiles.includes(file)),
127
- unindexedFiles: state.diskFiles.filter((file) => !referenced.has(file)),
128
60
  readError: null,
61
+ fileCount: all.filter((record) => record.status === "active").length,
62
+ files: all.filter((record) => record.status === "active").map(infoOf),
63
+ quarantined: all
64
+ .filter((record) => record.status === "quarantined")
65
+ .map((record) => ({ id: record.id, reason: quarantineReasonOf(store, record) })),
66
+ lastPatrol,
67
+ };
68
+ }
69
+ function infoOf(record) {
70
+ return {
71
+ id: record.id,
72
+ title: record.title,
73
+ description: record.description,
74
+ type: record.type,
75
+ status: record.status,
76
+ updatedAt: Date.parse(record.updatedAt) || 0,
129
77
  };
130
78
  }
79
+ /** The patrol reason recorded for a quarantined row, from the last patrol result. */
80
+ function quarantineReasonOf(store, record) {
81
+ const raw = store.state("", "patrol:last");
82
+ if (typeof raw === "string") {
83
+ try {
84
+ const parsed = JSON.parse(raw);
85
+ const hit = Array.isArray(parsed.quarantined)
86
+ ? parsed.quarantined.find((entry) => entry?.id === record.id)
87
+ : undefined;
88
+ if (hit !== undefined && typeof hit.reason === "string") {
89
+ return hit.reason;
90
+ }
91
+ }
92
+ catch {
93
+ // fall through to the generic reason
94
+ }
95
+ }
96
+ return "巡检异常(疑似密钥泄漏),条目已隔离";
97
+ }
131
98
  /**
132
- * Read one memory file's body for the card's content preview — the viewer
133
- * side of the same read-only fence as `memorySnapshot`. The requested file
134
- * name must be a plain entry of the workspace's memory directory (no path
135
- * separators, no traversal, resolved location stays inside the directory);
136
- * anything else answers `outside` without touching the filesystem, so the
137
- * endpoint cannot become a path oracle. Oversized files answer `too-large`
138
- * with the body unread.
99
+ * Read one memory record's body for the card's content preview — keyed by
100
+ * id, never by file name (the store is a database; there are no files to
101
+ * path-check). An unknown id answers `absent`; bodies are capped at 64KB by
102
+ * the schema, so `too-large` can only fire if the preview limit were ever
103
+ * lowered below that.
139
104
  */
140
- export function memoryFileContent(root, file) {
141
- const memoryDir = memoryDirFor(resolve(root));
142
- if (file.length === 0 ||
143
- !file.endsWith(".md") ||
144
- file.includes("/") ||
145
- file.includes("\\") ||
146
- basename(file) !== file) {
147
- return { ok: false, reason: "outside" };
148
- }
149
- const target = resolve(memoryDir, file);
150
- if (!target.startsWith(memoryDir + "/") && target !== memoryDir) {
151
- return { ok: false, reason: "outside" };
105
+ export function memoryEntryContent(store, root, id) {
106
+ if (id.length === 0) {
107
+ return { ok: false, reason: "absent" };
152
108
  }
153
- let before;
109
+ let record;
154
110
  try {
155
- before = statSync(target);
111
+ record = store.get(root, id);
156
112
  }
157
113
  catch {
158
114
  return { ok: false, reason: "absent" };
159
115
  }
160
- if (!before.isFile()) {
116
+ if (record === undefined) {
161
117
  return { ok: false, reason: "absent" };
162
118
  }
163
- if (before.size > MEMORY_FILE_PREVIEW_LIMIT) {
119
+ // Unreachable today: bodies are schema-capped at 64KB, far below the 5 MiB
120
+ // preview limit — kept so the day the limit shrinks, the endpoint stays honest.
121
+ /* v8 ignore next 3 */
122
+ if (record.body.length > MEMORY_FILE_PREVIEW_LIMIT) {
164
123
  return { ok: false, reason: "too-large" };
165
124
  }
166
- let content;
125
+ return { ok: true, id: record.id, content: record.body, updatedAtMs: Date.parse(record.updatedAt) || 0 };
126
+ }
127
+ /** The workspace rows the card lists: active partitions whose directory survives. */
128
+ export function listCardWorkspaces(store) {
167
129
  try {
168
- content = readFileSync(target, "utf8");
130
+ return store
131
+ .listWorkspaces()
132
+ .filter((workspace) => existsSync(workspace.workspaceId))
133
+ .map((workspace) => ({ root: workspace.workspaceId, label: basename(workspace.workspaceId) }));
169
134
  }
170
135
  catch {
171
- return { ok: false, reason: "absent" };
136
+ return [];
172
137
  }
173
- // The file may have been rewritten between stat and read — surface that
174
- // instead of silently showing stale bytes (same guard shape as ZCode's
175
- // memory viewer's "changed during read" state).
176
- const after = statSync(target);
177
- return { ok: true, file, content, mtimeMs: after.mtimeMs, changed: after.mtimeMs !== before.mtimeMs };
178
138
  }
179
139
  //# sourceMappingURL=memory-snapshot.js.map
@@ -0,0 +1,34 @@
1
+ import type { MemoryStore } from "./store.js";
2
+ /** Minimal slice of the host tools service. */
3
+ export interface ToolsService {
4
+ register(definition: unknown): unknown;
5
+ }
6
+ /** Minimal tool execution context (duck-typed host surface). */
7
+ interface ToolExec {
8
+ agent?: unknown;
9
+ }
10
+ /** A registered tool definition, exactly as the host's `defineTool` emits. */
11
+ export interface ToolDefinition {
12
+ name: string;
13
+ description: string;
14
+ parameters: Record<string, unknown>;
15
+ output: {
16
+ schema: Record<string, unknown>;
17
+ render(args: unknown, value: {
18
+ text?: string;
19
+ }): {
20
+ type: string;
21
+ text: string;
22
+ }[];
23
+ };
24
+ execute(args: Record<string, unknown>, exec: ToolExec): Promise<{
25
+ text: string;
26
+ }>;
27
+ }
28
+ /**
29
+ * Register the memory tools and return a disposer that removes them (the
30
+ * host may return one; call only the functions among whatever comes back).
31
+ */
32
+ export declare function registerMemoryTools(tools: ToolsService, store: MemoryStore): () => void;
33
+ export {};
34
+ //# sourceMappingURL=memory-tools.d.ts.map
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The model-facing memory tools: `memory_write` (the explicit write path
3
+ * through the store's gates) and `memory_read` (bodies and search — the
4
+ * injected index carries hooks only).
5
+ *
6
+ * The definitions are hand-built to the shape `ctx.tools.register` consumes
7
+ * (the same object the host's `defineTool` would emit): standard JSON Schema
8
+ * with a root-level `required` array — the raw-register path forwards
9
+ * `parameters` to the API verbatim, where property-level `required: true`
10
+ * is invalid (docs/FAQ.md #2). Host API shapes are declared locally, per the
11
+ * architecture standard; the plugin imports nothing from DSH at runtime.
12
+ */
13
+ import { cwdOf } from "./memory-section.js";
14
+ function textTool(name, description, parameters, execute) {
15
+ return {
16
+ name,
17
+ description,
18
+ parameters,
19
+ output: {
20
+ schema: {
21
+ type: "object",
22
+ properties: { text: { type: "string", description: "结果说明" } },
23
+ required: ["text"],
24
+ additionalProperties: false,
25
+ },
26
+ render: (_args, value) => [{ type: "text", text: value.text ?? "" }],
27
+ },
28
+ execute: async (args, exec) => ({ text: await execute(args, exec) }),
29
+ };
30
+ }
31
+ /** The calling agent's session id (provenance for the ledger). */
32
+ function sessionIdOf(exec) {
33
+ const id = exec?.agent?.id;
34
+ return typeof id === "string" && id.length > 0 ? id : undefined;
35
+ }
36
+ const MEMORY_WRITE_PARAMETERS = {
37
+ type: "object",
38
+ properties: {
39
+ action: {
40
+ type: "string",
41
+ enum: ["create", "update", "delete"],
42
+ description: "create 新建记忆;update 更新已有条目(只给要改的字段);delete 删除条目。",
43
+ },
44
+ id: {
45
+ type: "string",
46
+ description: "短横线小写标识(kebab slug)。update/delete 必填;create 可省略(从 title 推导,中文标题会得到哈希后缀)。",
47
+ },
48
+ type: {
49
+ type: "string",
50
+ enum: ["user", "feedback", "reference"],
51
+ description: "记忆类型:user 用户画像与环境事实;feedback 用户纠正/确认的做法规则(正文必带 **Why:** 与 **How to apply:** 两行);reference 未来会用的资源指针。",
52
+ },
53
+ title: { type: "string", description: "短标题(create 必填)。" },
54
+ description: {
55
+ type: "string",
56
+ description: "一句话相关性钩子——决定未来会话会不会想起这条记忆(create 必填)。",
57
+ },
58
+ body: { type: "string", description: "正文(create 必填;update 时传新正文整体替换)。" },
59
+ },
60
+ required: ["action"],
61
+ additionalProperties: false,
62
+ };
63
+ const MEMORY_READ_PARAMETERS = {
64
+ type: "object",
65
+ properties: {
66
+ id: { type: "string", description: "按 id 直取一条记忆的完整内容。" },
67
+ query: { type: "string", description: "关键词检索(不传 id 时生效;不传 query 则列出最近记忆)。" },
68
+ limit: { type: "number", description: "检索/列出的最大条数,默认 3,上限 10。" },
69
+ },
70
+ additionalProperties: false,
71
+ };
72
+ /**
73
+ * Register the memory tools and return a disposer that removes them (the
74
+ * host may return one; call only the functions among whatever comes back).
75
+ */
76
+ export function registerMemoryTools(tools, store) {
77
+ const disposers = [];
78
+ disposers.push(tools.register(textTool("memory_write", "写入持久记忆(create 新建 / update 更新 / delete 删除)。用户明确要求记住或忘记时使用;记忆按当前工作区分区存储,由插件代码校验后落库。", MEMORY_WRITE_PARAMETERS, async (args, exec) => {
79
+ const cwd = cwdOf(exec.agent);
80
+ if (cwd === undefined) {
81
+ return "错误:当前会话没有可用的工作区路径,无法写入记忆。";
82
+ }
83
+ const [outcome] = store.applyProposals({ workspaceId: cwd, trigger: "explicit", sessionId: sessionIdOf(exec) }, [
84
+ {
85
+ action: args.action,
86
+ id: typeof args.id === "string" ? args.id : undefined,
87
+ type: typeof args.type === "string" ? args.type : undefined,
88
+ title: typeof args.title === "string" ? args.title : undefined,
89
+ description: typeof args.description === "string" ? args.description : undefined,
90
+ body: typeof args.body === "string" ? args.body : undefined,
91
+ },
92
+ ]);
93
+ if (outcome === undefined || !outcome.ok) {
94
+ return `写入被门禁拒绝,记忆未落盘:${outcome?.reason ?? "未知原因"}`;
95
+ }
96
+ if (outcome.action === "delete") {
97
+ return `已删除记忆 \`${outcome.id}\`。旧内容保留在审计账本中,可由用户恢复。`;
98
+ }
99
+ return `已${outcome.action === "create" ? "写入" : "更新"}记忆 \`${outcome.id}\`(本工作区)。下次会话的注入索引会包含它。`;
100
+ })));
101
+ disposers.push(tools.register(textTool("memory_read", "读取持久记忆:按 id 取一条的完整正文,或按关键词检索本工作区的记忆(注入的索引只有钩子,需要正文时用它)。", MEMORY_READ_PARAMETERS, async (args, exec) => {
102
+ const cwd = cwdOf(exec.agent);
103
+ if (cwd === undefined) {
104
+ return "错误:当前会话没有可用的工作区路径,无法读取记忆。";
105
+ }
106
+ const limit = clampLimit(args.limit);
107
+ if (typeof args.id === "string" && args.id.length > 0) {
108
+ const record = store.get(cwd, args.id);
109
+ return record === undefined ? `没有找到 id 为 "${args.id}" 的记忆。` : formatRecord(record);
110
+ }
111
+ const query = typeof args.query === "string" ? args.query : "";
112
+ const records = query.length > 0 ? store.search(cwd, query, limit) : store.list(cwd).slice(0, limit);
113
+ if (records.length === 0) {
114
+ return query.length > 0 ? `没有匹配 "${query}" 的记忆。` : "本工作区还没有记忆。";
115
+ }
116
+ return records.map(formatRecord).join("\n\n---\n\n");
117
+ })));
118
+ return () => {
119
+ for (const disposer of disposers) {
120
+ if (typeof disposer === "function") {
121
+ disposer();
122
+ }
123
+ }
124
+ };
125
+ }
126
+ function clampLimit(value) {
127
+ if (typeof value !== "number" || !Number.isFinite(value)) {
128
+ return 3;
129
+ }
130
+ return Math.min(10, Math.max(1, Math.floor(value)));
131
+ }
132
+ /** Full record text: header line, then the body the index omitted. */
133
+ function formatRecord(record) {
134
+ return [
135
+ `# ${record.title}`,
136
+ `id: ${record.id} | 类型: ${record.type} | 状态: ${record.status} | 更新: ${record.updatedAt}`,
137
+ `钩子: ${record.description}`,
138
+ "",
139
+ record.body,
140
+ ].join("\n");
141
+ }
142
+ //# sourceMappingURL=memory-tools.js.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The transactional heart of the sole write path: validate every proposal,
3
+ * apply all of them inside one transaction, ledger each mutation with its
4
+ * before/after snapshot, and roll the whole batch back when any proposal
5
+ * fails. Explicit tool writes and extraction runs land through this same
6
+ * gate — there is no other writer.
7
+ */
8
+ import type { DatabaseSync } from "node:sqlite";
9
+ import { type MemoryProposal } from "./memory-rules.js";
10
+ import type { LedgerEntry } from "./store.js";
11
+ /** What a single proposal produced. */
12
+ export interface ProposalOutcome {
13
+ id: string;
14
+ action: MemoryProposal["action"];
15
+ ok: boolean;
16
+ /** Blocking reason when `ok` is false. */
17
+ reason?: string;
18
+ }
19
+ /** The write context every proposal batch carries (provenance + accounting). */
20
+ export interface ProposalContext {
21
+ workspaceId: string;
22
+ trigger: LedgerEntry["trigger"];
23
+ /** Origin session (stored on created rows and ledger rows). */
24
+ sessionId?: string | undefined;
25
+ /** Extraction run id; tool writes default to `'explicit'`. */
26
+ runId?: string | undefined;
27
+ model?: string | undefined;
28
+ usage?: unknown;
29
+ /** One-line model decision summary for the ledger. */
30
+ decision?: string | undefined;
31
+ }
32
+ /** Minimal database face `applyProposals` needs (store.ts hands the real one). */
33
+ export interface ApplyDatabase {
34
+ database: DatabaseSync;
35
+ now(): string;
36
+ }
37
+ /**
38
+ * Validate and apply the whole batch atomically. A batch with any invalid
39
+ * proposal mutates nothing and still lands a `rejected` ledger row; an
40
+ * applied batch lands an `applied` row with full before/after snapshots.
41
+ */
42
+ export declare function applyProposals(db: ApplyDatabase, ctx: ProposalContext, proposals: readonly MemoryProposal[]): {
43
+ outcomes: ProposalOutcome[];
44
+ applied: boolean;
45
+ };
46
+ //# sourceMappingURL=store-apply.d.ts.map