@kira905/dsh-memory-toolkit 0.1.3 → 0.2.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.en.md +225 -2
  3. package/README.md +180 -2
  4. package/lib/deploy-guard.mjs +463 -275
  5. package/lib/guard-http.mjs +59 -62
  6. package/lib/pitfall-form-map.mjs +94 -84
  7. package/lib/pitfall-score.mjs +262 -185
  8. package/lib/session-file-name.mjs +116 -100
  9. package/lib/zstd-frames.mjs +88 -67
  10. package/memory-db-tools/README.md +29 -1
  11. package/memory-db-tools/audit-analyze.mjs +1 -1
  12. package/memory-db-tools/audit-export.mjs +60 -16
  13. package/memory-db-tools/audit-struct-probe.mjs +29 -8
  14. package/memory-db-tools/lib/known-devices.mjs +137 -0
  15. package/memory-engine/README.md +58 -4
  16. package/memory-engine/bench/bench.mjs +1 -1
  17. package/memory-engine/cli/band-sentinel.mjs +164 -0
  18. package/memory-engine/cli/distill-rollback.mjs +112 -44
  19. package/memory-engine/cli/distill-sessions.mjs +237 -0
  20. package/memory-engine/cli/distill.mjs +196 -95
  21. package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
  22. package/memory-engine/cli/doc-index.mjs +47 -38
  23. package/memory-engine/cli/domain-tags.mjs +246 -162
  24. package/memory-engine/cli/layer-migrate.mjs +176 -0
  25. package/memory-engine/cli/layer-move.mjs +189 -0
  26. package/memory-engine/cli/pitfall-hook.mjs +136 -70
  27. package/memory-engine/cli/recall.mjs +243 -110
  28. package/memory-engine/cli/selfcheck.mjs +93 -44
  29. package/memory-engine/cli/serve-start.mjs +221 -140
  30. package/memory-engine/cli/sweep.mjs +83 -44
  31. package/memory-engine/cli/sync.mjs +93 -57
  32. package/memory-engine/config/audit-devices.json +6 -0
  33. package/memory-engine/config/bands.json +7 -0
  34. package/memory-engine/config/layers.json +7 -0
  35. package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
  36. package/memory-engine/dict/lexicon-config.json +11 -0
  37. package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
  38. package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
  39. package/memory-engine/lib/band-sentinel.mjs +417 -0
  40. package/memory-engine/lib/cli-strict.mjs +128 -0
  41. package/memory-engine/lib/cli-util.mjs +147 -125
  42. package/memory-engine/lib/config.mjs +83 -96
  43. package/memory-engine/lib/dirty-ledger.mjs +127 -94
  44. package/memory-engine/lib/distill-rollback.mjs +173 -133
  45. package/memory-engine/lib/distill.mjs +878 -585
  46. package/memory-engine/lib/doc-index-store.mjs +208 -235
  47. package/memory-engine/lib/doc-index.mjs +351 -350
  48. package/memory-engine/lib/doc-scan.mjs +178 -156
  49. package/memory-engine/lib/doc-summarize.mjs +220 -205
  50. package/memory-engine/lib/domain-tags.mjs +358 -277
  51. package/memory-engine/lib/embedder.mjs +175 -194
  52. package/memory-engine/lib/hashes.mjs +22 -7
  53. package/memory-engine/lib/layer-migrate.mjs +330 -0
  54. package/memory-engine/lib/layer-move.mjs +307 -0
  55. package/memory-engine/lib/layers.mjs +191 -0
  56. package/memory-engine/lib/lexicon-recall.mjs +473 -0
  57. package/memory-engine/lib/memory-source.mjs +78 -80
  58. package/memory-engine/lib/pitfall-source.mjs +115 -114
  59. package/memory-engine/lib/policy-source.mjs +279 -261
  60. package/memory-engine/lib/recall-log.mjs +285 -0
  61. package/memory-engine/lib/recall.mjs +565 -240
  62. package/memory-engine/lib/selfcheck.mjs +142 -106
  63. package/memory-engine/lib/session-distill.mjs +603 -0
  64. package/memory-engine/lib/session-source.mjs +421 -0
  65. package/memory-engine/lib/sweep.mjs +107 -130
  66. package/memory-engine/lib/sync.mjs +110 -140
  67. package/memory-engine/lib/tick-hook.mjs +123 -148
  68. package/memory-engine/lib/vectors-store.mjs +213 -188
  69. package/memory-engine/test/12-layering.test.mjs +539 -0
  70. package/memory-engine/test/13-session-distill.test.mjs +616 -0
  71. package/memory-engine/tools/bridge-smoke.mjs +94 -55
  72. package/memory-engine/tools/fetch-model.mjs +167 -87
  73. package/memory-engine/tools/smoke-recall.mjs +94 -38
  74. package/memory-engine/tools/verify-recall-chain.mjs +413 -0
  75. package/package.json +1 -1
