@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.1
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 +42 -0
- package/LICENSE +661 -0
- package/README.en.md +110 -0
- package/README.md +102 -2
- package/dsh-memory-local/FORK-BASE.json +24 -0
- package/dsh-memory-local/README.md +113 -0
- package/dsh-memory-local/build.mjs +101 -0
- package/dsh-memory-local/cordis.patch.yml +20 -0
- package/dsh-memory-local/deploy.mjs +306 -0
- package/dsh-memory-local/entity-status.mjs +75 -0
- package/dsh-memory-local/lib/bridge.mjs +108 -0
- package/dsh-memory-local/lib/host-adapter.js +1171 -0
- package/dsh-memory-local/lib/index.d.ts +128 -0
- package/dsh-memory-local/lib/index.js +944 -0
- package/dsh-memory-local/lib/injector.mjs +400 -0
- package/dsh-memory-local/lib/memory-read.mjs +116 -0
- package/dsh-memory-local/lib/module-gate.mjs +68 -0
- package/dsh-memory-local/lib/pointer.mjs +152 -0
- package/dsh-memory-local/lib/prod-gate.mjs +121 -0
- package/dsh-memory-local/lib/recall-log.mjs +118 -0
- package/dsh-memory-local/lib/recall.mjs +192 -0
- package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
- package/dsh-memory-local/lib/user-message.mjs +32 -0
- package/dsh-memory-local/lib/vectors-read.mjs +110 -0
- package/dsh-memory-local/package.json +51 -0
- package/dsh-memory-local/restore.mjs +259 -0
- package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
- package/dsh-memory-local/src/bridge.mjs +108 -0
- package/dsh-memory-local/src/host-adapter.js +1171 -0
- package/dsh-memory-local/src/index.d.ts +128 -0
- package/dsh-memory-local/src/index.js +944 -0
- package/dsh-memory-local/src/injector.mjs +400 -0
- package/dsh-memory-local/src/memory-read.mjs +116 -0
- package/dsh-memory-local/src/module-gate.mjs +68 -0
- package/dsh-memory-local/src/pointer.mjs +152 -0
- package/dsh-memory-local/src/recall-log.mjs +118 -0
- package/dsh-memory-local/src/recall.mjs +192 -0
- package/dsh-memory-local/src/schema-migrate.mjs +365 -0
- package/dsh-memory-local/src/user-message.mjs +32 -0
- package/dsh-memory-local/src/vectors-read.mjs +110 -0
- package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
- package/dsh-memory-local/test/probe-entity.mjs +170 -0
- package/dsh-memory-local/test/regression.mjs +270 -0
- package/dsh-memory-local/test/s2b.test.mjs +568 -0
- package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
- package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
- package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
- package/dsh-memory-local/verify-after-restart.mjs +181 -0
- package/lib/deploy-guard.mjs +391 -0
- package/lib/guard-http.mjs +93 -0
- package/lib/pitfall-form-map.mjs +130 -0
- package/lib/pitfall-score.mjs +185 -0
- package/lib/session-file-name.mjs +160 -0
- package/lib/zstd-frames.mjs +78 -0
- package/memory-db-tools/README.md +68 -0
- package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
- package/memory-db-tools/audit-analyze.mjs +352 -0
- package/memory-db-tools/audit-dump-ids.mjs +245 -0
- package/memory-db-tools/audit-export.mjs +270 -0
- package/memory-db-tools/audit-hit-probe.mjs +227 -0
- package/memory-db-tools/audit-ledger-probe.mjs +236 -0
- package/memory-db-tools/audit-persona-overlap.mjs +277 -0
- package/memory-db-tools/audit-struct-probe.mjs +213 -0
- package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
- package/memory-db-tools/check-memory-db.mjs +8 -0
- package/memory-db-tools/dump-memory-db.mjs +12 -0
- package/memory-db-tools/lib/memory-scope.mjs +446 -0
- package/memory-db-tools/list-memory-summary.mjs +477 -0
- package/memory-db-tools/memory-identity-check.mjs +101 -0
- package/memory-db-tools/memory-schema-migrate.mjs +290 -0
- package/memory-db-tools/memory-scope-fixture.mjs +197 -0
- package/memory-db-tools/read-memories.mjs +26 -0
- package/memory-db-tools/read-memory-db.mjs +20 -0
- package/memory-db-tools/refine-memories.mjs +247 -0
- package/memory-db-tools/show-mem-times.mjs +22 -0
- package/memory-engine/README.md +70 -0
- package/memory-engine/bench/bench.mjs +221 -0
- package/memory-engine/cli/distill-rollback.mjs +86 -0
- package/memory-engine/cli/distill.mjs +156 -0
- package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
- package/memory-engine/cli/doc-index.mjs +48 -0
- package/memory-engine/cli/domain-tags.mjs +251 -0
- package/memory-engine/cli/pitfall-hook.mjs +91 -0
- package/memory-engine/cli/recall.mjs +132 -0
- package/memory-engine/cli/selfcheck.mjs +57 -0
- package/memory-engine/cli/serve-start.mjs +247 -0
- package/memory-engine/cli/sweep.mjs +58 -0
- package/memory-engine/cli/sync.mjs +69 -0
- package/memory-engine/lib/cli-util.mjs +197 -0
- package/memory-engine/lib/config.mjs +135 -0
- package/memory-engine/lib/dirty-ledger.mjs +174 -0
- package/memory-engine/lib/distill-rollback.mjs +178 -0
- package/memory-engine/lib/distill.mjs +898 -0
- package/memory-engine/lib/doc-index-store.mjs +325 -0
- package/memory-engine/lib/doc-index.mjs +529 -0
- package/memory-engine/lib/doc-scan.mjs +271 -0
- package/memory-engine/lib/doc-summarize.mjs +305 -0
- package/memory-engine/lib/domain-tags.mjs +429 -0
- package/memory-engine/lib/embedder.mjs +242 -0
- package/memory-engine/lib/hashes.mjs +12 -0
- package/memory-engine/lib/memory-source.mjs +127 -0
- package/memory-engine/lib/pitfall-source.mjs +170 -0
- package/memory-engine/lib/policy-source.mjs +371 -0
- package/memory-engine/lib/recall.mjs +331 -0
- package/memory-engine/lib/selfcheck.mjs +131 -0
- package/memory-engine/lib/sweep.mjs +159 -0
- package/memory-engine/lib/sync.mjs +180 -0
- package/memory-engine/lib/tick-hook.mjs +218 -0
- package/memory-engine/lib/vectors-store.mjs +292 -0
- package/memory-engine/package-lock.json +1086 -0
- package/memory-engine/package.json +22 -0
- package/memory-engine/serve.mjs +171 -0
- package/memory-engine/test/01-store.test.mjs +127 -0
- package/memory-engine/test/02-sync.test.mjs +196 -0
- package/memory-engine/test/03-sweep.test.mjs +145 -0
- package/memory-engine/test/04-recall.test.mjs +214 -0
- package/memory-engine/test/05-selfcheck.test.mjs +175 -0
- package/memory-engine/test/06-doc-index.test.mjs +928 -0
- package/memory-engine/test/07-domain-tags.test.mjs +298 -0
- package/memory-engine/test/08-distill.test.mjs +686 -0
- package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
- package/memory-engine/test/10-tick-hook.test.mjs +252 -0
- package/memory-engine/test/11-policy-source.test.mjs +224 -0
- package/memory-engine/test/_tickhook-worker.mjs +56 -0
- package/memory-engine/test/fixtures.mjs +205 -0
- package/memory-engine/test/harness.mjs +73 -0
- package/memory-engine/test/run.mjs +35 -0
- package/memory-engine/tools/bridge-smoke.mjs +80 -0
- package/memory-engine/tools/fetch-model.mjs +128 -0
- package/memory-engine/tools/smoke-recall.mjs +76 -0
- package/package.json +53 -4
- package/persona-memory-audit.mjs +217 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
// 记忆引擎 v2 · S3c —— 夜间提炼内核(方案 §3.5 唯一权威条款的逐条落地)
|
|
2
|
+
//
|
|
3
|
+
// 一句话:把窗口内**新增/变更**的记忆按**语义向量**聚成组,交给便宜档模型提炼成**长期结论**,
|
|
4
|
+
// 写成带 `distilled` 标记的新记忆(可批量回滚)。
|
|
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/结果
|
|
19
|
+
//
|
|
20
|
+
// 红线:本内核**只读生产 `memory.db`**(`readOnly` 连接);写入一律走显式 `apply` 分支,
|
|
21
|
+
// 且测试/开发期一律指副本库(调用方负责)。
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
26
|
+
import { textHash } from './hashes.mjs';
|
|
27
|
+
import { recordDirty, listDirty, markConsumed, dirtyStats, ensureDirtySchema } from './dirty-ledger.mjs';
|
|
28
|
+
import { readCredential, extractJson, vacuumInto } from './domain-tags.mjs';
|
|
29
|
+
|
|
30
|
+
// ── 常量 ────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export const STATE_SCHEMA = 1;
|
|
33
|
+
|
|
34
|
+
/** 成本闸(方案 §3.5 现值;**D8 已拍照现值,不许改档**)。 */
|
|
35
|
+
export const COST_LIMITS = Object.freeze({ callsPerDay: 20, tokensPerDay: 150000 });
|
|
36
|
+
|
|
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,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** 标签前缀(`tags LIKE 'distilled%'` 是回滚/排除自我指涉的唯一判据)。 */
|
|
54
|
+
export const DISTILL_PREFIX = 'distilled';
|
|
55
|
+
|
|
56
|
+
export const DEFAULT_WINDOW_HOURS = 24;
|
|
57
|
+
export const DEFAULT_HISTORY_MAX = 20;
|
|
58
|
+
export const PROCESSED_MAX = 2000;
|
|
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
|
+
});
|
|
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
|
+
*/
|
|
81
|
+
|
|
82
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function sha256(s) {
|
|
85
|
+
return crypto.createHash('sha256').update(String(s ?? ''), 'utf8').digest('hex');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function shortKey(key, n = 24) {
|
|
89
|
+
return String(key ?? '').slice(0, n);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 本地日期串(YYYYMMDD,按本机时区)。 */
|
|
93
|
+
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')}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 本机日期串(YYYY-MM-DD,与 `localDay` 同源,只多连字符)。 */
|
|
99
|
+
export function localDateIso(ts = Date.now()) {
|
|
100
|
+
const s = localDay(ts);
|
|
101
|
+
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* **去重键 = 源正文哈希集合的哈希**(内容级)。
|
|
106
|
+
*
|
|
107
|
+
* ⚠️ 刻意**不用 id 集合**(哪怕方案 §3.5 :243 的字面写法是"源 id 集合哈希"):
|
|
108
|
+
* 跨机合并(`merge-memory.mjs` 的内容哈希主键口径)会把同一条内容灌**新的高位 id**,
|
|
109
|
+
* id 集合一变 ⇒ srcKey 变 ⇒ 会重新调一次 LLM 并写出**内容重复**的提炼条。
|
|
110
|
+
* 本卡验收明写「跨机改 id 后重跑仍不重复」⇒ 取正文哈希才是能过验收的那一版。
|
|
111
|
+
* (源 id 清单仍写进 `tags` 供人追溯,只是**不参与**去重判定。)
|
|
112
|
+
*
|
|
113
|
+
* @param {Array<{id:number, text:string}>} rows
|
|
114
|
+
* @returns {string} `d<16位十六进制>`
|
|
115
|
+
*/
|
|
116
|
+
export function srcKeyOf(rows) {
|
|
117
|
+
const hashes = rows.map((r) => textHash(r.text)).sort();
|
|
118
|
+
return `d${sha256(hashes.join('|')).slice(0, 16)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
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)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 从 tags 解析 `src=` 值(去重用)。 */
|
|
127
|
+
export function srcKeyInTags(tags) {
|
|
128
|
+
const m = String(tags ?? '').match(/(?:^|\s)src=([0-9a-f]+)(?:\s|$)/);
|
|
129
|
+
return m ? m[1] : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function isDistilledTags(tags) {
|
|
133
|
+
return String(tags ?? '').startsWith(DISTILL_PREFIX);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── 库:只读输入 ────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/** 打开记忆库(只读;与 `memory-source.mjs` 同口径:只读连接不阻塞写者)。 */
|
|
139
|
+
export function openMemoryDb(dbPath) {
|
|
140
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
141
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
142
|
+
return db;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
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}}
|
|
156
|
+
*/
|
|
157
|
+
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);
|
|
159
|
+
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;
|
|
165
|
+
|
|
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)))];
|
|
172
|
+
|
|
173
|
+
const allIds = [...new Set([...windowIds, ...dirtyIdSet, ...extraIdSet])].sort((a, b) => a - b);
|
|
174
|
+
const accepted = [];
|
|
175
|
+
const family = [];
|
|
176
|
+
const unknownOwner = [];
|
|
177
|
+
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
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const trimmed = limit > 0 ? accepted.slice(0, Number(limit)) : accepted;
|
|
206
|
+
return {
|
|
207
|
+
accepted: trimmed,
|
|
208
|
+
family,
|
|
209
|
+
unknownOwner,
|
|
210
|
+
distilledExcluded,
|
|
211
|
+
windowRows: windowIds.length,
|
|
212
|
+
dirtyRows: dirtyIdSet.length,
|
|
213
|
+
ownerColumn: hasOwner,
|
|
214
|
+
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,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
} finally {
|
|
221
|
+
try { db.close(); } catch { /* ignore */ }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
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}}
|
|
231
|
+
*/
|
|
232
|
+
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 = [];
|
|
237
|
+
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
|
+
}
|
|
259
|
+
}
|
|
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
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { items: rows.length, mismatched, missing: includeMissing ? missing : [], recorded: Boolean(record && details.length), details };
|
|
270
|
+
} finally {
|
|
271
|
+
try { mem.close(); } catch { /* ignore */ }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 读 `memories_dirty` 台账里**未消费**的 id(向量库不存在 ⇒ 空)。 */
|
|
276
|
+
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 */ }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── 聚类(语义向量;不复用 tags) ───────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
function cosine(a, b) {
|
|
290
|
+
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];
|
|
297
|
+
}
|
|
298
|
+
if (na === 0 || nb === 0) return 0;
|
|
299
|
+
return dot / Math.sqrt(na * nb);
|
|
300
|
+
}
|
|
301
|
+
|
|
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 });
|
|
306
|
+
try {
|
|
307
|
+
const stmt = db.prepare("SELECT vec, dim FROM vectors WHERE source = 'memory' AND ref_id = ?");
|
|
308
|
+
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));
|
|
314
|
+
}
|
|
315
|
+
} finally {
|
|
316
|
+
try { db.close(); } catch { /* ignore */ }
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
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;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 贪心语义聚类(单遍;O(n·k))。
|
|
332
|
+
* · 有向量的条 → 归入"质心最相似且 ≥ 阈值"的既有组(组满则另起一组);
|
|
333
|
+
* · **没有向量的条**(刚写入还没编码)→ **自成一组**(不猜、不按 tags 兜底)。
|
|
334
|
+
*
|
|
335
|
+
* @returns {{clusters:Array<{key:string, members:Array, chars:number, centroid:Float32Array|null}>, stats:object}}
|
|
336
|
+
*/
|
|
337
|
+
export function clusterRows(rows, opts = {}) {
|
|
338
|
+
const vectorsDbPath = opts.vectorsDbPath ?? null;
|
|
339
|
+
const sim = Number(opts.simThreshold ?? CLUSTER_DEFAULTS.simThreshold);
|
|
340
|
+
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;
|
|
344
|
+
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' });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
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
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
clusters,
|
|
373
|
+
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,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── LLM ────────────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
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');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
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')}`;
|
|
402
|
+
});
|
|
403
|
+
return `共 ${clusters.length} 组,请逐组提炼并按要求输出 JSON:\n\n${blocks.join('\n\n')}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 估算一次批量调用的 token(保守;调用前预判成本闸)。 */
|
|
407
|
+
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;
|
|
410
|
+
}
|
|
411
|
+
|
|
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 = {
|
|
415
|
+
model,
|
|
416
|
+
messages: [
|
|
417
|
+
{ role: 'system', content: buildSystemPrompt() },
|
|
418
|
+
{ role: 'user', content: buildUserPrompt(clusters) },
|
|
419
|
+
],
|
|
420
|
+
// deepseek-flash 默认开思考(effort=high ⇒ 白烧 token,且 content 可能为空)⇒ 显式关思考
|
|
421
|
+
// 官方口径见 api-docs.deepseek.com/guides/thinking_mode:`{"thinking":{"type":"disabled"}}`
|
|
422
|
+
thinking: { type: 'disabled' },
|
|
423
|
+
temperature,
|
|
424
|
+
max_tokens: maxTokens,
|
|
425
|
+
stream: false,
|
|
426
|
+
};
|
|
427
|
+
const ctrl = new AbortController();
|
|
428
|
+
const timer = setTimeout(() => ctrl.abort(), Number(timeoutMs));
|
|
429
|
+
const t0 = Date.now();
|
|
430
|
+
try {
|
|
431
|
+
const res = await fetchImpl(`${baseUrl}/chat/completions`, {
|
|
432
|
+
method: 'POST',
|
|
433
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
434
|
+
body: JSON.stringify(body),
|
|
435
|
+
signal: ctrl.signal,
|
|
436
|
+
});
|
|
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 ?? {};
|
|
444
|
+
const parsed = extractJson(content);
|
|
445
|
+
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 };
|
|
451
|
+
} finally {
|
|
452
|
+
clearTimeout(timer);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── 去重(先查库,再决定是否调 LLM) ────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
/** 库里已有的提炼条(`src=` 键集 + 正文哈希集)——**durable** 去重源(state 丢了也能挡)。 */
|
|
459
|
+
export function existingDistilled(dbPath) {
|
|
460
|
+
const db = openMemoryDb(dbPath);
|
|
461
|
+
try {
|
|
462
|
+
const rows = db.prepare("SELECT id, text, tags FROM memories WHERE tags LIKE 'distilled%'").all();
|
|
463
|
+
const keys = new Set();
|
|
464
|
+
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));
|
|
469
|
+
}
|
|
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
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── state 与成本闸 ──────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
export function defaultState(dbPath, nowIso) {
|
|
479
|
+
return {
|
|
480
|
+
schemaVersion: STATE_SCHEMA,
|
|
481
|
+
dbPath,
|
|
482
|
+
updatedAt: nowIso,
|
|
483
|
+
cost: { date: localDateIso(Date.parse(nowIso) || Date.now()), calls: 0, promptTokens: 0, completionTokens: 0 },
|
|
484
|
+
processed: {},
|
|
485
|
+
skipped: {},
|
|
486
|
+
lastRun: null,
|
|
487
|
+
lastDailyLine: null,
|
|
488
|
+
history: [],
|
|
489
|
+
stats: { runs: 0, written: 0, calls: 0, stopped: 0 },
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function readState(stateFile, dbPath, nowIso) {
|
|
494
|
+
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);
|
|
498
|
+
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 : [],
|
|
504
|
+
};
|
|
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
|
+
}
|
|
509
|
+
return merged;
|
|
510
|
+
} catch {
|
|
511
|
+
return defaultState(dbPath, nowIso);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function writeState(stateFile, state) {
|
|
516
|
+
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 */ }
|
|
524
|
+
}
|
|
525
|
+
return stateFile;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** 台账裁剪:`processed` 只留最近 PROCESSED_MAX 条(**去重的权威源是库里的 `src=` 标签**,state 只是快查缓存)。 */
|
|
529
|
+
export function pruneState(state) {
|
|
530
|
+
const entries = Object.entries(state.processed ?? {});
|
|
531
|
+
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 };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 成本闸判定:还能不能再调一次(§3.5:≤20 次/日、≤150K token/日,**超限即停不重试**)。 */
|
|
538
|
+
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 };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── 写入 ────────────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
/** 带重试的写事务(口径与 domain-tags 的 `withBusyRetry` 同源)。 */
|
|
552
|
+
export async function withBusyRetry(db, fn, deps = {}) {
|
|
553
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
554
|
+
const backoff = [1000, 4000, 16000];
|
|
555
|
+
const cap = 60000;
|
|
556
|
+
let waited = 0;
|
|
557
|
+
const started = Date.now();
|
|
558
|
+
let lastErr = null;
|
|
559
|
+
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
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return { ok: false, attempts: 3, error: String(lastErr?.message ?? lastErr), waitedMs: waited };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* 写入提炼条(**幂等**:库里已有同 `src=` 键 ⇒ 跳过)。
|
|
585
|
+
* @returns {{written:number, skippedExisting:number, ids:number[], details:Array}}
|
|
586
|
+
*/
|
|
587
|
+
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';
|
|
591
|
+
const deps = opts.deps ?? {};
|
|
592
|
+
const db = new DatabaseSync(dbPath);
|
|
593
|
+
let out = { written: 0, skippedExisting: 0, ids: [], details: [] };
|
|
594
|
+
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),
|
|
601
|
+
);
|
|
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);
|
|
617
|
+
const newId = Number(info.lastInsertRowid ?? 0);
|
|
618
|
+
ids.push(newId);
|
|
619
|
+
written += 1;
|
|
620
|
+
details.push({ srcKey: key, action: 'written', id: newId, tags, chars: String(it.conclusion).length });
|
|
621
|
+
}
|
|
622
|
+
return { written, skippedExisting };
|
|
623
|
+
}, 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;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ── 日报行 ──────────────────────────────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
export const DAILY_SECTION_TITLE = '## 🧪 记忆提炼(memory-distill)';
|
|
634
|
+
|
|
635
|
+
/** 「条数 + 首行样本 + 当日调用数与 token」(方案 §3.5 可观测行)。 */
|
|
636
|
+
export function renderDailyLine({ mode, run, cost, limits = COST_LIMITS, stopping = null }) {
|
|
637
|
+
const used = Number(cost.promptTokens ?? 0) + Number(cost.completionTokens ?? 0);
|
|
638
|
+
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`)是**截断**不是"停机":必须写清本轮到哪、还剩多少
|
|
649
|
+
if (run?.capped?.capped) parts.push(`本轮处理 ${run.capped.processed}/${run.capped.plannedCalls} 组(单轮上限 ${run.capped.cap})`);
|
|
650
|
+
if (stopping) parts.push(`停机原因:${stopping}`);
|
|
651
|
+
return parts.join(' · ');
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** 把日报行做「段级替换」(口径同 domain-tags;日报不存在则不新建)。 */
|
|
655
|
+
export function updateDailySection({ file, line, dryRun = false }) {
|
|
656
|
+
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);
|
|
659
|
+
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
|
+
}
|
|
668
|
+
if (!dryRun) fs.writeFileSync(file, next.join('\n'), 'utf8');
|
|
669
|
+
return { ok: true, replaced: idx >= 0, file };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
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`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ── 主流程 ──────────────────────────────────────────────────────────────────
|
|
678
|
+
|
|
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 = {}) {
|
|
689
|
+
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,
|
|
695
|
+
simThreshold = CLUSTER_DEFAULTS.simThreshold,
|
|
696
|
+
maxItemsPerCluster = CLUSTER_DEFAULTS.maxItemsPerCluster,
|
|
697
|
+
maxCharsPerItem = CLUSTER_DEFAULTS.maxCharsPerItem,
|
|
698
|
+
minClusterChars = CLUSTER_DEFAULTS.minClusterChars,
|
|
699
|
+
minClusterSize = CLUSTER_DEFAULTS.minClusterSize,
|
|
700
|
+
maxClustersPerRun = CLUSTER_DEFAULTS.maxClustersPerRun,
|
|
701
|
+
snapshotFile = null, log = () => {}, deps = {},
|
|
702
|
+
} = p;
|
|
703
|
+
|
|
704
|
+
const mode = apply ? 'apply' : 'dry-run';
|
|
705
|
+
const nowIso = new Date(Number(now)).toISOString();
|
|
706
|
+
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,
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
if (!memoryDb || !fs.existsSync(memoryDb)) {
|
|
715
|
+
result.ok = false;
|
|
716
|
+
result.exitCode = 3;
|
|
717
|
+
result.stopping = `记忆库不存在:${memoryDb}`;
|
|
718
|
+
return result;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ① 台账(补 A7 盲区):dry-run 只算不写
|
|
722
|
+
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} 条正文与向量库不符(台账补充输入)`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ② 挑输入(含 D2 从严筛)
|
|
732
|
+
const sel = selectInputs({
|
|
733
|
+
dbPath: memoryDb, now, windowHours, dirtyIds, extraIds, localOwner,
|
|
734
|
+
maxCharsPerItem, limit,
|
|
735
|
+
});
|
|
736
|
+
result.inputs = sel.stats;
|
|
737
|
+
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})`);
|
|
743
|
+
|
|
744
|
+
// ③ 聚类 + 去重规划
|
|
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)));
|
|
772
|
+
result.capped = {
|
|
773
|
+
plannedCalls: plannedCalls.length, processed: todo.length,
|
|
774
|
+
capped: plannedCalls.length > todo.length,
|
|
775
|
+
cap: Number(maxClustersPerRun),
|
|
776
|
+
};
|
|
777
|
+
result.clusters = todo.length;
|
|
778
|
+
|
|
779
|
+
if (!apply) {
|
|
780
|
+
result.cost = { ...state.cost };
|
|
781
|
+
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,
|
|
785
|
+
capped: result.capped,
|
|
786
|
+
};
|
|
787
|
+
result.dailyLine = renderDailyLine({ mode, run: result.run, cost: state.cost, limits, stopping: 'dry-run 不调用模型' });
|
|
788
|
+
log('[distill] dry-run:不调模型、不写库、不写 state');
|
|
789
|
+
return result;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ④ 逐组调用(成本闸逐次判;超限即停、不重试)
|
|
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;
|
|
803
|
+
}
|
|
804
|
+
const callKey = apiKeyResolved;
|
|
805
|
+
|
|
806
|
+
// 写库前快照(调用方给了才做;`VACUUM INTO` 只读源库)
|
|
807
|
+
if (snapshotFile) {
|
|
808
|
+
try {
|
|
809
|
+
vacuumInto(memoryDb, snapshotFile);
|
|
810
|
+
result.snapshot = snapshotFile;
|
|
811
|
+
log(`[distill] 写前快照:${snapshotFile}`);
|
|
812
|
+
} catch (e) {
|
|
813
|
+
result.warnings.push(`快照失败(不阻断写库):${e?.message ?? e}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
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,
|
|
822
|
+
capped: result.capped,
|
|
823
|
+
};
|
|
824
|
+
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
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// ⑤ 台账标记已消费(**只标真正并进输入的那些**:被 D2 排除的条保持 open,留着可见、不静默吞掉)
|
|
864
|
+
const acceptedIds = new Set(sel.accepted.map((a) => a.id));
|
|
865
|
+
const consumedIds = dirtyIds.filter((id) => acceptedIds.has(Number(id)));
|
|
866
|
+
if (consumedIds.length) {
|
|
867
|
+
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}`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
result.dirtyConsumed = consumedIds;
|
|
875
|
+
|
|
876
|
+
// ⑥ state 落盘(原子写)
|
|
877
|
+
result.cost = { ...state.cost };
|
|
878
|
+
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 };
|
|
882
|
+
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);
|
|
884
|
+
state.updatedAt = nowIso;
|
|
885
|
+
state.stats = {
|
|
886
|
+
...state.stats,
|
|
887
|
+
runs: Number(state.stats?.runs ?? 0) + 1,
|
|
888
|
+
written: Number(state.stats?.written ?? 0) + result.written,
|
|
889
|
+
calls: Number(state.stats?.calls ?? 0) + run.calls,
|
|
890
|
+
stopped: Number(state.stats?.stopped ?? 0) + (result.stopping ? 1 : 0),
|
|
891
|
+
};
|
|
892
|
+
if (stateFile) writeState(stateFile, state);
|
|
893
|
+
result.state = state;
|
|
894
|
+
result.dailyLine = dailyLine;
|
|
895
|
+
return result;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
export { vacuumInto, dirtyStats };
|