@modusensus/dsh-mneme 0.8.5 → 0.8.7

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.
Files changed (62) hide show
  1. package/README.md +45 -147
  2. package/bin/cli.mjs +689 -603
  3. package/lib/api-standalone.js +57 -1
  4. package/lib/api.js +34 -11
  5. package/lib/client.js +289 -59
  6. package/lib/config.js +78 -11
  7. package/lib/content-hash.js +54 -0
  8. package/lib/document-index.js +94 -0
  9. package/lib/document.js +93 -11
  10. package/lib/dream/sleep.js +122 -8
  11. package/lib/dream.js +1354 -1347
  12. package/lib/index.js +115 -14
  13. package/lib/inject.js +6 -2
  14. package/lib/lang.js +15 -4
  15. package/lib/local-embedder.js +44 -6
  16. package/lib/maintenance.js +142 -0
  17. package/lib/mirror.js +147 -15
  18. package/lib/recall-stats.js +52 -2
  19. package/lib/scope.js +34 -6
  20. package/lib/service.js +86 -7
  21. package/lib/settings.js +6 -3
  22. package/lib/store.js +248 -21
  23. package/lib/tools.js +27 -9
  24. package/lib/write-admission.js +259 -0
  25. package/package.json +4 -4
  26. package/src/api-standalone.js +57 -1
  27. package/src/api.js +34 -11
  28. package/src/config.js +78 -11
  29. package/src/content-hash.js +54 -0
  30. package/src/document-index.js +94 -0
  31. package/src/document.js +93 -11
  32. package/src/dream/sleep.js +122 -8
  33. package/src/dream.js +1354 -1347
  34. package/src/index.js +115 -14
  35. package/src/inject.js +6 -2
  36. package/src/lang.js +15 -4
  37. package/src/local-embedder.js +44 -6
  38. package/src/maintenance.js +142 -0
  39. package/src/mirror.js +147 -15
  40. package/src/recall-stats.js +52 -2
  41. package/src/scope.js +34 -6
  42. package/src/service.js +86 -7
  43. package/src/settings.js +6 -3
  44. package/src/store.js +248 -21
  45. package/src/tools.js +27 -9
  46. package/src/write-admission.js +259 -0
  47. package/test/api.test.js +15 -2
  48. package/test/client.test.js +90 -5
  49. package/test/content-hash.test.js +204 -0
  50. package/test/document-dir.test.js +233 -0
  51. package/test/document.test.js +151 -3
  52. package/test/inject-parent-gate.test.js +106 -0
  53. package/test/inject-pools.test.js +8 -3
  54. package/test/lastrunat-persist.test.js +101 -0
  55. package/test/llm-audit.test.js +136 -0
  56. package/test/local-embedder.test.js +45 -2
  57. package/test/maintenance.test.js +391 -0
  58. package/test/mirror-types.test.js +208 -0
  59. package/test/recall-evals.test.js +18 -0
  60. package/test/recall-stats.test.js +66 -0
  61. package/test/sleep.test.js +117 -0
  62. package/test/write-admission.test.js +345 -0
package/src/store.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { statSync } from "node:fs";
4
+ import { contentHashOf } from "./content-hash.js";
3
5
 