@@ -1,167 +1,196 @@
1
- // 记忆引擎 v2 · S3c —— `memories_dirty` 台账(兜底 hash 轮转的**产物**)
1
+ // `memories_dirty` 台账:兜底轮转扫出来的「正文被外部改过」清单。
2
2
  //
3
- // 为什么要有它(方案 §3.5 输入行「v3 补漏(关 I5)」):
4
- // A7 实测 5 个脚本 41 处 `UPDATE memories SET text=?`,其中 `apply-pinned-refine.mjs:110`
5
- // 改 text **不动 `updated_at`** ⇒ 这类被勘误的记忆**永远不在 24h 增量窗口内**。
6
- // 方案给的修法 = 把「§3.1 全表 `text_hash` 段轮转」的**产物**(差异清单)作为提炼 job 的补充输入源。
3
+ // 为什么必须落盘:轮转一旦发现差异,就会顺手把向量和 text_hash 刷成新的,差异信号当场消失;
4
+ // 那一刻要是没人看见,这条改动就再也不会出现在任何增量窗口里。所以差异一出现就先记账,
5
+ // 提炼侧日后按台账把漏掉的输入补回来。
7
6
  //
8
- // 关键设计:差异清单必须**持久化**,不能只在内存里过一遍 ——
9
- // 轮转(`lib/sweep.mjs`)发现差异后会**立刻重编码并更新 `vectors.text_hash`**,
10
- // 差异信号当场消失;若当时没人消费,这条改动就永久消失在增量窗口之外(正是要治的病)。
11
- // ⇒ 检测到差异的**那一刻**就落台账;提炼 job 事后按台账补齐输入。
12
- //
13
- // 落点:`vectors.db`(独立向量库,`sync:false` 不进任何仓,可重建)。
14
- // 放这里而不是新开 state 文件:它**本来就是**向量层的派生产物(与 `vectors.text_hash` 同源),
15
- // 且向量库随 `~/.dsh/memory-vectors/` 一起被清理/重建,台账不会成为孤儿。
16
- //
17
- // 语义:一条 = 一个 `ref_id`(memory.id)。
18
- // · `kind='mismatch'`:正文哈希与向量库记录不符 ⇒ **正文被改过**(本台账的唯一入账类型)
19
- // · `consumed_at`:提炼 job 消费过(= 已并入某次提炼输入)
20
- // ⚠️ **不删行**:删了就没法回答"这条改动是谁消费的";保留 + 定期 prune。
7
+ // 放哪儿:和向量库同一只库(它本来就是向量层的派生物,库被清理/重建时一起走,不会变成孤儿)。
8
+ // 一行 = 一个 ref_id;consumed_at 只表示"提炼侧取过了",行不删(要留痕),靠定期 prune 收掉。
9
+
21
10
  import { DatabaseSync } from 'node:sqlite';
22
11
 
