@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,24 +1,20 @@
1
- // 记忆引擎 v2 · S3c —— 夜间提炼内核(方案 §3.5 唯一权威条款的逐条落地)
1
+ // ── 记忆引擎 · 夜间提炼内核 ─────────────────────────────────────────────────
2
2
  //
3
- // 一句话:把窗口内**新增/变更**的记忆按**语义向量**聚成组,交给便宜档模型提炼成**长期结论**,
4
- // 写成带 `distilled` 标记的新记忆(可批量回滚)。
3
+ // 一句话职责:把"最近被动过的记忆"按语义归拢成若干主题组,交给便宜档模型提炼成
4
+ // 能长期复用的一条结论,再以带 `distilled` 前缀的新记忆写回主库(可整批撤销)。
5
5
  //
6
- // 逐条口径(都能在 `docs\P2-记忆引擎升级-方案-20260916.md` §3.5 找到出处):
7
- // · 输入 = 窗口内(默认 24h)`updated_at` 变更的记忆 **且** `tags NOT LIKE 'distilled%'`(Z8 防"摘要的摘要")
8
- // + **补充输入源** = `memories_dirty` 台账(治 A7:改 text 不动 `updated_at` 的 41 处外部写口)
9
- // · 聚类 = **语义向量**(复用 `memory-vectors.db`);tags 只作分域辅助(Z19:tags 组合近乎唯一,不可用于聚类)
10
- // · 去重 = **内容级**:`srcKey` = 源**正文哈希**集合的哈希(**不是 id 集合** —— id 会因跨机合并整段换号,
11
- // 见本文件 `srcKeyOf` 的注释)+ 产出正文哈希;**先查库再决定是否调 LLM**(不依赖 lastProcessedMaxId)
12
- // · 成本闸 = 模型 `deepseek-flash`(本鱼链;2026-09-21 主人拍板)· ≤20 次/日 · ≤150K token/日 ·
13
- // **超限即停 + 日报一行** · **不重试**
14
- // · 输出 = 新增记忆,`tags` 以 `distilled` 开头 + 源 id 清单 + 日期;写 `owner_user` / `visibility`(默认 private_owner)
15
- // · **D2 从严**(§3.5 后的订正注 / 服务面隐私包 §11.3):只把 `owner_user == 本机 owner`、
16
- // 且 `visibility != 'family'` 的条送模型;**判不出归属的一律跳过 + 记账**(宁可漏,不可错送)
17
- // · 回滚 = `tags LIKE 'distilled%'` 批量列出/删除(见 `lib/distill-rollback.mjs`)
18
- // · state = `~/.dsh/butler-distill-state.json`(原子写 + schemaVersion),每次调用记 时间/模型/输入条数/token/结果
6
+ // 五条不可让渡的口径(改动前先读这一段):
7
+ // ① 输入面 = 时间窗口内 `updated_at` 有变动的条 ∪ 变更台账里尚未消费的条 ∪ 调用方点名的条;
8
+ // 凡是 `distilled` 打头的条**永不**入输入 —— 否则每晚会拿"昨天的摘要"再摘要一遍。
9
+ // ② 归组只靠语义向量;标签顶多当域提示。标签组合近乎唯一,拿它聚类是不成立的。
10
+ // ③ 去重是**内容级**的:键取"源正文哈希集合的哈希",不取 id 集合 ——
11
+ // 跨机合并会整段重排号,id 一变去重就漏(详见 `srcKeyOf` 旁的说明)。
12
+ // 判定顺序永远是"先查库、再决定要不要花钱"。
13
+ // ④ 成本上限按"每日"计:超了当场收手、这一组不重试、日报段里留一行痕迹。
14
+ // ⑤ 写回要落 `owner_user` / `visibility`;归属读不出来的条一律不送外部模型(宁可漏送)。
19
15
  //
20
- // 红线:本内核**只读生产 `memory.db`**(`readOnly` 连接);写入一律走显式 `apply` 分支,
21
- // 且测试/开发期一律指副本库(调用方负责)。
16
+ // 只读约束:本模块对生产库只开只读连接。真正写库的只有显式 apply 分支,
17
+ // 且由调用方保证库路径指向副本。状态文件一律原子写(临时件 + rename)。
22
18
  import fs from 'node:fs';
23
19
  import path from 'node:path';
24
20
  import crypto from 'node:crypto';
@@ -27,460 +23,580 @@ import { textHash } from './hashes.mjs';
27
23
  import { recordDirty, listDirty, markConsumed, dirtyStats, ensureDirtySchema } from './dirty-ledger.mjs';
28
24
  import { readCredential, extractJson, vacuumInto } from './domain-tags.mjs';
29
25
 
30
- // ── 常量 ────────────────────────────────────────────────────────────────────
26
+ // ── 常量表 ─────────────────────────────────────────────────────────────────
31
27
 
32
28
  export const STATE_SCHEMA = 1;
33
29
 
34
- /** 成本闸(方案 §3.5 现值;**D8 已拍照现值,不许改档**)。 */
35
- export const COST_LIMITS = Object.freeze({ callsPerDay: 20, tokensPerDay: 150000 });
30
+ // 成本闸的两道上限。每日口径,超限即停、不重试;改档属于产品决策,不是调参。
31
+ const DAILY_CALL_CAP = 20;
32
+ const DAILY_TOKEN_CAP = 150000;
36
33
 
