@modusensus/dsh-mneme 0.9.4 → 0.10.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/README.md +57 -10
- package/bin/cli.mjs +215 -2
- package/docs/CONFIGURATION.md +25 -2
- package/docs/DAEMON.md +13 -2
- package/docs/DISTILL.md +150 -4
- package/docs/SHARE.md +552 -0
- package/docs/SLEEP.md +21 -7
- package/docs/STORAGE.md +2 -1
- package/lib/api-standalone.js +294 -8
- package/lib/api.js +217 -3
- package/lib/client.js +919 -39
- package/lib/config.js +33 -1
- package/lib/continuity.js +11 -3
- package/lib/document.js +15 -2
- package/lib/dream/decisions.js +176 -30
- package/lib/dream/sleep.js +530 -94
- package/lib/dream.js +174 -21
- package/lib/fence.js +24 -0
- package/lib/index.js +15 -6
- package/lib/inject.js +9 -4
- package/lib/lang.js +38 -16
- package/lib/memory-source.js +9 -0
- package/lib/organize.js +5 -1
- package/lib/pipeline-nudge-auto.js +3 -0
- package/lib/pipeline-nudge.js +41 -8
- package/lib/pipeline-runner.js +15 -0
- package/lib/pipeline.js +62 -1
- package/lib/pre-inject-gate.js +14 -0
- package/lib/profile-extract.js +4 -1
- package/lib/provenance-http.js +251 -0
- package/lib/serve.js +13 -0
- package/lib/service.js +97 -4
- package/lib/session-gate.js +91 -0
- package/lib/settings.js +13 -1
- package/lib/share-http.js +281 -0
- package/lib/share-import.js +234 -0
- package/lib/share.js +335 -0
- package/lib/store.js +797 -28
- package/lib/summarize.js +209 -23
- package/lib/tools.js +26 -6
- package/package.json +4 -2
- package/src/api-standalone.js +294 -8
- package/src/api.js +217 -3
- package/src/config.js +33 -1
- package/src/continuity.js +11 -3
- package/src/document.js +15 -2
- package/src/dream/decisions.js +176 -30
- package/src/dream/sleep.js +530 -94
- package/src/dream.js +174 -21
- package/src/fence.js +24 -0
- package/src/index.js +15 -6
- package/src/inject.js +9 -4
- package/src/lang.js +38 -16
- package/src/memory-source.js +9 -0
- package/src/organize.js +5 -1
- package/src/pipeline-nudge-auto.js +3 -0
- package/src/pipeline-nudge.js +41 -8
- package/src/pipeline-runner.js +15 -0
- package/src/pipeline.js +62 -1
- package/src/pre-inject-gate.js +14 -0
- package/src/profile-extract.js +4 -1
- package/src/provenance-http.js +251 -0
- package/src/serve.js +13 -0
- package/src/service.js +97 -4
- package/src/session-gate.js +91 -0
- package/src/settings.js +13 -1
- package/src/share-http.js +281 -0
- package/src/share-import.js +234 -0
- package/src/share.js +335 -0
- package/src/store.js +797 -28
- package/src/summarize.js +209 -23
- package/src/tools.js +26 -6
- package/test/api.test.js +523 -6
- package/test/audit.test.js +2 -2
- package/test/cli-receive.test.js +390 -0
- package/test/cli-share.test.js +437 -0
- package/test/client-share.test.js +225 -0
- package/test/client.test.js +354 -0
- package/test/config.test.js +8 -0
- package/test/conflict-reviewed.test.js +79 -0
- package/test/continuity.test.js +29 -1
- package/test/dedupe-candidate-window.test.js +180 -0
- package/test/dependency-overrides.test.js +78 -0
- package/test/differentiate-feedback.test.js +248 -0
- package/test/dream-candidate-hybrid.test.js +2 -2
- package/test/dream-decision-evidence.test.js +314 -0
- package/test/dream-fence.test.js +207 -0
- package/test/dream.test.js +87 -5
- package/test/helpers/dream-mock.js +6 -2
- package/test/inject-pools.test.js +2 -1
- package/test/llm-message-source.test.js +1 -1
- package/test/mint-source.test.js +84 -1
- package/test/pipeline-nudge-auto.test.js +26 -2
- package/test/pipeline-nudge.test.js +87 -0
- package/test/pipeline-r1r2.test.js +129 -0
- package/test/pipeline-runner.test.js +26 -0
- package/test/policy-epoch.test.js +2 -2
- package/test/prompt-stance.test.js +128 -0
- package/test/provenance-http.test.js +367 -0
- package/test/readme-claims.test.js +45 -0
- package/test/reasoning-effort.test.js +10 -10
- package/test/release-docs.test.js +7 -1
- package/test/scope-candidates.test.js +7 -4
- package/test/scope-state.test.js +130 -1
- package/test/security-doc.test.js +43 -4
- package/test/serve.test.js +62 -0
- package/test/session-gate.test.js +264 -0
- package/test/settings.test.js +4 -0
- package/test/share-foreign-rows.test.js +144 -0
- package/test/share-http.test.js +144 -0
- package/test/share-import.test.js +577 -0
- package/test/share-imported-guard.test.js +311 -0
- package/test/share-imported-identity.test.js +111 -0
- package/test/share-pack.test.js +511 -0
- package/test/share-receive-http.test.js +756 -0
- package/test/share-routes.test.js +337 -0
- package/test/share-store.test.js +133 -0
- package/test/share-validate.test.js +101 -0
- package/test/sleep-action-set.test.js +16 -11
- package/test/sleep-catchup.test.js +315 -0
- package/test/sleep-failure-backoff.test.js +156 -0
- package/test/sleep.test.js +293 -0
- package/test/standalone-api.test.js +574 -5
- package/test/store.test.js +220 -0
- package/test/summarize-parse-diagnostics.test.js +75 -0
- package/test/summarize.test.js +187 -2
package/lib/store.js
CHANGED
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
normalizeScopeStateState,
|
|
12
12
|
normalizeScopeStateValue
|
|
13
13
|
} from "./scope-state.js";
|
|
14
|
+
// 外来行的**唯一**定义住在 memory-source.js,这里只取那个常量:store 的查询口径与
|
|
15
|
+
// service/sleep 的 isImportedRow 必须是同一个字符串,自己再拼一份字面量就是第二个口径,
|
|
16
|
+
// 而「本库不巩固别人导入的原文」这条契约正靠它只有一个人说得出口。
|
|
17
|
+
// 依赖方向 store → memory-source 单向(该模块不 import 任何东西),不构成环。
|
|
18
|
+
import { IMPORTED_SOURCE } from "./memory-source.js";
|
|
14
19
|
|
|
15
20
|
const SCHEMA = `
|
|
16
21
|
CREATE TABLE IF NOT EXISTS memories (
|
|
@@ -71,7 +76,13 @@ CREATE TABLE IF NOT EXISTS dream_runs (
|
|
|
71
76
|
-- 写入就绕过阈值开跑整轮(#291 只持久化了 lastRunAt 那半边)。成功轮才落这两列,
|
|
72
77
|
-- 失败/降级不推进基线的既有语义不变;两列同时有值或同时为 NULL。
|
|
73
78
|
store_count INTEGER,
|
|
74
|
-
store_chars INTEGER
|
|
79
|
+
store_chars INTEGER,
|
|
80
|
+
-- #534:失败轮(拿得到模型输出,但解析或校验没过)的原始输出截断副本。
|
|
81
|
+
-- 此前失败轮只留一行 logger.warn,进程一退现场就没了——#534 实测 19 轮里
|
|
82
|
+
-- 17 轮整轮无效,事后一条模型原文都查不到。做成独立列而不是塞进 decisions:
|
|
83
|
+
-- 那一列的语义是「可重放的决策数组」,失败轮已被 _validationFailed 哨兵占着,
|
|
84
|
+
-- 塞原文进去会污染按决策数组消费的下游(重放、面板)。
|
|
85
|
+
raw_output TEXT
|
|
75
86
|
);
|
|
76
87
|
CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
|
|
77
88
|
|
|
@@ -175,6 +186,51 @@ CREATE TABLE IF NOT EXISTS conflict_pending (
|
|
|
175
186
|
);
|
|
176
187
|
CREATE INDEX IF NOT EXISTS idx_conflict_pending_unresolved ON conflict_pending(resolved_at);
|
|
177
188
|
|
|
189
|
+
-- differentiated_pairs: 「这一对已经判过 differentiate」的裁决账本(issue #533)。
|
|
190
|
+
-- 起因:#126 的 differentiate 把同一段差异注记同时追加到两侧正文,指望「注记进
|
|
191
|
+
-- embedding 后下一轮就不再判重复」;离线复现证伪了这个假设——两侧共享同一段
|
|
192
|
+
-- 后缀时向量同向靠拢(0.876→0.960/5 轮,注记截到 0 字也一样),而字面 includes
|
|
193
|
+
-- 幂等在措辞漂移下失效,于是同一对每轮都被追加一段,单条堆到 19 段。
|
|
194
|
+
-- 这里记的是「(a,b) 已裁决」这件事本身,正文一个字节都不动;hash_a/hash_b 是
|
|
195
|
+
-- 裁决当时两侧正文的 content_hash,任一侧后来被改写就让该对重新进入候选(宁可
|
|
196
|
+
-- 再判一次,不静音真的演化)。与 conflict_pending 同款的 bookkeeping 表:只做
|
|
197
|
+
-- 裁决记账,不触发 write hooks。
|
|
198
|
+
CREATE TABLE IF NOT EXISTS differentiated_pairs (
|
|
199
|
+
id TEXT PRIMARY KEY,
|
|
200
|
+
memory_a TEXT NOT NULL,
|
|
201
|
+
memory_b TEXT NOT NULL,
|
|
202
|
+
hash_a TEXT,
|
|
203
|
+
hash_b TEXT,
|
|
204
|
+
-- 模型给出的差异点(JSON 数组)。不再写进正文,留在这里只为了让「当初为什么
|
|
205
|
+
-- 判它们互补」事后查得到——裁决本身必须可审计,否则和静音丢弃没有区别。
|
|
206
|
+
distinctions TEXT,
|
|
207
|
+
run_id TEXT,
|
|
208
|
+
created_at TEXT NOT NULL
|
|
209
|
+
);
|
|
210
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_differentiated_pairs_pair ON differentiated_pairs(memory_a, memory_b);
|
|
211
|
+
|
|
212
|
+
-- conflict_reviewed: 机器侧裁决台账(issue #529)。冲突相位真正送给 LLM 的每一对
|
|
213
|
+
-- 都在这里留一行,连同最后拿到的结果——判 keep 的对因此不会在下一轮被原样再送。
|
|
214
|
+
--
|
|
215
|
+
-- 为什么不复用 conflict_pending:那张表是**人工**待审队列,countConflictPending()
|
|
216
|
+
-- 与 listConflictPending() 的口径直接喂面板的待确认徽标。机器记账混进去,用户打开
|
|
217
|
+
-- 面板看到的就是几百条自己从没要求审的条目,人工裁决会被淹掉。两者的生命周期也相反:
|
|
218
|
+
-- pending 是「等人来」,reviewed 是「人不用来了」。
|
|
219
|
+
--
|
|
220
|
+
-- 主键就是归一化后的对(sort 过,与 saveConflictPending 同一口径),同一对只留最新
|
|
221
|
+
-- 一次判决,INSERT OR REPLACE 天然幂等。「是否还需重判」不靠本表自身判定,而是比
|
|
222
|
+
-- 两侧 updated_at 与 judged_at(宽松代理,与 conflict_pending 复核项 4 同一条约定:
|
|
223
|
+
-- 任何字段更新都算「值得再看一眼」,宁可多看一眼,不静音真冲突)。
|
|
224
|
+
CREATE TABLE IF NOT EXISTS conflict_reviewed (
|
|
225
|
+
memory_a TEXT NOT NULL,
|
|
226
|
+
memory_b TEXT NOT NULL,
|
|
227
|
+
judged_at TEXT NOT NULL,
|
|
228
|
+
outcome TEXT,
|
|
229
|
+
run_id TEXT,
|
|
230
|
+
PRIMARY KEY (memory_a, memory_b)
|
|
231
|
+
);
|
|
232
|
+
CREATE INDEX IF NOT EXISTS idx_conflict_reviewed_judged ON conflict_reviewed(judged_at);
|
|
233
|
+
|
|
178
234
|
-- scope_changes: v0.8.1 底座(issue #170)scope 归属的人工修正审计。显式声明
|
|
179
235
|
-- (memory_save/memory_update 的 scope 参数、面板编辑)每次改变某条记忆的
|
|
180
236
|
-- agent/workspace 归属都落一行:改动前后值 + 来源 + actor(tool=模型侧 /
|
|
@@ -275,10 +331,12 @@ CREATE INDEX IF NOT EXISTS idx_distill_records_distilled_at ON distill_records(d
|
|
|
275
331
|
-- 不该因删掉其中一条派生记忆而消失。
|
|
276
332
|
-- memory_id 是 TEXT 而非 issue 草稿里的 INTEGER:memories.id 是 TEXT 主键,
|
|
277
333
|
-- 类型必须与父表一致(否则 join 恒空)。
|
|
278
|
-
-- ON DELETE CASCADE
|
|
279
|
-
--
|
|
280
|
-
--
|
|
281
|
-
--
|
|
334
|
+
-- ON DELETE CASCADE 在本库是**生效的**:createStore 走 new DatabaseSync(path) 不带选项,
|
|
335
|
+
-- 而 node:sqlite 的 enableForeignKeyConstraints 默认为真(实测:新库上 PRAGMA foreign_keys
|
|
336
|
+
-- 返回 1,删父行确实级联清掉本表)。本库两条 FK 都因此一直是活的——这一条,以及
|
|
337
|
+
-- recall_evals(recall_run_id) → recall_runs(id)。级联在兜底,remove() 仍然显式清链接,
|
|
338
|
+
-- 理由不是「FK 没开」,而是这份清理不该挂在一条没人钉过的驱动默认上:连接哪天被以
|
|
339
|
+
-- enableForeignKeyConstraints:false 打开,悬空链接只有那条 DELETE 挡得住。见 remove() 的注释。
|
|
282
340
|
CREATE TABLE IF NOT EXISTS memory_provenance (
|
|
283
341
|
memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
|
|
284
342
|
session_id TEXT NOT NULL,
|
|
@@ -293,6 +351,77 @@ CREATE INDEX IF NOT EXISTS idx_memory_provenance_session ON memory_provenance(se
|
|
|
293
351
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_provenance_link
|
|
294
352
|
ON memory_provenance(memory_id, session_id, seq_start, seq_end);
|
|
295
353
|
|
|
354
|
+
-- #512 蒸馏运行(run):一次 autoSummarize 调用就是一轮。此前这条链路只在
|
|
355
|
+
-- llm_audit_logs 里留一条计费痕迹,记忆侧的链接却只记到「会话 + seq 区间」——
|
|
356
|
+
-- 「这条记忆是哪一次蒸馏带进来的」答不上,同一个区间重跑两次更分不出前后。
|
|
357
|
+
-- 与 llm_audit_logs 的关系是**归因主体与它的开销读数**:run 是被归因的那一轮,
|
|
358
|
+
-- audit 行是那一轮的开销;两边靠 distill_runs.audit_id 直接连,不靠时间戳猜。
|
|
359
|
+
-- 为什么不复用 llm_audit_logs 的行 id 当 run id:audit 表按 llmAudit.retentionDays
|
|
360
|
+
-- 到期裁剪(index.js 启动即调 deleteOldLlmAudits),归因会悬空;而 memory_provenance
|
|
361
|
+
-- 与蒸馏窗口刻意活得更久(见 clearDistillRecords 注释)。归因不能挂在会被删掉的表上。
|
|
362
|
+
-- 为什么不复用 dream_runs:那张表的每一列(snapshot_hash / input_count / decisions /
|
|
363
|
+
-- outcome / receipt)都是巩固的语义,硬塞一个 run_type='autoSummarize' 会让
|
|
364
|
+
-- 「dream_runs 的每一行都能回放一次巩固」这条契约失效。两张表各自的形状才是真的。
|
|
365
|
+
-- audit_id 可空:llmAudit 关掉时那一轮没有 audit 行,run 仍然成立(归因不该依赖计费开关)。
|
|
366
|
+
CREATE TABLE IF NOT EXISTS distill_runs (
|
|
367
|
+
id TEXT PRIMARY KEY,
|
|
368
|
+
created_at TEXT NOT NULL,
|
|
369
|
+
status TEXT NOT NULL, -- ok | degraded | noop | failed | aborted
|
|
370
|
+
error TEXT,
|
|
371
|
+
-- 只放**有窗口**的蒸馏(autoSummarize,以及将来手动的 manual)。dream / sleep 的
|
|
372
|
+
-- 轮次不进这张表:它们是 dream_runs 的行,把同一轮复制成两行会让「哪张表是事实源」
|
|
373
|
+
-- 这个问题永远没有答案;那些记忆的归因走 memory_mints(见下)。
|
|
374
|
+
run_type TEXT NOT NULL, -- autoSummarize | manual
|
|
375
|
+
trigger_source TEXT NOT NULL, -- autoSummarize | manual
|
|
376
|
+
session_id TEXT,
|
|
377
|
+
seq_start INTEGER,
|
|
378
|
+
seq_end INTEGER,
|
|
379
|
+
memory_count INTEGER NOT NULL DEFAULT 0,
|
|
380
|
+
provider TEXT,
|
|
381
|
+
model TEXT,
|
|
382
|
+
audit_id INTEGER -- llm_audit_logs.id(NULL = 那一轮没有 audit 行)
|
|
383
|
+
);
|
|
384
|
+
CREATE INDEX IF NOT EXISTS idx_distill_runs_created ON distill_runs(created_at);
|
|
385
|
+
|
|
386
|
+
-- #512 记忆级「出生自哪一轮」。为什么它不是 memory_provenance 的一列:那张表的
|
|
387
|
+
-- 粒度是 (记忆, 会话, seq 区间),能表达的只有**窗口派生**的归因;而 dream / sleep
|
|
388
|
+
-- 铸造的记忆根本没有窗口(session 与 seq 都不存在),硬塞一个合成区间就是
|
|
389
|
+
-- 「看起来能追、实际是编的」。所以归因分两层,各自只记它真知道的事:
|
|
390
|
+
-- · memory_provenance(+ distill_run_id)= 窗口级,「这段原文是哪一轮蒸出来的」;
|
|
391
|
+
-- · 本表 = 行级,「这条记忆出生自哪一轮」,覆盖没有窗口的铸造路径。
|
|
392
|
+
-- 只填 **create** 一种动作:merge/conflict/update/supersede 对既有行的改动,receipt_chain
|
|
393
|
+
-- 已经按 (run_id, record_id) 记过了——再造一份就是第二套账。create 是那边唯一的洞
|
|
394
|
+
-- (buildOutcome 刻意不给 create 派 disposition,receipt_chain 也就不产行)。
|
|
395
|
+
-- run_type / trigger_source 就地冗余:run_id 在 dream_runs 与 distill_runs 两张表里
|
|
396
|
+
-- 各有自己的主键域,读侧不该为了显示一个类型去 union 两张审计表。
|
|
397
|
+
-- ON DELETE CASCADE:记忆行没了,归因没有指代对象;remove() 仍显式清(与
|
|
398
|
+
-- memory_provenance 同一口径,不把清理挂在驱动默认上)。
|
|
399
|
+
CREATE TABLE IF NOT EXISTS memory_mints (
|
|
400
|
+
memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
|
|
401
|
+
run_id TEXT NOT NULL,
|
|
402
|
+
run_type TEXT NOT NULL, -- dream | sleep
|
|
403
|
+
trigger_source TEXT NOT NULL, -- autoDream | sleep
|
|
404
|
+
created_at TEXT NOT NULL,
|
|
405
|
+
PRIMARY KEY (memory_id, run_id)
|
|
406
|
+
);
|
|
407
|
+
CREATE INDEX IF NOT EXISTS idx_memory_mints_run ON memory_mints(run_id);
|
|
408
|
+
|
|
409
|
+
-- #493 补充 §②:连续解析失败的**跨进程**计数(失败护栏 summarizeParseFailureMaxRetries
|
|
410
|
+
-- 的判据)。原先只活在 summarize.js 的进程内 Map 里,于是重启即清零——而这条护栏要
|
|
411
|
+
-- 防的恰恰是「确定性失败反复重试」,重启一次就绕过去了。
|
|
412
|
+
-- 独立成表、不并进 distill_cursors 的三条理由:
|
|
413
|
+
-- 1) 写 streak 不必先有游标行(窗口可能还没成功消费过任何一个 seq);
|
|
414
|
+
-- 2) 成功即归零是 DELETE,而游标那句 UPSERT 是 last_seq = MAX(...)(单调),
|
|
415
|
+
-- 两种语义塞进同一句会把「归零」写成「保留最大值」,护栏永远不解除;
|
|
416
|
+
-- 3) 失效判据要读时间戳,就地一行更省事。
|
|
417
|
+
-- 失效由读侧判(streakStaleAfterMs):超过窗口没更新就当作 0,**不在写侧定时清**——
|
|
418
|
+
-- 停了几周的会话再开跑时,若拿旧 streak 直接触发熔断,护栏本身就成了新的误伤路径。
|
|
419
|
+
CREATE TABLE IF NOT EXISTS distill_parse_failures (
|
|
420
|
+
session_id TEXT PRIMARY KEY,
|
|
421
|
+
streak INTEGER NOT NULL,
|
|
422
|
+
updated_at TEXT NOT NULL
|
|
423
|
+
);
|
|
424
|
+
|
|
296
425
|
-- entity gene (v0.3.0): named entities mentioned across memories, with
|
|
297
426
|
-- time-boxed attributes (valid_from → valid_until) and typed relations.
|
|
298
427
|
-- Attributes follow the snapshot style: saveAttr invalidates the previous
|
|
@@ -403,6 +532,10 @@ export const TYPES = new Set(["preference", "project", "decision", "history", "s
|
|
|
403
532
|
// 截断上限(continuity.js)是两件事。
|
|
404
533
|
const MAX_CONTINUITY_PENDING = 200;
|
|
405
534
|
|
|
535
|
+
// dream_runs.run_type 的取值域(schema 注释同款)。既是白名单也是下推条件的护栏:
|
|
536
|
+
// listDreamRuns 的 excludeRunType 会拼进 SQL 的 != 比较,非白名单值一律忽略。
|
|
537
|
+
const DREAM_RUN_TYPES = ["auto", "sleep", "organize"];
|
|
538
|
+
|
|
406
539
|
// Epistemic status: what kind of evidence a memory rests on. Defaults to
|
|
407
540
|
// 'subjective' so legacy rows (and rows without any signal) stay compatible.
|
|
408
541
|
const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]);
|
|
@@ -536,6 +669,11 @@ function toRow(row) {
|
|
|
536
669
|
// 顺手 `?? updated_at` 把代理口径捡回来。
|
|
537
670
|
archived_at: row.archived_at ?? undefined,
|
|
538
671
|
source: row.source ?? undefined,
|
|
672
|
+
// 车道 C:外来行身份两格。NULL 读作 undefined(与 archived_at 同款「不可知就不编
|
|
673
|
+
// 值」)。注意「这行是不是外来行」的判据仍是 source(memory-source.js 的
|
|
674
|
+
// isImportedRow)——这两格只回答「来自哪个包 / 幂等键是什么」,不回答身份。
|
|
675
|
+
share_pack_id: row.share_pack_id ?? undefined,
|
|
676
|
+
share_external_id: row.share_external_id ?? undefined,
|
|
539
677
|
content_history: parseJsonArray(row.content_history),
|
|
540
678
|
evidence: parseJsonArray(row.evidence),
|
|
541
679
|
doc_path: row.doc_path ?? undefined,
|
|
@@ -578,7 +716,29 @@ function toDreamRun(row) {
|
|
|
578
716
|
skipped: row.skipped ? JSON.parse(row.skipped) : undefined,
|
|
579
717
|
// Issue #89(基线半边):成功轮落下的阈值基线(NULL = 该轮没有推进基线)。
|
|
580
718
|
store_count: row.store_count ?? undefined,
|
|
581
|
-
store_chars: row.store_chars ?? undefined
|
|
719
|
+
store_chars: row.store_chars ?? undefined,
|
|
720
|
+
// #534:失败轮的模型原始输出截断副本(NULL = 该轮没留下,或该轮是历史行)。
|
|
721
|
+
raw_output: row.raw_output ? JSON.parse(row.raw_output) : undefined
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function toDistillRun(row) {
|
|
726
|
+
if (!row) return undefined;
|
|
727
|
+
return {
|
|
728
|
+
id: row.id,
|
|
729
|
+
created_at: row.created_at,
|
|
730
|
+
status: row.status,
|
|
731
|
+
error: row.error ?? undefined,
|
|
732
|
+
run_type: row.run_type,
|
|
733
|
+
trigger_source: row.trigger_source,
|
|
734
|
+
session_id: row.session_id ?? undefined,
|
|
735
|
+
seq_start: row.seq_start ?? undefined,
|
|
736
|
+
seq_end: row.seq_end ?? undefined,
|
|
737
|
+
memory_count: row.memory_count ?? 0,
|
|
738
|
+
provider: row.provider ?? undefined,
|
|
739
|
+
model: row.model ?? undefined,
|
|
740
|
+
// NULL = 那一轮没有 audit 行(llmAudit 关着,或还没写回)。
|
|
741
|
+
audit_id: row.audit_id ?? undefined
|
|
582
742
|
};
|
|
583
743
|
}
|
|
584
744
|
|
|
@@ -616,6 +776,20 @@ function toConflictPending(row) {
|
|
|
616
776
|
};
|
|
617
777
|
}
|
|
618
778
|
|
|
779
|
+
function toDifferentiatedPair(row) {
|
|
780
|
+
if (!row) return undefined;
|
|
781
|
+
return {
|
|
782
|
+
id: row.id,
|
|
783
|
+
memory_a: row.memory_a,
|
|
784
|
+
memory_b: row.memory_b,
|
|
785
|
+
hash_a: row.hash_a ?? undefined,
|
|
786
|
+
hash_b: row.hash_b ?? undefined,
|
|
787
|
+
distinctions: row.distinctions ? JSON.parse(row.distinctions) : undefined,
|
|
788
|
+
run_id: row.run_id ?? undefined,
|
|
789
|
+
created_at: row.created_at
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
619
793
|
function toScopeChange(row) {
|
|
620
794
|
if (!row) return undefined;
|
|
621
795
|
return {
|
|
@@ -886,6 +1060,32 @@ export function createStore(path) {
|
|
|
886
1060
|
// 存量行的值是 NULL,且**不推断**:按 updated_at 猜出的模型名会变成日后审计信
|
|
887
1061
|
// 以为真的证据。NULL 一律读作「出身未知」,等价的补法是重算,不是猜。
|
|
888
1062
|
addColumn("memories", "embedding_model", "ALTER TABLE memories ADD COLUMN embedding_model TEXT");
|
|
1063
|
+
// 车道 C:外来行的身份两件套。share_pack_id 是「整包退出」的寻址键,share_external_id
|
|
1064
|
+
// 是导入的幂等键。它们只对自己铸造的行有意义,所以缺省是 NULL 而不是 '':空串会参与
|
|
1065
|
+
// 唯一性(第二条本地行就撞响),而存量行零重写、读侧 undefined = 「不是外来行」。
|
|
1066
|
+
addColumn("memories", "share_pack_id", "ALTER TABLE memories ADD COLUMN share_pack_id TEXT");
|
|
1067
|
+
addColumn("memories", "share_external_id", "ALTER TABLE memories ADD COLUMN share_external_id TEXT");
|
|
1068
|
+
// 索引建在加列之后而非写进 SCHEMA(本文件 content_hash 那段:存量库打开时 CREATE TABLE
|
|
1069
|
+
// 是 no-op,列还不存在就建索引会直接 no such column)。
|
|
1070
|
+
// 部分索引(WHERE ... IS NOT NULL)是刻意的,但它承重的地方要说准:SQLite 的 UNIQUE
|
|
1071
|
+
// 本就把 NULL 视为互不相等,本地行(这一格恒 NULL)挂在普通 UNIQUE 上也不撞——变异检验
|
|
1072
|
+
// 证实摘掉 WHERE 五个用例照绿。它承的是另外两件事:①约束的适用范围写进索引本身,
|
|
1073
|
+
// 「只有导入路径铸造的 id 参与唯一性」这条语义没有别的表达渠道,读代码的人不必再去猜
|
|
1074
|
+
// NULL 在这里算什么;②本地行不进这棵 b 树——普通 UNIQUE 要给每一条本地记忆插一个永不
|
|
1075
|
+
// 生效的空键,而库里绝大多数是本地行,每次写都白付一次索引维护。
|
|
1076
|
+
// 「NULL 必须自由共享」那条用例锁的是行为而非实现:它防的是将来有人把空串当缺省值写
|
|
1077
|
+
// 进来('' 参与唯一性,第二条 '' 直接撞响),不是防 WHERE 子句消失。
|
|
1078
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_share_external ON memories(share_external_id) WHERE share_external_id IS NOT NULL");
|
|
1079
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_share_pack ON memories(share_pack_id)");
|
|
1080
|
+
// Issue #505:saveWithDedupe 的定向候选是 `type = ? AND TRIM(title) = ?`。只有
|
|
1081
|
+
// idx_memories_type 时这条语句等于「把该类型的行全读一遍、逐行 TRIM」,代价随最大
|
|
1082
|
+
// 类型的行数线性长(本机 node:sqlite 实测同类型 1000 / 5000 / 20000 行 = 0.13 /
|
|
1083
|
+
// 0.68 / 4.63 ms,而它是每存一条都要付一次的写路径)。TRIM 本身不是瓶颈——同句去掉
|
|
1084
|
+
// TRIM 只差 0.07 ms,缺的是能把整个条件当 seek 用的索引;建好之后同一查询在同一批
|
|
1085
|
+
// 数据上 ~0.00 ms。索引表达式必须与 SQL 里的写法逐字相同(TRIM(title),不是 title),
|
|
1086
|
+
// 差一点优化器就静默不认,而所有行为用例照样全绿——所以 dedupe-candidate-window
|
|
1087
|
+
// 那条用例用 EXPLAIN QUERY PLAN 钉住它真的被选中。改 service 的 WHERE 必须同时改这里。
|
|
1088
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_type_trimmed_title ON memories(type, TRIM(title))");
|
|
889
1089
|
// issue #24 块2(权重演化):entity_relations 加 weight + source。存量关系
|
|
890
1090
|
// 行缺列 → 默认 manual 1.0(最保守:既有语义不因迁移而变弱)。
|
|
891
1091
|
addColumn("entity_relations", "weight", "ALTER TABLE entity_relations ADD COLUMN weight REAL NOT NULL DEFAULT 1.0");
|
|
@@ -924,6 +1124,9 @@ export function createStore(path) {
|
|
|
924
1124
|
// 成功轮之前播种端拿不到基线,行为与升级前一致;跑过一轮之后闸门即跨重启生效。
|
|
925
1125
|
addColumn("dream_runs", "store_count", "ALTER TABLE dream_runs ADD COLUMN store_count INTEGER");
|
|
926
1126
|
addColumn("dream_runs", "store_chars", "ALTER TABLE dream_runs ADD COLUMN store_chars INTEGER");
|
|
1127
|
+
// #534:失败轮的模型原始输出。存量行两列皆 NULL——历史失败轮反推不出原文
|
|
1128
|
+
// (那正是这条要补的口子),升级后新失败轮才有值。
|
|
1129
|
+
addColumn("dream_runs", "raw_output", "ALTER TABLE dream_runs ADD COLUMN raw_output TEXT");
|
|
927
1130
|
|
|
928
1131
|
// Legacy mirror_state without v0.3.6 generation columns → add each missing
|
|
929
1132
|
// column idempotently (old DBs open cleanly, no data loss).
|
|
@@ -938,6 +1141,14 @@ export function createStore(path) {
|
|
|
938
1141
|
addColumn("llm_audit_logs", "session_key", "ALTER TABLE llm_audit_logs ADD COLUMN session_key TEXT");
|
|
939
1142
|
db.exec("CREATE INDEX IF NOT EXISTS idx_llm_audit_session ON llm_audit_logs(session_key);");
|
|
940
1143
|
|
|
1144
|
+
// #512:把每条溯源链接指回产出它的那一轮蒸馏(distill_runs.id)。
|
|
1145
|
+
// 存量链接一律留 NULL——那是**本次改造之前的历史**,按 (session, 区间, created_at)
|
|
1146
|
+
// 反推一个合成 id 会得到「读数正常、实际是猜的」归因,比不填更坏(与 #534
|
|
1147
|
+
// raw_output「历史失败轮反推不出原文」同一口径:补不出的就承认补不出)。
|
|
1148
|
+
// 索引建在加列之后:老库打开时列还不存在(SCHEMA 的 CREATE TABLE 只对新建库生效)。
|
|
1149
|
+
addColumn("memory_provenance", "distill_run_id", "ALTER TABLE memory_provenance ADD COLUMN distill_run_id TEXT");
|
|
1150
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_memory_provenance_run ON memory_provenance(distill_run_id);");
|
|
1151
|
+
|
|
941
1152
|
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
|
|
942
1153
|
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
|
|
943
1154
|
// enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
|
|
@@ -1042,6 +1253,59 @@ export function createStore(path) {
|
|
|
1042
1253
|
return getDistillCursor(sessionId);
|
|
1043
1254
|
}
|
|
1044
1255
|
|
|
1256
|
+
/**
|
|
1257
|
+
* 读指定会话的连续解析失败计数。
|
|
1258
|
+
*
|
|
1259
|
+
* `staleAfterMs` 是**读侧**失效窗口(#493 补充 §②):超过这么久没更新就把计数
|
|
1260
|
+
* 当作 0,而不是拿几周前攒下的值去触发熔断。劣化方向选得很明确——宁可多试一次
|
|
1261
|
+
* (多花一次 LLM 调用),也不要因为「会话停了很久」而误判一个其实健康的窗口。
|
|
1262
|
+
* 传 0 或负数表示不判失效(照原样返回),测试用它构造边界。
|
|
1263
|
+
* @returns {number} 0 表示没有在途失败(含「已失效」与「从未记录」两种)
|
|
1264
|
+
*/
|
|
1265
|
+
function getDistillParseFailureStreak(sessionId, { staleAfterMs = 0, nowMs = Date.now() } = {}) {
|
|
1266
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return 0;
|
|
1267
|
+
const row = db.prepare(
|
|
1268
|
+
"SELECT streak, updated_at FROM distill_parse_failures WHERE session_id = ?"
|
|
1269
|
+
).get(sessionId);
|
|
1270
|
+
if (!row) return 0;
|
|
1271
|
+
if (staleAfterMs > 0) {
|
|
1272
|
+
const stamp = Date.parse(row.updated_at);
|
|
1273
|
+
// 时间戳读不出来时按「已失效」处理:一个解析不了的 updated_at 不能当作「刚刚
|
|
1274
|
+
// 失败过」,否则坏数据会永久钉住一道护栏(fail-open,与整条护栏的默认关一致)。
|
|
1275
|
+
if (!Number.isFinite(stamp) || nowMs - stamp > staleAfterMs) return 0;
|
|
1276
|
+
}
|
|
1277
|
+
return Number(row.streak) || 0;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* 记一次解析失败(+1 并刷新时间戳)。返回写后的计数。
|
|
1282
|
+
*
|
|
1283
|
+
* 先按 staleAfterMs 读一遍再**覆写**,而不是在 SQL 里做 streak + 1——失效判断只
|
|
1284
|
+
* 许有一份(就是上面那个读函数)。写成 + 1 的话,一条几周前的旧行会把「新的第一
|
|
1285
|
+
* 次失败」直接加成 2,等于失效窗口在这条路径上完全不生效(本块实现的首版就是
|
|
1286
|
+
* 这么写的,被回归用例抓出来)。
|
|
1287
|
+
*/
|
|
1288
|
+
function bumpDistillParseFailureStreak(sessionId, { staleAfterMs = 0, nowMs = Date.now() } = {}) {
|
|
1289
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
1290
|
+
throw new TypeError("bumpDistillParseFailureStreak: sessionId must be a non-empty string");
|
|
1291
|
+
}
|
|
1292
|
+
const next = getDistillParseFailureStreak(sessionId, { staleAfterMs, nowMs }) + 1;
|
|
1293
|
+
db.prepare(`
|
|
1294
|
+
INSERT INTO distill_parse_failures (session_id, streak, updated_at)
|
|
1295
|
+
VALUES (?, ?, ?)
|
|
1296
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
1297
|
+
streak = excluded.streak,
|
|
1298
|
+
updated_at = excluded.updated_at
|
|
1299
|
+
`).run(sessionId, next, nowIso());
|
|
1300
|
+
return next;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/** 清零(不是写 0):没有在途失败时不该留一行,否则「有没有失败过」变得不可区分。 */
|
|
1304
|
+
function clearDistillParseFailureStreak(sessionId) {
|
|
1305
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return 0;
|
|
1306
|
+
return db.prepare("DELETE FROM distill_parse_failures WHERE session_id = ?").run(sessionId).changes;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1045
1309
|
function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
|
|
1046
1310
|
const clauses = [];
|
|
1047
1311
|
const params = [];
|
|
@@ -1123,6 +1387,30 @@ export function createStore(path) {
|
|
|
1123
1387
|
return toRow(row);
|
|
1124
1388
|
}
|
|
1125
1389
|
|
|
1390
|
+
/**
|
|
1391
|
+
* 车道 C 导入的幂等读:按包内那条记忆的铸造 id 找本库的行。
|
|
1392
|
+
* 走 idx_memories_share_external(部分索引,非 NULL 那一侧),等值 seek。
|
|
1393
|
+
* 本地行这一格恒 NULL,因此本函数永远不会误命中本地记忆。
|
|
1394
|
+
*/
|
|
1395
|
+
function getByShareExternalId(externalId) {
|
|
1396
|
+
if (typeof externalId !== "string" || !externalId) return undefined;
|
|
1397
|
+
return toRow(db.prepare("SELECT * FROM memories WHERE share_external_id = ?").get(externalId));
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* 一个包在本库落下的全部行(撤回前的清点 / 面板「外来区」列表)。
|
|
1402
|
+
*
|
|
1403
|
+
* 不过滤 archived / forgotten:整包退出要连归档面与遗忘面一起收走,否则撤回后库里
|
|
1404
|
+
* 还剩几条「来自那个已经收起的包」的行,身份与包对不上。
|
|
1405
|
+
* 读显式列清单而非 SELECT *(#430 的口径):一个包可以有几百行,每行 9.4 MB 级别
|
|
1406
|
+
* 的 embedding 拖进来没有任何消费方会读。
|
|
1407
|
+
*/
|
|
1408
|
+
function listBySharePackId(packId) {
|
|
1409
|
+
if (typeof packId !== "string" || !packId) return [];
|
|
1410
|
+
return db.prepare(`SELECT ${memoryColumnList} FROM memories WHERE share_pack_id = ? ORDER BY updated_at DESC, id DESC`)
|
|
1411
|
+
.all(packId).map(toRow);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1126
1414
|
// #230 写入权分离(存储层唯一铸造口,CodeRabbit 复核最终形态):document
|
|
1127
1415
|
// 行只能经 saveDocument 铸造,通用 save 整类拒绝。doc_path 是任意调用方
|
|
1128
1416
|
// 都能捏造的字符串——「必带 doc_path」挡不住绕过注册校验(flag 闸 / 文件
|
|
@@ -1168,8 +1456,8 @@ export function createStore(path) {
|
|
|
1168
1456
|
: inferEpistemicStatus(memory);
|
|
1169
1457
|
runAtomically(() => {
|
|
1170
1458
|
db.prepare(
|
|
1171
|
-
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, archived_at, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, doc_path, content_hash, created_at, updated_at)
|
|
1172
|
-
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1459
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, archived_at, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, doc_path, content_hash, share_pack_id, share_external_id, created_at, updated_at)
|
|
1460
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1173
1461
|
).run(
|
|
1174
1462
|
id,
|
|
1175
1463
|
type,
|
|
@@ -1197,6 +1485,11 @@ export function createStore(path) {
|
|
|
1197
1485
|
evidence,
|
|
1198
1486
|
docPath,
|
|
1199
1487
|
contentHash,
|
|
1488
|
+
// 车道 C:外来行身份。空串一律落 NULL——空串会占住 UNIQUE 索引的一格,第二条
|
|
1489
|
+
// 空串外来行就撞响;这两格只由导入路径铸造,其余写入恒 NULL。
|
|
1490
|
+
// created_at / updated_at / content_hash 仍由本函数铸造,不接受调用方传值。
|
|
1491
|
+
typeof memory.share_pack_id === "string" && memory.share_pack_id.trim() ? memory.share_pack_id.trim() : null,
|
|
1492
|
+
typeof memory.share_external_id === "string" && memory.share_external_id.trim() ? memory.share_external_id.trim() : null,
|
|
1200
1493
|
now,
|
|
1201
1494
|
now
|
|
1202
1495
|
);
|
|
@@ -1332,12 +1625,15 @@ export function createStore(path) {
|
|
|
1332
1625
|
|
|
1333
1626
|
function remove(id) {
|
|
1334
1627
|
runAtomically(() => {
|
|
1335
|
-
// #399:memory_provenance
|
|
1336
|
-
//
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1628
|
+
// #399:memory_provenance 挂着 ON DELETE CASCADE,而 node:sqlite 默认就把
|
|
1629
|
+
// foreign_keys 开成 1(实测 PRAGMA foreign_keys = 1),所以级联今天确实在兜底。
|
|
1630
|
+
// 这条显式 DELETE 不是冗余、也不是「FK 没开才需要」:它是不把清理挂在一份没人
|
|
1631
|
+
// 钉过的驱动默认上——连接哪天以 enableForeignKeyConstraints:false 打开,悬空链接
|
|
1632
|
+
// 只有这一条挡得住。记录本身(distill_records)不删——删掉一条派生记忆
|
|
1339
1633
|
// 不该让整个窗口的原文凭证消失。
|
|
1340
1634
|
db.prepare("DELETE FROM memory_provenance WHERE memory_id = ?").run(id);
|
|
1635
|
+
// #512:铸造记录同理——它也只是指着记忆行的索引,行没了索引就没有指代对象。
|
|
1636
|
+
db.prepare("DELETE FROM memory_mints WHERE memory_id = ?").run(id);
|
|
1341
1637
|
db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
1342
1638
|
// Mirror sync must reflect the deletion; bump desired generation so a
|
|
1343
1639
|
// crash between the delete and syncMirror leaves a recoverable debt.
|
|
@@ -1345,6 +1641,68 @@ export function createStore(path) {
|
|
|
1345
1641
|
});
|
|
1346
1642
|
}
|
|
1347
1643
|
|
|
1644
|
+
// 一条 DELETE ... WHERE id IN (...) 最多绑多少个 `?`。SQLite 的上限是 32766
|
|
1645
|
+
// (SQLITE_MAX_VARIABLE_NUMBER),取 500 后离上限还有六十多倍余量:这条语句一行一个参数,
|
|
1646
|
+
// 而包的大小按字节不按条数封顶(见 deleteBySharePackId 的分块理由),500 让单条语句的
|
|
1647
|
+
// 解析成本可以忽略、又永远够不到那格上限,块数只随包条数线性增长。
|
|
1648
|
+
const DELETE_ID_CHUNK = 500;
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* 整包退出(车道 C)。删空一个不存在的包是幂等的 0,不是错误——对方点第二次「收起
|
|
1652
|
+
* 这个包」与「收起一个从来没导入过的包」都该是这个答案;早退也不 bump generation:
|
|
1653
|
+
* 什么都没删就别记一笔镜像债(remove() 每次必 bump,那是它的既有行为,不在这里扩大)。
|
|
1654
|
+
*
|
|
1655
|
+
* side table 清理口径 = remove() 的口径(#399 / #512):显式同事务清 memory_provenance
|
|
1656
|
+
* 与 memory_mints。
|
|
1657
|
+
* 这条是双保险,不是「FK 没开所以必须手写」:memory_provenance 挂着 ON DELETE CASCADE,
|
|
1658
|
+
* 而 node:sqlite 默认就把 foreign_keys 开成 1(实测 PRAGMA foreign_keys = 1),今天删行
|
|
1659
|
+
* 确实另有级联兜底;显式清理的存在是为了不依赖这份没人钉过的驱动默认,同时跟 remove()
|
|
1660
|
+
* 保持一致的口径(连接哪天以 enableForeignKeyConstraints:false 打开,悬空链接只有它挡得住)。
|
|
1661
|
+
*
|
|
1662
|
+
* remove() 不碰的(entity_attrs / entity_relations 的 memory_id 反查、conflict_pending、
|
|
1663
|
+
* scope_changes、embedding 解析缓存)这里也不碰——批删不是新的清理语义。
|
|
1664
|
+
*
|
|
1665
|
+
* 向量索引这一格 C4 实测过,把 C1 留下的那句「由 C4 在 service 侧处理」改对:service
|
|
1666
|
+
* 侧没有活儿要接。`vector-index.js` 是 memories.embedding 那一列的薄层,余弦检索
|
|
1667
|
+
* (`searchVector`) 每次调用都从 SQL 现取「带向量的行」的 id 集合,行被物理删掉即从集合
|
|
1668
|
+
* 里消失——向量跟着行一起走,一列残值都留不下;事后落到的 embed 也只是
|
|
1669
|
+
* `UPDATE ... WHERE id = ?` 命中 0 行,不会把僵尸行写回来。唯一的残留是 #202 那个
|
|
1670
|
+
* 进程内的解析缓存(id → 已解析向量),它与 remove() 同处置且读不出僵尸:每个调用方
|
|
1671
|
+
* (searchVector / getEmbeddings / document.js / organize.js)的 id 集合都来自活行的
|
|
1672
|
+
* SQL 读,未命中缓存时按 id 点查库,行没了就返回 undefined——最坏是一条等 FIFO 驱逐的
|
|
1673
|
+
* 内存,不是一条搜得到的鬼行。要在这里清它等于给批删加一份 remove() 没有的新语义,
|
|
1674
|
+
* 与本方法的口径声明相反,所以不动(C4 的报告记了这一格,remove() 是否要一起补是
|
|
1675
|
+
* 单独一刀的事)。
|
|
1676
|
+
*
|
|
1677
|
+
* IN 列表分块(chunk = DELETE_ID_CHUNK):SQLite 一条语句最多绑 32766 个参数
|
|
1678
|
+
* (SQLITE_MAX_VARIABLE_NUMBER,实测第 32767 个报 "too many SQL variables"),而这条
|
|
1679
|
+
* 语句原本一行一个 `?`。车道 A 的包水位按字节不按条数(MAX_PACK_BYTES = 3 MiB),所以
|
|
1680
|
+
* 「正文很短、条数很多」的包理论上能逼近那一格——一条语句撞上限是整笔回滚,效果等于
|
|
1681
|
+
* 「这个包永远撤不掉」,而那正是退出这条路径最不该坏的方式。
|
|
1682
|
+
* 分块不是语义变化,四点逐条对齐分块前的形状:ids 是本事务开头一次读到的固定快照
|
|
1683
|
+
* (slice 出来的连续段,块之间互不相交、并起来就是全集);按主键删本就幂等;整包裹在同
|
|
1684
|
+
* 一个 runAtomically 里,原子性与快照语义一字未动;generation 只在最后加一次,所以
|
|
1685
|
+
* 「删了行却没记镜像债」这个 crash 窗口和分块前同样不存在。changes 逐块求和——那是
|
|
1686
|
+
* 调用方答复里报出去的数,少加一块就少报一块。
|
|
1687
|
+
*/
|
|
1688
|
+
function deleteBySharePackId(packId) {
|
|
1689
|
+
if (typeof packId !== "string" || !packId) return 0;
|
|
1690
|
+
return runAtomically(() => {
|
|
1691
|
+
const ids = db.prepare("SELECT id FROM memories WHERE share_pack_id = ?").all(packId).map((r) => r.id);
|
|
1692
|
+
if (!ids.length) return 0;
|
|
1693
|
+
let deleted = 0;
|
|
1694
|
+
for (let start = 0; start < ids.length; start += DELETE_ID_CHUNK) {
|
|
1695
|
+
const chunk = ids.slice(start, start + DELETE_ID_CHUNK);
|
|
1696
|
+
const marks = chunk.map(() => "?").join(", ");
|
|
1697
|
+
db.prepare(`DELETE FROM memory_provenance WHERE memory_id IN (${marks})`).run(...chunk);
|
|
1698
|
+
db.prepare(`DELETE FROM memory_mints WHERE memory_id IN (${marks})`).run(...chunk);
|
|
1699
|
+
deleted += db.prepare(`DELETE FROM memories WHERE id IN (${marks})`).run(...chunk).changes;
|
|
1700
|
+
}
|
|
1701
|
+
incrementGeneration();
|
|
1702
|
+
return deleted;
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1348
1706
|
/**
|
|
1349
1707
|
* Atomic compare-and-set update: applies `patch` only when the row still
|
|
1350
1708
|
* carries `expectedUpdatedAt` (the version token read by the caller). Returns
|
|
@@ -1521,7 +1879,7 @@ export function createStore(path) {
|
|
|
1521
1879
|
return rows.map(toRow);
|
|
1522
1880
|
}
|
|
1523
1881
|
|
|
1524
|
-
function list({ type, excludeTypes = null, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
|
|
1882
|
+
function list({ type, excludeTypes = null, excludeImported = false, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
|
|
1525
1883
|
const clauses = [];
|
|
1526
1884
|
const params = [];
|
|
1527
1885
|
if (type) {
|
|
@@ -1545,6 +1903,17 @@ export function createStore(path) {
|
|
|
1545
1903
|
clauses.push("source = ?");
|
|
1546
1904
|
params.push(source);
|
|
1547
1905
|
}
|
|
1906
|
+
// excludeImported:「本库不巩固别人导入的原文」这条政策轴的查询层口径。
|
|
1907
|
+
// 它必须站在 LIMIT 之前而不是跟着 .filter 走在后面——与上面 excludeTypes 那条同理由
|
|
1908
|
+
// (#230):先截断后丢弃的话,被丢掉的外来行照样吃掉窗口名额,导入过包的库里
|
|
1909
|
+
// 模式扫描池会因此饿到 sleepPatternMinMemories 以下而静默跳过。
|
|
1910
|
+
// `source IS NULL OR` 那一支不是防御性冗余,是必填:本方行的 source 恒为 NULL,
|
|
1911
|
+
// 而 SQL 里三值逻辑让 `NULL <> IMPORTED_SOURCE` 求值为 NULL(不是 true),只写后者的
|
|
1912
|
+
// 话本方行整批消失。opt-in 默认关,其余调用方的读数逐字节不变。
|
|
1913
|
+
if (excludeImported) {
|
|
1914
|
+
clauses.push("(source IS NULL OR source <> ?)");
|
|
1915
|
+
params.push(IMPORTED_SOURCE);
|
|
1916
|
+
}
|
|
1548
1917
|
// Optional updated_at closed range (date-only "to" is normalized to the
|
|
1549
1918
|
// end of that day). Same helper as count() so total matches the rows.
|
|
1550
1919
|
const bounds = updatedAtBounds(updatedFrom, updatedTo);
|
|
@@ -1645,6 +2014,30 @@ export function createStore(path) {
|
|
|
1645
2014
|
return rows.map(toRow);
|
|
1646
2015
|
}
|
|
1647
2016
|
|
|
2017
|
+
// Issue #505:写路径的去重候选必须「按同标题定向取」,不能「按 importance 取
|
|
2018
|
+
// 前 N 条」。后者是「该类型最重要的 100 行」,与「找出同标题的那一行」无关:
|
|
2019
|
+
// 某类型里 importance 高于待写行的行数一旦超过窗口,同标题行与待写行双双落在
|
|
2020
|
+
// 窗口外,saveWithDedupe 的 find 恒不命中,于是静默走创建分支——不报错、不告
|
|
2021
|
+
// 警,合并语义随库增长而失效(报告实测 1047 行类型、importance=5 有 180 行时
|
|
2022
|
+
// 稳定复现)。
|
|
2023
|
+
//
|
|
2024
|
+
// 口径刻意与 saveWithDedupe 的比较式对齐:那边用 m.title.trim() 比
|
|
2025
|
+
// String(memory.title).trim(),而 title 入库时并不 trim(见 save() 的
|
|
2026
|
+
// `memory.title` 直写)⇒ 这里必须 TRIM(title),否则存量里带首尾空白的标题
|
|
2027
|
+
// 会被精确匹配漏掉,退回同一种静默降级。
|
|
2028
|
+
//
|
|
2029
|
+
// archived/forgotten 的过滤与 list() 默认口径一致(两者都为 0):合并目标必须
|
|
2030
|
+
// 是活跃行,否则会把内容并进一条已经归档/遗忘的记忆里。
|
|
2031
|
+
// 无 LIMIT:同 (type, title) 的活跃行本就极少,加窗口等于把 #505 重新引进来。
|
|
2032
|
+
function activeByTypeAndTitle(type, title) {
|
|
2033
|
+
const rows = db.prepare(
|
|
2034
|
+
`SELECT ${memoryColumnList} FROM memories
|
|
2035
|
+
WHERE type = ? AND TRIM(title) = ?
|
|
2036
|
+
AND forgotten = 0 AND archived = 0`
|
|
2037
|
+
).all(type, String(title ?? "").trim());
|
|
2038
|
+
return rows.map(toRow);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
1648
2041
|
// Issue #430:写路径要的「最近若干条正文」。质量过滤的近重复判定只读 content
|
|
1649
2042
|
// 这一列 + 前 20 行,此前写成 all().slice(0, 20) —— 全表逐行 toRow(27 字段、
|
|
1650
2043
|
// 三次 JSON 解析)之后只截前 20 条正文;5k 行实测 100ms 级 → 0.06ms 级。
|
|
@@ -1801,6 +2194,71 @@ export function createStore(path) {
|
|
|
1801
2194
|
}, new Map());
|
|
1802
2195
|
}
|
|
1803
2196
|
|
|
2197
|
+
/**
|
|
2198
|
+
* 维度探针(#383):**存在性判据**——库里有没有一条活跃向量的维度等于查询维度。
|
|
2199
|
+
*
|
|
2200
|
+
* #508 评审把判据从「读一行的维度」换成了存在性,因为 LIMIT 1 拿任意一条代表全库,
|
|
2201
|
+
* 在混合维度库里两个方向都错:抽到与查询不符的那条 → 明明有可比的 3 维向量却报
|
|
2202
|
+
* degraded;抽到的那条恰好解析失败 → 其余有效向量都不匹配时又漏报成 full。一行
|
|
2203
|
+
* 读数回答不了「这个维度在这库里能不能比」这个问题。
|
|
2204
|
+
*
|
|
2205
|
+
* 行集与 searchVector **必须同源**:下面这段 WHERE 的谓词与 searchVector(本文件
|
|
2206
|
+
* vector search 段)逐条相同(只是书写顺序不同,语义等价),改一处必须同步改另一处
|
|
2207
|
+
* ——否则 tier 的核心断言「searchVector 会不会找到可比向量」会悄悄失真。活跃 = 未
|
|
2208
|
+
* 遗忘未归档、embedding 非空;/context 只经 searchVector 触达检索,从不传
|
|
2209
|
+
* includeArchived。
|
|
2210
|
+
*
|
|
2211
|
+
* 状态语义(后两个是 fail-closed 兜底态,不是正常读数):
|
|
2212
|
+
* match 有维度相符的活跃向量 → 语义路通。注意「通」≠「有命中」:type /
|
|
2213
|
+
* importance / codingGate / scope 四道门(service.js 的 inject 段)
|
|
2214
|
+
* 仍可能让语义档贡献为 0,这是旧探针就有的边界。
|
|
2215
|
+
* mismatch 有向量,但没有一条维度相符 → 语义召回实际不可用 → degraded
|
|
2216
|
+
* none 行集本身为空(空库 / 从未建索引)→ 没有东西可比,**不是**维度
|
|
2217
|
+
* 不符 → full
|
|
2218
|
+
* unreadable 有行,但一条都读不出来(embedding 列损坏)→ 与 none 是**两种空**:
|
|
2219
|
+
* 前者是还没有内容,后者是内容在那儿却读不出来。判不出来 → unknown,
|
|
2220
|
+
* 绝不因为「读不到」就宣称 full。
|
|
2221
|
+
* invalid 查询维度本身不是正整数 → 同样判不出来 → unknown。当前 /context
|
|
2222
|
+
* 不可达(embedQueryVector 保证返回非空数组),防御性留着:别让
|
|
2223
|
+
* 「判不了」落到 full 上。
|
|
2224
|
+
*
|
|
2225
|
+
* 成本模型(取代原来「只读一行,O(1)」的说法——那句话对现在的实现是假的):
|
|
2226
|
+
* · SQL 是一次**无 ORDER BY 无 LIMIT 的全 id 扫描**,每次调用都有,不免费
|
|
2227
|
+
* (只拖 id 列,约 40 KB/千行,比 SELECT * 的全表 TEXT 便宜得多)。
|
|
2228
|
+
* · 按匹配短路:全库同维度(常态)时 JS 循环第一行就 return,只解析 1 个向量。
|
|
2229
|
+
* · 一条都不匹配时才走完整个循环,而那正是要报 degraded 的情形,值得。此时每个
|
|
2230
|
+
* 缓存未命中的行都要点查一次 embedding TEXT(冷缓存下 = N+1 条语句);
|
|
2231
|
+
* getParsedEmbedding 不缓存否定结果,所以读不出来的行每次请求都会重解析一次。
|
|
2232
|
+
* · 缓存热不热是有条件的:/context 里让 embeddingCache 变热的只有 injectCandidates
|
|
2233
|
+
* 内部两个 vectorIndex 分支(service.js:1696 / :1768),需要 hybridInject 与
|
|
2234
|
+
* selectiveInject 都没被关掉、行数在 4000 条 FIFO 上限内;standalone 装配不设
|
|
2235
|
+
* vectorIndex 时缓存是冷的。判档跑在 injectCandidates 之后,装配齐时缓存已热。
|
|
2236
|
+
*
|
|
2237
|
+
* @param {number} dimension 查询向量维度
|
|
2238
|
+
* @returns {{state: "match"|"mismatch"|"none"|"unreadable"|"invalid"}}
|
|
2239
|
+
*/
|
|
2240
|
+
function vectorDimensionState(dimension) {
|
|
2241
|
+
if (!Number.isInteger(dimension) || dimension <= 0) return { state: "invalid" };
|
|
2242
|
+
// 与 searchVector 的 WHERE 谓词逐条相同(顺序不同、语义等价)——同源行集,同步改。
|
|
2243
|
+
const rows = db.prepare(
|
|
2244
|
+
`SELECT id FROM memories
|
|
2245
|
+
WHERE embedding IS NOT NULL AND embedding != ''
|
|
2246
|
+
AND forgotten = 0 AND archived = 0`
|
|
2247
|
+
).all();
|
|
2248
|
+
if (!rows.length) return { state: "none" };
|
|
2249
|
+
let sawVector = false;
|
|
2250
|
+
for (const row of rows) {
|
|
2251
|
+
// 走 getParsedEmbedding 的解析缓存,不自己 JSON.parse——与 searchVector /
|
|
2252
|
+
// getEmbedding(s) 同一条读路径,缓存热时零解析。
|
|
2253
|
+
const v = getParsedEmbedding(row.id);
|
|
2254
|
+
if (!v || !v.length) continue;
|
|
2255
|
+
sawVector = true;
|
|
2256
|
+
if (v.length === dimension) return { state: "match" };
|
|
2257
|
+
}
|
|
2258
|
+
// 有行却零条可解析:不是「没有向量」,是「有向量读不出来」。
|
|
2259
|
+
return { state: sawVector ? "mismatch" : "unreadable" };
|
|
2260
|
+
}
|
|
2261
|
+
|
|
1804
2262
|
function search(query, { limit = 20, includeArchived = false } = {}) {
|
|
1805
2263
|
const q = String(query).trim();
|
|
1806
2264
|
if (!q) return [];
|
|
@@ -1902,18 +2360,22 @@ export function createStore(path) {
|
|
|
1902
2360
|
Number.isInteger(run.store_chars) && run.store_chars >= 0;
|
|
1903
2361
|
const storeCount = baselineKnown ? run.store_count : null;
|
|
1904
2362
|
const storeChars = baselineKnown ? run.store_chars : null;
|
|
2363
|
+
// #534:失败轮的模型原始输出(对象:kind/raw_len/raw_head/raw_tail?/parsed?)。
|
|
2364
|
+
// 未提供落 NULL 而不是 "{}"——读侧靠「有没有值」区分「这轮没留」与「留了个空」。
|
|
2365
|
+
const rawOutput = run.raw_output !== undefined ? JSON.stringify(run.raw_output) : null;
|
|
1905
2366
|
db.prepare(
|
|
1906
2367
|
`INSERT INTO dream_runs (id, created_at, status, error, provider, model, snapshot_hash,
|
|
1907
2368
|
input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch, run_type, skipped,
|
|
1908
|
-
store_count, store_chars)
|
|
1909
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2369
|
+
store_count, store_chars, raw_output)
|
|
2370
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1910
2371
|
ON CONFLICT(id) DO UPDATE SET
|
|
1911
2372
|
created_at=excluded.created_at, status=excluded.status, error=excluded.error,
|
|
1912
2373
|
provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
|
|
1913
2374
|
input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
|
|
1914
2375
|
outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
|
|
1915
2376
|
receipt=excluded.receipt, policy_epoch=excluded.policy_epoch, run_type=excluded.run_type,
|
|
1916
|
-
skipped=excluded.skipped, store_count=excluded.store_count, store_chars=excluded.store_chars
|
|
2377
|
+
skipped=excluded.skipped, store_count=excluded.store_count, store_chars=excluded.store_chars,
|
|
2378
|
+
raw_output=excluded.raw_output`
|
|
1917
2379
|
).run(
|
|
1918
2380
|
id,
|
|
1919
2381
|
run.created_at ?? now,
|
|
@@ -1933,7 +2395,8 @@ export function createStore(path) {
|
|
|
1933
2395
|
runType,
|
|
1934
2396
|
skipped,
|
|
1935
2397
|
storeCount,
|
|
1936
|
-
storeChars
|
|
2398
|
+
storeChars,
|
|
2399
|
+
rawOutput
|
|
1937
2400
|
);
|
|
1938
2401
|
return getDreamRun(id);
|
|
1939
2402
|
}
|
|
@@ -1943,11 +2406,16 @@ export function createStore(path) {
|
|
|
1943
2406
|
return toDreamRun(row);
|
|
1944
2407
|
}
|
|
1945
2408
|
|
|
1946
|
-
|
|
2409
|
+
// #495:excludeRunType 是可选下推条件,默认不过滤——审计面(面板的最近运行、
|
|
2410
|
+
// 回放脚本、各测试的 `listDreamRuns()` 无参调用)本来就该看全量,过滤只在
|
|
2411
|
+
// 「最近一次真·巩固」这种需要排除 agent 主动整理的场合由调用方显式指定。
|
|
2412
|
+
// 值走白名单:这个参数会拼进 SQL 的 != 条件,不接受任意字符串。
|
|
2413
|
+
function listDreamRuns({ limit = 50, offset = 0, excludeRunType = null } = {}) {
|
|
1947
2414
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
2415
|
+
const excluded = DREAM_RUN_TYPES.includes(excludeRunType) ? excludeRunType : null;
|
|
1948
2416
|
const rows = db.prepare(
|
|
1949
|
-
"SELECT * FROM dream_runs ORDER BY created_at DESC, id LIMIT ? OFFSET ?"
|
|
1950
|
-
).all(lim, off);
|
|
2417
|
+
"SELECT * FROM dream_runs WHERE (? IS NULL OR run_type != ?) ORDER BY created_at DESC, id LIMIT ? OFFSET ?"
|
|
2418
|
+
).all(excluded, excluded, lim, off);
|
|
1951
2419
|
return rows.map(toDreamRun);
|
|
1952
2420
|
}
|
|
1953
2421
|
|
|
@@ -2227,7 +2695,7 @@ export function createStore(path) {
|
|
|
2227
2695
|
const now = nowIso();
|
|
2228
2696
|
const inTokens = Number.isFinite(entry.input_tokens) ? entry.input_tokens : 0;
|
|
2229
2697
|
const outTokens = Number.isFinite(entry.output_tokens) ? entry.output_tokens : 0;
|
|
2230
|
-
db.prepare(
|
|
2698
|
+
const info = db.prepare(
|
|
2231
2699
|
`INSERT INTO llm_audit_logs (timestamp, trigger_source, operation_type, model_id,
|
|
2232
2700
|
input_tokens, output_tokens, total_tokens, cost_usd, duration_ms, status,
|
|
2233
2701
|
error_message, related_memory_ids, metadata, session_key)
|
|
@@ -2250,7 +2718,38 @@ export function createStore(path) {
|
|
|
2250
2718
|
: null,
|
|
2251
2719
|
entry.session_key ?? null
|
|
2252
2720
|
);
|
|
2253
|
-
|
|
2721
|
+
// #534 / CodeRabbit on #535:回读必须用**这一次插入**的 rowid,不能用
|
|
2722
|
+
// `ORDER BY id DESC LIMIT 1`。库支持多进程共用(见 AGENTS.md 的
|
|
2723
|
+
// busy_timeout + WAL 约定),另一个写者在这两条语句之间插入就会把这行
|
|
2724
|
+
// 读成别人的调用——调用方拿它去 annotate 校验判决时,会把本轮结论写到
|
|
2725
|
+
// 另一轮头上。lastInsertRowid 是连接内的事实,不受并发写影响。
|
|
2726
|
+
return toLlmAudit(db.prepare("SELECT * FROM llm_audit_logs WHERE id = ?").get(info.lastInsertRowid));
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
/**
|
|
2730
|
+
* #534 建议 3:给已落库的审计行补一层「事后才知道」的读数。
|
|
2731
|
+
*
|
|
2732
|
+
* 两套账的根源是时序:审计行在 LLM 调用返回的那一刻就写死了(状态、token、
|
|
2733
|
+
* 耗时都是调用层事实),而决策校验发生在调用之后——一轮「调用成功、输出却被
|
|
2734
|
+
* 校验整单拒绝」在审计表里永远是 success,于是聚合出来的成功率与 dream_runs
|
|
2735
|
+
* 的真实成功率对不上(作者实测 95% vs 10.5%)。
|
|
2736
|
+
*
|
|
2737
|
+
* 只合并 metadata(表里写明是 free-form extras),绝不改 status / token /
|
|
2738
|
+
* timestamp:那些是调用层的事实,改写它们等于伪造账本。metadata 是「这一轮还
|
|
2739
|
+
* 有什么值得记的」的垃圾桶,把校验判决放进去既补上了分账,又不制造第二张表。
|
|
2740
|
+
*
|
|
2741
|
+
* patch 是浅合并:已有键保留,同名键覆盖。行不存在(被清理/删除)静默返回
|
|
2742
|
+
* undefined——补注失败不该把一轮巩固变成异常。
|
|
2743
|
+
*/
|
|
2744
|
+
function annotateLlmAudit(id, patch) {
|
|
2745
|
+
if (id == null || !patch || typeof patch !== "object") return undefined;
|
|
2746
|
+
const row = db.prepare("SELECT * FROM llm_audit_logs WHERE id = ?").get(id);
|
|
2747
|
+
if (!row) return undefined;
|
|
2748
|
+
const current = toLlmAudit(row)?.metadata;
|
|
2749
|
+
const base = current && typeof current === "object" && !Array.isArray(current) ? current : {};
|
|
2750
|
+
const merged = { ...base, ...patch };
|
|
2751
|
+
db.prepare("UPDATE llm_audit_logs SET metadata = ? WHERE id = ?").run(JSON.stringify(merged), id);
|
|
2752
|
+
return toLlmAudit(db.prepare("SELECT * FROM llm_audit_logs WHERE id = ?").get(id));
|
|
2254
2753
|
}
|
|
2255
2754
|
|
|
2256
2755
|
function listLlmAudits({ limit = 50, offset = 0, source, sessionKey } = {}) {
|
|
@@ -2413,22 +2912,151 @@ export function createStore(path) {
|
|
|
2413
2912
|
* 重跑的重复链接(否则幂等测试锁会挂)。
|
|
2414
2913
|
* @returns {number} 实际插入行数
|
|
2415
2914
|
*/
|
|
2416
|
-
function saveMemoryProvenance({ memoryId, sessionId, seqStart, seqEnd, createdAt }) {
|
|
2915
|
+
function saveMemoryProvenance({ memoryId, sessionId, seqStart, seqEnd, createdAt, distillRunId }) {
|
|
2916
|
+
// #512:distill_run_id 是「这条链接由哪一轮蒸馏写下」的可选归因。缺省落 NULL,
|
|
2917
|
+
// 且**不回填**——存量链接没有第二轮真相可查,反推一个合成 id 比留空更坏。
|
|
2417
2918
|
return db.prepare(
|
|
2418
|
-
`INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at)
|
|
2419
|
-
VALUES (?, ?, ?, ?, ?)
|
|
2919
|
+
`INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at, distill_run_id)
|
|
2920
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2420
2921
|
ON CONFLICT DO NOTHING`
|
|
2421
|
-
).run(memoryId, sessionId, seqStart, seqEnd, createdAt).changes;
|
|
2922
|
+
).run(memoryId, sessionId, seqStart, seqEnd, createdAt, distillRunId ?? null).changes;
|
|
2422
2923
|
}
|
|
2423
2924
|
|
|
2424
|
-
/** 一条记忆的全部溯源链接(#399 面板/CLI
|
|
2925
|
+
/** 一条记忆的全部溯源链接(#399 面板/CLI 读取面;#512 起带 distill_run_id)。 */
|
|
2425
2926
|
function provenanceFor(memoryId) {
|
|
2426
2927
|
return db.prepare(
|
|
2427
|
-
`SELECT memory_id, session_id, seq_start, seq_end, created_at
|
|
2928
|
+
`SELECT memory_id, session_id, seq_start, seq_end, created_at, distill_run_id
|
|
2428
2929
|
FROM memory_provenance WHERE memory_id = ? ORDER BY created_at, session_id, seq_start`
|
|
2429
2930
|
).all(memoryId);
|
|
2430
2931
|
}
|
|
2431
2932
|
|
|
2933
|
+
/**
|
|
2934
|
+
* 落盘一轮蒸馏运行(#512)。写入幂等(同 id replay 覆写、绝不重复),与
|
|
2935
|
+
* saveDreamRun 同一口径,让同一逻辑轮可以重放核对。
|
|
2936
|
+
*
|
|
2937
|
+
* audit_id 是入参而不是在这里顺手写 audit:audit 行在 summarize.js 的 finally 里
|
|
2938
|
+
* 落盘,而且**可能压根不存在**(llmAudit 关着)。run 先落、audit 后补再回填这一列,
|
|
2939
|
+
* 比把两件事塞进一个事务诚实——归因不该依赖计费开关。
|
|
2940
|
+
*/
|
|
2941
|
+
function saveDistillRun(run) {
|
|
2942
|
+
const id = run.id ?? randomUUID();
|
|
2943
|
+
const now = nowIso();
|
|
2944
|
+
db.prepare(
|
|
2945
|
+
`INSERT INTO distill_runs (id, created_at, status, error, run_type, trigger_source,
|
|
2946
|
+
session_id, seq_start, seq_end, memory_count, provider, model, audit_id)
|
|
2947
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2948
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2949
|
+
created_at=excluded.created_at, status=excluded.status, error=excluded.error,
|
|
2950
|
+
run_type=excluded.run_type, trigger_source=excluded.trigger_source,
|
|
2951
|
+
session_id=excluded.session_id, seq_start=excluded.seq_start, seq_end=excluded.seq_end,
|
|
2952
|
+
memory_count=excluded.memory_count, provider=excluded.provider, model=excluded.model,
|
|
2953
|
+
audit_id=excluded.audit_id`
|
|
2954
|
+
).run(
|
|
2955
|
+
id,
|
|
2956
|
+
run.created_at ?? now,
|
|
2957
|
+
run.status,
|
|
2958
|
+
run.error ?? null,
|
|
2959
|
+
run.run_type,
|
|
2960
|
+
run.trigger_source,
|
|
2961
|
+
run.session_id ?? null,
|
|
2962
|
+
Number.isInteger(run.seq_start) ? run.seq_start : null,
|
|
2963
|
+
Number.isInteger(run.seq_end) ? run.seq_end : null,
|
|
2964
|
+
Number.isInteger(run.memory_count) ? run.memory_count : 0,
|
|
2965
|
+
run.provider ?? null,
|
|
2966
|
+
run.model ?? null,
|
|
2967
|
+
Number.isInteger(run.audit_id) ? run.audit_id : null
|
|
2968
|
+
);
|
|
2969
|
+
return getDistillRun(id);
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
/** #512:事后补注某一轮的开销读数 id(audit 行在 run 之后才落盘)。 */
|
|
2973
|
+
function attachDistillRunAudit(id, auditId) {
|
|
2974
|
+
if (!id || !Number.isInteger(auditId)) return 0;
|
|
2975
|
+
return db.prepare("UPDATE distill_runs SET audit_id = ? WHERE id = ?").run(auditId, id).changes;
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
function getDistillRun(id) {
|
|
2979
|
+
return toDistillRun(db.prepare("SELECT * FROM distill_runs WHERE id = ?").get(id));
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
function listDistillRuns({ limit = 50, offset = 0 } = {}) {
|
|
2983
|
+
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
2984
|
+
return db.prepare(
|
|
2985
|
+
"SELECT * FROM distill_runs ORDER BY created_at DESC, id LIMIT ? OFFSET ?"
|
|
2986
|
+
).all(lim, off).map(toDistillRun);
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/**
|
|
2990
|
+
* 记一条「这条记忆出生自哪一轮」(#512)。只记 create:既有行的改动由
|
|
2991
|
+
* receipt_chain 按 (run_id, record_id) 记,两套账不能有第二份。
|
|
2992
|
+
* INSERT OR IGNORE 让重放幂等——同一 run 重放不该重复记一次铸造。
|
|
2993
|
+
*/
|
|
2994
|
+
function saveMemoryMint({ memoryId, runId, runType, triggerSource, createdAt }) {
|
|
2995
|
+
if (!memoryId || !runId || !runType || !triggerSource) return 0;
|
|
2996
|
+
return db.prepare(
|
|
2997
|
+
`INSERT OR IGNORE INTO memory_mints (memory_id, run_id, run_type, trigger_source, created_at)
|
|
2998
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
2999
|
+
).run(memoryId, runId, runType, triggerSource, createdAt ?? nowIso()).changes;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
/** 一条记忆的铸造记录(#512 读侧;通常 0 或 1 条,跨轮重放才会多)。 */
|
|
3003
|
+
function mintsFor(memoryId) {
|
|
3004
|
+
return db.prepare(
|
|
3005
|
+
`SELECT memory_id, run_id, run_type, trigger_source, created_at
|
|
3006
|
+
FROM memory_mints WHERE memory_id = ? ORDER BY created_at, run_id`
|
|
3007
|
+
).all(memoryId);
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
/**
|
|
3011
|
+
* 一个 (session, seq 区间) 的蒸馏原文行(#399 写入、#421 读出口)。
|
|
3012
|
+
*
|
|
3013
|
+
* 按 seq 升序——原文的先后顺序就是区间顺序,乱序会让「这句从哪来」读不出来。
|
|
3014
|
+
* 到期裁剪后区间可能变空,那时返回 []:链接刻意保留并指向空区间
|
|
3015
|
+
* (见 clearDistillRecords 注释),面板据此显示「原文已过期」而非「没有来源」。
|
|
3016
|
+
*/
|
|
3017
|
+
function distillRecordsFor(sessionId, seqStart, seqEnd) {
|
|
3018
|
+
return db.prepare(
|
|
3019
|
+
`SELECT seq, role, text, window_id, distilled_at
|
|
3020
|
+
FROM distill_records
|
|
3021
|
+
WHERE session_id = ? AND seq >= ? AND seq <= ?
|
|
3022
|
+
ORDER BY seq`
|
|
3023
|
+
).all(sessionId, seqStart, seqEnd);
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
/**
|
|
3027
|
+
* 一个会话产出的蒸馏窗口(#524 方向 2 的存储侧)。
|
|
3028
|
+
*
|
|
3029
|
+
* #421 把溯源读出口拆成两个方向:方向 1(记忆 → 窗口)由 #509 落地,走
|
|
3030
|
+
* provenanceFor;这是方向 2(会话 → 窗口)。两个方向读的是同一张表,只是索引不
|
|
3031
|
+
* 同(idx_memory_provenance_session 就是为这一条建的,早已存在——所以这不是
|
|
3032
|
+
* schema 变更)。
|
|
3033
|
+
*
|
|
3034
|
+
* **必须带 limit**:一个会话可能有几十个窗口,每个窗口接着就是整段逐字原文,
|
|
3035
|
+
* 不限量等于「按会话导出全量转录」(#524 第 2 条前置)。上限的口径在协议层
|
|
3036
|
+
* (provenance-http.js 的 PROVENANCE_MAX_WINDOWS),这一层只照参数取。
|
|
3037
|
+
*
|
|
3038
|
+
* 一行 = 一个窗口(同一 (session, seq_start, seq_end) 的多条链接 GROUP BY 成一行)
|
|
3039
|
+
* ——一条蒸馏窗口通常产出多条记忆,逐链接返回会让「这个会话蒸了几次」这个唯一
|
|
3040
|
+
* 有意义的计数被重复行淹没。link_count 说明该窗口派生出几条记忆,是按窗口看的
|
|
3041
|
+
* 第二维;**刻意不回 memory_id 列表**:方向 1 已经是「记忆 → 窗口」,把 id 也塞进
|
|
3042
|
+
* 这一侧会让两个方向不再正交(同一份映射有两条读法就得有两处维护),而真要逐条
|
|
3043
|
+
* 查仍是按方向 1 走。
|
|
3044
|
+
*
|
|
3045
|
+
* ORDER BY seq_start:seq 是事件序号,就是会话内的先后;窗口按它排才是「这个会话
|
|
3046
|
+
* 按时间蒸了哪几段」,按 created_at 排会把同一批重跑的行打散。
|
|
3047
|
+
*/
|
|
3048
|
+
function distillWindowsFor(sessionId, limit) {
|
|
3049
|
+
return db.prepare(
|
|
3050
|
+
`SELECT session_id, seq_start, seq_end, MIN(created_at) AS created_at,
|
|
3051
|
+
COUNT(*) AS link_count
|
|
3052
|
+
FROM memory_provenance
|
|
3053
|
+
WHERE session_id = ?
|
|
3054
|
+
GROUP BY session_id, seq_start, seq_end
|
|
3055
|
+
ORDER BY seq_start, seq_end
|
|
3056
|
+
LIMIT ?`
|
|
3057
|
+
).all(sessionId, limit);
|
|
3058
|
+
}
|
|
3059
|
+
|
|
2432
3060
|
// --- 存储生命周期:无损回收(#275 第一批)--------------------------------
|
|
2433
3061
|
//
|
|
2434
3062
|
// 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。
|
|
@@ -2644,6 +3272,108 @@ export function createStore(path) {
|
|
|
2644
3272
|
).get().c;
|
|
2645
3273
|
}
|
|
2646
3274
|
|
|
3275
|
+
// --- differentiate adjudication ledger (issue #533) ----------------------
|
|
3276
|
+
|
|
3277
|
+
/**
|
|
3278
|
+
* Record that the pair (a, b) has already been adjudicated as "different
|
|
3279
|
+
* enough to both stay" — the differentiate decision itself. Nothing is
|
|
3280
|
+
* written to either memory: #533 的复现证明「把同一段注记追加到两侧」会
|
|
3281
|
+
* 把两条向量越推越近(共享后缀同向拉高余弦),所以裁决只记账、不动正文。
|
|
3282
|
+
*
|
|
3283
|
+
* hash_a/hash_b are the content hashes captured at adjudication time; the
|
|
3284
|
+
* sleep candidate builder uses them to decide whether the verdict still
|
|
3285
|
+
* stands (any later edit to either side re-opens the pair). Sorting the pair
|
|
3286
|
+
* keeps one row per unordered pair, same normalization as saveConflictPending.
|
|
3287
|
+
*/
|
|
3288
|
+
function saveDifferentiatedPair({ run_id, memory_a, memory_b, hash_a, hash_b, distinctions }) {
|
|
3289
|
+
const [a, b] = [memory_a, memory_b].sort();
|
|
3290
|
+
// hashes travel with their side; sort the (id, hash) tuples so hash_a
|
|
3291
|
+
// always belongs to memory_a after normalization.
|
|
3292
|
+
const [ha, hb] = memory_a <= memory_b ? [hash_a, hash_b] : [hash_b, hash_a];
|
|
3293
|
+
db.prepare(
|
|
3294
|
+
`INSERT INTO differentiated_pairs (id, memory_a, memory_b, hash_a, hash_b, distinctions, run_id, created_at)
|
|
3295
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3296
|
+
ON CONFLICT(memory_a, memory_b) DO UPDATE SET
|
|
3297
|
+
hash_a = excluded.hash_a,
|
|
3298
|
+
hash_b = excluded.hash_b,
|
|
3299
|
+
distinctions = excluded.distinctions,
|
|
3300
|
+
run_id = excluded.run_id,
|
|
3301
|
+
created_at = excluded.created_at`
|
|
3302
|
+
).run(
|
|
3303
|
+
randomUUID(), a, b, ha ?? null, hb ?? null,
|
|
3304
|
+
// 没有 distinctions 就落 NULL 而不是 "[]"——空数组会被读成「判过、理由是空」,
|
|
3305
|
+
// NULL 才是「这条账本行没记理由」的诚实写法(与 dream_runs.skipped 同口径)。
|
|
3306
|
+
Array.isArray(distinctions) && distinctions.length ? JSON.stringify(distinctions) : null,
|
|
3307
|
+
run_id ?? null, nowIso()
|
|
3308
|
+
);
|
|
3309
|
+
return findDifferentiatedPair(a, b);
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
/** The adjudication row for an unordered pair, or undefined. */
|
|
3313
|
+
function findDifferentiatedPair(memory_a, memory_b) {
|
|
3314
|
+
const [a, b] = [memory_a, memory_b].sort();
|
|
3315
|
+
return toDifferentiatedPair(
|
|
3316
|
+
db.prepare("SELECT * FROM differentiated_pairs WHERE memory_a = ? AND memory_b = ?").get(a, b)
|
|
3317
|
+
);
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
/** Adjudicated pairs, newest first (audit view / tests). */
|
|
3321
|
+
function listDifferentiatedPairs({ limit = 50, offset = 0 } = {}) {
|
|
3322
|
+
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
3323
|
+
const rows = db.prepare(
|
|
3324
|
+
"SELECT * FROM differentiated_pairs ORDER BY created_at DESC, id LIMIT ? OFFSET ?"
|
|
3325
|
+
).all(lim, off);
|
|
3326
|
+
return rows.map(toDifferentiatedPair);
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
// --- conflict adjudication ledger (issue #529) ---------------------------
|
|
3330
|
+
|
|
3331
|
+
/**
|
|
3332
|
+
* Record that the conflict phase adjudicated a pair, whatever the verdict was
|
|
3333
|
+
* — including a bare "keep". This is the machine-side ledger that stops a
|
|
3334
|
+
* no-op verdict from being re-sent forever; it is deliberately NOT
|
|
3335
|
+
* conflict_pending (see the schema comment: that one is the human queue).
|
|
3336
|
+
*
|
|
3337
|
+
* 幂等靠主键:同一对(sort 归一化后)只留最新一次判决。多次写是常态——
|
|
3338
|
+
* 一对被重判说明它确实值得重判,旧判决没有留存价值。
|
|
3339
|
+
*/
|
|
3340
|
+
function saveConflictReviewed({ run_id, memory_a, memory_b, outcome, judged_at }) {
|
|
3341
|
+
const [a, b] = [memory_a, memory_b].sort();
|
|
3342
|
+
db.prepare(
|
|
3343
|
+
`INSERT INTO conflict_reviewed (memory_a, memory_b, judged_at, outcome, run_id)
|
|
3344
|
+
VALUES (?, ?, ?, ?, ?)
|
|
3345
|
+
ON CONFLICT(memory_a, memory_b) DO UPDATE SET
|
|
3346
|
+
judged_at = excluded.judged_at,
|
|
3347
|
+
outcome = excluded.outcome,
|
|
3348
|
+
run_id = excluded.run_id`
|
|
3349
|
+
).run(a, b, judged_at ?? nowIso(), outcome ?? null, run_id ?? null);
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
/**
|
|
3353
|
+
* Load the ledger as a Map keyed by the normalized pair, for the conflict
|
|
3354
|
+
* phase's pre-filter. Bounded by the number of distinct pairs ever judged,
|
|
3355
|
+
* which is at most the number of pairs the phase can form — the same ceiling
|
|
3356
|
+
* the candidate list itself has.
|
|
3357
|
+
*/
|
|
3358
|
+
function loadConflictReviewed() {
|
|
3359
|
+
const map = new Map();
|
|
3360
|
+
for (const row of db.prepare("SELECT * FROM conflict_reviewed").all()) {
|
|
3361
|
+
map.set(`${row.memory_a}\u0000${row.memory_b}`, {
|
|
3362
|
+
judged_at: row.judged_at,
|
|
3363
|
+
outcome: row.outcome ?? undefined,
|
|
3364
|
+
run_id: row.run_id ?? undefined
|
|
3365
|
+
});
|
|
3366
|
+
}
|
|
3367
|
+
return map;
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
/** Test/ops visibility: the raw ledger rows, newest first. */
|
|
3371
|
+
function listConflictReviewed({ limit = 200 } = {}) {
|
|
3372
|
+
return db.prepare(
|
|
3373
|
+
"SELECT * FROM conflict_reviewed ORDER BY judged_at DESC, rowid DESC LIMIT ?"
|
|
3374
|
+
).all(Math.max(1, Math.min(5000, limit | 0)));
|
|
3375
|
+
}
|
|
3376
|
+
|
|
2647
3377
|
/**
|
|
2648
3378
|
* v0.8.1 底座(issue #170):scope 归属人工修正的审计行。record 描述「一次
|
|
2649
3379
|
* 决策后的完整状态」(prev_* 为改前、next_* 为改后,NULL=global/未标注),
|
|
@@ -3338,6 +4068,9 @@ export function createStore(path) {
|
|
|
3338
4068
|
db,
|
|
3339
4069
|
count,
|
|
3340
4070
|
getById,
|
|
4071
|
+
// 车道 C:外来行身份的两个读口(幂等键 / 整包清点)。
|
|
4072
|
+
getByShareExternalId,
|
|
4073
|
+
listBySharePackId,
|
|
3341
4074
|
save,
|
|
3342
4075
|
// document 唯一铸造口(#230 写入权分离):registerDocument 专用,通用
|
|
3343
4076
|
// save/update/CAS 均拒绝 document 创建或类型转换。
|
|
@@ -3347,6 +4080,15 @@ export function createStore(path) {
|
|
|
3347
4080
|
// #254:写入准入的内容哈希候选集(只读)。
|
|
3348
4081
|
findContentHashMatches,
|
|
3349
4082
|
remove,
|
|
4083
|
+
// 车道 C:整包退出(幂等批删,side table 口径同 remove())。
|
|
4084
|
+
deleteBySharePackId,
|
|
4085
|
+
// 只管 SQLite 回滚的存储层原语(本文件每个写方法都在用它,这里只是对外露一层:
|
|
4086
|
+
// 外层已开着事务时它直接执行回调,db.isTransaction 早退,嵌套不会二次 BEGIN)。
|
|
4087
|
+
// **它不是导入口**:车道 C 的导入走 service.transaction()——那层除回滚外还会随
|
|
4088
|
+
// ROLLBACK 截断 pendingEmbed / pendingEntity,用这个裸原语导入会让队列和库分叉。
|
|
4089
|
+
// 露出的用途是存储级批写与用例:C1 的 UNIQUE 撞库用例(test/share-store.test.js)
|
|
4090
|
+
// 就靠它把「撞一条、整批不落」这条 ROLLBACK 路径直接命中。
|
|
4091
|
+
runAtomically,
|
|
3350
4092
|
setForget,
|
|
3351
4093
|
setArchived,
|
|
3352
4094
|
touchLastAccess,
|
|
@@ -3355,6 +4097,9 @@ export function createStore(path) {
|
|
|
3355
4097
|
getUnrecalledSince,
|
|
3356
4098
|
list,
|
|
3357
4099
|
all,
|
|
4100
|
+
// Issue #505:写路径去重的定向候选(按 type + TRIM(title) 精确取活跃行),
|
|
4101
|
+
// 替代「importance 前 N 条」那个会随库增长失效的窗口。
|
|
4102
|
+
activeByTypeAndTitle,
|
|
3358
4103
|
// #430:有界单列读,写路径(质量过滤)用。
|
|
3359
4104
|
recentContents,
|
|
3360
4105
|
search,
|
|
@@ -3367,6 +4112,7 @@ export function createStore(path) {
|
|
|
3367
4112
|
hasStaleEmbedding,
|
|
3368
4113
|
embeddingModelBreakdown,
|
|
3369
4114
|
embeddingDimensionBreakdown,
|
|
4115
|
+
vectorDimensionState,
|
|
3370
4116
|
searchVector,
|
|
3371
4117
|
saveDreamRun,
|
|
3372
4118
|
lastDreamRunAt,
|
|
@@ -3386,8 +4132,14 @@ export function createStore(path) {
|
|
|
3386
4132
|
getRecallEval,
|
|
3387
4133
|
listRecallEvals,
|
|
3388
4134
|
saveLlmAudit,
|
|
4135
|
+
annotateLlmAudit,
|
|
3389
4136
|
getDistillCursor,
|
|
3390
4137
|
setDistillCursor,
|
|
4138
|
+
// #493 补充 §②:解析失败计数跨进程持久化(读侧带失效窗口)。单独成表,
|
|
4139
|
+
// 不并进 distill_cursors——那句 UPSERT 是单调的,归零语义会打架。
|
|
4140
|
+
getDistillParseFailureStreak,
|
|
4141
|
+
bumpDistillParseFailureStreak,
|
|
4142
|
+
clearDistillParseFailureStreak,
|
|
3391
4143
|
listLlmAudits,
|
|
3392
4144
|
countLlmAudits,
|
|
3393
4145
|
getLlmAuditStats,
|
|
@@ -3398,6 +4150,16 @@ export function createStore(path) {
|
|
|
3398
4150
|
clearDistillRecords,
|
|
3399
4151
|
saveMemoryProvenance,
|
|
3400
4152
|
provenanceFor,
|
|
4153
|
+
distillRecordsFor,
|
|
4154
|
+
distillWindowsFor,
|
|
4155
|
+
// #512 蒸馏运行与铸造归因:run 是归因主体,audit 行只是它的开销读数。
|
|
4156
|
+
// 写入幂等(replay 覆写),读侧只读——两处都不触发 write hooks(bookkeeping)。
|
|
4157
|
+
saveDistillRun,
|
|
4158
|
+
attachDistillRunAudit,
|
|
4159
|
+
getDistillRun,
|
|
4160
|
+
listDistillRuns,
|
|
4161
|
+
saveMemoryMint,
|
|
4162
|
+
mintsFor,
|
|
3401
4163
|
// 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。
|
|
3402
4164
|
dreamRunInputStats,
|
|
3403
4165
|
clearDreamRunInputs,
|
|
@@ -3413,6 +4175,13 @@ export function createStore(path) {
|
|
|
3413
4175
|
listConflictPending,
|
|
3414
4176
|
resolveConflictPending,
|
|
3415
4177
|
countConflictPending,
|
|
4178
|
+
saveDifferentiatedPair,
|
|
4179
|
+
findDifferentiatedPair,
|
|
4180
|
+
listDifferentiatedPairs,
|
|
4181
|
+
// #529:机器侧裁决台账(与上面四条人工队列的函数刻意分开,见 schema 注释)。
|
|
4182
|
+
saveConflictReviewed,
|
|
4183
|
+
loadConflictReviewed,
|
|
4184
|
+
listConflictReviewed,
|
|
3416
4185
|
saveScopeChange,
|
|
3417
4186
|
listScopeChanges,
|
|
3418
4187
|
// 政策轴(issue #471):按下/松开一个作用域。纯 bookkeeping,不调
|