23
- /** 台账 DDL(幂等;与 `vectors-store.mjs` 同库,加表不前向破坏既有表)。 */
12
+ const TABLE = 'memories_dirty';
13
+ const COLUMNS = 'ref_id, kind, old_hash, new_hash, first_at, last_at, hits, consumed_at';
14
+
15
+ /** 建表语句(幂等;与向量表同库,只加表、不动既有表)。 */
24
16
  export const DIRTY_DDL = `
25
17
  CREATE TABLE IF NOT EXISTS memories_dirty (
26
- ref_id TEXT NOT NULL PRIMARY KEY,
27
- kind TEXT NOT NULL,
28
- old_hash TEXT,
29
- new_hash TEXT,
30
- first_at INTEGER NOT NULL,
31
- last_at INTEGER NOT NULL,
32
- hits INTEGER NOT NULL DEFAULT 1,
18
+ ref_id TEXT NOT NULL PRIMARY KEY,
19
+ kind TEXT NOT NULL,
20
+ old_hash TEXT,
21
+ new_hash TEXT,
22
+ first_at INTEGER NOT NULL,
23
+ last_at INTEGER NOT NULL,
24
+ hits INTEGER NOT NULL DEFAULT 1,
33
25
  consumed_at INTEGER
34
26
  );
35
27
  CREATE INDEX IF NOT EXISTS memories_dirty_open ON memories_dirty (consumed_at, last_at);
36
28
  `;
37
29
 
38
- export const DIRTY_SCHEMA = 1;
30
+ export const DIRTY_SCHEMA = 1; // 表结构版本;列一旦增减就地加一
39
31
 
40
32
  export function ensureDirtySchema(db) {
41
33
  db.exec(DIRTY_DDL);
42
34
  return true;
43
35
  }
44
36
 
45
- function hasTable(db, name) {
46
- return Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(name));
37
+ // 表在不在:不在就当"还没轮转过",一律返回空结果,不抛错(老库也要能读)。
38
+ function tablePresent(db) {
39
+ const hit = db.prepare("SELECT 1 AS hit FROM sqlite_master WHERE type = 'table' AND name = ?").get(TABLE);
40
+ return Boolean(hit);
41
+ }
42
+
43
+ // 三条热语句备一次就够:本模块的写入方都是短命脚本,连接不会活太久。
44
+ function prepareWrites(db) {
45
+ return {
46
+ peek: db.prepare(`SELECT ref_id, new_hash, consumed_at FROM ${TABLE} WHERE ref_id = ?`),
47
+ spawn: db.prepare(
48
+ `INSERT INTO ${TABLE} (ref_id, kind, old_hash, new_hash, first_at, last_at, hits, consumed_at)
49
+ VALUES (?, ?, ?, ?, ?, ?, 1, NULL)`,
50
+ ),
51
+ bump: db.prepare(
52
+ `UPDATE ${TABLE} SET kind = ?, old_hash = ?, new_hash = ?, last_at = ?, hits = hits + 1, consumed_at = ?
53
+ WHERE ref_id = ?`,
54
+ ),
55
+ };
56
+ }
57
+
58
+ // 库里的一行 → 对外形状(驼峰;数值列统一转 number)
59
+ function toEntry(row) {
60
+ const consumed = row.consumed_at;
61
+ return {
62
+ refId: String(row.ref_id),
63
+ kind: row.kind,
64
+ oldHash: row.old_hash,
65
+ newHash: row.new_hash,
66
+ firstAt: Number(row.first_at),
67
+ lastAt: Number(row.last_at),
68
+ hits: Number(row.hits),
69
+ consumedAt: consumed == null ? null : Number(consumed),
70
+ };
47
71
  }
48
72
 
