dsh-engram 0.3.4 → 0.3.5

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.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * dsh-engram: bounded CJK substring search over the workspace's own session
3
+ * corpus.
4
+ *
5
+ * WHY: the cross-session FTS fallback (`ctx.sessionQuery`) is tokenizer-blind
6
+ * to Chinese. The harness's session-query SQLite builds its FTS5 index with
7
+ * `tokenize='unicode61'` (packages/session-query/session-query-sqlite), which
8
+ * keeps a contiguous CJK run as ONE token — a mid-run substring like 「中间表示」
9
+ * never matches a session containing 「…的中间表示生成」 (verified against the
10
+ * harness source and empirically). The host search API additionally
11
+ * double-quotes every query (`quoteFtsData`), so FTS5 prefix/OR tricks like
12
+ * `编译*` are unavailable through it either.
13
+ *
14
+ * This module is the in-constraint fix: pure mechanical, zero LLM, bounded
15
+ * (newest N session files, LRU text cache, contiguous substring match), and
16
+ * consulted ONLY for CJK queries after the official FTS has returned zero.
17
+ * Same privacy scope as the FTS fallback — a single workspace === one cwd
18
+ * bucket under `~/.dsh/sessions`, so we never look outside the caller's
19
+ * workspace. Deterministic ordering: match count desc, then mtime desc.
20
+ */
21
+
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+
25
+ import { decodeZstd, sessionsRoot } from "./usage.js";
26
+ import { redactText } from "./redact.js";
27
+
28
+ /** CJK ideographs (incl. Ext-A and compatibility ideographs). */
29
+ const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
30
+
31
+ /** Newest session files scanned per query (keeps the hot path bounded). */
32
+ const MAX_FILES = 4;
33
+ /** Max decompressed files kept in the LRU cache. */
34
+ const MAX_CACHE_FILES = 6;
35
+ /** Max cached decompressed bytes (~24 MB). */
36
+ const MAX_CACHE_BYTES = 24 * 1024 * 1024;
37
+ /** Snippet length around the first match. */
38
+ const SNIPPET = 160;
39
+
40
+ /** Does the query contain CJK ideographs? */
41
+ export function hasCJK(text) {
42
+ return CJK_RE.test(String(text));
43
+ }
44
+
45
+ /**
46
+ * Map a session cwd to its bucket directory name under `~/.dsh/sessions`
47
+ * (`--` + workspace-key with separators flattened + `--`), mirroring the
48
+ * workspaceKey() normalization (win32 lowercasing included).
49
+ */
50
+ export function bucketFromCwd(cwd) {
51
+ let norm = path.resolve(String(cwd)).replace(/[\\/]+$/, "");
52
+ if (process.platform === "win32") norm = norm.toLowerCase();
53
+ return `--${norm.replace(/^\/*/, "").replace(/[\\/]/g, "-")}--`;
54
+ }
55
+
56
+ // LRU-ish text cache keyed by file path; re-set moves the entry last.
57
+ const textCache = new Map(); // file -> { mtimeMs, size, text }
58
+ let cacheBytes = 0;
59
+
60
+ function decodedText(file, st) {
61
+ const hit = textCache.get(file);
62
+ if (hit !== void 0 && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.text;
63
+ const text = decodeZstd(file);
64
+ while (textCache.size >= MAX_CACHE_FILES || cacheBytes + text.length > MAX_CACHE_BYTES) {
65
+ const oldest = textCache.keys().next().value;
66
+ if (oldest === void 0) break;
67
+ const evicted = textCache.get(oldest);
68
+ textCache.delete(oldest);
69
+ cacheBytes -= evicted.text.length;
70
+ }
71
+ textCache.set(file, { mtimeMs: st.mtimeMs, size: st.size, text });
72
+ cacheBytes += text.length;
73
+ return text;
74
+ }
75
+
76
+ /** Count contiguous occurrences of `needle` in `text` (ASCII case-folded). */
77
+ export function countMatches(text, needle) {
78
+ let t = String(text);
79
+ let n = String(needle);
80
+ if (t.length === 0 || n.length === 0) return 0;
81
+ // Case-folding only matters for the non-CJK part of a mixed query.
82
+ if (/[a-zA-Z]/.test(n)) {
83
+ t = t.toLowerCase();
84
+ n = n.toLowerCase();
85
+ }
86
+ let count = 0;
87
+ let idx = 0;
88
+ while ((idx = t.indexOf(n, idx)) !== -1) {
89
+ count += 1;
90
+ idx += n.length;
91
+ }
92
+ return count;
93
+ }
94
+
95
+ function snippetAround(text, needle) {
96
+ const t = String(text);
97
+ const n = String(needle);
98
+ let hay = t;
99
+ let at = hay.indexOf(n);
100
+ if (at === -1 && /[a-zA-Z]/.test(n)) {
101
+ hay = t.toLowerCase();
102
+ at = hay.toLowerCase().indexOf(n.toLowerCase());
103
+ }
104
+ const start = Math.max(0, at - 60);
105
+ const end = Math.min(t.length, at + n.length + 100);
106
+ // PRIVACY: these snippets come from RAW session logs (never redacted at the
107
+ // source), so they run through the same deterministic secret redactor as
108
+ // the memory write path before any text reaches the model or the GUI.
109
+ const raw = redactText(t.slice(start, end).replace(/\s*\n+\s*/g, " ").replace(/\s+/g, " ").trim());
110
+ const prefix = start > 0 ? "…" : "";
111
+ const suffix = end < t.length ? "…" : "";
112
+ return prefix + raw.slice(0, SNIPPET) + suffix;
113
+ }
114
+
115
+ /**
116
+ * Bounded CJK substring search over the workspace's own recent sessions.
117
+ * @returns [{ sessionId, snippet, hits }] ordered by hits desc, then recency.
118
+ * Empty when the query has no CJK or no session file matched.
119
+ */
120
+ export function searchSessionsCJK({
121
+ cwd,
122
+ query,
123
+ limit = 3,
124
+ maxFiles = MAX_FILES,
125
+ root = sessionsRoot(),
126
+ } = {}) {
127
+ const q = String(query ?? "").trim();
128
+ if (q.length === 0 || !hasCJK(q)) return [];
129
+ const bucketDir = path.join(root, bucketFromCwd(cwd));
130
+ let sids = [];
131
+ try {
132
+ sids = fs.readdirSync(bucketDir);
133
+ } catch {
134
+ return [];
135
+ }
136
+ const files = [];
137
+ for (const sid of sids) {
138
+ const file = path.join(bucketDir, sid, "session.jsonl.zstd");
139
+ let st;
140
+ try {
141
+ st = fs.statSync(file);
142
+ } catch {
143
+ continue;
144
+ }
145
+ files.push({ file, sid, mtimeMs: st.mtimeMs, size: st.size });
146
+ }
147
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
148
+
149
+ const matches = [];
150
+ for (const f of files.slice(0, maxFiles)) {
151
+ const text = decodedText(f.file, f);
152
+ const hits = countMatches(text, q);
153
+ if (hits > 0) {
154
+ matches.push({ sessionId: f.sid, snippet: snippetAround(text, q), hits, mtimeMs: f.mtimeMs });
155
+ }
156
+ }
157
+ matches.sort((a, b) => b.hits - a.hits || b.mtimeMs - a.mtimeMs);
158
+ return matches.slice(0, limit).map(({ sessionId, snippet, hits }) => ({ sessionId, snippet, hits }));
159
+ }
package/lib/settings.js CHANGED
@@ -34,6 +34,10 @@ export const SETTINGS_KEYS = [
34
34
  "gcStableRetentionDays",
35
35
  "gcReplacesCompaction",
36
36
  "gcNarrative",
37
+ "verifyArtifact",
38
+ "autoSupersede",
39
+ "autoSinkTodosOnEnd",
40
+ "autoWebCompaction",
37
41
  "trustedHosts",
38
42
  ];
39
43
 
@@ -64,6 +68,9 @@ export function makeSettingsSchema(defaults) {
64
68
  gcReplacesCompaction: z.boolean().default(defaults.gcReplacesCompaction),
65
69
  gcNarrative: z.boolean().default(defaults.gcNarrative),
66
70
  verifyArtifact: z.boolean().default(defaults.verifyArtifact).description("ESR 闭环时校验 artifact 路径真实存在"),
71
+ autoSupersede: z.boolean().default(defaults.autoSupersede).description("实体锚定 + 替换式更新(改用/不再…)自动把旧记忆标为 superseded(默认关)"),
72
+ autoSinkTodosOnEnd: z.boolean().default(defaults.autoSinkTodosOnEnd).description("会话结束时把仍 pending 的 todo 自动沉淀为 ESR draft 任务(去重、受容量上限约束;关掉=会话结束即弃)"),
73
+ autoWebCompaction: z.boolean().default(defaults.autoWebCompaction).description("web 面启动时把全部 stock 布局的 agent 预设(shipped + 用户根)的 compaction 组自动换成 dsh-engram/compaction(带备份、幂等、不碰自定义/无 compaction 预设;关掉=不再自动接管,已接管的预设保持 Context GC、需 npm run web-compaction:revert 还原,host 面不受影响)"),
67
74
  // Security knob, not rendered in the card: hostnames allowed past the
68
75
  // loopback fence for /api/dsh-engram (operator-authorized tunnel hosts).
69
76
  trustedHosts: z.array(z.string()).default([]),
package/lib/status.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Durable Context GC status artifact.
3
+ *
4
+ * The plugin writes a tiny JSON file under the DSH home at boot once the host
5
+ * compaction engine and the web-plane wiring have both settled. It is the
6
+ * offline, inspectable truth for "is Context GC actually active and where?" —
7
+ * readable by the shipped `dsh-engram` CLI and by support/diagnostics without
8
+ * booting a session or parsing startup logs.
9
+ * @module dsh-engram/status
10
+ */
11
+
12
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
13
+ import { dirname, join } from "node:path";
14
+ import { homedir } from "node:os";
15
+
16
+ /** The DSH home directory (env override, else `~/.dsh`). */
17
+ export function engramHomeDir() {
18
+ return process.env.DSH_HOME !== undefined && process.env.DSH_HOME.length > 0
19
+ ? process.env.DSH_HOME
20
+ : join(homedir(), ".dsh");
21
+ }
22
+
23
+ /** Absolute path of the Context GC status file. */
24
+ export function contextGcStatusPath() {
25
+ return join(engramHomeDir(), "engram", "context-gc.status.json");
26
+ }
27
+
28
+ /**
29
+ * Atomically write the status snapshot (tmp + rename). Never throws for the
30
+ * caller's convenience; a failure is logged by the caller if it cares.
31
+ * @param {object} payload
32
+ * @returns {Promise<void>}
33
+ */
34
+ export async function writeContextGcStatus(payload) {
35
+ const path = contextGcStatusPath();
36
+ await mkdir(dirname(path), { recursive: true });
37
+ const tmp = `${path}.tmp`;
38
+ await writeFile(
39
+ tmp,
40
+ `${JSON.stringify({ version: 1, ...payload, writtenAt: Date.now() }, null, 2)}\n`,
41
+ "utf8",
42
+ );
43
+ await rename(tmp, path);
44
+ }
45
+
46
+ /**
47
+ * Read the last status snapshot. Returns `undefined` when none exists yet
48
+ * (safe to treat as "not yet initialized").
49
+ * @returns {Promise<Record<string, any> | undefined>}
50
+ */
51
+ export async function readContextGcStatus() {
52
+ try {
53
+ return JSON.parse(await readFile(contextGcStatusPath(), "utf8"));
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
package/lib/store.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dsh-engram: storage layer over DSH's own `ctx.storageDomain`.
3
3
  *
4
- * One domain `dsh_engram` (JSON unit `~/.dsh/storages/dsh_engram.json`) with three
4
+ * One domain `dsh_engram` (JSON unit `~/.dsh/storages/dsh_engram.json`) with four
5
5
  * tables:
6
6
  * - `memories` — the memory pool (kinds: decision/error/procedure/fact/
7
7
  * insight/handoff/task), with provenance, signal strength, hit counts,
@@ -22,6 +22,7 @@ import path from "node:path";
22
22
  import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
23
23
  import { hashText, byRecency, uuid, now, truncate as truncateText, bm25Rank, tokenize as tokenizeLocal, dedupeBySession } from "./util.js";
24
24
  import { deriveObservations } from "./obs.js";
25
+ import { redactText } from "./redact.js";
25
26
 
26
27
  export const DOMAIN_VERSION = 1;
27
28
 
@@ -64,6 +65,10 @@ const MemorySchema = z.object({
64
65
  status: z.enum(["active", "archived"]).default("active"),
65
66
  /** Absolute TTL expiry (epoch ms), null = no expiry. */
66
67
  expiresAt: z.number().int().nullable().default(null),
68
+ /** Memory id this row supersedes (newer statement replaces older truth). */
69
+ supersedes: z.string().nullable().default(null),
70
+ /** Memory id this row directly contradicts (conflicting claim, keep both). */
71
+ contradicts: z.string().nullable().default(null),
67
72
  });
68
73
 
69
74
  const TaskSchema = z.object({
@@ -201,6 +206,72 @@ function repeatOverlap(previous, incoming) {
201
206
  return repeatOverlapRatio(previous, incoming) >= REPEAT_OVERLAP;
202
207
  }
203
208
 
209
+ /**
210
+ * Validate a memory-to-memory supersedes/contradicts target: must be a real
211
+ * memory in the same workspace (fail loud on typos / cross-workspace refs).
212
+ */
213
+ function validateRelationTarget(workspace, target, kind, memories) {
214
+ if (target === null || target === void 0 || target === "") return null;
215
+ const mem = memories.find((m) => m.id === target);
216
+ if (mem === void 0 || mem.workspace !== workspace) {
217
+ throw new EngramError("ENGRAM_INVALID_ARGS", `${kind} target memory "${target}" does not exist in this workspace`);
218
+ }
219
+ return target;
220
+ }
221
+
222
+ /** Build supersede/contradiction lookup maps from a memory pool (deterministic). */
223
+ function memoryRelationMaps(pool) {
224
+ const supersededBy = new Map(); // target id -> newer row
225
+ const contradictedBy = new Map(); // target id -> rows[]
226
+ for (const m of pool) {
227
+ if (m.supersedes !== null && m.supersedes !== void 0) supersededBy.set(m.supersedes, m);
228
+ if (m.contradicts !== null && m.contradicts !== void 0) {
229
+ const list = contradictedBy.get(m.contradicts) ?? [];
230
+ list.push(m);
231
+ contradictedBy.set(m.contradicts, list);
232
+ }
233
+ }
234
+ return { supersededBy, contradictedBy };
235
+ }
236
+
237
+ /**
238
+ * Conservative replacement/negation cues that justify an auto-supersede edge.
239
+ * Kept explicit and small on purpose: a plain fact update must NOT silently
240
+ * stale-mark its sibling — only "replaced / switched / no longer / 改用 / 不再"
241
+ * style statements do.
242
+ */
243
+ const SUPERSEDE_CUES = [
244
+ "supersedes", "replaces", "replaced", "deprecated", "no longer", "instead",
245
+ "switched", "migrated", "dropped", "removed",
246
+ "改用", "换成", "切换", "改为", "取代", "替代", "迁移", "废除", "弃用", "不再",
247
+ ];
248
+
249
+ /**
250
+ * Deterministic auto-supersede candidate (config.autoSupersede, default OFF).
251
+ * Fires only when ALL hold:
252
+ * - the new entry has a non-empty entity anchor,
253
+ * - its text carries a replacement/negation cue (SUPERSEDE_CUES),
254
+ * - an older ACTIVE memory shares that entity AND kind AND ≥1 token with it.
255
+ * The most recent such memory is superseded; already-superseded rows and
256
+ * cross-kind/entity rows are never picked. Zero LLM, conservative by design.
257
+ */
258
+ function autoSupersedeCandidate(all, entry, text) {
259
+ const kind = entry.kind ?? "fact";
260
+ if (typeof entry.entity !== "string" || entry.entity.length === 0) return null;
261
+ const lowered = text.toLowerCase();
262
+ if (!SUPERSEDE_CUES.some((cue) => lowered.includes(cue))) return null;
263
+ const newTokens = new Set(tokenizeLocal(text));
264
+ let best = null;
265
+ for (const m of all) {
266
+ if (m.kind !== kind || m.entity !== entry.entity || m.status !== "active") continue;
267
+ if (m.supersedes !== null) continue;
268
+ const overlap = tokenizeLocal(m.text).some((t) => newTokens.has(t));
269
+ if (!overlap) continue;
270
+ if (best === null || (m.updatedAt ?? m.createdAt ?? 0) > (best.updatedAt ?? best.createdAt ?? 0)) best = m;
271
+ }
272
+ return best;
273
+ }
274
+
204
275
  /** Identity validation (mirrors shipped defineDomain; fails loud at load). */
205
276
  export const engramDomainSpec = defineDomain({
206
277
  name: "dsh_engram",
@@ -290,7 +361,7 @@ export async function openEngramDomain(facility) {
290
361
  * is falsy) every workspace. Optional kind/status filters; `q` reuses the
291
362
  * deterministic recall scorer.
292
363
  */
293
- searchMemories({ workspace, q, kind, status, limit = 100 } = {}) {
364
+ searchMemories({ workspace, q, kind, tag, status, limit = 100 } = {}) {
294
365
  const bounded = Math.max(1, Math.min(500, Math.trunc(limit) || 100));
295
366
  let pool = [...memories.entries()].map(([, m]) => m);
296
367
  if (workspace) pool = pool.filter((m) => m.workspace === workspace);
@@ -298,6 +369,7 @@ export async function openEngramDomain(facility) {
298
369
  // (GC archives expired memories; the archive view is their pointer).
299
370
  if (status !== "archived") pool = pool.filter(notExpiredNow);
300
371
  if (kind) pool = pool.filter((m) => m.kind === kind);
372
+ if (tag) pool = pool.filter((m) => (m.tags ?? []).includes(tag));
301
373
  if (status) pool = pool.filter((m) => m.status === status);
302
374
  if (q !== void 0 && String(q).trim().length > 0) {
303
375
  return bm25Rank(pool, String(q).trim(), bounded);
@@ -362,12 +434,19 @@ export async function openEngramDomain(facility) {
362
434
  * near-repeat of a past ERROR re-warms that entry (failure revival).
363
435
  */
364
436
  async storeMemory(entry, config) {
365
- const text = String(entry.text).trim();
437
+ // Redact secrets before anything else: before the char cap, before the
438
+ // dedup hash, and before the revive overlap ratio — so sensitive text
439
+ // never lands in storage and two identical secrets collapse the same
440
+ // way as two identical plain texts.
441
+ const text = redactText(String(entry.text).trim());
366
442
  if (text.length === 0) throw new EngramError("ENGRAM_INVALID_ARGS", "memory text must not be empty");
367
443
  if ([...text].length > config.maxMemoryChars) {
368
444
  throw new EngramError("ENGRAM_CAP_EXCEEDED", `memory text is ${[...text].length} characters, over the cap of ${config.maxMemoryChars}`);
369
445
  }
370
- const dup = allMemories(entry.workspace).find((m) => hashText(m.text) === hashText(text) && m.kind === (entry.kind ?? "fact"));
446
+ const all = allMemories(entry.workspace);
447
+ let supersedes = validateRelationTarget(entry.workspace, entry.supersedes ?? null, "supersedes", all);
448
+ const contradicts = validateRelationTarget(entry.workspace, entry.contradicts ?? null, "contradicts", all);
449
+ const dup = all.find((m) => hashText(m.text) === hashText(text) && m.kind === (entry.kind ?? "fact"));
371
450
  if (dup !== void 0) {
372
451
  // Repeat of the exact same failure is itself a signal: refresh recency
373
452
  // AND climb the hit counter (errors only) so the [ENGRAM] index can
@@ -396,6 +475,13 @@ export async function openEngramDomain(facility) {
396
475
  if (allMemories(entry.workspace).length >= config.maxMemoriesPerWorkspace) {
397
476
  throw new EngramError("ENGRAM_CAP_EXCEEDED", `workspace already holds ${config.maxMemoriesPerWorkspace} active memories (the cap)`);
398
477
  }
478
+ // Auto-supersede (config.autoSupersede, OFF by default): only when the
479
+ // writer did not name a target AND the hired heuristic fires (see
480
+ // autoSupersedeCandidate) — explicit supersedes always wins.
481
+ if (supersedes === null && config?.autoSupersede === true) {
482
+ const auto = autoSupersedeCandidate(all, entry, text);
483
+ if (auto !== null) supersedes = auto.id;
484
+ }
399
485
  const expiresAt =
400
486
  entry.expiresAt !== void 0 && entry.expiresAt !== null
401
487
  ? entry.expiresAt
@@ -418,6 +504,8 @@ export async function openEngramDomain(facility) {
418
504
  filePath: entry.filePath ?? null,
419
505
  signal: typeof entry.signal === "number" ? Math.max(0, Math.min(1, entry.signal)) : 0.5,
420
506
  status: "active",
507
+ supersedes,
508
+ contradicts,
421
509
  expiresAt,
422
510
  };
423
511
  await memories.put(stored.id, stored);
@@ -444,12 +532,29 @@ export async function openEngramDomain(facility) {
444
532
  const active = allMemories(workspace).filter(notExpiredNow);
445
533
  const bounded = Math.max(1, Math.min(50, Math.trunc(limit) || 20));
446
534
  if (query !== void 0 && String(query).trim().length > 0) {
535
+ const { supersededBy } = memoryRelationMaps(active);
447
536
  const ranked = bm25Rank(active, String(query).trim(), bounded);
448
- if (maxPerSession === void 0 || maxPerSession === null) return ranked;
449
- return dedupeBySession(ranked, maxPerSession); // 候选① per-session cap
537
+ // Superseded = stale truth: an active memory marked itself as replacing
538
+ // that row. Keep it re-fetchable, but never let it win the ranking —
539
+ // demote it to the tail so the current statement surfaces first.
540
+ const main = ranked.filter((m) => !supersededBy.has(m.id));
541
+ const stale = ranked.filter((m) => supersededBy.has(m.id));
542
+ const merged = main.concat(stale).slice(0, bounded);
543
+ if (maxPerSession === void 0 || maxPerSession === null) return merged;
544
+ return dedupeBySession(merged, maxPerSession); // 候选① per-session cap
450
545
  }
451
546
  return active.sort(byRecency).slice(0, bounded);
452
547
  },
548
+ /**
549
+ * Memory-to-memory relation lookup maps for a workspace (deterministic):
550
+ * supersededBy: id -> { newer } (a row marked m.supersedes === id)
551
+ * contradictedBy: id -> [ rows ] (rows marked m.contradicts === id)
552
+ * Built purely from the active pool; used by recall rendering and the
553
+ * [ENGRAM] index to surface "stale truth" honestly without deleting.
554
+ */
555
+ memoryRelations(workspace) {
556
+ return memoryRelationMaps(allMemories(workspace).filter(notExpiredNow));
557
+ },
453
558
  timeline(workspace, entity, limit = 30) {
454
559
  const active = allMemories(workspace).filter(notExpiredNow);
455
560
  const bounded = Math.max(1, Math.min(100, Math.trunc(limit) || 30));
@@ -468,6 +573,19 @@ export async function openEngramDomain(facility) {
468
573
  await memories.put(id, { ...m, status: "archived", updatedAt: now() });
469
574
  return true;
470
575
  },
576
+ /**
577
+ * Append a tag to an ACTIVE memory (idempotent; does NOT touch updatedAt,
578
+ * so a resolution annotation never re-ranks an error into recall).
579
+ * Used by the failure→fix closure to mark resolved errors. Zero LLM.
580
+ */
581
+ async tagMemory(workspace, id, tag) {
582
+ const m = memories.get(id);
583
+ if (m === void 0 || m.workspace !== workspace || m.status !== "active") return false;
584
+ if (typeof tag !== "string" || tag.length === 0) return true;
585
+ const tags = (m.tags ?? []).includes(tag) ? m.tags : [...(m.tags ?? []), tag];
586
+ await memories.put(id, { ...m, tags });
587
+ return true;
588
+ },
471
589
  /** Lazy TTL sweep (write path only; async). Returns archived count. */
472
590
  async expireSweep(workspace) {
473
591
  let n = 0;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * dsh-engram: session-end todo auto-sink (audit follow-up).
3
+ *
4
+ * The todo plane (`todo_write`) is per-session working memory: last-write-wins,
5
+ * dies with the session. The promote hint (P0-A) and the GUI 「沉淀到 ESR」 button
6
+ * cover the INTENTIONAL path, but both only fire/run while the session is alive
7
+ * and the funnel is narrow — a session that ends with pending todos silently
8
+ * evaporates its plan (the observed "todo 一直建、esr 长期空" leak).
9
+ *
10
+ * This module closes that gap at the ONE safe boundary: `session/disposed`
11
+ * (the session's teardown edge). The trade-off from the design audit is kept
12
+ * intact:
13
+ * - nothing is auto-written DURING the session — todo stays cheap/ephemeral
14
+ * and the [ESR] prefix stays stable;
15
+ * - the sink never pollutes the active list — landed tasks are `draft`
16
+ * (excluded from [ESR] active rows by buildEsrSnapshot), deduped against
17
+ * existing non-stable tasks by name, and capped by `maxTasksPerWorkspace`;
18
+ * - a draft still needs the deliberate `esr_claim` / `esr_task` step to
19
+ * become active, so the "committed work" obligation is never created
20
+ * implicitly — the session plan becomes lossless, not obligated.
21
+ *
22
+ * All functions are pure / contained and unit-testable without the harness.
23
+ */
24
+
25
+ import { pendingTodos } from "./trigger.js";
26
+ import { shortId, uuid } from "./util.js";
27
+
28
+ /** Description stamped on every auto-sunk draft (mirrors the GUI button's note). */
29
+ export const SINK_DESCRIPTION = "源自会话计划(会话结束自动沉淀)";
30
+
31
+ /**
32
+ * Extract the FINAL pending todo list from a session's event log: the last
33
+ * `todo/write` event (whole-list snapshot) filtered to unfinished items, in
34
+ * order. Returns [] when the session never wrote todos or everything is done.
35
+ * Deterministic — this is a pure function of the session log, so it survives
36
+ * process restarts (no reliance on the process-local recorder snapshot).
37
+ */
38
+ export function pendingTodosFromSession(session) {
39
+ if (session === null || typeof session !== "object") return [];
40
+ const events = Array.isArray(session.events) ? session.events : [];
41
+ for (let i = events.length - 1; i >= 0; i--) {
42
+ const event = events[i];
43
+ if (event === null || typeof event !== "object" || event.type !== "todo/write") continue;
44
+ // Event data is the same { todos: TodoItem[] } shape todo_write args wear.
45
+ return pendingTodos(event.data);
46
+ }
47
+ return [];
48
+ }
49
+
50
+ /**
51
+ * Decide which pending todos should sink into ESR, and in what order. Pure:
52
+ * - names already held by a non-stable task are skipped (dedupe — a task the
53
+ * promote hint / GUI button already created is not re-created);
54
+ * - within-list duplicates sink only once;
55
+ * - the workspace cap (`maxTasksPerWorkspace`, counting drafts) is honoured:
56
+ * once `activeCount + created` reaches `maxTasks`, the rest are reported as
57
+ * `atCap` instead of created.
58
+ *
59
+ * @param {string[]} todos pending todo names, in plan order
60
+ * @param {Array<{name?: string}>} existingTasks non-stable tasks of the workspace
61
+ * @param {{ maxTasks?: number, activeCount?: number }} opts
62
+ * @returns {{ toCreate: string[], existed: string[], atCap: string[] }}
63
+ */
64
+ export function planTodoSink(todos, existingTasks, opts = {}) {
65
+ const maxTasks = Number.isFinite(opts.maxTasks) && opts.maxTasks > 0 ? opts.maxTasks : 10;
66
+ const activeCount = opts.activeCount !== void 0
67
+ ? opts.activeCount
68
+ : (Array.isArray(existingTasks) ? existingTasks.length : 0);
69
+ const existing = new Set(
70
+ (Array.isArray(existingTasks) ? existingTasks : [])
71
+ .map((t) => String(t?.name ?? "").trim().toLowerCase())
72
+ .filter(Boolean),
73
+ );
74
+ const toCreate = [];
75
+ const existed = [];
76
+ const atCap = [];
77
+ const seen = new Set();
78
+ for (const todo of todos ?? []) {
79
+ const name = String(todo ?? "").trim().slice(0, 200);
80
+ if (name.length === 0) continue;
81
+ const key = name.toLowerCase();
82
+ if (seen.has(key)) continue; // within-list duplicate → single sink
83
+ seen.add(key);
84
+ if (existing.has(key)) {
85
+ existed.push(name);
86
+ continue;
87
+ }
88
+ if (activeCount + toCreate.length >= maxTasks) {
89
+ atCap.push(name);
90
+ continue;
91
+ }
92
+ toCreate.push(name);
93
+ }
94
+ return { toCreate, existed, atCap };
95
+ }
96
+
97
+ /**
98
+ * Sink a finished session's pending todos into ESR as DRAFT tasks. Best-effort,
99
+ * never throws into the caller — failures surface via the returned counts plus
100
+ * whatever the caller logs. Returns { created, existed, atCap } name lists.
101
+ *
102
+ * @param {object} domain the opened engram store domain (putTask/listTasks/activeTaskCount)
103
+ * @param {string} workspace normalized workspace key
104
+ * @param {string[]} todos pending todo names
105
+ * @param {{ sessionId?: string, maxTasks?: number, description?: string, now?: number }} opts
106
+ */
107
+ export async function sinkPendingTodos(domain, workspace, todos, opts = {}) {
108
+ const record = { created: [], existed: [], atCap: [] };
109
+ if (!Array.isArray(todos) || todos.length === 0) return record;
110
+ if (domain === null || typeof domain !== "object" || typeof domain.putTask !== "function") return record;
111
+ const sessionId = opts.sessionId ?? "auto-sink";
112
+ const nowMs = typeof opts.now === "function" ? opts.now() : Number.isFinite(opts.now) ? opts.now : Date.now();
113
+ const existingTasks = typeof domain.listTasks === "function"
114
+ ? domain.listTasks(workspace, { includeStable: false })
115
+ : [];
116
+ const activeCount = typeof domain.activeTaskCount === "function"
117
+ ? domain.activeTaskCount(workspace)
118
+ : existingTasks.length;
119
+ const { toCreate, existed, atCap } = planTodoSink(todos, existingTasks, {
120
+ maxTasks: opts.maxTasks,
121
+ activeCount,
122
+ });
123
+ record.existed = existed;
124
+ record.atCap = atCap;
125
+ for (const name of toCreate) {
126
+ await domain.putTask({
127
+ id: `tsk_${shortId(uuid())}`,
128
+ workspace,
129
+ name,
130
+ description: opts.description ?? SINK_DESCRIPTION,
131
+ state: "draft",
132
+ artifact: null,
133
+ evaluation: null,
134
+ memoryRefs: [],
135
+ sessionId,
136
+ createdAt: nowMs,
137
+ updatedAt: nowMs,
138
+ stateChangedAt: 0,
139
+ deps: [],
140
+ assignee: null,
141
+ claimedAt: null,
142
+ });
143
+ record.created.push(name);
144
+ }
145
+ return record;
146
+ }