4
6
  const SCHEMA = `
5
7
  CREATE TABLE IF NOT EXISTS memories (
@@ -13,6 +15,7 @@ CREATE TABLE IF NOT EXISTS memories (
13
15
  archived INTEGER NOT NULL DEFAULT 0,
14
16
  source TEXT,
15
17
  content_history TEXT,
18
+ content_hash TEXT,
16
19
  embedding TEXT,
17
20
  epistemic_status TEXT NOT NULL DEFAULT 'subjective',
18
21
  last_accessed_at TEXT,
@@ -192,7 +195,8 @@ CREATE TABLE IF NOT EXISTS llm_audit_logs (
192
195
  status TEXT NOT NULL, -- success | error | skipped
193
196
  error_message TEXT,
194
197
  related_memory_ids TEXT, -- JSON: ids the call operated on
195
- metadata TEXT -- JSON: free-form extras
198
+ metadata TEXT, -- JSON: free-form extras
199
+ session_key TEXT -- #254 写入准入的会话键(LLM 调用行恒 NULL)
196
200
  );
197
201
  CREATE INDEX IF NOT EXISTS idx_llm_audit_timestamp ON llm_audit_logs(timestamp);
198
202
  CREATE INDEX IF NOT EXISTS idx_llm_audit_source ON llm_audit_logs(trigger_source);
@@ -405,6 +409,7 @@ function toRow(row) {
405
409
  type: row.type,
406
410
  title: row.title,
407
411
  content: row.content,
412
+ content_hash: row.content_hash ?? undefined,
408
413
  tags: parseTags(row.tags),
409
414
  importance: row.importance,
410
415
  forgotten: row.forgotten === 1,
@@ -607,7 +612,9 @@ function toLlmAudit(row) {
607
612
  status: row.status,
608
613
  error_message: row.error_message ?? undefined,
609
614
  related_memory_ids: parseJsonArray(row.related_memory_ids),
610
- metadata
615
+ metadata,
616
+ // #254 写入准入的会话键;LLM 调用行恒 NULL。
617
+ session_key: row.session_key ?? undefined
611
618
  };
612
619
  }
613
620
 
@@ -711,6 +718,34 @@ export function createStore(path) {
711
718
  addColumn("memories", "workspace_scope_source", "ALTER TABLE memories ADD COLUMN workspace_scope_source TEXT");
712
719
  addColumn("memories", "scope_decided_at", "ALTER TABLE memories ADD COLUMN scope_decided_at TEXT");
713
720
 
721
+ // #254 计量信号:内容归一化哈希(口径与动机写在 content-hash.js 的文件头)。派生
722
+ // 列,不参与任何判定——只给「同一内容又被写了一次」当等值锚。索引建在加列之后:
723
+ // 老库打开时 SCHEMA 的 CREATE TABLE 对既有表不生效,列还不存在,把索引写进 SCHEMA
724
+ // 会直接报 no such column(与下方 llm_audit_logs.session_key 同理)。
725
+ addColumn("memories", "content_hash", "ALTER TABLE memories ADD COLUMN content_hash TEXT");
726
+ // 列序是 content_hash 打头:哈希几乎唯一,等值 seek 就已收窄到候选行,type 只当同
727
+ // 一次 seek 里的第二列过滤;反过来(type 打头)下面的存量回填就得全表扫——每次打开
728
+ // 都要把全部正文读一遍。
729
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memories_content_hash ON memories(content_hash, type)");
730
+ // 存量回填:老库(含归档区)的行都没有值,而「归档行纳入去重候选集」这条口径正是
731
+ // 要靠既有归档行出数据。只算 NULL 行、幂等;改口径不是补 NULL 能修好的,要整列重算
732
+ // (见 content-hash.js 的文件头)。空标题空正文的行算不出锚,跳过不写——否则每次
733
+ // 打开都要为这些行再跑一遍 UPDATE。
734
+ {
735
+ const updates = db
736
+ .prepare("SELECT id, title, content FROM memories WHERE content_hash IS NULL")
737
+ .all()
738
+ .map((row) => [contentHashOf(row), row.id])
739
+ .filter(([hash]) => hash);
740
+ if (updates.length) {
741
+ const stmt = db.prepare("UPDATE memories SET content_hash = ? WHERE id = ? AND content_hash IS NULL");
742
+ // 一个事务包住整批:逐条自动提交是每行一次 WAL 提交,几千行的存量库上纯属浪费。
743
+ runAtomically(() => {
744
+ for (const [hash, id] of updates) stmt.run(hash, id);
745
+ });
746
+ }
747
+ }
748
+
714
749
  // Legacy dream_runs without policy_epoch → backfill with the default epoch.
715
750
  addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
716
751
  addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
@@ -724,6 +759,13 @@ export function createStore(path) {
724
759
  addColumn("mirror_state", "applied_generation", "ALTER TABLE mirror_state ADD COLUMN applied_generation INTEGER NOT NULL DEFAULT 0");
725
760
  addColumn("mirror_state", "type_status", "ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
726
761
 
762
+ // #254 写入准入(第一阶段只计量):准入决策行按会话聚合——「会话内新建了几行」
763
+ // 与「同话题重复间隔」都靠它算出来,所以会话键必须是可等值查询的列,而不是塞在
764
+ // metadata 里做 JSON 匹配。列可空且不带 DEFAULT(存量行零重写)。索引建在加列
765
+ // 之后:老库打开时列还不存在(SCHEMA 的 CREATE TABLE 只对新建库生效)。
766
+ addColumn("llm_audit_logs", "session_key", "ALTER TABLE llm_audit_logs ADD COLUMN session_key TEXT");
767
+ db.exec("CREATE INDEX IF NOT EXISTS idx_llm_audit_session ON llm_audit_logs(session_key);");
768
+
727
769
  // Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
728
770
  // (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
729
771
  // enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
@@ -942,6 +984,8 @@ export function createStore(path) {
942
984
  const importance = Number.isInteger(memory.importance) ? memory.importance : 3;
943
985
  const evidence = Array.isArray(memory.evidence) ? JSON.stringify(memory.evidence) : null;
944
986
  const docPath = typeof memory.doc_path === "string" && memory.doc_path.trim() ? memory.doc_path : null;
987
+ // #254 计量锚:由本行的 title/content 派生,调用方传什么都以这里算出的为准。
988
+ const contentHash = contentHashOf(memory);
945
989
  const embedding = Array.isArray(memory.embedding) && memory.embedding.length
946
990
  ? JSON.stringify(memory.embedding)
947
991
  : null;
@@ -952,8 +996,8 @@ export function createStore(path) {
952
996
  : inferEpistemicStatus(memory);
953
997
  runAtomically(() => {
954
998
  db.prepare(
955
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, doc_path, created_at, updated_at)
956
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
999
+ `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, doc_path, content_hash, created_at, updated_at)
1000
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
957
1001
  ).run(
958
1002
  id,
959
1003
  type,
@@ -976,6 +1020,7 @@ export function createStore(path) {
976
1020
  normalizeOccurredAt(memory.occurred_at),
977
1021
  evidence,
978
1022
  docPath,
1023
+ contentHash,
979
1024
  now,
980
1025
  now
981
1026
  );
@@ -988,6 +1033,41 @@ export function createStore(path) {
988
1033
  return getById(id);
989
1034
  }
990
1035
 
1036
+ /**
1037
+ * #254 计量:与本次写入「归一化内容哈希」相同的既有行(去重候选集)。
1038
+ * 结构化锚先收窄成本:按哈希等值 seek(走 idx_memories_content_hash,哈希几乎唯
1039
+ * 一,落在候选集上的行只有几条),再按 type 与 scope 三维过滤——不是全表扫、也不
1040
+ * 逐行比哈希。scope 用 IS 比(NULL 安全):未标注行互相匹配、与已标注行不匹配,
1041
+ * 与 service.js 去重键(scopeKeyOf)同口径;入参先按 store 自己的 normalizeScopeText
1042
+ * 归一,免得调用方传 " foo " 就漏配。
1043
+ * 归档行与已遗忘行都在集内:#275 的分界是「出口止体积、不止重复」——被质量闸归档
1044
+ * 的同一个事实必须仍能判成重复,否则同样的内容再写一次又是一条新行(#254 拍板:
1045
+ * 归档行纳入去重候选集)。
1046
+ * 排序即取舍:活跃行排在归档/遗忘行之前,再按 updated_at 倒序。LIMIT 先于调用方的
1047
+ * 「活区优先」判断执行,而归档动作本身会顶 updated_at——纯按时间倒序时,同键命中一
1048
+ * 旦超过窗口宽度,活跃行就被归档行挤出候选集,调用方只能看到归档命中,把「活跃重复」
1049
+ * 误报成「归档重复」,恰好把这条信号要分流的两类弄反。
1050
+ * @returns {Array<{id: string, archived: boolean, forgotten: boolean}>} 活跃行优先,其后按最近写入
1051
+ */
1052
+ function findContentHashMatches({ type, hash, agent_scope: agentScope, workspace_scope: workspaceScope, sensitivity, limit = 10 } = {}) {
1053
+ if (!hash || !type) return [];
1054
+ const lim = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 50) : 10;
1055
+ const rows = db.prepare(
1056
+ `SELECT id, archived, forgotten FROM memories
1057
+ WHERE type = ? AND content_hash = ?
1058
+ AND agent_scope IS ? AND workspace_scope IS ? AND sensitivity IS ?
1059
+ ORDER BY archived ASC, forgotten ASC, updated_at DESC, id LIMIT ?`
1060
+ ).all(
1061
+ type,
1062
+ hash,
1063
+ normalizeScopeText(agentScope),
1064
+ normalizeScopeText(workspaceScope),
1065
+ normalizeScopeText(sensitivity),
1066
+ lim
1067
+ );
1068
+ return rows.map((row) => ({ id: row.id, archived: row.archived === 1, forgotten: row.forgotten === 1 }));
1069
+ }
1070
+
991
1071
  function update(id, patch) {
992
1072
  const existing = getById(id);
993
1073
  if (!existing) throw new Error(`memory not found: ${id}`);
@@ -1034,13 +1114,18 @@ export function createStore(path) {
1034
1114
  const nextDecidedAt = patch.scope_decided_at !== undefined
1035
1115
  ? normalizeOccurredAt(patch.scope_decided_at)
1036
1116
  : (existing.scope_decided_at ?? null);
1117
+ // #254:标题或正文变了,锚跟着重算——它是当前内容的派生物,留旧值等于让「这行
1118
+ // 现在装的什么」和标记对不上。
1119
+ const nextTitle = patch.title ?? existing.title;
1120
+ const nextContent = patch.content ?? existing.content;
1121
+ const contentHash = contentHashOf({ title: nextTitle, content: nextContent });
1037
1122
  runAtomically(() => {
1038
1123
  db.prepare(
1039
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, agent_scope=?, workspace_scope=?, agent_scope_source=?, workspace_scope_source=?, scope_decided_at=?, evidence=?, updated_at=? WHERE id=?`
1124
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, agent_scope=?, workspace_scope=?, agent_scope_source=?, workspace_scope_source=?, scope_decided_at=?, evidence=?, content_hash=?, updated_at=? WHERE id=?`
1040
1125
  ).run(
1041
1126
  type,
1042
- patch.title ?? existing.title,
1043
- patch.content ?? existing.content,
1127
+ nextTitle,
1128
+ nextContent,
1044
1129
  JSON.stringify(patch.tags ?? existing.tags),
1045
1130
  Number.isInteger(patch.importance) ? patch.importance : existing.importance,
1046
1131
  patch.source !== undefined ? patch.source : (existing.source ?? null),
@@ -1054,6 +1139,7 @@ export function createStore(path) {
1054
1139
  nextWorkspaceSource,
1055
1140
  nextDecidedAt,
1056
1141
  evidence,
1142
+ contentHash,
1057
1143
  now,
1058
1144
  id
1059
1145
  );
@@ -1115,15 +1201,19 @@ export function createStore(path) {
1115
1201
  // dirty == false — recoverMirror sees no debt and the mirror stays stale.
1116
1202
  // Wrapping both in one transaction means a CAS miss rolls back cleanly too
1117
1203
  // (no write, no generation bump).
1204
+ // 同 update:#254 的锚随标题/正文重算(CAS 路径也改这两列)。
1205
+ const nextTitle = patch.title ?? existing.title;
1206
+ const nextContent = patch.content ?? existing.content;
1207
+ const contentHash = contentHashOf({ title: nextTitle, content: nextContent });
1118
1208
  let applied = false;
1119
1209
  runAtomically(() => {
1120
1210
  const result = db.prepare(
1121
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, updated_at=?
1211
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, content_hash=?, updated_at=?
1122
1212
  WHERE id=? AND updated_at=?`
1123
1213
  ).run(
1124
1214
  type,
1125
- patch.title ?? existing.title,
1126
- patch.content ?? existing.content,
1215
+ nextTitle,
1216
+ nextContent,
1127
1217
  JSON.stringify(patch.tags ?? existing.tags),
1128
1218
  Number.isInteger(patch.importance) ? patch.importance : existing.importance,
1129
1219
  patch.source !== undefined ? patch.source : (existing.source ?? null),
@@ -1131,6 +1221,7 @@ export function createStore(path) {
1131
1221
  qualityScore,
1132
1222
  embedding,
1133
1223
  epistemicStatus,
1224
+ contentHash,
1134
1225
  now,
1135
1226
  id,
1136
1227
  expectedUpdatedAt
@@ -1182,15 +1273,15 @@ export function createStore(path) {
1182
1273
  function demoteToSummary(id, summary, { minRefTimeMs } = {}) {
1183
1274
  let changed = false;
1184
1275
  runAtomically(() => {
1185
- const row = db.prepare("SELECT last_accessed_at, content, _full_content FROM memories WHERE id = ?").get(id);
1276
+ const row = db.prepare("SELECT title, last_accessed_at, content, _full_content FROM memories WHERE id = ?").get(id);
1186
1277
  if (!row || row._full_content) return;
1187
1278
  if (minRefTimeMs !== undefined && row.last_accessed_at) {
1188
1279
  const lastMs = Date.parse(row.last_accessed_at);
1189
1280
  if (lastMs >= minRefTimeMs) return; // touched after snapshot — still hot
1190
1281
  }
1191
1282
  db.prepare(
1192
- "UPDATE memories SET content = ?, _full_content = ?, updated_at = ? WHERE id = ?"
1193
- ).run(summary, row.content, nowIso(), id);
1283
+ "UPDATE memories SET content = ?, _full_content = ?, content_hash = ?, updated_at = ? WHERE id = ?"
1284
+ ).run(summary, row.content, contentHashOf({ title: row.title, content: summary }), nowIso(), id);
1194
1285
  incrementGeneration();
1195
1286
  changed = true;
1196
1287
  });
@@ -1201,11 +1292,11 @@ export function createStore(path) {
1201
1292
  function restoreContent(id) {
1202
1293
  let changed = false;
1203
1294
  runAtomically(() => {
1204
- const row = db.prepare("SELECT content, _full_content FROM memories WHERE id = ?").get(id);
1295
+ const row = db.prepare("SELECT title, content, _full_content FROM memories WHERE id = ?").get(id);
1205
1296
  if (!row || !row._full_content) return;
1206
1297
  db.prepare(
1207
- "UPDATE memories SET content = ?, _full_content = NULL, updated_at = ? WHERE id = ?"
1208
- ).run(row._full_content, nowIso(), id);
1298
+ "UPDATE memories SET content = ?, _full_content = NULL, content_hash = ?, updated_at = ? WHERE id = ?"
1299
+ ).run(row._full_content, contentHashOf({ title: row.title, content: row._full_content }), nowIso(), id);
1209
1300
  incrementGeneration();
1210
1301
  changed = true;
1211
1302
  });
@@ -1532,6 +1623,22 @@ export function createStore(path) {
1532
1623
  return rows.map(toDreamRun);
1533
1624
  }
1534
1625
 
1626
+ // Issue #89:上次实际开跑时刻(epoch ms),从审计表恢复——调度器的 lastRunAt
1627
+ // 只活在内存里,进程重启即归零,闸门对新实例放行 → 重启后立刻连发。审计表
1628
+ // 本来就逐 run 落库(failed/degraded 也算 run),直接读它就是事实源,零迁移。
1629
+ function lastDreamRunAt(runType = null) {
1630
+ const row = runType
1631
+ ? db.prepare(
1632
+ "SELECT created_at FROM dream_runs WHERE run_type = ? ORDER BY created_at DESC, id LIMIT 1"
1633
+ ).get(runType)
1634
+ : db.prepare(
1635
+ "SELECT created_at FROM dream_runs ORDER BY created_at DESC, id LIMIT 1"
1636
+ ).get();
1637
+ if (!row?.created_at) return 0;
1638
+ const ms = Date.parse(row.created_at);
1639
+ return Number.isFinite(ms) ? ms : 0;
1640
+ }
1641
+
1535
1642
  /**
1536
1643
  * Latest ruling-rule version seen on the audit trail. policy_epoch is a config
1537
1644
  * value stamped onto each run by the caller; reading the newest row's epoch
@@ -1750,8 +1857,8 @@ export function createStore(path) {
1750
1857
  db.prepare(
1751
1858
  `INSERT INTO llm_audit_logs (timestamp, trigger_source, operation_type, model_id,
1752
1859
  input_tokens, output_tokens, total_tokens, cost_usd, duration_ms, status,
1753
- error_message, related_memory_ids, metadata)
1754
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1860
+ error_message, related_memory_ids, metadata, session_key)
1861
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1755
1862
  ).run(
1756
1863
  entry.timestamp ?? now,
1757
1864
  entry.trigger_source,
@@ -1767,12 +1874,13 @@ export function createStore(path) {
1767
1874
  JSON.stringify(entry.related_memory_ids ?? []),
1768
1875
  entry.metadata !== undefined
1769
1876
  ? (typeof entry.metadata === "string" ? entry.metadata : JSON.stringify(entry.metadata))
1770
- : null
1877
+ : null,
1878
+ entry.session_key ?? null
1771
1879
  );
1772
1880
  return toLlmAudit(db.prepare("SELECT * FROM llm_audit_logs ORDER BY id DESC LIMIT 1").get());
1773
1881
  }
1774
1882
 
1775
- function listLlmAudits({ limit = 50, offset = 0, source } = {}) {
1883
+ function listLlmAudits({ limit = 50, offset = 0, source, sessionKey } = {}) {
1776
1884
  const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1777
1885
  const clauses = [];
1778
1886
  const params = [];
@@ -1780,6 +1888,11 @@ export function createStore(path) {
1780
1888
  clauses.push("trigger_source = ?");
1781
1889
  params.push(source);
1782
1890
  }
1891
+ // #254 写入准入:会话内的话题回填只需要该会话的行(走 idx_llm_audit_session)。
1892
+ if (sessionKey) {
1893
+ clauses.push("session_key = ?");
1894
+ params.push(sessionKey);
1895
+ }
1783
1896
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1784
1897
  const rows = db.prepare(
1785
1898
  `SELECT * FROM llm_audit_logs ${where} ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`
@@ -1787,13 +1900,17 @@ export function createStore(path) {
1787
1900
  return rows.map(toLlmAudit);
1788
1901
  }
1789
1902
 
1790
- function countLlmAudits({ source } = {}) {
1903
+ function countLlmAudits({ source, sessionKey } = {}) {
1791
1904
  const clauses = [];
1792
1905
  const params = [];
1793
1906
  if (source) {
1794
1907
  clauses.push("trigger_source = ?");
1795
1908
  params.push(source);
1796
1909
  }
1910
+ if (sessionKey) {
1911
+ clauses.push("session_key = ?");
1912
+ params.push(sessionKey);
1913
+ }
1797
1914
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1798
1915
  return db.prepare(`SELECT count(*) AS c FROM llm_audit_logs ${where}`).get(...params).c;
1799
1916
  }
@@ -1842,6 +1959,106 @@ export function createStore(path) {
1842
1959
  return db.prepare("DELETE FROM llm_audit_logs WHERE timestamp < ?").run(before).changes;
1843
1960
  }
1844
1961
 
1962
+ // --- 存储生命周期:无损回收(#275 第一批)--------------------------------
1963
+ //
1964
+ // 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。
1965
+ // 删行不在本模块里(维护者拍板 3:裁列同意、删行不同意)——dream_runs 的骨架、
1966
+ // LLM 决策原文与 receipt 永不删。
1967
+
1968
+ /**
1969
+ * 历史 run 的输入快照统计(#275 A 项,dry-run 用)。
1970
+ *
1971
+ * `input` 这一列的含义**按 run_type 分叉**:auto / sleep 的 run 存的是当时的记忆库
1972
+ * 快照(可由记忆库重建),organize 的 run 存的是 apply 的重放载荷——`organize.js`
1973
+ * 的 apply 直接读它(`snapshot = Array.isArray(report.input) ? report.input : []`),
1974
+ * 置空会让 apply 找不到候选、静默什么都不做却照写回执并盖上 applied_at,那份报告
1975
+ * 从此永远重放不了。所以 organize 行不在可清范围里(同一列,两种语义,只能按 type 分)。
1976
+ *
1977
+ * `bytes` 是列文本大小,只作上界(真实释放看 VACUUM 前后)。
1978
+ * @returns {{runs: number, bytes: number}}
1979
+ */
1980
+ function dreamRunInputStats(before) {
1981
+ const row = db.prepare(
1982
+ `SELECT count(*) AS c, COALESCE(SUM(LENGTH(input)), 0) AS b
1983
+ FROM dream_runs
1984
+ WHERE input IS NOT NULL AND created_at < ? AND run_type != 'organize'`
1985
+ ).get(before);
1986
+ return { runs: row.c, bytes: row.b };
1987
+ }
1988
+
1989
+ /** 置空历史 run 的输入快照(organize 的重放载荷除外)。返回实际改动的行数。 */
1990
+ function clearDreamRunInputs(before) {
1991
+ return db.prepare(
1992
+ `UPDATE dream_runs SET input = NULL
1993
+ WHERE input IS NOT NULL AND created_at < ? AND run_type != 'organize'`
1994
+ ).run(before).changes;
1995
+ }
1996
+
1997
+ /**
1998
+ * 归档行仍带的向量统计(#275 B 项)。检索 SQL 恒带 `archived = 0`,所以这部分
1999
+ * 向量按定义不可达,清掉不改变任何检索结果。
2000
+ * @returns {{rows: number, bytes: number}}
2001
+ */
2002
+ function archivedEmbeddingStats() {
2003
+ const row = db.prepare(
2004
+ `SELECT count(*) AS c, COALESCE(SUM(LENGTH(embedding)), 0) AS b
2005
+ FROM memories WHERE archived = 1 AND embedding IS NOT NULL`
2006
+ ).get();
2007
+ return { rows: row.c, bytes: row.b };
2008
+ }
2009
+
2010
+ /** 清掉归档行的向量。返回实际改动行数。 */
2011
+ function clearArchivedEmbeddings() {
2012
+ const changed = db.prepare(
2013
+ "UPDATE memories SET embedding = NULL WHERE archived = 1 AND embedding IS NOT NULL"
2014
+ ).run().changes;
2015
+ // Issue #202 的单条失效点在 setEmbedding 里,批量清走不到它——整表清缓存最省事,
2016
+ // 代价只是活跃行下次检索多解析一次(FIFO 上限 4000,自愈)。
2017
+ if (changed > 0) embeddingCache.clear();
2018
+ return changed;
2019
+ }
2020
+
2021
+ /**
2022
+ * 库文件与页统计(#275 报告口径):回收收益按 VACUUM 前后体积量,不按列字节估——
2023
+ * 实际释放来自溢出页与索引页的回收,列文本大小只是上界。
2024
+ *
2025
+ * 体积是**磁盘足迹**:WAL 模式下主文件之外还有 `-wal` / `-shm`,只量主文件会把
2026
+ * 「刚清完还没落盘」的那部分算漏(实测过:VACUUM 后主文件可能一动不动,要等一次
2027
+ * checkpoint)。所以三份一起量;`:memory:` 库没有文件。
2028
+ */
2029
+ function storageStats() {
2030
+ const pageSize = db.prepare("PRAGMA page_size").get().page_size;
2031
+ const pageCount = db.prepare("PRAGMA page_count").get().page_count;
2032
+ const freelistCount = db.prepare("PRAGMA freelist_count").get().freelist_count;
2033
+ const sizeOf = (p) => {
2034
+ try {
2035
+ return statSync(p).size;
2036
+ } catch {
2037
+ return 0;
2038
+ }
2039
+ };
2040
+ let fileBytes = null;
2041
+ if (typeof path === "string" && path !== ":memory:") {
2042
+ fileBytes = sizeOf(path) + sizeOf(`${path}-wal`) + sizeOf(`${path}-shm`);
2043
+ }
2044
+ return { path: typeof path === "string" ? path : null, pageSize, pageCount, freelistCount, fileBytes };
2045
+ }
2046
+
2047
+ /**
2048
+ * 整库 VACUUM(#275 的手动步骤):代价 O(库大小) 且需要排他写锁,所以只在显式
2049
+ * 入口里跑,绝不挂启动路径、也不开 auto_vacuum。
2050
+ *
2051
+ * 收尾补一次 `wal_checkpoint(TRUNCATE)`:WAL 模式下 VACUUM 重排的是主文件里的页,
2052
+ * 已回收的空间可能还挂在 WAL 里,不 checkpoint 就量不到体积下降——报告口径要的是
2053
+ * 真实释放量,量不到等于没做(评审实测:VACUUM 后主文件长度不变,checkpoint 后才降)。
2054
+ */
2055
+ function vacuum() {
2056
+ const started = Date.now();
2057
+ db.exec("VACUUM");
2058
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
2059
+ return { duration_ms: Date.now() - started };
2060
+ }
2061
+
1845
2062
  // --- failure memories ----------------------------------------------------
1846
2063
 
1847
2064
  /**
@@ -2469,6 +2686,8 @@ export function createStore(path) {
2469
2686
  saveDocument,
2470
2687
  update,
2471
2688
  compareAndUpdate,
2689
+ // #254:写入准入的内容哈希候选集(只读)。
2690
+ findContentHashMatches,
2472
2691
  remove,
2473
2692
  setForget,
2474
2693
  setArchived,
@@ -2486,6 +2705,7 @@ export function createStore(path) {
2486
2705
  needsEmbedding,
2487
2706
  searchVector,
2488
2707
  saveDreamRun,
2708
+ lastDreamRunAt,
2489
2709
  getDreamRun,
2490
2710
  listDreamRuns,
2491
2711
  getLatestPolicyEpoch,
@@ -2506,6 +2726,13 @@ export function createStore(path) {
2506
2726
  countLlmAudits,
2507
2727
  getLlmAuditStats,
2508
2728
  deleteOldLlmAudits,
2729
+ // 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。
2730
+ dreamRunInputStats,
2731
+ clearDreamRunInputs,
2732
+ archivedEmbeddingStats,
2733
+ clearArchivedEmbeddings,
2734
+ storageStats,
2735
+ vacuum,
2509
2736
  saveFailure,
2510
2737
  listFailures,
2511
2738
  getFailureStats,
package/src/tools.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
- import { createScopeResolver, normalizeExplicitScope } from "./scope.js";
2
+ import { createScopeResolver, normalizeExplicitScope, sessionKeyOf } from "./scope.js";
3
3
  import { describeLocalRuntime, resolveRuntimeEntry } from "./runtime/loader.js";
4
4
  import { defaultRuntimeDir } from "./runtime/layout.js";
5
5
  import { hostModulesDir, loadRuntimeManifest, provisionRuntime } from "./runtime/provision.js";
6
6
  import { matchesPlatform } from "./runtime/closure.js";
7
7
  import { verifyPayload } from "./runtime/verify.js";
8
8
  import { TOOL_GUIDE } from "./guide.js";
9
+ import { injectChildEnabled } from "./config.js";
9
10
 
10
11
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
11
12
  // Per-registry tool-name registry: guards against duplicate registration on
@@ -84,11 +85,13 @@ export function createTools(ctx, service, config, embedder) {
84
85
  // flag 关闭时恒返回 null——写入不标注,检索不加权,行为与 A1 前完全一致。
85
86
  // logger 透传:registry 反查失败时 warnOnce 才有出口(否则静默降级无观测)。
86
87
  const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
87
- // #249 第一批:能力说明(injectGuidanceEnabled)开启时,给「何时用」最有歧义
88
- // 的两个工具补一句判断指引。工具描述常驻、不进每轮上下文,这个位子零注入
89
- // 成本;关闭时描述逐字节不变,也不动其他工具的文案。
88
+ // #249 能力说明(injectGuidanceEnabled):开启时给「何时用」最有歧义的两个
89
+ // 工具补一句判断指引。工具描述常驻、不进每轮上下文,这个位子零注入成本;
90
+ // 关闭时描述逐字节不变,也不动其他工具的文案。
91
+ // 它是注入父开关的子项(#249 第二批):`autoInject` 关掉时这里同样不生效——
92
+ // 工具描述是这个子项唯一住在注入器之外的落点,不在闸门内就漏了。
90
93
  const withToolGuide = (name, description) =>
91
- config?.injectGuidanceEnabled === true && TOOL_GUIDE[name] ? `${description}${TOOL_GUIDE[name]}` : description;
94
+ injectChildEnabled(config, "injectGuidanceEnabled") && TOOL_GUIDE[name] ? `${description}${TOOL_GUIDE[name]}` : description;
92
95
 
93
96
  // 复核项 4(issue #170):strictScope 下他 scope(explicit)的行对工具侧按
94
97
  // 「不存在」处理——update/delete 与 memory_get 同款无存在性泄漏。strictScope
@@ -162,6 +165,10 @@ export function createTools(ctx, service, config, embedder) {
162
165
  ? { value: normalizeExplicitScope(args.workspace_scope), source: "explicit" }
163
166
  : autoStamping && scope ? { value: scope.workspace_scope, source: "auto" } : null;
164
167
  const { action, memory } = service.saveWithDedupe({
168
+ // #254 写入准入(第一阶段只计量)的计数单位:会话键作为瞬时字段随写入
169
+ // 传递(与 _mergeInto / _overwrite 同款约定,不落库)。缺会话身份的宿主
170
+ // 返回 null = 这次写入不进预算。
171
+ _sessionKey: sessionKeyOf(exec),
165
172
  type: args.type,
166
173
  title: args.title,
167
174
  content: args.content,
@@ -482,7 +489,11 @@ export function createTools(ctx, service, config, embedder) {
482
489
  "intersects evidence with real memory ids (all-fabricated evidence is rejected; unknown ids are dropped and the row " +
483
490
  "is tagged evidence_degraded), and dedupes: re-registering the same path or title supersedes the old row (the old " +
484
491
  "file is never touched; content_history stays traceable), while a merely near-duplicate summary of a different " +
485
- "document row is rejected — update that row instead. Use for 'where is the conclusion doc for this project?' " +
492
+ "document row is rejected — update that row instead. On success the absorbed atomic evidence rows leave the active " +
493
+ "face in the same transaction (recoverable, nothing deleted); constraint/preference rows and other " +
494
+ "document/summary rows are never auto-archived, and keep_evidence_active: true opts out. Ids this same document " +
495
+ "absorbed on an earlier version still count as its evidence, so re-registering a new version is not read as " +
496
+ "fabricated evidence. Use for 'where is the conclusion doc for this project?' " +
486
497
  "lookups; atomic facts still go to memory_save.",
487
498
  parameters: {
488
499
  path: { type: "string", required: true, description: "Absolute path of the document file (~ is expanded); must already exist as a non-empty regular file. The full text stays agent-owned — this pipeline never touches it." },
@@ -490,7 +501,8 @@ export function createTools(ctx, service, config, embedder) {
490
501
  summary: { type: "string", required: true, description: "One-paragraph summary stored in the DB and used for injection (any language)" },
491
502
  tags: { type: "array", items: { type: "string" }, description: "Optional tags (English recommended)" },
492
503
  importance: { type: "integer", description: "1-5 (default 3); the summary row injects at the next-priority tier within documentInjectBudget when importance >= threshold" },
493
- evidence: { type: "array", items: { type: "string" }, description: "Memory ids this document is grounded in; each is verified against the store (fabricated evidence is rejected; unknown/archived ids are dropped and the row is tagged evidence_degraded)" },
504
+ evidence: { type: "array", items: { type: "string" }, description: "Memory ids this document is grounded in (atomic facts, not other pointer rows); each is verified against the store — fabricated evidence is rejected, unknown/archived ids are dropped and the row is tagged evidence_degraded, except ids this same document absorbed on an earlier version, which stay its evidence. On success the absorbed atomic rows leave the active face unless keep_evidence_active is true." },
505
+ keep_evidence_active: { type: "boolean", description: "Opt out of archiving the evidence rows absorbed by this document. Default false: after a successful registration the absorbed atomic rows leave the active face (recoverable, nothing deleted; constraint/preference rows and other document/summary rows are never auto-archived)." },
494
506
  source: { type: "string", description: "Optional provenance" },
495
507
  sensitivity: { type: "string", description: "Optional sensitivity label (free-form, e.g. personal). Part of the supersede matching key — same path/title with a different sensitivity stays a separate document." },
496
508
  agent_scope: { type: "string", description: "Optional explicit agent-scope declaration (issue #170): 'global' or '*' makes this document visible to every agent; any other value narrows it to that label. Overrides the automatic carrier label for this write; honored even when automatic scope labeling is disabled." },
@@ -506,12 +518,13 @@ export function createTools(ctx, service, config, embedder) {
506
518
  superseded_id: { type: "string" },
507
519
  evidence_kept: { type: "integer", required: true },
508
520
  evidence_dropped: { type: "integer", required: true },
521
+ evidence_archived: { type: "integer", required: true },
509
522
  degraded: { type: "boolean", required: true }
510
523
  }
511
524
  },
512
525
  render: (_args, value) => {
513
526
  const sup = value.superseded_id ? ` (supersedes ${value.superseded_id})` : "";
514
- const ev = ` | evidence: ${value.evidence_kept} kept, ${value.evidence_dropped} dropped${value.degraded ? " [degraded]" : ""}`;
527
+ const ev = ` | evidence: ${value.evidence_kept} kept, ${value.evidence_dropped} dropped, ${value.evidence_archived} archived${value.degraded ? " [degraded]" : ""}`;
515
528
  return TEXT_OUTPUT(`document ${value.action}: ${value.id}${sup}${ev}`);
516
529
  }
517
530
  },
@@ -550,13 +563,18 @@ export function createTools(ctx, service, config, embedder) {
550
563
  ...(args.sensitivity !== undefined ? { sensitivity: args.sensitivity } : {}),
551
564
  ...(agentLabel ? { agent_scope: agentLabel.value, agent_scope_source: agentLabel.source } : {}),
552
565
  ...(workspaceLabel ? { workspace_scope: workspaceLabel.value, workspace_scope_source: workspaceLabel.source } : {})
553
- }, { hiddenEvidenceIds: hiddenEvidence });
566
+ }, {
567
+ hiddenEvidenceIds: hiddenEvidence,
568
+ // 默认开着归档(#275 拍板 5);agent 显式要保留活跃面时走 keep_evidence_active。
569
+ archiveEvidence: args.keep_evidence_active !== true
570
+ });
554
571
  return {
555
572
  action: result.action,
556
573
  id: result.memory.id,
557
574
  ...(result.superseded ? { superseded_id: result.superseded.id } : {}),
558
575
  evidence_kept: result.evidence_kept,
559
576
  evidence_dropped: result.evidence_dropped,
577
+ evidence_archived: result.evidence_archived,
560
578
  degraded: result.degraded
561
579
  };
562
580
  }