49
73
  /**
50
- * 记一批差异(**幂等 upsert**)。
74
+ * 记一批差异(幂等 upsert)。
51
75
  *
52
- * 重复记账的口径(写死,别再各写各的):
53
- * · 同一 `ref_id` 再次被检测到 → `hits+1` / `last_at` 刷新 / `new_hash` 刷新;
54
- * · **`new_hash` 与库里不同 = 正文又变了** ⇒ 把 `consumed_at` 清空(重新变成待消费),
55
- * 否则"消费过"的标记会把第二次改动永久吞掉(这正是台账最容易写错的一处)。
76
+ * 重复记账的口径只有两条,写死在这里:
77
+ * · 同一个 ref_id 再被扫到 ⇒ hits 加一、last_at 刷新、new_hash 跟着更新;
78
+ * · 只有当 new_hash 相对库里存的那份**又变了**,才把 consumed_at 清掉让它重新待消费。
79
+ * 反过来,同一条差异被反复扫到**不许**复活 —— 否则每轮轮转都会把它重新消费一遍。
56
80
  *
57
- * @param {object} db 向量库连接(写)
81
+ * @param {object} db 向量库连接(可写)
58
82
  * @param {Array<{refId:string|number, kind?:string, oldHash?:string|null, newHash?:string|null}>} items
59
83
  * @param {{now?:number}} [opts]
60
84
  * @returns {{recorded:number, reopened:number}}
61
85
  */
