@kira905/dsh-memory-toolkit 0.1.2 → 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.
- package/CHANGELOG.md +130 -1
- package/README.en.md +225 -2
- package/README.md +208 -2
- package/cordis.patch.yml +32 -0
- package/lib/deploy-guard.mjs +463 -275
- package/lib/guard-http.mjs +59 -62
- package/lib/pitfall-form-map.mjs +94 -84
- package/lib/pitfall-score.mjs +262 -185
- package/lib/session-file-name.mjs +116 -100
- package/lib/zstd-frames.mjs +88 -67
- package/memory-db-tools/README.md +29 -1
- package/memory-db-tools/audit-analyze.mjs +1 -1
- package/memory-db-tools/audit-export.mjs +60 -16
- package/memory-db-tools/audit-struct-probe.mjs +29 -8
- package/memory-db-tools/lib/known-devices.mjs +137 -0
- package/memory-engine/README.md +58 -4
- package/memory-engine/bench/bench.mjs +1 -1
- package/memory-engine/cli/band-sentinel.mjs +164 -0
- package/memory-engine/cli/distill-rollback.mjs +112 -44
- package/memory-engine/cli/distill-sessions.mjs +237 -0
- package/memory-engine/cli/distill.mjs +196 -95
- package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
- package/memory-engine/cli/doc-index.mjs +47 -38
- package/memory-engine/cli/domain-tags.mjs +246 -162
- package/memory-engine/cli/layer-migrate.mjs +176 -0
- package/memory-engine/cli/layer-move.mjs +189 -0
- package/memory-engine/cli/pitfall-hook.mjs +136 -70
- package/memory-engine/cli/recall.mjs +243 -110
- package/memory-engine/cli/selfcheck.mjs +93 -44
- package/memory-engine/cli/serve-start.mjs +221 -140
- package/memory-engine/cli/sweep.mjs +83 -44
- package/memory-engine/cli/sync.mjs +93 -57
- package/memory-engine/config/audit-devices.json +6 -0
- package/memory-engine/config/bands.json +7 -0
- package/memory-engine/config/layers.json +7 -0
- package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
- package/memory-engine/dict/lexicon-config.json +11 -0
- package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
- package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
- package/memory-engine/lib/band-sentinel.mjs +417 -0
- package/memory-engine/lib/cli-strict.mjs +128 -0
- package/memory-engine/lib/cli-util.mjs +147 -125
- package/memory-engine/lib/config.mjs +83 -96
- package/memory-engine/lib/dirty-ledger.mjs +127 -94
- package/memory-engine/lib/distill-rollback.mjs +173 -133
- package/memory-engine/lib/distill.mjs +878 -585
- package/memory-engine/lib/doc-index-store.mjs +208 -235
- package/memory-engine/lib/doc-index.mjs +351 -350
- package/memory-engine/lib/doc-scan.mjs +178 -156
- package/memory-engine/lib/doc-summarize.mjs +220 -205
- package/memory-engine/lib/domain-tags.mjs +358 -277
- package/memory-engine/lib/embedder.mjs +175 -194
- package/memory-engine/lib/hashes.mjs +22 -7
- package/memory-engine/lib/layer-migrate.mjs +330 -0
- package/memory-engine/lib/layer-move.mjs +307 -0
- package/memory-engine/lib/layers.mjs +191 -0
- package/memory-engine/lib/lexicon-recall.mjs +473 -0
- package/memory-engine/lib/memory-source.mjs +78 -80
- package/memory-engine/lib/pitfall-source.mjs +115 -114
- package/memory-engine/lib/policy-source.mjs +279 -261
- package/memory-engine/lib/recall-log.mjs +285 -0
- package/memory-engine/lib/recall.mjs +565 -240
- package/memory-engine/lib/selfcheck.mjs +142 -106
- package/memory-engine/lib/session-distill.mjs +603 -0
- package/memory-engine/lib/session-source.mjs +421 -0
- package/memory-engine/lib/sweep.mjs +107 -130
- package/memory-engine/lib/sync.mjs +110 -140
- package/memory-engine/lib/tick-hook.mjs +123 -148
- package/memory-engine/lib/vectors-store.mjs +213 -188
- package/memory-engine/test/12-layering.test.mjs +539 -0
- package/memory-engine/test/13-session-distill.test.mjs +616 -0
- package/memory-engine/tools/bridge-smoke.mjs +94 -55
- package/memory-engine/tools/fetch-model.mjs +167 -87
- package/memory-engine/tools/smoke-recall.mjs +94 -38
- package/memory-engine/tools/verify-recall-chain.mjs +413 -0
- package/package.json +11 -1
|
@@ -1,24 +1,20 @@
|
|
|
1
|
-
// 记忆引擎
|
|
1
|
+
// ── 记忆引擎 · 夜间提炼内核 ─────────────────────────────────────────────────
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// 一句话职责:把"最近被动过的记忆"按语义归拢成若干主题组,交给便宜档模型提炼成
|
|
4
|
+
// 能长期复用的一条结论,再以带 `distilled` 前缀的新记忆写回主库(可整批撤销)。
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
35
|
-
|
|
30
|
+
// 成本闸的两道上限。每日口径,超限即停、不重试;改档属于产品决策,不是调参。
|
|
31
|
+
const DAILY_CALL_CAP = 20;
|
|
32
|
+
const DAILY_TOKEN_CAP = 150000;
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
simThreshold
|
|
63
|
-
maxItemsPerCluster
|
|
64
|
-
maxCharsPerItem
|
|
65
|
-
minClusterChars
|
|
66
|
-
minClusterSize
|
|
67
|
-
maxClustersPerRun
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
85
|
+
const width = Number(n);
|
|
86
|
+
return String(key ?? '').slice(0, width);
|
|
90
87
|
}
|
|
91
88
|
|
|
92
|
-
/**
|
|
89
|
+
/** 本机时区的日期串(YYYYMMDD)。 */
|
|
93
90
|
export function localDay(ts = Date.now()) {
|
|
94
|
-
const
|
|
95
|
-
|
|
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
|
-
/**
|
|
97
|
+
/** 本机时区的日期串(YYYY-MM-DD),与 `localDay` 同一份,只是加连字符。 */
|
|
99
98
|
export function localDateIso(ts = Date.now()) {
|
|
100
|
-
const
|
|
101
|
-
return
|
|
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
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* id
|
|
110
|
-
*
|
|
111
|
-
* (源 id
|
|
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
|
|
113
|
+
* @returns {string} `d` + 16 位十六进制
|
|
115
114
|
*/
|
|
116
115
|
export function srcKeyOf(rows) {
|
|
117
|
-
const
|
|
118
|
-
return `d${
|
|
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(
|
|
123
|
-
|
|
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
|
-
/**
|
|
126
|
+
/** 从标签串里抠出 `src=` 的值(去重时用)。 */
|
|
127
127
|
export function srcKeyInTags(tags) {
|
|
128
|
-
const
|
|
129
|
-
return
|
|
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
|
-
|
|
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
|
-
/**
|
|
146
|
+
/** 打开记忆库(只读;只读连接不会挡住写者)。 */
|
|
139
147
|
export function openMemoryDb(dbPath) {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
return
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
219
|
+
const handle = openMemoryDb(dbPath);
|
|
159
220
|
try {
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
const byId =
|
|
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
|
|
167
|
-
|
|
168
|
-
.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (isDistilledTags(tags)) { distilledExcluded += 1; continue; }
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
267
|
+
|
|
268
|
+
const capped = Number(limit) > 0 ? accepted.slice(0, Number(limit)) : accepted;
|
|
206
269
|
return {
|
|
207
|
-
accepted:
|
|
270
|
+
accepted: capped,
|
|
208
271
|
family,
|
|
209
272
|
unknownOwner,
|
|
210
273
|
distilledExcluded,
|
|
211
274
|
windowRows: windowIds.length,
|
|
212
|
-
dirtyRows:
|
|
213
|
-
ownerColumn
|
|
275
|
+
dirtyRows: dirtyList.length,
|
|
276
|
+
ownerColumn,
|
|
214
277
|
stats: {
|
|
215
|
-
windowRows: windowIds.length,
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
227
|
-
*
|
|
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
|
|
234
|
-
const
|
|
235
|
-
const missing = [];
|
|
236
|
-
const details = [];
|
|
312
|
+
const scan = { mismatched: [], missing: [], details: [] };
|
|
313
|
+
const handle = openMemoryDb(dbPath);
|
|
237
314
|
try {
|
|
238
|
-
const rows =
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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 {
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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))
|
|
278
|
-
|
|
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
|
-
// ──
|
|
354
|
+
// ── 语义聚类(只看向量,不看标签) ─────────────────────────────────────────
|
|
288
355
|
|
|
289
|
-
function
|
|
356
|
+
function cosineSim(lhs, rhs) {
|
|
290
357
|
let dot = 0;
|
|
291
|
-
let
|
|
292
|
-
let
|
|
293
|
-
for (let i = 0; i <
|
|
294
|
-
dot +=
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
return dot /
|
|
365
|
+
const denom = Math.sqrt(normL * normR);
|
|
366
|
+
return denom === 0 ? 0 : dot / denom;
|
|
300
367
|
}
|
|
301
368
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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 =
|
|
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
|
|
310
|
-
if (!
|
|
311
|
-
const
|
|
312
|
-
const
|
|
313
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
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
|
|
435
|
+
const threshold = Number(opts.simThreshold ?? CLUSTER_DEFAULTS.simThreshold);
|
|
340
436
|
const maxItems = Number(opts.maxItemsPerCluster ?? CLUSTER_DEFAULTS.maxItemsPerCluster);
|
|
341
|
-
const
|
|
342
|
-
const
|
|
343
|
-
let
|
|
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
|
|
346
|
-
if (!
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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,
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
// ──
|
|
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((
|
|
400
|
-
const
|
|
401
|
-
|
|
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
|
-
/**
|
|
499
|
+
/** 保守估算一次批量调用的 token;调用前先用它过成本闸。 */
|
|
407
500
|
export function estimateTokens(clusters) {
|
|
408
|
-
const
|
|
409
|
-
|
|
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
|
-
/**
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
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(
|
|
435
|
-
signal:
|
|
538
|
+
body: JSON.stringify(payload),
|
|
539
|
+
signal: controller.signal,
|
|
436
540
|
});
|
|
437
|
-
const
|
|
438
|
-
const ms =
|
|
439
|
-
if (!
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
447
|
-
if (!
|
|
448
|
-
return { ok: true, items
|
|
449
|
-
} catch (
|
|
450
|
-
return { ok: false, error: `请求失败:${
|
|
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
|
-
// ──
|
|
565
|
+
// ── 去重源(先查库,再决定要不要调模型) ────────────────────────────────────
|
|
457
566
|
|
|
458
|
-
/**
|
|
567
|
+
/** 库里已有的提炼条:`src=` 键集 + 正文哈希集。**durable** 去重源(状态文件丢了也挡得住)。 */
|
|
459
568
|
export function existingDistilled(dbPath) {
|
|
460
|
-
const
|
|
569
|
+
const handle = openMemoryDb(dbPath);
|
|
461
570
|
try {
|
|
462
|
-
const rows =
|
|
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
|
|
466
|
-
const
|
|
467
|
-
if (
|
|
468
|
-
textHashes.add(textHash(
|
|
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 {
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
// ──
|
|
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:
|
|
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
|
|
496
|
-
const
|
|
497
|
-
const base =
|
|
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,
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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 !==
|
|
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
|
|
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
|
|
518
|
-
fs.writeFileSync(
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
fs.copyFileSync(
|
|
523
|
-
try { fs.unlinkSync(
|
|
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
|
-
/**
|
|
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((
|
|
533
|
-
|
|
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
|
-
/**
|
|
653
|
+
/** 成本闸:逐条列上限,第一条被触发的就返回它的原因(超限即停,不重试)。 */
|
|
538
654
|
export function costGate(state, estimatedTokens, limits = COST_LIMITS) {
|
|
539
|
-
const
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
/**
|
|
685
|
+
/**
|
|
686
|
+
* 带退避重试的写事务(只在"库被占用"这类错误上重试)。
|
|
687
|
+
* 三次机会,退避 1s / 4s / 16s,累计等待不超过 60s。
|
|
688
|
+
*/
|
|
552
689
|
export async function withBusyRetry(db, fn, deps = {}) {
|
|
553
|
-
const
|
|
690
|
+
const pause = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
554
691
|
const backoff = [1000, 4000, 16000];
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
let
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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(
|
|
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
|
|
589
|
-
const
|
|
590
|
-
const
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
596
|
-
const
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
.
|
|
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
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
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(
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
/**
|
|
779
|
+
/**
|
|
780
|
+
* 拼日报行:条数 / 首行样本 / 当日调用数与 token。
|
|
781
|
+
* 单轮组数上限属于**截断**而不是停机,所以要写清"本轮到哪、一共几组"。
|
|
782
|
+
*/
|
|
636
783
|
export function renderDailyLine({ mode, run, cost, limits = COST_LIMITS, stopping = null }) {
|
|
637
|
-
const
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
/**
|
|
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
|
|
658
|
-
const
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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:
|
|
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
|
|
674
|
-
return path.join(docsDir, '管家报告', `${localDateIso(now)}-${
|
|
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
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
705
|
-
|
|
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,
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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 (!
|
|
1006
|
+
if (!opts.memoryDbPath || !fs.existsSync(opts.memoryDbPath)) {
|
|
715
1007
|
result.ok = false;
|
|
716
1008
|
result.exitCode = 3;
|
|
717
|
-
result.stopping = `记忆库不存在:${
|
|
1009
|
+
result.stopping = `记忆库不存在:${opts.memoryDbPath}`;
|
|
718
1010
|
return result;
|
|
719
1011
|
}
|
|
720
1012
|
|
|
721
|
-
// ①
|
|
1013
|
+
// ① 台账补盲(dry-run 只算不写)
|
|
722
1014
|
let dirtyIds = [];
|
|
723
|
-
if (dirtyProbe) {
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
// ②
|
|
732
|
-
const
|
|
733
|
-
dbPath:
|
|
734
|
-
|
|
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 =
|
|
1033
|
+
result.inputs = selection.stats;
|
|
737
1034
|
result.dirtyInputIds = dirtyIds;
|
|
738
|
-
if (
|
|
739
|
-
if (
|
|
740
|
-
if (!
|
|
741
|
-
log(`[distill] 输入:窗口 ${
|
|
742
|
-
+ `接受 ${
|
|
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
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
const
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
const
|
|
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:
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
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:
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
794
|
-
if (!
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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(
|
|
810
|
-
result.snapshot = snapshotFile;
|
|
811
|
-
log(`[distill] 写前快照:${snapshotFile}`);
|
|
812
|
-
} catch (
|
|
813
|
-
result.warnings.push(`快照失败(不阻断写库):${
|
|
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:
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
// ⑤
|
|
864
|
-
const acceptedIds = new Set(
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
// ⑥
|
|
1157
|
+
// ⑥ 状态落盘(原子写)
|
|
877
1158
|
result.cost = { ...state.cost };
|
|
878
1159
|
result.run = run;
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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 = [
|
|
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 (
|
|
1185
|
+
if (opts.stateFilePath) writeState(opts.stateFilePath, state);
|
|
893
1186
|
result.state = state;
|
|
894
1187
|
result.dailyLine = dailyLine;
|
|
895
1188
|
return result;
|