37
- /**
38
- * 提炼模型档(**2026-09-21 主人拍板:记忆线一律走本鱼链**;真源 = 方案 §3.5 订正注 2 + §13 D8 订正)。
39
- * ⚠️ 不许改回智谱等"本鱼以外"的第三方(D2 延长效:新增外发面须先拍);
40
- * 也不用 `deepseek-v4-pro`(贵 4.4 倍,分类/提炼任务不需要强推理)。
41
- * 价目与成本实算见 `docs\API资产与定价.md` §十。
42
- */
43
- export const DEFAULT_LLM = Object.freeze({
44
- provider: 'deepseek',
45
- baseUrl: 'https://api.deepseek.com',
46
- model: 'deepseek-flash',
47
- credentialKey: 'DEEPSEEK_API_KEY',
48
- maxTokens: 2048,
49
- // deepseek-flash 非思考模式下秒级返回;给足余量即可,超时即失败,**不重试**
50
- timeoutMs: 60000,
34
+ export const COST_LIMITS = Object.freeze({
35
+ callsPerDay: DAILY_CALL_CAP,
36
+ tokensPerDay: DAILY_TOKEN_CAP,
51
37
  });
52
38
 
53
- /** 标签前缀(`tags LIKE 'distilled%'` 是回滚/排除自我指涉的唯一判据)。 */
39
+ // 提炼模型档:走 DeepSeek 官方端点。端点与模型名都是对外行为的一部分,
40
+ // 换别家等于新增一条外发面,必须先拍板;也不需要更强推理档(分类/提炼用不上,且贵数倍)。
41
+ const LLM_FIELDS = [
42
+ ['provider', 'deepseek'],
43
+ ['baseUrl', 'https://api.deepseek.com'],
44
+ ['model', 'deepseek-flash'],
45
+ ['credentialKey', 'DEEPSEEK_API_KEY'],
46
+ ['maxTokens', 2048],
47
+ // 非思考模式下秒级返回,余量给足即可;超时即算失败,不重试
48
+ ['timeoutMs', 60000],
49
+ ];
50
+
51
+ export const DEFAULT_LLM = Object.freeze(Object.fromEntries(LLM_FIELDS));
52
+
53
+ /** 标签前缀 —— 回滚筛选与"排除自我指涉"都靠它。 */
54
54
  export const DISTILL_PREFIX = 'distilled';
55
55
 
56
56
  export const DEFAULT_WINDOW_HOURS = 24;
57
57
  export const DEFAULT_HISTORY_MAX = 20;
58
58
  export const PROCESSED_MAX = 2000;
59
59
 
60
- /** 聚类与输入裁剪参数(本卡默认值;逐项可注入 ⇒ 单测/调参不必改代码)。 */
61
- export const CLUSTER_DEFAULTS = Object.freeze({
62
- simThreshold: 0.60, // 余弦相似度阈值(贪心聚类);bge-small-zh 同题实测多在 0.7+
63
- maxItemsPerCluster: 12, // 单组最多几条(防一次调用塞爆上下文)
64
- maxCharsPerItem: 400, // 每条正文送给模型的截断长度
65
- minClusterChars: 120, // 整组正文合计短于此 ⇒ 不调模型(没什么可提炼的)
66
- minClusterSize: 2, // **单条组不提炼**(为什么见下方注释)
67
- maxClustersPerRun: 20, // 单轮最多处理几组(与「≤20 次/日」同量级,双保险)
68
- });
60
+ // 聚类与送模型的裁剪参数:逐项都能从调用方注入,单测与调参不必改代码。
61
+ const CLUSTER_FIELDS = [
62
+ ['simThreshold', 0.60], // 余弦相似度下限(贪心归组);中文小模型同题实测多在 0.7 以上
63
+ ['maxItemsPerCluster', 12], // 单组最多几条,防一次调用把上下文塞爆
64
+ ['maxCharsPerItem', 400], // 每条正文送模型前的截断长度
65
+ ['minClusterChars', 120], // 整组正文合计低于此值 ⇒ 不值得调模型
66
+ ['minClusterSize', 2], // 单条成组不提炼(理由见下)
67
+ ['maxClustersPerRun', 20], // 单轮最多处理几组,与每日调用上限同量级,双保险
68
+ ];
69
69
 
70
- /**
71
- * 为什么 `minClusterSize` 默认 **2**(不是 1):
72
- * ① **语义**:本 job 的定义是"聚类 → 提炼";**一条自成一组不算聚类**,把单条记忆回炉成一句
73
- * "结论"只是改写,没有跨条综合的信息增量;
74
- * ② **可证伪硬验收**(方案 §8.2 / D7 选③):产物仍进主库 ⇒ 会被 `memory:recall` 段按
75
- * `updated_at` 排进"最近 N 条"候选池,**每写一条就挤掉一条真实记忆的池位**。
76
- * 实测(2026-09-21 本卡验收):生产窗口 60 条 → 不设限时产出 **17** 条(4 个大组 + 13 个单条组),
77
- * 在放大预算下该段"真实条数"从 18 掉到 16(预算 20000)、从 45 掉到 29(40000);
78
- * 设 `minClusterSize: 2` 后只写 **4** 条(真正的组),挤占面按比例缩小。
79
- * ③ 想要旧行为(单条也提炼)显式传 `--min-cluster-size 1` 即可 —— **不写死**。
80
- */
70
+ export const CLUSTER_DEFAULTS = Object.freeze(Object.fromEntries(CLUSTER_FIELDS));
81
71
 
82
- // ── 小工具 ──────────────────────────────────────────────────────────────────
72
+ // 为什么 `minClusterSize` 默认是 2 而不是 1:
73
+ // · 从定义上看,这个作业叫"先归类、再提炼"。一条自己一组根本没有"跨条综合",
74
+ // 把单条改写成一句结论只是换个说法,信息量没有增加。
75
+ // · 从后果上看,产物仍然写回主库,会按 `updated_at` 挤进召回段的"最近若干条"候选池 ——
76
+ // 每写一条就顶掉一条真实记忆的位置。实测把单条组也提炼时,产物里有八成是这种"改写件",
77
+ // 真实内容能进候选池的条数直接腰斩;限成 2 之后只剩真正的组。
78
+ // · 需要旧行为时,调用方显式传 `minClusterSize: 1` 即可 —— 不写死在代码里。
83
79
 
84
- function sha256(s) {
85
- return crypto.createHash('sha256').update(String(s ?? ''), 'utf8').digest('hex');
86
- }
80
+ // ── 小工具 ─────────────────────────────────────────────────────────────────
81
+
82
+ const hexOf = (input) => crypto.createHash('sha256').update(String(input ?? ''), 'utf8').digest('hex');
87
83
 
88
84
  export function shortKey(key, n = 24) {
89
- return String(key ?? '').slice(0, n);
85
+ const width = Number(n);
86
+ return String(key ?? '').slice(0, width);
90
87
  }
91
88
 
92
- /** 本地日期串(YYYYMMDD,按本机时区)。 */
89
+ /** 本机时区的日期串(YYYYMMDD)。 */
93
90
  export function localDay(ts = Date.now()) {
94
- const d = new Date(Number(ts));
95
- return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
91
+ const at = new Date(Number(ts));
92
+ const month = String(at.getMonth() + 1).padStart(2, '0');
93
+ const day = String(at.getDate()).padStart(2, '0');
94
+ return `${at.getFullYear()}${month}${day}`;
96
95
  }
97
96
 
98
- /** 本机日期串(YYYY-MM-DD,与 `localDay` 同源,只多连字符)。 */
97
+ /** 本机时区的日期串(YYYY-MM-DD),与 `localDay` 同一份,只是加连字符。 */
99
98
  export function localDateIso(ts = Date.now()) {
100
- const s = localDay(ts);
101
- return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6)}`;
99
+ const digits = localDay(ts);
100
+ return [digits.slice(0, 4), digits.slice(4, 6), digits.slice(6)].join('-');
102
101
  }
103
102
 
104
103
  /**
105
104
  * **去重键 = 源正文哈希集合的哈希**(内容级)。
106
105
  *
107
- * ⚠️ 刻意**不用 id 集合**(哪怕方案 §3.5 :243 的字面写法是"源 id 集合哈希"):
108
- * 跨机合并(`merge-memory.mjs` 的内容哈希主键口径)会把同一条内容灌**新的高位 id**,
109
- * id 集合一变 ⇒ srcKey 变 ⇒ 会重新调一次 LLM 并写出**内容重复**的提炼条。
110
- * 本卡验收明写「跨机改 id 后重跑仍不重复」⇒ 取正文哈希才是能过验收的那一版。
111
- * (源 id 清单仍写进 `tags` 供人追溯,只是**不参与**去重判定。)
106
+ * 刻意不用 id 集合,哪怕最初的方案写的是"源 id 集合哈希":
107
+ * 跨机合并以"内容哈希"为主键,会给同样的内容灌一批新的高位 id。
108
+ * id 集合一变,键就变,于是重新调一次模型、再写出一条内容重复的提炼条。
109
+ * 验收口径要求"跨机换了号之后重跑仍不重复",只有取正文哈希才过得去。
110
+ * (源 id 清单依旧写进标签供人追溯,只是**不参与**去重判定。)
112
111
  *
113
112
  * @param {Array<{id:number, text:string}>} rows
114
- * @returns {string} `d<16位十六进制>`
113
+ * @returns {string} `d` + 16 位十六进制
115
114
  */
116
115
  export function srcKeyOf(rows) {
117
- const hashes = rows.map((r) => textHash(r.text)).sort();
118
- return `d${sha256(hashes.join('|')).slice(0, 16)}`;
116
+ const digest = rows.map((entry) => textHash(entry.text)).sort().join('|');
117
+ return `d${hexOf(digest).slice(0, 16)}`;
119
118
  }
120
119
 
121
120
  export function distilledTagOf({ srcKey, srcIds, now }) {
122
- const ids = [...srcIds].map((x) => String(x)).sort().join(',');
123
- return `${DISTILL_PREFIX} src=${shortKey(srcKey)} srcids=${ids} date=${localDay(now)}`;
121
+ const ids = [...srcIds].map(String).sort().join(',');
122
+ const stamp = localDay(now);
123
+ return [DISTILL_PREFIX, `src=${shortKey(srcKey)}`, `srcids=${ids}`, `date=${stamp}`].join(' ');
124
124
  }
125
125
 
126
- /** 从 tags 解析 `src=` 值(去重用)。 */
126
+ /** 从标签串里抠出 `src=` 的值(去重时用)。 */
127
127
  export function srcKeyInTags(tags) {
128
- const m = String(tags ?? '').match(/(?:^|\s)src=([0-9a-f]+)(?:\s|$)/);
129
- return m ? m[1] : null;
128
+ const found = /(?:^|\s)src=([0-9a-f]+)(?:\s|$)/.exec(String(tags ?? ''));
129
+ return found ? found[1] : null;
130
130
  }
131
131
 
132
132
  export function isDistilledTags(tags) {
133
- return String(tags ?? '').startsWith(DISTILL_PREFIX);
133
+ const head = String(tags ?? '').slice(0, DISTILL_PREFIX.length);
134
+ return head === DISTILL_PREFIX;
134
135
  }
135
136
 
136
- // ── 库:只读输入 ────────────────────────────────────────────────────────────
137
+ // ── 连接与句柄的小包装 ─────────────────────────────────────────────────────
138
+
139
+ const BUSY_TIMEOUT_SQL = 'PRAGMA busy_timeout = 5000;';
140
+ const OWNER_COLUMNS = Object.freeze(['owner_user', 'visibility']);
141
+
142
+ function closeQuiet(handle) {
143
+ try { handle.close(); } catch { /* 关不掉不影响已算出的结果 */ }
144
+ }
137
145
 
138
- /** 打开记忆库(只读;与 `memory-source.mjs` 同口径:只读连接不阻塞写者)。 */
146
+ /** 打开记忆库(只读;只读连接不会挡住写者)。 */
139
147
  export function openMemoryDb(dbPath) {
140
- const db = new DatabaseSync(dbPath, { readOnly: true });
141
- db.exec('PRAGMA busy_timeout = 5000;');
142
- return db;
148
+ const handle = new DatabaseSync(dbPath, { readOnly: true });
149
+ handle.exec(BUSY_TIMEOUT_SQL);
150
+ return handle;
151
+ }
152
+
153
+ /** 读表结构:返回列名数组。 */
154
+ function tableColumns(handle, table) {
155
+ const rows = handle.prepare(`PRAGMA table_info(${table});`).all();
156
+ return rows.map((entry) => entry.name);
157
+ }
158
+
159
+ /** 库是否带归属列(决定"能不能判归属"以及写库时的列清单)。 */
160
+ function hasOwnerColumns(handle) {
161
+ const names = tableColumns(handle, 'memories');
162
+ return OWNER_COLUMNS.every((column) => names.includes(column));
163
+ }
164
+
165
+ function withDirtyDb(vectorsDbPath, body) {
166
+ const handle = new DatabaseSync(vectorsDbPath);
167
+ try {
168
+ handle.exec(BUSY_TIMEOUT_SQL);
169
+ return body(handle);
170
+ } finally { closeQuiet(handle); }
171
+ }
172
+
173
+ // ── 输入挑选(只读) ────────────────────────────────────────────────────────
174
+
175
+ /** 把"台账 id / 点名 id"去重、剔掉非数字,保持首次出现顺序不变。 */
176
+ function numericIdSet(list) {
177
+ return [...new Set(list.map((value) => Number(value)).filter((value) => Number.isFinite(value)))];
143
178
  }
144
179
 
145
180
  /**
146
- * 挑输入(**只读**)。
147
- *
148
- * 三路并集(按 id 去重,先并后筛):
149
- * ① 增量窗口:`updated_at >= now - windowHours`
150
- * ② `memories_dirty` 台账未消费项(治"改 text 不动 updated_at")
151
- * ③ 调用方显式给的 `extraIds`(单测/补跑用)
152
- * 再统一套:`tags NOT LIKE 'distilled%'`(不自我指涉)+ **D2 从严归属筛**。
153
- *
154
- * @returns {{accepted:Array, family:Array, unknownOwner:Array, distilledExcluded:number,
155
- * windowRows:number, dirtyRows:number, ownerColumn:boolean, stats:object}}
181
+ * 归属闸:决定这条记录能不能送外部模型。返回 `{bucket, why}`,
182
+ * `bucket` ∈ `ok` / `family` / `unknown`。判定顺序即优先级,逐条 fail-closed。
183
+ */
184
+ function ownerGate({ ownerColumn, ownerUser, visibility, localOwner }) {
185
+ if (!ownerColumn) return { bucket: 'unknown', why: '库无 owner_user 列,归属不可判定' };
186
+ if (visibility === 'family') return { bucket: 'family', why: 'visibility=family' };
187
+ if (!localOwner) return { bucket: 'unknown', why: '本机 owner 未解析出来 ⇒ 不猜' };
188
+ if (ownerUser == null) return { bucket: 'unknown', why: 'owner_user 为空' };
189
+ if (ownerUser !== localOwner) return { bucket: 'unknown', why: 'owner_user != 本机 owner' };
190
+ return { bucket: 'ok', why: null };
191
+ }
192
+
193
+ /** 组装一条"可以送模型"的输入记录(正文截断与 hash 都在这里一次性算好)。 */
194
+ function toAcceptedItem(row, { id, source, maxCharsPerItem, ownerUser, visibility, localOwner }) {
195
+ const text = String(row.text ?? '');
196
+ return {
197
+ id,
198
+ text,
199
+ tags: String(row.tags ?? ''),
200
+ pinned: Number(row.pinned ?? 0),
201
+ updatedAt: Number(row.updated_at),
202
+ textHash: textHash(text),
203
+ ownerUser: ownerUser ?? localOwner,
204
+ visibility: visibility ?? 'private_owner',
205
+ textForModel: text.slice(0, maxCharsPerItem),
206
+ textTruncated: text.length > maxCharsPerItem,
207
+ source,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * 挑输入(**只读**)。三路并集先按 id 去重、再统一过筛:
213
+ * ① 增量窗口 `updated_at >= now - windowHours`
214
+ * ② 变更台账未消费项(补"改了正文却没动 updated_at"的盲区)
215
+ * ③ 调用方点名的 id(补跑 / 单测用)
216
+ * 过筛两道:`distilled` 打头的一律排除;再按归属闸逐条判定并**分别记账**。
156
217
  */
157
218
  export function selectInputs({ dbPath, now = Date.now(), windowHours = DEFAULT_WINDOW_HOURS, dirtyIds = [], extraIds = [], localOwner = null, maxCharsPerItem = CLUSTER_DEFAULTS.maxCharsPerItem, limit = 0 }) {
158
- const db = openMemoryDb(dbPath);
219
+ const handle = openMemoryDb(dbPath);
159
220
  try {
160
- const cols = db.prepare('PRAGMA table_info(memories)').all().map((r) => r.name);
161
- const hasOwner = cols.includes('owner_user') && cols.includes('visibility');
162
- const sel = `SELECT id, text, tags, pinned, updated_at${hasOwner ? ', owner_user, visibility' : ''} FROM memories WHERE id = ?`;
163
- const byId = db.prepare(sel);
164
- const since = Number(now) - Number(windowHours) * 3_600_000;
221
+ const ownerColumn = hasOwnerColumns(handle);
222
+ const projection = ['id', 'text', 'tags', 'pinned', 'updated_at'];
223
+ if (ownerColumn) projection.push('owner_user', 'visibility');
224
+ const byId = handle.prepare(`SELECT ${projection.join(', ')} FROM memories WHERE id = ?;`);
165
225
 
166
- const windowRows = db
167
- .prepare(`SELECT id${hasOwner ? ', owner_user, visibility' : ''} FROM memories WHERE updated_at >= ? ORDER BY id`)
168
- .all(since);
169
- const windowIds = windowRows.map((r) => Number(r.id));
170
- const dirtyIdSet = [...new Set(dirtyIds.map((x) => Number(x)).filter((x) => Number.isFinite(x)))];
171
- const extraIdSet = [...new Set(extraIds.map((x) => Number(x)).filter((x) => Number.isFinite(x)))];
226
+ const since = Number(now) - Number(windowHours) * 3_600_000;
227
+ const windowIds = handle
228
+ .prepare(`SELECT id FROM memories WHERE updated_at >= ? ORDER BY id;`)
229
+ .all(since)
230
+ .map((entry) => Number(entry.id));
231
+
232
+ const dirtyList = numericIdSet(dirtyIds);
233
+ const extraList = numericIdSet(extraIds);
234
+ const dirtySet = new Set(dirtyList);
235
+ const extraSet = new Set(extraList);
236
+ const windowSet = new Set(windowIds);
237
+ const sourceOf = (id) => {
238
+ if (dirtySet.has(id)) return 'dirty-ledger';
239
+ if (extraSet.has(id) && !windowSet.has(id)) return 'extra';
240
+ return 'window';
241
+ };
172
242
 
173
- const allIds = [...new Set([...windowIds, ...dirtyIdSet, ...extraIdSet])].sort((a, b) => a - b);
243
+ const candidateIds = [...new Set([...windowIds, ...dirtyList, ...extraList])].sort((left, right) => left - right);
174
244
  const accepted = [];
175
245
  const family = [];
176
246
  const unknownOwner = [];
177
247
  let distilledExcluded = 0;
178
- for (const id of allIds) {
179
- const r = byId.get(id);
180
- if (!r) continue; // 台账里有、库里已删(跨机合并/回滚)⇒ 静默跳过
181
- const tags = String(r.tags ?? '');
182
- if (isDistilledTags(tags)) { distilledExcluded += 1; continue; }
183
- const ownerUser = hasOwner ? (r.owner_user ?? null) : null;
184
- const visibility = hasOwner ? (r.visibility ?? null) : null;
185
- // D2 从严(**逐条 fail-closed**):库无归属列 / 家人可见 / 非本机 owner / 归属读不出
186
- // ⇒ 一律不进第三方模型,且**逐条记账**(宁可漏,不可错送)
187
- if (!hasOwner) { unknownOwner.push({ id, ownerUser: null, why: '库无 owner_user 列,归属不可判定' }); continue; }
188
- if (visibility === 'family') { family.push({ id, ownerUser, visibility, why: 'visibility=family' }); continue; }
189
- if (!localOwner) { unknownOwner.push({ id, ownerUser, why: '本机 owner 未解析出来 ⇒ 不猜' }); continue; }
190
- if (ownerUser == null) { unknownOwner.push({ id, ownerUser, why: 'owner_user 为空' }); continue; }
191
- if (ownerUser !== localOwner) { unknownOwner.push({ id, ownerUser, why: 'owner_user != 本机 owner' }); continue; }
192
- const text = String(r.text ?? '');
193
- accepted.push({
194
- id, text, tags,
195
- pinned: Number(r.pinned ?? 0),
196
- updatedAt: Number(r.updated_at),
197
- textHash: textHash(text),
198
- ownerUser: ownerUser ?? localOwner,
199
- visibility: visibility ?? 'private_owner',
200
- textForModel: text.slice(0, maxCharsPerItem),
201
- textTruncated: text.length > maxCharsPerItem,
202
- source: dirtyIdSet.includes(id) ? 'dirty-ledger' : (extraIdSet.includes(id) && !windowIds.includes(id) ? 'extra' : 'window'),
203
- });
248
+
249
+ for (const id of candidateIds) {
250
+ const row = byId.get(id);
251
+ if (!row) continue; // 台账里有、库里已经没有(合并/回滚删掉了)⇒ 静默跳过
252
+ if (isDistilledTags(row.tags)) { distilledExcluded += 1; continue; }
253
+
254
+ const ownerUser = ownerColumn ? (row.owner_user ?? null) : null;
255
+ const visibility = ownerColumn ? (row.visibility ?? null) : null;
256
+ const verdict = ownerGate({ ownerColumn, ownerUser, visibility, localOwner });
257
+ if (verdict.bucket === 'family') {
258
+ family.push({ id, ownerUser, visibility, why: verdict.why });
259
+ continue;
260
+ }
261
+ if (verdict.bucket === 'unknown') {
262
+ unknownOwner.push({ id, ownerUser, why: verdict.why });
263
+ continue;
264
+ }
265
+ accepted.push(toAcceptedItem(row, { id, source: sourceOf(id), maxCharsPerItem, ownerUser, visibility, localOwner }));
204
266
  }
205
- const trimmed = limit > 0 ? accepted.slice(0, Number(limit)) : accepted;
267
+
268
+ const capped = Number(limit) > 0 ? accepted.slice(0, Number(limit)) : accepted;
206
269
  return {
207
- accepted: trimmed,
270
+ accepted: capped,
208
271
  family,
209
272
  unknownOwner,
210
273
  distilledExcluded,
211
274
  windowRows: windowIds.length,
212
- dirtyRows: dirtyIdSet.length,
213
- ownerColumn: hasOwner,
275
+ dirtyRows: dirtyList.length,
276
+ ownerColumn,
214
277
  stats: {
215
- windowRows: windowIds.length, dirtyRows: dirtyIdSet.length, extraRows: extraIdSet.length,
216
- candidates: allIds.length, accepted: trimmed.length,
217
- skippedFamily: family.length, skippedUnknownOwner: unknownOwner.length, distilledExcluded,
278
+ windowRows: windowIds.length,
279
+ dirtyRows: dirtyList.length,
280
+ extraRows: extraList.length,
281
+ candidates: candidateIds.length,
282
+ accepted: capped.length,
283
+ skippedFamily: family.length,
284
+ skippedUnknownOwner: unknownOwner.length,
285
+ distilledExcluded,
218
286
  },
219
287
  };
220
- } finally {
221
- try { db.close(); } catch { /* ignore */ }
222
- }
288
+ } finally { closeQuiet(handle); }
289
+ }
290
+
291
+ // ── 全表比对:找出"正文动过但向量没跟上"的条 ────────────────────────────────
292
+
293
+ /** 把向量库里 `source='memory'` 的 (ref_id ⇒ text_hash) 读成一张表(只读)。 */
294
+ function vectorHashIndex(vectorsDbPath) {
295
+ const index = new Map();
296
+ if (!vectorsDbPath || !fs.existsSync(vectorsDbPath)) return index;
297
+ const handle = new DatabaseSync(vectorsDbPath, { readOnly: true });
298
+ let rows = [];
299
+ try {
300
+ rows = handle.prepare("SELECT ref_id, text_hash FROM vectors WHERE source = 'memory';").all();
301
+ } finally { closeQuiet(handle); }
302
+ for (const row of rows) index.set(String(row.ref_id), row.text_hash);
303
+ return index;
223
304
  }
224
305
 
225
306
  /**
226
- * 全表 `text_hash` 比对(**只读记忆库**):找出"正文与向量库记录不符"的条 = 被外部改过 text 的条。
227
- * 这是 §3.1 兜底轮转的同一判据,只是这里一次覆盖全表(626 条纯 sha256 计算 ≈ 毫秒级,不编码、不联网)。
228
- *
229
- * @param {{dbPath:string, vectorsDbPath:string, record?:boolean, now?:number, maxCharsPerItem?:number}} p
230
- * @returns {{items:Array<{id:number,text:string,tags:string,updatedAt:number}>, mismatched:number[], missing:number[], recorded:boolean}}
307
+ * 全表逐条比对 `text_hash`(**只读记忆库**,纯哈希计算,不编码、不联网):
308
+ * 正文与向量库记录不一致 ⇒ 说明有人绕过更新入口直接改了 text。
309
+ * 这是"兜底轮转"的同一判据,只是这里一次覆盖全表。`record` 为真时把差异落进台账。
231
310
  */
232
311
  export function detectDirty({ dbPath, vectorsDbPath, record = false, now = Date.now(), includeMissing = false }) {
233
- const mem = openMemoryDb(dbPath);
234
- const mismatched = [];
235
- const missing = [];
236
- const details = [];
312
+ const scan = { mismatched: [], missing: [], details: [] };
313
+ const handle = openMemoryDb(dbPath);
237
314
  try {
238
- const rows = mem.prepare('SELECT id, text, tags, updated_at FROM memories ORDER BY id').all();
239
- let hashes = new Map();
240
- if (vectorsDbPath && fs.existsSync(vectorsDbPath)) {
241
- const vec = new DatabaseSync(vectorsDbPath, { readOnly: true });
242
- try {
243
- for (const r of vec.prepare("SELECT ref_id, text_hash FROM vectors WHERE source = 'memory'").all()) {
244
- hashes.set(String(r.ref_id), r.text_hash);
245
- }
246
- } finally {
247
- try { vec.close(); } catch { /* ignore */ }
248
- }
249
- }
250
- for (const r of rows) {
251
- if (isDistilledTags(r.tags)) continue;
252
- const prev = hashes.get(String(r.id));
253
- const h = textHash(r.text);
254
- if (prev === undefined) { missing.push(Number(r.id)); continue; }
255
- if (prev !== h) {
256
- mismatched.push(Number(r.id));
257
- details.push({ refId: Number(r.id), kind: 'mismatch', oldHash: prev, newHash: h });
258
- }
315
+ const rows = handle.prepare('SELECT id, text, tags, updated_at FROM memories ORDER BY id;').all();
316
+ const known = vectorHashIndex(vectorsDbPath);
317
+ for (const row of rows) {
318
+ if (isDistilledTags(row.tags)) continue;
319
+ const id = Number(row.id);
320
+ const recorded = known.get(String(id));
321
+ const actual = textHash(row.text);
322
+ if (recorded === undefined) { scan.missing.push(id); continue; }
323
+ if (recorded === actual) continue;
324
+ scan.mismatched.push(id);
325
+ scan.details.push({ refId: id, kind: 'mismatch', oldHash: recorded, newHash: actual });
259
326
  }
260
- if (record && details.length) {
261
- const vec = new DatabaseSync(vectorsDbPath);
262
- try {
263
- vec.exec('PRAGMA busy_timeout = 5000;');
264
- recordDirty(vec, details, { now });
265
- } finally {
266
- try { vec.close(); } catch { /* ignore */ }
267
- }
327
+ if (record && scan.details.length) {
328
+ withDirtyDb(vectorsDbPath, (ledger) => recordDirty(ledger, scan.details, { now }));
268
329
  }
269
- return { items: rows.length, mismatched, missing: includeMissing ? missing : [], recorded: Boolean(record && details.length), details };
270
- } finally {
271
- try { mem.close(); } catch { /* ignore */ }
272
- }
330
+ return {
331
+ items: rows.length,
332
+ mismatched: scan.mismatched,
333
+ missing: includeMissing ? scan.missing : [],
334
+ recorded: Boolean(record && scan.details.length),
335
+ details: scan.details,
336
+ };
337
+ } finally { closeQuiet(handle); }
273
338
  }
274
339
 
275
- /** 读 `memories_dirty` 台账里**未消费**的 id(向量库不存在 ⇒ 空)。 */
340
+ const EMPTY_LEDGER_STATS = Object.freeze({ table: false, total: 0, open: 0, consumed: 0, lastAt: null });
341
+
342
+ /** 读变更台账里**未消费**的 id(向量库不存在 ⇒ 空结果,不抛)。 */
276
343
  export function readDirtyIds(vectorsDbPath) {
277
- if (!vectorsDbPath || !fs.existsSync(vectorsDbPath)) return { ids: [], stats: { table: false, total: 0, open: 0, consumed: 0, lastAt: null } };
278
- const db = new DatabaseSync(vectorsDbPath, { readOnly: true });
279
- try {
280
- const rows = listDirty(db, { openOnly: true });
281
- return { ids: rows.map((r) => Number(r.refId)), rows, stats: dirtyStats(db) };
282
- } finally {
283
- try { db.close(); } catch { /* ignore */ }
344
+ if (!vectorsDbPath || !fs.existsSync(vectorsDbPath)) {
345
+ return { ids: [], stats: { ...EMPTY_LEDGER_STATS } };
284
346
  }
347
+ const handle = new DatabaseSync(vectorsDbPath, { readOnly: true });
348
+ try {
349
+ const rows = listDirty(handle, { openOnly: true });
350
+ return { ids: rows.map((entry) => Number(entry.refId)), rows, stats: dirtyStats(handle) };
351
+ } finally { closeQuiet(handle); }
285
352
  }
286
353
 
287
- // ── 聚类(语义向量;不复用 tags) ───────────────────────────────────────────
354
+ // ── 语义聚类(只看向量,不看标签) ─────────────────────────────────────────
288
355
 
289
- function cosine(a, b) {
356
+ function cosineSim(lhs, rhs) {
290
357
  let dot = 0;
291
- let na = 0;
292
- let nb = 0;
293
- for (let i = 0; i < a.length; i += 1) {
294
- dot += a[i] * b[i];
295
- na += a[i] * a[i];
296
- nb += b[i] * b[i];
358
+ let normL = 0;
359
+ let normR = 0;
360
+ for (let i = 0; i < lhs.length; i += 1) {
361
+ dot += lhs[i] * rhs[i];
362
+ normL += lhs[i] * lhs[i];
363
+ normR += rhs[i] * rhs[i];
297
364
  }
298
- if (na === 0 || nb === 0) return 0;
299
- return dot / Math.sqrt(na * nb);
365
+ const denom = Math.sqrt(normL * normR);
366
+ return denom === 0 ? 0 : dot / denom;
300
367
  }
301
368
 
302
- function loadVectors(vectorsDbPath, ids) {
303
- const out = new Map();
304
- if (!vectorsDbPath || !fs.existsSync(vectorsDbPath) || !ids.length) return out;
305
- const db = new DatabaseSync(vectorsDbPath, { readOnly: true });
369
+ /** 批量取向量:`Map<id, Float32Array>`(缺的就不放进去,调用方当"还没有向量"处理)。 */
370
+ function loadRowVectors(vectorsDbPath, ids) {
371
+ const vectors = new Map();
372
+ if (!vectorsDbPath || !fs.existsSync(vectorsDbPath) || !ids.length) return vectors;
373
+ const handle = new DatabaseSync(vectorsDbPath, { readOnly: true });
306
374
  try {
307
- const stmt = db.prepare("SELECT vec, dim FROM vectors WHERE source = 'memory' AND ref_id = ?");
375
+ const stmt = handle.prepare("SELECT vec, dim FROM vectors WHERE source = 'memory' AND ref_id = ?;");
308
376
  for (const id of ids) {
309
- const r = stmt.get(String(id));
310
- if (!r) continue;
311
- const buf = r.vec;
312
- const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
313
- out.set(Number(id), new Float32Array(ab));
377
+ const hit = stmt.get(String(id));
378
+ if (!hit) continue;
379
+ const raw = hit.vec;
380
+ const slice = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength);
381
+ vectors.set(Number(id), new Float32Array(slice));
314
382
  }
315
- } finally {
316
- try { db.close(); } catch { /* ignore */ }
383
+ } finally { closeQuiet(handle); }
384
+ return vectors;
385
+ }
386
+
387
+ /** 质心 = 组内向量逐分量平均(新数组,不就地改入参)。 */
388
+ function centroidOf(list) {
389
+ const dim = list[0].length;
390
+ const center = new Float32Array(dim);
391
+ for (const vec of list) {
392
+ for (let i = 0; i < dim; i += 1) center[i] += vec[i];
393
+ }
394
+ for (let i = 0; i < dim; i += 1) center[i] /= list.length;
395
+ return center;
396
+ }
397
+
398
+ /** 在既有组里挑"质心最像且没过阈值"的那一个;挑不出来返回 null。 */
399
+ function nearestGroup(groups, vec, threshold, maxItems) {
400
+ let winner = null;
401
+ let bestScore = -1;
402
+ for (const group of groups) {
403
+ if (group.members.length >= maxItems) continue;
404
+ if (!group.centroid) continue;
405
+ const score = cosineSim(vec, group.centroid);
406
+ if (score > bestScore) { bestScore = score; winner = group; }
317
407
  }
318
- return out;
408
+ return bestScore >= threshold ? winner : null;
409
+ }
410
+
411
+ function newGroup(row, vec) {
412
+ return {
413
+ members: [row],
414
+ vectors: vec ? [vec] : [],
415
+ centroid: vec,
416
+ chars: row.textForModel.length,
417
+ simToAssign: vec ? null : 'no-vector',
418
+ };
319
419
  }
320
420
 
321
- /** 向量平均(就地返回新数组)。 */
322
- function centroidOf(vecs) {
323
- const dim = vecs[0].length;
324
- const c = new Float32Array(dim);
325
- for (const v of vecs) for (let i = 0; i < dim; i += 1) c[i] += v[i];
326
- for (let i = 0; i < dim; i += 1) c[i] /= vecs.length;
327
- return c;
421
+ function absorbInto(group, row, vec) {
422
+ group.members.push(row);
423
+ group.vectors.push(vec);
424
+ group.centroid = centroidOf(group.vectors);
425
+ group.chars += row.textForModel.length;
328
426
  }
329
427
 
330
428
  /**
331
- * 贪心语义聚类(单遍;O(n·k))。
332
- * · 有向量的条 → 归入"质心最相似且 ≥ 阈值"的既有组(组满则另起一组);
333
- * · **没有向量的条**(刚写入还没编码)→ **自成一组**(不猜、不按 tags 兜底)。
334
- *
335
- * @returns {{clusters:Array<{key:string, members:Array, chars:number, centroid:Float32Array|null}>, stats:object}}
429
+ * 单遍贪心聚类(O(n·组数)):
430
+ * · 有向量的条 ⇒ 归到"质心最像且相似度过阈值"的组;组满了就另起一组;
431
+ * · **没有向量的条**(刚写进来还没编码)⇒ 自成一组。不按标签猜,也不硬塞。
336
432
  */
337
433
  export function clusterRows(rows, opts = {}) {
338
434
  const vectorsDbPath = opts.vectorsDbPath ?? null;
339
- const sim = Number(opts.simThreshold ?? CLUSTER_DEFAULTS.simThreshold);
435
+ const threshold = Number(opts.simThreshold ?? CLUSTER_DEFAULTS.simThreshold);
340
436
  const maxItems = Number(opts.maxItemsPerCluster ?? CLUSTER_DEFAULTS.maxItemsPerCluster);
341
- const vecs = loadVectors(vectorsDbPath, rows.map((r) => r.id));
342
- const clusters = [];
343
- let noVector = 0;
437
+ const lookup = loadRowVectors(vectorsDbPath, rows.map((row) => row.id));
438
+ const groups = [];
439
+ let withoutVector = 0;
440
+
344
441
  for (const row of rows) {
345
- const v = vecs.get(row.id) ?? null;
346
- if (!v) noVector += 1;
347
- let best = null;
348
- let bestSim = -1;
349
- if (v) {
350
- for (const c of clusters) {
351
- if (c.members.length >= maxItems) continue;
352
- if (!c.centroid) continue;
353
- const s = cosine(v, c.centroid);
354
- if (s > bestSim) { bestSim = s; best = c; }
355
- }
356
- }
357
- if (best && bestSim >= sim) {
358
- best.members.push(row);
359
- best.vecs.push(v);
360
- best.centroid = centroidOf(best.vecs);
361
- best.chars += row.textForModel.length;
362
- } else {
363
- clusters.push({ members: [row], vecs: v ? [v] : [], centroid: v, chars: row.textForModel.length, simToAssign: v ? null : 'no-vector' });
442
+ const vec = lookup.get(row.id) ?? null;
443
+ if (!vec) {
444
+ withoutVector += 1;
445
+ groups.push(newGroup(row, null));
446
+ continue;
364
447
  }
448
+ const target = nearestGroup(groups, vec, threshold, maxItems);
449
+ if (target) absorbInto(target, row, vec);
450
+ else groups.push(newGroup(row, vec));
365
451
  }
366
- for (const c of clusters) {
367
- c.key = srcKeyOf(c.members);
368
- c.ids = c.members.map((m) => m.id);
369
- delete c.vecs;
370
- }
452
+
453
+ const clusters = groups.map((group) => {
454
+ const { vectors, ...keep } = group;
455
+ return { ...keep, key: srcKeyOf(group.members), ids: group.members.map((member) => member.id) };
456
+ });
371
457
  return {
372
458
  clusters,
373
459
  stats: {
374
- rows: rows.length, clusters: clusters.length, withVector: vecs.size, noVector,
375
- singletonClusters: clusters.filter((c) => c.members.length === 1).length,
376
- simThreshold: sim,
460
+ rows: rows.length,
461
+ clusters: clusters.length,
462
+ withVector: lookup.size,
463
+ noVector: withoutVector,
464
+ singletonClusters: clusters.filter((cluster) => cluster.members.length === 1).length,
465
+ simThreshold: threshold,
377
466
  },
378
467
  };
379
468
  }
380
469
 
381
- // ── LLM ────────────────────────────────────────────────────────────────────
470
+ // ── 提示词与模型调用 ───────────────────────────────────────────────────────
471
+
472
+ const SYSTEM_RULES = [
473
+ '你是一个长期记忆的「提炼器」。给你若干组同主题的中文记忆条目(组编号 + 条目编号 + 正文),',
474
+ '把**每一组**提炼成 1 条**长期有效的结论**(将来会作为一条独立记忆保存下来)。',
475
+ '规则:',
476
+ '1. 只写这些条目**共同支撑**的结论;不得引入条目里没有的事实,不得脑补,不得评价作者。',
477
+ '2. 结论要能长期复用:写清「是什么 / 在什么条件下适用 / 关键做法或阈值」;',
478
+ ' 禁止「今天/昨天/本次」这类相对时间,必须提日期时写绝对日期。',
479
+ '3. 若这组只是流水账、提炼不出长期结论 ⇒ conclusion 置为空字符串(宁缺勿滥)。',
480
+ '4. conclusion 用中文写成一整段(不要分点、不要 markdown、不要"综上所述"),不超过 200 字。',
481
+ '5. 输出**严格 JSON**:{"items":[{"key":"<组编号>","conclusion":"..."}]};',
482
+ ' 每组一条、key 逐字照抄输入的组编号、不要漏组、不要输出代码围栏或任何解释文字。',
483
+ ];
382
484
 
383
485
  export function buildSystemPrompt() {
384
- return [
385
- '你是一个长期记忆的「提炼器」。给你若干组同主题的中文记忆条目(组编号 + 条目编号 + 正文),',
386
- '把**每一组**提炼成 1 条**长期有效的结论**(将来会作为一条独立记忆保存下来)。',
387
- '规则:',
388
- '1. 只写这些条目**共同支撑**的结论;不得引入条目里没有的事实,不得脑补,不得评价作者。',
389
- '2. 结论要能长期复用:写清「是什么 / 在什么条件下适用 / 关键做法或阈值」;',
390
- ' 禁止「今天/昨天/本次」这类相对时间,必须提日期时写绝对日期。',
391
- '3. 若这组只是流水账、提炼不出长期结论 ⇒ conclusion 置为空字符串(宁缺勿滥)。',
392
- '4. conclusion 用中文写成一整段(不要分点、不要 markdown、不要"综上所述"),不超过 200 字。',
393
- '5. 输出**严格 JSON**:{"items":[{"key":"<组编号>","conclusion":"..."}]};',
394
- ' 每组一条、key 逐字照抄输入的组编号、不要漏组、不要输出代码围栏或任何解释文字。',
395
- ].join('\n');
486
+ return SYSTEM_RULES.join('\n');
396
487
  }
397
488
 
398
489
  export function buildUserPrompt(clusters) {
399
- const blocks = clusters.map((c) => {
400
- const lines = c.members.map((m) => `[${m.id}] ${m.textForModel.replace(/\s+/g, ' ').trim()}`);
401
- return `【组 ${shortKey(c.key)}】\n${lines.join('\n')}`;
490
+ const blocks = clusters.map((cluster) => {
491
+ const body = cluster.members
492
+ .map((member) => `[${member.id}] ${member.textForModel.replace(/\s+/g, ' ').trim()}`)
493
+ .join('\n');
494
+ return `【组 ${shortKey(cluster.key)}】\n${body}`;
402
495
  });
403
496
  return `共 ${clusters.length} 组,请逐组提炼并按要求输出 JSON:\n\n${blocks.join('\n\n')}`;
404
497
  }
405
498
 
406
- /** 估算一次批量调用的 token(保守;调用前预判成本闸)。 */
499
+ /** 保守估算一次批量调用的 token;调用前先用它过成本闸。 */
407
500
  export function estimateTokens(clusters) {
408
- const chars = clusters.reduce((n, c) => n + c.chars + 32, 0) + buildSystemPrompt().length + 200;
409
- return Math.ceil(chars / 1.4) + 400;
501
+ const fixed = buildSystemPrompt().length + 200;
502
+ const body = clusters.reduce((sum, cluster) => sum + cluster.chars + 32, 0);
503
+ return Math.ceil((body + fixed) / 1.4) + 400;
410
504
  }
411
505
 
412
- /** 调一次提炼模型(`fetchImpl` 可注入 ⇒ 单测不真发网络)。**单次尝试,不重试。** */
413
- export async function callDistiller({ clusters, apiKey, baseUrl = DEFAULT_LLM.baseUrl, model = DEFAULT_LLM.model, fetchImpl = fetch, timeoutMs = DEFAULT_LLM.timeoutMs, maxTokens = DEFAULT_LLM.maxTokens, temperature = 0.2 }) {
414
- const body = {
506
+ /** 组装请求体。`thinking` 必须显式关掉:该模型默认开思考,白烧 token 且 content 可能为空。 */
507
+ function distillerPayload({ model, clusters, temperature, maxTokens }) {
508
+ return {
415
509
  model,
416
510
  messages: [
417
511
  { role: 'system', content: buildSystemPrompt() },
418
512
  { role: 'user', content: buildUserPrompt(clusters) },
419
513
  ],
420
- // deepseek-flash 默认开思考(effort=high ⇒ 白烧 token,且 content 可能为空)⇒ 显式关思考
421
- // 官方口径见 api-docs.deepseek.com/guides/thinking_mode:`{"thinking":{"type":"disabled"}}`
422
514
  thinking: { type: 'disabled' },
423
515
  temperature,
424
516
  max_tokens: maxTokens,
425
517
  stream: false,
426
518
  };
427
- const ctrl = new AbortController();
428
- const timer = setTimeout(() => ctrl.abort(), Number(timeoutMs));
429
- const t0 = Date.now();
519
+ }
520
+
521
+ /** 模型输出可能是 `{items:[...]}` 也可能直接是数组,两种都认。 */
522
+ function itemsOf(parsed) {
523
+ if (Array.isArray(parsed)) return parsed;
524
+ return Array.isArray(parsed?.items) ? parsed.items : null;
525
+ }
526
+
527
+ /** 调一次提炼模型(`fetchImpl` 可注入 ⇒ 单测不真发网络)。**单次尝试,不重试。** */
528
+ export async function callDistiller({ clusters, apiKey, baseUrl = DEFAULT_LLM.baseUrl, model = DEFAULT_LLM.model, fetchImpl = fetch, timeoutMs = DEFAULT_LLM.timeoutMs, maxTokens = DEFAULT_LLM.maxTokens, temperature = 0.2 }) {
529
+ const payload = distillerPayload({ model, clusters, temperature, maxTokens });
530
+ const controller = new AbortController();
531
+ const timer = setTimeout(() => controller.abort(), Number(timeoutMs));
532
+ const beganAt = Date.now();
533
+ const spent = () => Date.now() - beganAt;
430
534
  try {
431
- const res = await fetchImpl(`${baseUrl}/chat/completions`, {
535
+ const response = await fetchImpl(`${baseUrl}/chat/completions`, {
432
536
  method: 'POST',
433
537
  headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
434
- body: JSON.stringify(body),
435
- signal: ctrl.signal,
538
+ body: JSON.stringify(payload),
539
+ signal: controller.signal,
436
540
  });
437
- const text = await res.text();
438
- const ms = Date.now() - t0;
439
- if (!res.ok) return { ok: false, error: `HTTP ${res.status}: ${text.slice(0, 300)}`, ms };
440
- let json;
441
- try { json = JSON.parse(text); } catch (e) { return { ok: false, error: `响应不是 JSON:${e.message}`, raw: text.slice(0, 300), ms }; }
442
- const content = json?.choices?.[0]?.message?.content ?? '';
443
- const usage = json?.usage ?? {};
541
+ const raw = await response.text();
542
+ const ms = spent();
543
+ if (!response.ok) return { ok: false, error: `HTTP ${response.status}: ${raw.slice(0, 300)}`, ms };
544
+
545
+ let envelope;
546
+ try {
547
+ envelope = JSON.parse(raw);
548
+ } catch (parseError) {
549
+ return { ok: false, error: `响应不是 JSON:${parseError.message}`, raw: raw.slice(0, 300), ms };
550
+ }
551
+ const content = envelope?.choices?.[0]?.message?.content ?? '';
552
+ const usage = envelope?.usage ?? {};
444
553
  const parsed = extractJson(content);
445
554
  if (!parsed) return { ok: false, error: '模型输出里找不到可解析的 JSON', raw: String(content).slice(0, 300), usage, ms };
446
- const list = Array.isArray(parsed) ? parsed : (Array.isArray(parsed.items) ? parsed.items : null);
447
- if (!list) return { ok: false, error: 'JSON 结构不含 items 数组', raw: String(content).slice(0, 300), usage, ms };
448
- return { ok: true, items: list, usage, content, ms };
449
- } catch (e) {
450
- return { ok: false, error: `请求失败:${e && e.message ? e.message : e}`, ms: Date.now() - t0 };
555
+ const items = itemsOf(parsed);
556
+ if (!items) return { ok: false, error: 'JSON 结构不含 items 数组', raw: String(content).slice(0, 300), usage, ms };
557
+ return { ok: true, items, usage, content, ms };
558
+ } catch (failure) {
559
+ return { ok: false, error: `请求失败:${failure && failure.message ? failure.message : failure}`, ms: spent() };
451
560
  } finally {
452
561
  clearTimeout(timer);
453
562
  }
454
563
  }
455
564
 
456
- // ── 去重(先查库,再决定是否调 LLM) ────────────────────────────────────────
565
+ // ── 去重源(先查库,再决定要不要调模型) ────────────────────────────────────
457
566
 
458
- /** 库里已有的提炼条(`src=` 键集 + 正文哈希集)——**durable** 去重源(state 丢了也能挡)。 */
567
+ /** 库里已有的提炼条:`src=` 键集 + 正文哈希集。**durable** 去重源(状态文件丢了也挡得住)。 */
459
568
  export function existingDistilled(dbPath) {
460
- const db = openMemoryDb(dbPath);
569
+ const handle = openMemoryDb(dbPath);
461
570
  try {
462
- const rows = db.prepare("SELECT id, text, tags FROM memories WHERE tags LIKE 'distilled%'").all();
571
+ const rows = handle.prepare("SELECT id, text, tags FROM memories WHERE tags LIKE 'distilled%';").all();
463
572
  const keys = new Set();
464
573
  const textHashes = new Set();
465
- for (const r of rows) {
466
- const k = srcKeyInTags(r.tags);
467
- if (k) keys.add(k);
468
- textHashes.add(textHash(r.text));
574
+ for (const row of rows) {
575
+ const key = srcKeyInTags(row.tags);
576
+ if (key) keys.add(key);
577
+ textHashes.add(textHash(row.text));
469
578
  }
470
- return { count: rows.length, keys, textHashes, rows: rows.map((r) => ({ id: Number(r.id), tags: r.tags })) };
471
- } finally {
472
- try { db.close(); } catch { /* ignore */ }
473
- }
579
+ return {
580
+ count: rows.length,
581
+ keys,
582
+ textHashes,
583
+ rows: rows.map((row) => ({ id: Number(row.id), tags: row.tags })),
584
+ };
585
+ } finally { closeQuiet(handle); }
474
586
  }
475
587
 
476
- // ── state 与成本闸 ──────────────────────────────────────────────────────────
588
+ // ── 状态文件与成本闸 ────────────────────────────────────────────────────────
589
+
590
+ function blankCost(nowIso) {
591
+ return { date: localDateIso(Date.parse(nowIso) || Date.now()), calls: 0, promptTokens: 0, completionTokens: 0 };
592
+ }
477
593
 
478
594
  export function defaultState(dbPath, nowIso) {
479
595
  return {
480
596
  schemaVersion: STATE_SCHEMA,
481
597
  dbPath,
482
598
  updatedAt: nowIso,
483
- cost: { date: localDateIso(Date.parse(nowIso) || Date.now()), calls: 0, promptTokens: 0, completionTokens: 0 },
599
+ cost: blankCost(nowIso),
484
600
  processed: {},
485
601
  skipped: {},
486
602
  lastRun: null,
@@ -491,207 +607,232 @@ export function defaultState(dbPath, nowIso) {
491
607
  }
492
608
 
493
609
  export function readState(stateFile, dbPath, nowIso) {
610
+ const fresh = () => defaultState(dbPath, nowIso);
494
611
  try {
495
- if (!fs.existsSync(stateFile)) return defaultState(dbPath, nowIso);
496
- const raw = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
497
- const base = defaultState(dbPath, nowIso);
612
+ if (!fs.existsSync(stateFile)) return fresh();
613
+ const stored = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
614
+ const base = fresh();
498
615
  const merged = {
499
- ...base, ...raw,
500
- cost: { ...base.cost, ...(raw.cost ?? {}) },
501
- stats: { ...base.stats, ...(raw.stats ?? {}) },
502
- processed: raw.processed ?? {}, skipped: raw.skipped ?? {},
503
- history: Array.isArray(raw.history) ? raw.history : [],
616
+ ...base,
617
+ ...stored,
618
+ cost: { ...base.cost, ...(stored.cost ?? {}) },
619
+ stats: { ...base.stats, ...(stored.stats ?? {}) },
620
+ processed: stored.processed ?? {},
621
+ skipped: stored.skipped ?? {},
622
+ history: Array.isArray(stored.history) ? stored.history : [],
504
623
  };
505
- // 跨日 ⇒ 当日成本归零(成本闸是"每日"口径)
506
- if (merged.cost.date !== localDateIso(Date.parse(nowIso) || Date.now())) {
507
- merged.cost = { date: localDateIso(Date.parse(nowIso) || Date.now()), calls: 0, promptTokens: 0, completionTokens: 0 };
508
- }
624
+ // 成本闸是"每日"口径 ⇒ 跨日就把当日计数清零
625
+ if (merged.cost.date !== base.cost.date) return { ...merged, cost: { ...base.cost } };
509
626
  return merged;
510
627
  } catch {
511
- return defaultState(dbPath, nowIso);
628
+ return fresh();
512
629
  }
513
630
  }
514
631
 
515
632
  export function writeState(stateFile, state) {
516
633
  fs.mkdirSync(path.dirname(stateFile), { recursive: true });
517
- const tmp = `${stateFile}.tmp-${process.pid}`;
518
- fs.writeFileSync(tmp, `${JSON.stringify(pruneState(state), null, 2)}\n`, 'utf8');
519
- try {
520
- fs.renameSync(tmp, stateFile);
521
- } catch {
522
- fs.copyFileSync(tmp, stateFile);
523
- try { fs.unlinkSync(tmp); } catch { /* ignore */ }
634
+ const staged = `${stateFile}.tmp-${process.pid}`;
635
+ fs.writeFileSync(staged, `${JSON.stringify(pruneState(state), null, 2)}\n`, 'utf8');
636
+ let moved = true;
637
+ try { fs.renameSync(staged, stateFile); } catch { moved = false; }
638
+ if (!moved) {
639
+ fs.copyFileSync(staged, stateFile);
640
+ try { fs.unlinkSync(staged); } catch { /* 临时件删不掉也没有影响 */ }
524
641
  }
525
642
  return stateFile;
526
643
  }
527
644
 
528
- /** 台账裁剪:`processed` 只留最近 PROCESSED_MAX 条(**去重的权威源是库里的 `src=` 标签**,state 只是快查缓存)。 */
645
+ /** `processed` 只留最近若干条 —— 去重的权威源是库里的 `src=` 标签,这里只是快查缓存。 */
529
646
  export function pruneState(state) {
530
647
  const entries = Object.entries(state.processed ?? {});
531
648
  if (entries.length <= PROCESSED_MAX) return state;
532
- entries.sort((a, b) => String(a[1]?.at ?? '').localeCompare(String(b[1]?.at ?? '')));
533
- const keep = Object.fromEntries(entries.slice(entries.length - PROCESSED_MAX));
534
- return { ...state, processed: keep };
649
+ const ordered = entries.sort((left, right) => String(left[1]?.at ?? '').localeCompare(String(right[1]?.at ?? '')));
650
+ return { ...state, processed: Object.fromEntries(ordered.slice(ordered.length - PROCESSED_MAX)) };
535
651
  }
536
652
 
537
- /** 成本闸判定:还能不能再调一次(§3.5:≤20 次/日、≤150K token/日,**超限即停不重试**)。 */
653
+ /** 成本闸:逐条列上限,第一条被触发的就返回它的原因(超限即停,不重试)。 */
538
654
  export function costGate(state, estimatedTokens, limits = COST_LIMITS) {
539
- const used = Number(state.cost.promptTokens ?? 0) + Number(state.cost.completionTokens ?? 0);
540
- const calls = Number(state.cost.calls ?? 0);
541
- if (calls >= limits.callsPerDay) return { ok: false, reason: `当日调用数已达上限 ${limits.callsPerDay} 次` };
542
- if (used >= limits.tokensPerDay) return { ok: false, reason: `当日 token 已达上限 ${limits.tokensPerDay}` };
543
- if (used + estimatedTokens > limits.tokensPerDay) {
544
- return { ok: false, reason: `本组估算 ${estimatedTokens} token 会超过当日上限(已用 ${used}/${limits.tokensPerDay})` };
545
- }
546
- return { ok: true };
655
+ const spent = Number(state.cost.promptTokens ?? 0) + Number(state.cost.completionTokens ?? 0);
656
+ const usedCalls = Number(state.cost.calls ?? 0);
657
+ const verdicts = [
658
+ { hit: usedCalls >= limits.callsPerDay, reason: `当日调用数已达上限 ${limits.callsPerDay} 次` },
659
+ { hit: spent >= limits.tokensPerDay, reason: `当日 token 已达上限 ${limits.tokensPerDay}` },
660
+ { hit: spent + estimatedTokens > limits.tokensPerDay, reason: `本组估算 ${estimatedTokens} token 会超过当日上限(已用 ${spent}/${limits.tokensPerDay})` },
661
+ ];
662
+ const blocked = verdicts.find((verdict) => verdict.hit);
663
+ return blocked ? { ok: false, reason: blocked.reason } : { ok: true };
547
664
  }
548
665
 
549
- // ── 写入 ────────────────────────────────────────────────────────────────────
666
+ // ── 写库 ───────────────────────────────────────────────────────────────────
667
+
668
+ /** 开一次显式事务跑一段同步逻辑;失败先回滚再把原错误抛出来(错误信息一个字都不许改)。 */
669
+ function runTransaction(handle, body) {
670
+ try {
671
+ handle.exec('BEGIN IMMEDIATE');
672
+ } catch (error) {
673
+ return { ok: false, error };
674
+ }
675
+ try {
676
+ const value = body();
677
+ handle.exec('COMMIT');
678
+ return { ok: true, value };
679
+ } catch (error) {
680
+ try { handle.exec('ROLLBACK'); } catch { /* 回滚失败也要把原始错误抛出去 */ }
681
+ return { ok: false, error };
682
+ }
683
+ }
550
684
 
551
- /** 带重试的写事务(口径与 domain-tags 的 `withBusyRetry` 同源)。 */
685
+ /**
686
+ * 带退避重试的写事务(只在"库被占用"这类错误上重试)。
687
+ * 三次机会,退避 1s / 4s / 16s,累计等待不超过 60s。
688
+ */
552
689
  export async function withBusyRetry(db, fn, deps = {}) {
553
- const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
690
+ const pause = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
554
691
  const backoff = [1000, 4000, 16000];
555
- const cap = 60000;
556
- let waited = 0;
557
- const started = Date.now();
558
- let lastErr = null;
692
+ const ceiling = 60000;
693
+ const startedAt = Date.now();
694
+ let slept = 0;
695
+ let lastError = null;
696
+
559
697
  for (let attempt = 1; attempt <= 3; attempt += 1) {
560
- try {
561
- db.exec('BEGIN IMMEDIATE');
562
- try {
563
- const value = fn();
564
- db.exec('COMMIT');
565
- return { ok: true, value, attempts: attempt, waitedMs: waited };
566
- } catch (e) {
567
- try { db.exec('ROLLBACK'); } catch { /* ignore */ }
568
- throw e;
569
- }
570
- } catch (e) {
571
- lastErr = e;
572
- const busy = /busy|locked/i.test(String(e?.message ?? e));
573
- if (!busy || attempt === 3) break;
574
- const wait = backoff[attempt - 1];
575
- if (waited + wait > cap || Date.now() - started + wait > cap) break;
576
- await sleep(wait);
577
- waited += wait;
578
- }
698
+ const outcome = runTransaction(db, fn);
699
+ if (outcome.ok) return { ok: true, value: outcome.value, attempts: attempt, waitedMs: slept };
700
+ lastError = outcome.error;
701
+ const contended = /busy|locked/i.test(String(outcome.error?.message ?? outcome.error));
702
+ if (!contended || attempt === 3) break;
703
+ const nap = backoff[attempt - 1];
704
+ const projected = Math.max(slept + nap, Date.now() - startedAt + nap);
705
+ if (projected > ceiling) break;
706
+ await pause(nap);
707
+ slept += nap;
579
708
  }
580
- return { ok: false, attempts: 3, error: String(lastErr?.message ?? lastErr), waitedMs: waited };
709
+ return { ok: false, attempts: 3, error: String(lastError?.message ?? lastError), waitedMs: slept };
710
+ }
711
+
712
+ function insertStatement(handle, withOwner) {
713
+ const columns = 'text, tags, pinned, created_at, updated_at';
714
+ return withOwner
715
+ ? handle.prepare(`INSERT INTO memories (${columns}, owner_user, visibility) VALUES (?,?,0,?,?,?,?);`)
716
+ : handle.prepare(`INSERT INTO memories (${columns}) VALUES (?,?,0,?,?);`);
581
717
  }
582
718
 
583
719
  /**
584
- * 写入提炼条(**幂等**:库里已有同 `src=` 键 ⇒ 跳过)。
585
- * @returns {{written:number, skippedExisting:number, ids:number[], details:Array}}
720
+ * 写入提炼条(**幂等**:库里已有同 `src=` 键就跳过,不重复写)。
721
+ * @returns {{written:number, skippedExisting:number, ids:number[], details:Array, ok:boolean, error:string|null}}
586
722
  */
587
723
  export async function applyDistilled(dbPath, results, opts = {}) {
588
- const now = Number(opts.now ?? Date.now());
589
- const ownerUser = opts.ownerUser ?? null;
590
- const visibility = opts.visibility ?? 'private_owner';
724
+ const stamp = Number(opts.now ?? Date.now());
725
+ const fallbackOwner = opts.ownerUser ?? null;
726
+ const fallbackVisibility = opts.visibility ?? 'private_owner';
591
727
  const deps = opts.deps ?? {};
592
- const db = new DatabaseSync(dbPath);
593
- let out = { written: 0, skippedExisting: 0, ids: [], details: [] };
728
+
729
+ const handle = new DatabaseSync(dbPath);
730
+ const details = [];
731
+ const ids = [];
732
+ let written = 0;
733
+ let skippedExisting = 0;
594
734
  try {
595
- db.exec('PRAGMA busy_timeout = 5000;');
596
- const cols = db.prepare('PRAGMA table_info(memories)').all().map((r) => r.name);
597
- const hasOwner = cols.includes('owner_user') && cols.includes('visibility');
598
- const existing = new Set(
599
- db.prepare("SELECT tags FROM memories WHERE tags LIKE 'distilled%'").all()
600
- .map((r) => srcKeyInTags(r.tags)).filter(Boolean),
735
+ handle.exec(BUSY_TIMEOUT_SQL);
736
+ const withOwner = hasOwnerColumns(handle);
737
+ const already = new Set(
738
+ handle.prepare("SELECT tags FROM memories WHERE tags LIKE 'distilled%';").all()
739
+ .map((row) => srcKeyInTags(row.tags))
740
+ .filter(Boolean),
601
741
  );
602
- const details = [];
603
- let written = 0;
604
- let skippedExisting = 0;
605
- const ids = [];
606
- const r = await withBusyRetry(db, () => {
607
- const ins = hasOwner
608
- ? db.prepare('INSERT INTO memories (text, tags, pinned, created_at, updated_at, owner_user, visibility) VALUES (?, ?, 0, ?, ?, ?, ?)')
609
- : db.prepare('INSERT INTO memories (text, tags, pinned, created_at, updated_at) VALUES (?, ?, 0, ?, ?)');
610
- for (const it of results) {
611
- const key = shortKey(it.srcKey);
612
- if (existing.has(key)) { skippedExisting += 1; details.push({ srcKey: key, action: 'already-in-db' }); continue; }
613
- const tags = distilledTagOf({ srcKey: key, srcIds: it.srcIds ?? [], now });
614
- const info = hasOwner
615
- ? ins.run(it.conclusion, tags, now, now, it.ownerUser ?? ownerUser, it.visibility ?? visibility)
616
- : ins.run(it.conclusion, tags, now, now);
742
+ const insert = insertStatement(handle, withOwner);
743
+
744
+ const outcome = await withBusyRetry(handle, () => {
745
+ for (const entry of results) {
746
+ const key = shortKey(entry.srcKey);
747
+ if (already.has(key)) {
748
+ skippedExisting += 1;
749
+ details.push({ srcKey: key, action: 'already-in-db' });
750
+ continue;
751
+ }
752
+ const tags = distilledTagOf({ srcKey: key, srcIds: entry.srcIds ?? [], now: stamp });
753
+ const info = withOwner
754
+ ? insert.run(entry.conclusion, tags, stamp, stamp, entry.ownerUser ?? fallbackOwner, entry.visibility ?? fallbackVisibility)
755
+ : insert.run(entry.conclusion, tags, stamp, stamp);
617
756
  const newId = Number(info.lastInsertRowid ?? 0);
618
757
  ids.push(newId);
619
758
  written += 1;
620
- details.push({ srcKey: key, action: 'written', id: newId, tags, chars: String(it.conclusion).length });
759
+ details.push({ srcKey: key, action: 'written', id: newId, tags, chars: String(entry.conclusion).length });
621
760
  }
622
761
  return { written, skippedExisting };
623
762
  }, deps);
624
- out = { ...out, written: r.value?.written ?? 0, skippedExisting: r.value?.skippedExisting ?? 0, ids, details, ok: r.ok, error: r.error ?? null };
625
- } finally {
626
- try { db.close(); } catch { /* ignore */ }
627
- }
628
- return out;
763
+
764
+ return {
765
+ written: outcome.value?.written ?? 0,
766
+ skippedExisting: outcome.value?.skippedExisting ?? 0,
767
+ ids,
768
+ details,
769
+ ok: outcome.ok,
770
+ error: outcome.error ?? null,
771
+ };
772
+ } finally { closeQuiet(handle); }
629
773
  }
630
774
 
631
- // ── 日报行 ──────────────────────────────────────────────────────────────────
775
+ // ── 日报那一行 ─────────────────────────────────────────────────────────────
632
776
 
633
777
  export const DAILY_SECTION_TITLE = '## 🧪 记忆提炼(memory-distill)';
634
778
 
635
- /** 「条数 + 首行样本 + 当日调用数与 token」(方案 §3.5 可观测行)。 */
779
+ /**
780
+ * 拼日报行:条数 / 首行样本 / 当日调用数与 token。
781
+ * 单轮组数上限属于**截断**而不是停机,所以要写清"本轮到哪、一共几组"。
782
+ */
636
783
  export function renderDailyLine({ mode, run, cost, limits = COST_LIMITS, stopping = null }) {
637
- const used = Number(cost.promptTokens ?? 0) + Number(cost.completionTokens ?? 0);
784
+ const spent = Number(cost.promptTokens ?? 0) + Number(cost.completionTokens ?? 0);
785
+ const sample = run?.firstSample ? `「${String(run.firstSample).slice(0, 60)}」` : '(无)';
638
786
  const parts = [
639
- `模式 ${mode}`,
640
- `输入 ${run?.inputs ?? 0} 条/${run?.clustersPlanned ?? 0} 组`,
641
- `提炼条数 ${run?.written ?? 0}`,
642
- `跳过(去重) ${run?.skippedDuplicate ?? 0}`,
643
- `跳过(非本机owner/家人) ${(run?.skippedOwner ?? 0) + (run?.skippedFamily ?? 0)}`,
644
- `当日调用 ${cost.calls ?? 0}/${limits.callsPerDay}`,
645
- `当日 token ${used}/${limits.tokensPerDay}`,
646
- `首行样本:${run?.firstSample ? `「${String(run.firstSample).slice(0, 60)}」` : '(无)'}`,
647
- ];
648
- // 单轮组数上限(`--max-clusters`)是**截断**不是"停机":必须写清本轮到哪、还剩多少
787
+ ['模式', mode],
788
+ ['输入', `${run?.inputs ?? 0} 条/${run?.clustersPlanned ?? 0} 组`],
789
+ ['提炼条数', run?.written ?? 0],
790
+ ['跳过(去重)', run?.skippedDuplicate ?? 0],
791
+ ['跳过(非本机owner/家人)', (run?.skippedOwner ?? 0) + (run?.skippedFamily ?? 0)],
792
+ ['当日调用', `${cost.calls ?? 0}/${limits.callsPerDay}`],
793
+ ['当日 token', `${spent}/${limits.tokensPerDay}`],
794
+ ].map(([label, value]) => `${label} ${value}`);
795
+ parts.push(`首行样本:${sample}`);
649
796
  if (run?.capped?.capped) parts.push(`本轮处理 ${run.capped.processed}/${run.capped.plannedCalls} 组(单轮上限 ${run.capped.cap})`);
650
797
  if (stopping) parts.push(`停机原因:${stopping}`);
651
798
  return parts.join(' · ');
652
799
  }
653
800
 
654
- /** 把日报行做「段级替换」(口径同 domain-tags;日报不存在则不新建)。 */
801
+ /** 日报段落结束位置:从标题下一行起,遇到下一个二级标题(且不是空行紧跟)为止。 */
802
+ function sectionEnd(lines, titleAt) {
803
+ let cursor = titleAt + 1;
804
+ while (cursor < lines.length && !(lines[cursor].startsWith('## ') && cursor > titleAt + 1)) cursor += 1;
805
+ return cursor;
806
+ }
807
+
808
+ /** 段级替换(同一天的日报只保留一条提炼行;日报不存在时不新建)。 */
655
809
  export function updateDailySection({ file, line, dryRun = false }) {
656
810
  if (!fs.existsSync(file)) return { ok: false, reason: '日报不存在(不新建)', file };
657
- const lines = fs.readFileSync(file, 'utf8').split('\n');
658
- const idx = lines.findIndex((l) => l.trim() === DAILY_SECTION_TITLE);
811
+ const original = fs.readFileSync(file, 'utf8').split('\n');
812
+ const titleAt = original.findIndex((entry) => entry.trim() === DAILY_SECTION_TITLE);
659
813
  const block = [DAILY_SECTION_TITLE, '', `> ${line}`, ''];
660
- let next;
661
- if (idx >= 0) {
662
- let end = idx + 1;
663
- while (end < lines.length && !(lines[end].startsWith('## ') && end > idx + 1)) end += 1;
664
- next = [...lines.slice(0, idx), ...block, ...lines.slice(end)];
665
- } else {
666
- next = [...lines, ...block];
667
- }
814
+ const next = titleAt >= 0
815
+ ? [...original.slice(0, titleAt), ...block, ...original.slice(sectionEnd(original, titleAt))]
816
+ : [...original, ...block];
668
817
  if (!dryRun) fs.writeFileSync(file, next.join('\n'), 'utf8');
669
- return { ok: true, replaced: idx >= 0, file };
818
+ return { ok: true, replaced: titleAt >= 0, file };
670
819
  }
671
820
 
672
821
  export function dailyReportPath({ docsDir = path.join(process.cwd(), 'docs'), host = null, now = Date.now() } = {}) {
673
- const name = host || process.env.COMPUTERNAME || 'unknown-host';
674
- return path.join(docsDir, '管家报告', `${localDateIso(now)}-${name}.md`);
822
+ const machine = host || process.env.COMPUTERNAME || 'unknown-host';
823
+ return path.join(docsDir, '管家报告', `${localDateIso(now)}-${machine}.md`);
675
824
  }
676
825
 
677
- // ── 主流程 ──────────────────────────────────────────────────────────────────
826
+ // ── 主流程 ─────────────────────────────────────────────────────────────────
678
827
 
679
- /**
680
- * 一轮提炼(dry-run 时不写任何东西:不调 LLM、不写库、不写 state、不写台账)。
681
- *
682
- * @param {object} p
683
- * @param {string} p.memoryDb 记忆库路径(**测试一律副本**)
684
- * @param {string} p.vectorsDb 向量库路径(聚类 + 台账)
685
- * @param {string} p.stateFile `butler-distill-state.json`
686
- * @param {boolean} p.apply false = dry-run(默认)
687
- */
688
- export async function runDistill(p = {}) {
828
+ /** 一次运行的入参归一(缺省值即产品默认值,调用方可逐项覆盖)。 */
829
+ function readRunOptions(p) {
689
830
  const {
690
- memoryDb, vectorsDb, stateFile,
691
- now = Date.now(), windowHours = DEFAULT_WINDOW_HOURS, apply = false,
692
- limits = COST_LIMITS, model = DEFAULT_LLM.model, baseUrl = DEFAULT_LLM.baseUrl,
693
- apiKey = null, fetchImpl = fetch, timeoutMs = DEFAULT_LLM.timeoutMs,
694
- localOwner = null, limit = 0, extraIds = [], dirtyProbe = true,
831
+ memoryDb: memoryDbPath, vectorsDb: vectorsDbPath, stateFile: stateFilePath,
832
+ now = Date.now(), windowHours = DEFAULT_WINDOW_HOURS, apply = false, limits = COST_LIMITS,
833
+ model = DEFAULT_LLM.model, baseUrl = DEFAULT_LLM.baseUrl, apiKey = null,
834
+ fetchImpl = fetch, timeoutMs = DEFAULT_LLM.timeoutMs, localOwner = null,
835
+ limit = 0, extraIds = [], dirtyProbe = true,
695
836
  simThreshold = CLUSTER_DEFAULTS.simThreshold,
696
837
  maxItemsPerCluster = CLUSTER_DEFAULTS.maxItemsPerCluster,
697
838
  maxCharsPerItem = CLUSTER_DEFAULTS.maxCharsPerItem,
@@ -700,187 +841,339 @@ export async function runDistill(p = {}) {
700
841
  maxClustersPerRun = CLUSTER_DEFAULTS.maxClustersPerRun,
701
842
  snapshotFile = null, log = () => {}, deps = {},
702
843
  } = p;
844
+ return {
845
+ memoryDbPath, vectorsDbPath, stateFilePath, now, windowHours, apply, limits, model, baseUrl,
846
+ apiKey, fetchImpl, timeoutMs, localOwner, limit, extraIds, dirtyProbe, simThreshold,
847
+ maxItemsPerCluster, maxCharsPerItem, minClusterChars, minClusterSize, maxClustersPerRun,
848
+ snapshotFile, log, deps,
849
+ };
850
+ }
851
+
852
+ /** 台账补盲:全表比对 + 未消费台账,两者并起来就是本轮的补充输入源。 */
853
+ function probeDirtyInputs({ memoryDbPath, vectorsDbPath, shouldRecord, now }) {
854
+ const scan = detectDirty({ dbPath: memoryDbPath, vectorsDbPath, record: shouldRecord, now });
855
+ const ledger = readDirtyIds(vectorsDbPath);
856
+ return {
857
+ ids: [...new Set([...ledger.ids, ...scan.mismatched])],
858
+ summary: {
859
+ mismatched: scan.mismatched.length,
860
+ missingVectors: scan.missing.length,
861
+ ledgerOpen: ledger.stats.open,
862
+ ledgerTable: ledger.stats.table,
863
+ },
864
+ };
865
+ }
703
866
 
704
- const mode = apply ? 'apply' : 'dry-run';
705
- const nowIso = new Date(Number(now)).toISOString();
867
+ /** 失败记录的"哪一天":优先看显式 day,其次从时间戳前 10 位截。 */
868
+ function failureDay(failure) {
869
+ return failure.day ?? String(failure.at ?? '').slice(0, 10);
870
+ }
871
+
872
+ /** 给一个组下结论:跳过还是值得调模型。判定顺序即优先级,命中即返回。 */
873
+ function classifyCluster(cluster, plan) {
874
+ const key = shortKey(cluster.key);
875
+ const base = { key, ids: cluster.ids };
876
+ if (cluster.members.length < plan.minClusterSize) {
877
+ return { ...base, action: 'skip-singleton', chars: cluster.chars, reason: `组内 ${cluster.members.length} 条 < minClusterSize ${plan.minClusterSize}` };
878
+ }
879
+ if (cluster.chars < plan.minClusterChars) return { ...base, action: 'skip-too-short', chars: cluster.chars };
880
+ const handled = plan.processed[key];
881
+ if (handled) return { ...base, action: 'skip-processed', at: handled.at };
882
+ if (plan.knownKeys.has(key)) return { ...base, action: 'skip-in-db' };
883
+ // 同一天已经试过且失败/没结论 ⇒ 今天不再试("不重试"要跨轮也成立),换一天才给第二次机会。
884
+ // 依据:上游限流时如果同一天反复重跑,会对同一组反复烧额度。
885
+ const failure = plan.failures[key];
886
+ if (failure && failureDay(failure) === plan.today) {
887
+ return { ...base, action: 'skip-failed-today', reason: failure.reason };
888
+ }
889
+ return { key, ids: cluster.ids, action: 'call', chars: cluster.chars, members: cluster.members };
890
+ }
891
+
892
+ /** 从模型返回的一组结论里挑出与本组对应那一条(对不上就退化为第一条)。 */
893
+ function pickClusterItem(items, key) {
894
+ const hit = items.find((entry) => shortKey(entry?.key) === key);
895
+ return hit ?? items[0];
896
+ }
897
+
898
+ /**
899
+ * 处理一个组:过成本闸 → 调模型 → 记账 → 写库 → 更新状态。
900
+ * 返回 `{verdict}`:`stop`(成本闸拦下,调用方必须停机)/ `failed` / `empty` / `written`。
901
+ */
902
+ async function distillOneCluster(job, env) {
903
+ const estimate = estimateTokens([{ chars: job.chars }]);
904
+ const gate = costGate(env.state, estimate, env.limits);
905
+ if (!gate.ok) return { verdict: 'stop', reason: gate.reason };
906
+
907
+ const answer = await callDistiller({
908
+ clusters: [job], apiKey: env.apiKey, baseUrl: env.baseUrl, model: env.model,
909
+ fetchImpl: env.fetchImpl, timeoutMs: env.timeoutMs, maxTokens: DEFAULT_LLM.maxTokens,
910
+ });
911
+ // 调用即记账 —— 失败也记,因为额度是真的花掉了
912
+ env.state.cost.calls = Number(env.state.cost.calls ?? 0) + 1;
913
+ env.run.calls += 1;
914
+ const usage = answer.usage ?? {};
915
+ const promptTokens = Number(usage.prompt_tokens ?? 0);
916
+ const completionTokens = Number(usage.completion_tokens ?? 0);
917
+ env.state.cost.promptTokens = Number(env.state.cost.promptTokens ?? 0) + promptTokens;
918
+ env.state.cost.completionTokens = Number(env.state.cost.completionTokens ?? 0) + completionTokens;
919
+
920
+ const conclusion = String(pickClusterItem(answer.items ?? [], job.key)?.conclusion ?? '').trim();
921
+ env.result.calls.push({
922
+ key: job.key, ids: job.ids, ok: answer.ok, ms: answer.ms ?? null,
923
+ tokens: promptTokens + completionTokens,
924
+ conclusionChars: conclusion.length, error: answer.ok ? null : answer.error,
925
+ });
926
+
927
+ if (!answer.ok) {
928
+ env.run.modelFailures += 1;
929
+ env.state.skipped[job.key] = { at: env.nowIso, day: env.today, reason: `model-error: ${String(answer.error).slice(0, 160)}` };
930
+ return { verdict: 'failed' }; // 不重试:同一天再跑也走 skip-failed-today
931
+ }
932
+ if (!conclusion) {
933
+ env.state.skipped[job.key] = { at: env.nowIso, day: env.today, reason: '模型判定无可提炼的长期结论' };
934
+ return { verdict: 'empty' };
935
+ }
936
+
937
+ const outcome = await applyDistilled(
938
+ env.memoryDbPath,
939
+ [{ srcKey: job.key, srcIds: job.ids, conclusion, ownerUser: job.members[0]?.ownerUser, visibility: 'private_owner' }],
940
+ { now: env.now, ownerUser: env.localOwner, deps: env.deps },
941
+ );
942
+ env.result.written += outcome.written;
943
+ if (outcome.written) {
944
+ env.result.writtenIds.push(...outcome.ids);
945
+ env.run.written += outcome.written;
946
+ if (!env.run.firstSample) env.run.firstSample = conclusion;
947
+ env.state.processed[job.key] = { at: env.nowIso, ids: job.ids, outHash: textHash(conclusion), written: outcome.written, rowIds: outcome.ids };
948
+ } else {
949
+ env.result.warnings.push(`写库被跳过(库里已有同源键):${job.key}`);
950
+ env.state.processed[job.key] = { at: env.nowIso, ids: job.ids, outHash: textHash(conclusion), written: 0 };
951
+ }
952
+ return { verdict: 'written' };
953
+ }
954
+
955
+ /** 把一批 id 标成台账"已消费"(幂等;幂等性由台账层保证)。 */
956
+ function markLedgerConsumed(vectorsDbPath, ids, now) {
957
+ withDirtyDb(vectorsDbPath, (ledger) => {
958
+ ensureDirtySchema(ledger);
959
+ markConsumed(ledger, ids, { now });
960
+ });
961
+ }
962
+
963
+ /**
964
+ * 跑一轮提炼。
965
+ *
966
+ * dry-run 什么都不写:不调模型、不写库、不写状态文件、不写台账;只是把"本来会怎么走"算给你看。
967
+ *
968
+ * @param {object} p
969
+ * @param {string} p.memoryDb 记忆库路径(**测试一律指副本**)
970
+ * @param {string} p.vectorsDb 向量库路径(聚类与台账都在这里)
971
+ * @param {string} p.stateFile 状态文件(缺省落在库的上两级)
972
+ * @param {boolean} p.apply false = dry-run(默认)
973
+ */
974
+ export async function runDistill(p = {}) {
975
+ const opts = readRunOptions(p);
976
+ const mode = opts.apply ? 'apply' : 'dry-run';
977
+ const nowIso = new Date(Number(opts.now)).toISOString();
706
978
  const result = {
707
- ok: true, mode, at: nowIso, memoryDb, vectorsDb, stateFile, model, baseUrl,
708
- windowHours, limits, localOwner,
709
- inputs: null, clusters: [], planned: [], calls: [], written: 0, writtenIds: [],
710
- cost: null, stopping: null, dailyLine: null, warnings: [], dirty: null, snapshot: null,
711
- state: null, exitCode: 0,
979
+ ok: true,
980
+ mode,
981
+ at: nowIso,
982
+ memoryDb: opts.memoryDbPath,
983
+ vectorsDb: opts.vectorsDbPath,
984
+ stateFile: opts.stateFilePath,
985
+ model: opts.model,
986
+ baseUrl: opts.baseUrl,
987
+ windowHours: opts.windowHours,
988
+ limits: opts.limits,
989
+ localOwner: opts.localOwner,
990
+ inputs: null,
991
+ clusters: [],
992
+ planned: [],
993
+ calls: [],
994
+ written: 0,
995
+ writtenIds: [],
996
+ cost: null,
997
+ stopping: null,
998
+ dailyLine: null,
999
+ warnings: [],
1000
+ dirty: null,
1001
+ snapshot: null,
1002
+ state: null,
1003
+ exitCode: 0,
712
1004
  };
713
1005
 
714
- if (!memoryDb || !fs.existsSync(memoryDb)) {
1006
+ if (!opts.memoryDbPath || !fs.existsSync(opts.memoryDbPath)) {
715
1007
  result.ok = false;
716
1008
  result.exitCode = 3;
717
- result.stopping = `记忆库不存在:${memoryDb}`;
1009
+ result.stopping = `记忆库不存在:${opts.memoryDbPath}`;
718
1010
  return result;
719
1011
  }
720
1012
 
721
- // ① 台账(补 A7 盲区):dry-run 只算不写
1013
+ // ① 台账补盲(dry-run 只算不写)
722
1014
  let dirtyIds = [];
723
- if (dirtyProbe) {
724
- const d = detectDirty({ dbPath: memoryDb, vectorsDbPath: vectorsDb, record: apply, now });
725
- const led = readDirtyIds(vectorsDb);
726
- dirtyIds = [...new Set([...led.ids, ...d.mismatched])];
727
- result.dirty = { mismatched: d.mismatched.length, missingVectors: d.missing.length, ledgerOpen: led.stats.open, ledgerTable: led.stats.table };
728
- if (d.mismatched.length) log(`[distill] 全表 hash 比对:${d.mismatched.length} 条正文与向量库不符(台账补充输入)`);
1015
+ if (opts.dirtyProbe) {
1016
+ const probe = probeDirtyInputs({
1017
+ memoryDbPath: opts.memoryDbPath, vectorsDbPath: opts.vectorsDbPath,
1018
+ shouldRecord: opts.apply, now: opts.now,
1019
+ });
1020
+ dirtyIds = probe.ids;
1021
+ result.dirty = probe.summary;
1022
+ if (probe.summary.mismatched) {
1023
+ opts.log(`[distill] 全表 hash 比对:${probe.summary.mismatched} 条正文与向量库不符(台账补充输入)`);
1024
+ }
729
1025
  }
730
1026
 
731
- // ② 挑输入(含 D2 从严筛)
732
- const sel = selectInputs({
733
- dbPath: memoryDb, now, windowHours, dirtyIds, extraIds, localOwner,
734
- maxCharsPerItem, limit,
1027
+ // ② 挑输入(含归属闸)
1028
+ const selection = selectInputs({
1029
+ dbPath: opts.memoryDbPath, now: opts.now, windowHours: opts.windowHours,
1030
+ dirtyIds, extraIds: opts.extraIds, localOwner: opts.localOwner,
1031
+ maxCharsPerItem: opts.maxCharsPerItem, limit: opts.limit,
735
1032
  });
736
- result.inputs = sel.stats;
1033
+ result.inputs = selection.stats;
737
1034
  result.dirtyInputIds = dirtyIds;
738
- if (sel.family.length) result.warnings.push(`D2:${sel.family.length} 条 visibility=family 已排除(不进第三方)`);
739
- if (sel.unknownOwner.length) result.warnings.push(`D2:${sel.unknownOwner.length} 条归属不可判定已排除(不进第三方)`);
740
- if (!sel.ownerColumn) result.warnings.push('库无 owner_user 列 ⇒ 归属不可判定,本轮 0 调用(红线:不猜)');
741
- log(`[distill] 输入:窗口 ${sel.stats.windowRows} + 台账 ${sel.stats.dirtyRows} ⇒ 候选 ${sel.stats.candidates},`
742
- + `接受 ${sel.stats.accepted}(排除 family ${sel.stats.skippedFamily} / 非本机 owner ${sel.stats.skippedUnknownOwner} / distilled ${sel.stats.distilledExcluded})`);
1035
+ if (selection.family.length) result.warnings.push(`D2:${selection.family.length} 条 visibility=family 已排除(不进第三方)`);
1036
+ if (selection.unknownOwner.length) result.warnings.push(`D2:${selection.unknownOwner.length} 条归属不可判定已排除(不进第三方)`);
1037
+ if (!selection.ownerColumn) result.warnings.push('库无 owner_user 列 ⇒ 归属不可判定,本轮 0 调用(红线:不猜)');
1038
+ opts.log(`[distill] 输入:窗口 ${selection.stats.windowRows} + 台账 ${selection.stats.dirtyRows} ⇒ 候选 ${selection.stats.candidates},`
1039
+ + `接受 ${selection.stats.accepted}(排除 family ${selection.stats.skippedFamily} / 非本机 owner ${selection.stats.skippedUnknownOwner} / distilled ${selection.stats.distilledExcluded})`);
743
1040
 
744
1041
  // ③ 聚类 + 去重规划
745
- const cl = clusterRows(sel.accepted, { vectorsDbPath: vectorsDb, simThreshold, maxItemsPerCluster });
746
- result.clusterStats = cl.stats;
747
- const existing = existingDistilled(memoryDb);
748
- const state = readState(stateFile ?? path.join(path.dirname(memoryDb), '..', 'butler-distill-state.json'), memoryDb, nowIso);
749
- const today = localDateIso(now);
750
- const planned = [];
751
- for (const c of cl.clusters.sort((a, b) => b.members.length - a.members.length || a.ids[0] - b.ids[0])) {
752
- const key = shortKey(c.key);
753
- if (c.members.length < Number(minClusterSize)) {
754
- planned.push({ key, ids: c.ids, action: 'skip-singleton', chars: c.chars, reason: `组内 ${c.members.length} 条 < minClusterSize ${minClusterSize}` });
755
- continue;
756
- }
757
- if (c.chars < minClusterChars) { planned.push({ key, ids: c.ids, action: 'skip-too-short', chars: c.chars }); continue; }
758
- if (state.processed[key]) { planned.push({ key, ids: c.ids, action: 'skip-processed', at: state.processed[key].at }); continue; }
759
- if (existing.keys.has(key)) { planned.push({ key, ids: c.ids, action: 'skip-in-db' }); continue; }
760
- // 同日已尝试过且失败/无结论 ⇒ **同一天不再试**("不重试"要跨轮也成立;换日才给第二次机会)。
761
- // 实测依据:上游限流(智谱档时期曾实测 429「当前访问量过大」)⇒ 若同日重跑会对同一组再烧一次额度。
762
- const sk = state.skipped[key];
763
- if (sk && (sk.day ?? String(sk.at ?? '').slice(0, 10)) === today) {
764
- planned.push({ key, ids: c.ids, action: 'skip-failed-today', reason: sk.reason });
765
- continue;
766
- }
767
- planned.push({ key, ids: c.ids, action: 'call', chars: c.chars, members: c.members });
768
- }
769
- result.planned = planned.map((x) => ({ key: x.key, ids: x.ids, action: x.action, chars: x.chars, reason: x.reason ?? null }));
770
- const plannedCalls = planned.filter((x) => x.action === 'call');
771
- const todo = plannedCalls.slice(0, Math.max(0, Number(maxClustersPerRun)));
1042
+ const clustered = clusterRows(selection.accepted, {
1043
+ vectorsDbPath: opts.vectorsDbPath, simThreshold: opts.simThreshold, maxItemsPerCluster: opts.maxItemsPerCluster,
1044
+ });
1045
+ result.clusterStats = clustered.stats;
1046
+ const known = existingDistilled(opts.memoryDbPath);
1047
+ const state = readState(
1048
+ opts.stateFilePath ?? path.join(path.dirname(opts.memoryDbPath), '..', 'butler-distill-state.json'),
1049
+ opts.memoryDbPath,
1050
+ nowIso,
1051
+ );
1052
+ const today = localDateIso(opts.now);
1053
+ const plan = {
1054
+ minClusterSize: Number(opts.minClusterSize),
1055
+ minClusterChars: opts.minClusterChars,
1056
+ processed: state.processed ?? {},
1057
+ knownKeys: known.keys,
1058
+ failures: state.skipped ?? {},
1059
+ today,
1060
+ };
1061
+ const ordered = [...clustered.clusters].sort((left, right) => right.members.length - left.members.length || left.ids[0] - right.ids[0]);
1062
+ const planned = ordered.map((cluster) => classifyCluster(cluster, plan));
1063
+ result.planned = planned.map((entry) => ({
1064
+ key: entry.key, ids: entry.ids, action: entry.action, chars: entry.chars, reason: entry.reason ?? null,
1065
+ }));
1066
+
1067
+ const callable = planned.filter((entry) => entry.action === 'call');
1068
+ const perRunCap = Math.max(0, Number(opts.maxClustersPerRun));
1069
+ const todo = callable.slice(0, perRunCap);
772
1070
  result.capped = {
773
- plannedCalls: plannedCalls.length, processed: todo.length,
774
- capped: plannedCalls.length > todo.length,
775
- cap: Number(maxClustersPerRun),
1071
+ plannedCalls: callable.length,
1072
+ processed: todo.length,
1073
+ capped: callable.length > todo.length,
1074
+ cap: Number(opts.maxClustersPerRun),
776
1075
  };
777
1076
  result.clusters = todo.length;
778
1077
 
779
- if (!apply) {
1078
+ const skippedByPlan = planned.filter((entry) => entry.action !== 'call').length;
1079
+
1080
+ if (!opts.apply) {
780
1081
  result.cost = { ...state.cost };
781
1082
  result.run = {
782
- inputs: sel.stats.accepted, clustersPlanned: plannedCalls.length,
783
- written: 0, skippedDuplicate: planned.filter((x) => x.action !== 'call').length,
784
- skippedOwner: sel.stats.skippedUnknownOwner, skippedFamily: sel.stats.skippedFamily, firstSample: null,
1083
+ inputs: selection.stats.accepted,
1084
+ clustersPlanned: callable.length,
1085
+ written: 0,
1086
+ skippedDuplicate: skippedByPlan,
1087
+ skippedOwner: selection.stats.skippedUnknownOwner,
1088
+ skippedFamily: selection.stats.skippedFamily,
1089
+ firstSample: null,
785
1090
  capped: result.capped,
786
1091
  };
787
- result.dailyLine = renderDailyLine({ mode, run: result.run, cost: state.cost, limits, stopping: 'dry-run 不调用模型' });
788
- log('[distill] dry-run:不调模型、不写库、不写 state');
1092
+ result.dailyLine = renderDailyLine({ mode, run: result.run, cost: state.cost, limits: opts.limits, stopping: 'dry-run 不调用模型' });
1093
+ opts.log('[distill] dry-run:不调模型、不写库、不写 state');
789
1094
  return result;
790
1095
  }
791
1096
 
792
1097
  // ④ 逐组调用(成本闸逐次判;超限即停、不重试)
793
- let apiKeyResolved = apiKey;
794
- if (!apiKeyResolved) {
795
- const key = readCredential(DEFAULT_LLM.credentialKey);
796
- if (!key) {
797
- result.ok = false;
798
- result.exitCode = 1;
799
- result.stopping = `读不到凭证 ${DEFAULT_LLM.credentialKey}(.credentials.yaml)`;
800
- return result;
801
- }
802
- apiKeyResolved = key;
1098
+ const apiKey = opts.apiKey || readCredential(DEFAULT_LLM.credentialKey);
1099
+ if (!apiKey) {
1100
+ result.ok = false;
1101
+ result.exitCode = 1;
1102
+ result.stopping = `读不到凭证 ${DEFAULT_LLM.credentialKey}(.credentials.yaml)`;
1103
+ return result;
803
1104
  }
804
- const callKey = apiKeyResolved;
805
1105
 
806
1106
  // 写库前快照(调用方给了才做;`VACUUM INTO` 只读源库)
807
- if (snapshotFile) {
1107
+ if (opts.snapshotFile) {
808
1108
  try {
809
- vacuumInto(memoryDb, snapshotFile);
810
- result.snapshot = snapshotFile;
811
- log(`[distill] 写前快照:${snapshotFile}`);
812
- } catch (e) {
813
- result.warnings.push(`快照失败(不阻断写库):${e?.message ?? e}`);
1109
+ vacuumInto(opts.memoryDbPath, opts.snapshotFile);
1110
+ result.snapshot = opts.snapshotFile;
1111
+ opts.log(`[distill] 写前快照:${opts.snapshotFile}`);
1112
+ } catch (failure) {
1113
+ result.warnings.push(`快照失败(不阻断写库):${failure?.message ?? failure}`);
814
1114
  }
815
1115
  }
816
1116
 
817
1117
  const run = {
818
- inputs: sel.stats.accepted, clustersPlanned: plannedCalls.length,
819
- written: 0, skippedDuplicate: planned.filter((x) => x.action !== 'call').length,
820
- skippedOwner: sel.stats.skippedUnknownOwner, skippedFamily: sel.stats.skippedFamily,
821
- calls: 0, promptTokens: 0, completionTokens: 0, firstSample: null, modelFailures: 0,
1118
+ inputs: selection.stats.accepted,
1119
+ clustersPlanned: callable.length,
1120
+ written: 0,
1121
+ skippedDuplicate: skippedByPlan,
1122
+ skippedOwner: selection.stats.skippedUnknownOwner,
1123
+ skippedFamily: selection.stats.skippedFamily,
1124
+ calls: 0,
1125
+ promptTokens: 0,
1126
+ completionTokens: 0,
1127
+ firstSample: null,
1128
+ modelFailures: 0,
822
1129
  capped: result.capped,
823
1130
  };
1131
+ const env = {
1132
+ state, run, result, limits: opts.limits, apiKey,
1133
+ baseUrl: opts.baseUrl, model: opts.model, fetchImpl: opts.fetchImpl,
1134
+ timeoutMs: opts.timeoutMs, memoryDbPath: opts.memoryDbPath,
1135
+ now: opts.now, nowIso, today, localOwner: opts.localOwner, deps: opts.deps,
1136
+ };
824
1137
  for (const job of todo) {
825
- const est = estimateTokens([{ chars: job.chars }]);
826
- const gate = costGate(state, est, limits);
827
- if (!gate.ok) { result.stopping = gate.reason; log(`[distill] 成本闸停:${gate.reason}`); break; }
828
- const r = await callDistiller({ clusters: [job], apiKey: callKey, baseUrl, model, fetchImpl, timeoutMs, maxTokens: DEFAULT_LLM.maxTokens });
829
- state.cost.calls = Number(state.cost.calls ?? 0) + 1; // 调用即记账(失败也记:额度真花了)
830
- run.calls += 1;
831
- const usage = r.usage ?? {};
832
- state.cost.promptTokens = Number(state.cost.promptTokens ?? 0) + Number(usage.prompt_tokens ?? 0);
833
- state.cost.completionTokens = Number(state.cost.completionTokens ?? 0) + Number(usage.completion_tokens ?? 0);
834
- const item = (r.items ?? []).find((x) => shortKey(x?.key) === job.key) ?? (r.items ?? [])[0];
835
- const conclusion = String(item?.conclusion ?? '').trim();
836
- result.calls.push({
837
- key: job.key, ids: job.ids, ok: r.ok, ms: r.ms ?? null,
838
- tokens: Number(usage.prompt_tokens ?? 0) + Number(usage.completion_tokens ?? 0),
839
- conclusionChars: conclusion.length, error: r.ok ? null : r.error,
840
- });
841
- if (!r.ok) {
842
- run.modelFailures += 1;
843
- state.skipped[job.key] = { at: nowIso, day: today, reason: `model-error: ${String(r.error).slice(0, 160)}` };
844
- continue; // ❗ 不重试(方案 §3.5 成本闸);同日重跑也不再试(见 plan 的 skip-failed-today)
845
- }
846
- if (!conclusion) {
847
- state.skipped[job.key] = { at: nowIso, day: today, reason: '模型判定无可提炼的长期结论' };
848
- continue;
849
- }
850
- const w = await applyDistilled(memoryDb, [{ srcKey: job.key, srcIds: job.ids, conclusion, ownerUser: job.members[0]?.ownerUser, visibility: 'private_owner' }], { now, ownerUser: localOwner, deps });
851
- result.written += w.written;
852
- if (w.written) {
853
- result.writtenIds.push(...w.ids);
854
- run.written += w.written;
855
- if (!run.firstSample) run.firstSample = conclusion;
856
- state.processed[job.key] = { at: nowIso, ids: job.ids, outHash: textHash(conclusion), written: w.written, rowIds: w.ids };
857
- } else {
858
- result.warnings.push(`写库被跳过(库里已有同源键):${job.key}`);
859
- state.processed[job.key] = { at: nowIso, ids: job.ids, outHash: textHash(conclusion), written: 0 };
860
- }
1138
+ const step = await distillOneCluster(job, env);
1139
+ if (step.verdict !== 'stop') continue;
1140
+ result.stopping = step.reason;
1141
+ opts.log(`[distill] 成本闸停:${step.reason}`);
1142
+ break;
861
1143
  }
862
1144
 
863
- // ⑤ 台账标记已消费(**只标真正并进输入的那些**:被 D2 排除的条保持 open,留着可见、不静默吞掉)
864
- const acceptedIds = new Set(sel.accepted.map((a) => a.id));
1145
+ // ⑤ 台账标记已消费 —— 只标真正进了输入的那些;被归属闸排除的保持 open,留着可见
1146
+ const acceptedIds = new Set(selection.accepted.map((entry) => entry.id));
865
1147
  const consumedIds = dirtyIds.filter((id) => acceptedIds.has(Number(id)));
866
1148
  if (consumedIds.length) {
867
1149
  try {
868
- const vec = new DatabaseSync(vectorsDb);
869
- try { ensureDirtySchema(vec); markConsumed(vec, consumedIds, { now }); } finally { try { vec.close(); } catch { /* ignore */ } }
870
- } catch (e) {
871
- result.warnings.push(`台账标记失败(不阻断):${e?.message ?? e}`);
1150
+ markLedgerConsumed(opts.vectorsDbPath, consumedIds, opts.now);
1151
+ } catch (failure) {
1152
+ result.warnings.push(`台账标记失败(不阻断):${failure?.message ?? failure}`);
872
1153
  }
873
1154
  }
874
1155
  result.dirtyConsumed = consumedIds;
875
1156
 
876
- // ⑥ state 落盘(原子写)
1157
+ // ⑥ 状态落盘(原子写)
877
1158
  result.cost = { ...state.cost };
878
1159
  result.run = run;
879
- result.stopping = result.stopping ?? null;
880
- const dailyLine = renderDailyLine({ mode, run, cost: state.cost, limits, stopping: result.stopping });
881
- state.lastRun = { at: nowIso, mode, inputs: sel.stats, clusters: result.planned, calls: result.calls, written: result.written, writtenIds: result.writtenIds, stopping: result.stopping, model };
1160
+ const dailyLine = renderDailyLine({ mode, run, cost: state.cost, limits: opts.limits, stopping: result.stopping });
1161
+ state.lastRun = {
1162
+ at: nowIso, mode, inputs: selection.stats, clusters: result.planned,
1163
+ calls: result.calls, written: result.written, writtenIds: result.writtenIds,
1164
+ stopping: result.stopping, model: opts.model,
1165
+ };
882
1166
  state.lastDailyLine = dailyLine;
883
- state.history = [...(state.history ?? []), { at: nowIso, written: result.written, calls: run.calls, tokens: Number(state.cost.promptTokens) + Number(state.cost.completionTokens), stopping: result.stopping }].slice(-DEFAULT_HISTORY_MAX);
1167
+ state.history = [
1168
+ ...(state.history ?? []),
1169
+ {
1170
+ at: nowIso,
1171
+ written: result.written,
1172
+ calls: run.calls,
1173
+ tokens: Number(state.cost.promptTokens) + Number(state.cost.completionTokens),
1174
+ stopping: result.stopping,
1175
+ },
1176
+ ].slice(-DEFAULT_HISTORY_MAX);
884
1177
  state.updatedAt = nowIso;
885
1178
  state.stats = {
886
1179
  ...state.stats,
@@ -889,7 +1182,7 @@ export async function runDistill(p = {}) {
889
1182
  calls: Number(state.stats?.calls ?? 0) + run.calls,
890
1183
  stopped: Number(state.stats?.stopped ?? 0) + (result.stopping ? 1 : 0),
891
1184
  };
892
- if (stateFile) writeState(stateFile, state);
1185
+ if (opts.stateFilePath) writeState(opts.stateFilePath, state);
893
1186
  result.state = state;
894
1187
  result.dailyLine = dailyLine;
895
1188
  return result;