62
86
  export function recordDirty(db, items, opts = {}) {
63
- if (!items || !items.length) return { recorded: 0, reopened: 0 };
87
+ const batch = items || [];
88
+ if (!batch.length) return { recorded: 0, reopened: 0 };
64
89
  ensureDirtySchema(db);
65
- const now = Number(opts.now ?? Date.now());
66
- const sel = db.prepare('SELECT ref_id, new_hash, consumed_at FROM memories_dirty WHERE ref_id = ?');
67
- const ins = db.prepare(`INSERT INTO memories_dirty (ref_id, kind, old_hash, new_hash, first_at, last_at, hits, consumed_at)
68
- VALUES (?, ?, ?, ?, ?, ?, 1, NULL)`);
69
- const upd = db.prepare(`UPDATE memories_dirty SET kind = ?, old_hash = ?, new_hash = ?, last_at = ?, hits = hits + 1,
70
- consumed_at = ? WHERE ref_id = ?`);
90
+ const at = Number(opts.now ?? Date.now());
91
+ const writes = prepareWrites(db);
71
92
  let recorded = 0;
72
93
  let reopened = 0;
73
94
  db.exec('BEGIN IMMEDIATE');
74
95
  try {
75
- for (const it of items) {
76
- const refId = String(it.refId);
96
+ for (const item of batch) {
97
+ const refId = String(item.refId);
77
98
  if (!refId) continue;
78
- const kind = String(it.kind ?? 'mismatch');
79
- const oldHash = it.oldHash ?? null;
80
- const newHash = it.newHash ?? null;
81
- const prev = sel.get(refId);
82
- if (!prev) {
83
- ins.run(refId, kind, oldHash, newHash, now, now);
99
+ const kind = String(item.kind ?? 'mismatch');
100
+ const oldHash = item.oldHash ?? null;
101
+ const newHash = item.newHash ?? null;
102
+ const before = writes.peek.get(refId);
103
+ if (!before) {
104
+ writes.spawn.run(refId, kind, oldHash, newHash, at, at);
84
105
  recorded += 1;
85
106
  continue;
86
107
  }
87
- const changedAgain = newHash != null && prev.new_hash != null && prev.new_hash !== newHash;
88
- if (changedAgain && prev.consumed_at != null) reopened += 1;
89
- // ⚠️ 只有"正文又变了"才清 consumed_at;**同一条重复被扫到不得清**(否则每轮轮转都会重新消费一遍)
90
- upd.run(kind, oldHash, newHash, now, changedAgain ? null : prev.consumed_at, refId);
108
+ const rewritten = newHash != null && before.new_hash != null && before.new_hash !== newHash;
109
+ writes.bump.run(kind, oldHash, newHash, at, rewritten ? null : before.consumed_at, refId);
110
+ if (rewritten && before.consumed_at != null) reopened += 1;
91
111
  recorded += 1;
92
112
  }
93
113
  db.exec('COMMIT');
94
114
  } catch (e) {
95
- try { db.exec('ROLLBACK'); } catch { /* ignore */ }
115
+ try {
116
+ db.exec('ROLLBACK');
117
+ } catch {
118
+ // 回滚本身失败也不能盖掉原始错误
119
+ }
96
120
  throw e;
97
121
  }
98
122
  return { recorded, reopened };
99
123
  }
100
124
 
101
125
  /**
102
- * 读台账。
126
+ * 读台账:默认只给未消费的;要全量就显式传 includeConsumed(或 openOnly:false)。
103
127
  * @param {object} db
104
128
  * @param {{includeConsumed?:boolean, openOnly?:boolean, limit?:number}} [opts]
105
129
  */
106
130
  export function listDirty(db, opts = {}) {
107
- if (!hasTable(db, 'memories_dirty')) return [];
108
- const openOnly = opts.openOnly !== false && opts.includeConsumed !== true;
109
- const lim = Number(opts.limit ?? 0);
110
- const sql = `SELECT ref_id, kind, old_hash, new_hash, first_at, last_at, hits, consumed_at
111
- FROM memories_dirty ${openOnly ? 'WHERE consumed_at IS NULL' : ''}
112
- ORDER BY last_at DESC${lim > 0 ? ` LIMIT ${lim}` : ''}`;
113
- return db.prepare(sql).all().map((r) => ({
114
- refId: String(r.ref_id), kind: r.kind, oldHash: r.old_hash, newHash: r.new_hash,
115
- firstAt: Number(r.first_at), lastAt: Number(r.last_at), hits: Number(r.hits),
116
- consumedAt: r.consumed_at == null ? null : Number(r.consumed_at),
117
- }));
131
+ if (!tablePresent(db)) return [];
132
+ const wantAll = opts.includeConsumed === true;
133
+ const onlyOpen = opts.openOnly !== false && !wantAll;
134
+ const cap = Number(opts.limit ?? 0);
135
+ const filter = onlyOpen ? 'WHERE consumed_at IS NULL' : '';
136
+ const page = cap > 0 ? `LIMIT ${cap}` : '';
137
+ const sql = `SELECT ${COLUMNS} FROM ${TABLE} ${filter} ORDER BY last_at DESC ${page}`;
138
+ return db.prepare(sql).all().map(toEntry);
118
139
  }
119
140
 
120
- /** 标记已消费(提炼 job 把这批 id 并入输入后调用)。 */
141
+ /** 标记已消费(提炼侧把这批 id 并进输入之后调)。只动还没消费过的行,天然幂等。 */
121
142
  export function markConsumed(db, refIds, opts = {}) {
122
- if (!refIds || !refIds.length) return 0;
123
- if (!hasTable(db, 'memories_dirty')) return 0;
124
- const now = Number(opts.now ?? Date.now());
125
- const stmt = db.prepare('UPDATE memories_dirty SET consumed_at = ? WHERE ref_id = ? AND consumed_at IS NULL');
126
- let n = 0;
143
+ const ids = refIds || [];
144
+ if (!ids.length || !tablePresent(db)) return 0;
145
+ const at = Number(opts.now ?? Date.now());
146
+ const stmt = db.prepare(`UPDATE ${TABLE} SET consumed_at = ? WHERE ref_id = ? AND consumed_at IS NULL`);
147
+ let touched = 0;
127
148
  db.exec('BEGIN IMMEDIATE');
128
149
  try {
129
- for (const id of refIds) n += Number(stmt.run(now, String(id)).changes || 0);
150
+ for (const id of ids) touched += Number(stmt.run(at, String(id)).changes || 0);
130
151
  db.exec('COMMIT');
131
152
  } catch (e) {
132
- try { db.exec('ROLLBACK'); } catch { /* ignore */ }
153
+ try {
154
+ db.exec('ROLLBACK');
155
+ } catch {
156
+ // 同上:保留原始错误
157
+ }
133
158
  throw e;
134
159
  }
135
- return n;
160
+ return touched;
136
161
  }
137
162
 
138
- /** 台账概览(state / CLI / 日报用)。 */
163
+ /** 台账概览(状态面板 / 日报用)。表不在 ⇒ table:false 的空壳,不抛错。 */
139
164
  export function dirtyStats(db) {
140
- if (!hasTable(db, 'memories_dirty')) return { table: false, total: 0, open: 0, consumed: 0, lastAt: null };
141
- const r = db.prepare(`SELECT COUNT(*) total,
142
- SUM(CASE WHEN consumed_at IS NULL THEN 1 ELSE 0 END) open_count,
143
- SUM(CASE WHEN consumed_at IS NOT NULL THEN 1 ELSE 0 END) consumed_count,
144
- MAX(last_at) last_at FROM memories_dirty`).get();
165
+ if (!tablePresent(db)) return { table: false, total: 0, open: 0, consumed: 0, lastAt: null };
166
+ const agg = db
167
+ .prepare(
168
+ `SELECT COUNT(*) AS total,
169
+ SUM(CASE WHEN consumed_at IS NULL THEN 1 ELSE 0 END) AS open_count,
170
+ SUM(CASE WHEN consumed_at IS NOT NULL THEN 1 ELSE 0 END) AS consumed_count,
171
+ MAX(last_at) AS last_at
172
+ FROM ${TABLE}`,
173
+ )
174
+ .get();
145
175
  return {
146
176
  table: true,
147
- total: Number(r.total ?? 0),
148
- open: Number(r.open_count ?? 0),
149
- consumed: Number(r.consumed_count ?? 0),
150
- lastAt: r.last_at == null ? null : Number(r.last_at),
177
+ total: Number(agg.total ?? 0),
178
+ open: Number(agg.open_count ?? 0),
179
+ consumed: Number(agg.consumed_count ?? 0),
180
+ lastAt: agg.last_at == null ? null : Number(agg.last_at),
151
181
  };
152
182
  }
153
183
 
154
- /** 定期清理:清掉**已消费**且早于 `keepDays` 的行(未消费的永不清理)。 */
184
+ /** 定期收尾:只清"已消费且够老"的行,没消费过的永远留着。 */
155
185
  export function pruneDirty(db, opts = {}) {
156
- if (!hasTable(db, 'memories_dirty')) return 0;
157
- const now = Number(opts.now ?? Date.now());
186
+ if (!tablePresent(db)) return 0;
158
187
  const keepDays = Number(opts.keepDays ?? 90);
159
- const cutoff = now - keepDays * 86_400_000;
160
- const r = db.prepare('DELETE FROM memories_dirty WHERE consumed_at IS NOT NULL AND consumed_at < ?').run(cutoff);
161
- return Number(r.changes || 0);
188
+ const cutoff = Number(opts.now ?? Date.now()) - keepDays * 86_400_000;
189
+ const res = db.prepare(`DELETE FROM ${TABLE} WHERE consumed_at IS NOT NULL AND consumed_at < ?`).run(cutoff);
190
+ return Number(res.changes || 0);
162
191
  }
163
192
 
164
- /** 便捷:直接按库路径开一个写连接执行 fn(台账写入方都是短命脚本)。 */
193
+ /** 便捷入口:按库路径开一只写连接,跑完 fn 就关(台账写入方都是短命脚本)。 */
165
194
  export function withDirtyDb(dbPath, fn) {
166
195
  const db = new DatabaseSync(dbPath);
167
196
  try {
@@ -169,6 +198,10 @@ export function withDirtyDb(dbPath, fn) {
169
198
  ensureDirtySchema(db);
170
199
  return fn(db);
171
200
  } finally {
172
- try { db.close(); } catch { /* ignore */ }
201
+ try {
202
+ db.close();
203
+ } catch {
204
+ // 关不掉不算错:调用方多半已经在 finally 里兜着
205
+ }
173
206
  }
174
207
  }