@modusensus/dsh-mneme 0.9.5 → 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 +9 -5
- package/docs/CONFIGURATION.md +21 -0
- package/docs/DAEMON.md +6 -2
- package/docs/DISTILL.md +90 -6
- package/docs/SLEEP.md +20 -6
- package/docs/STORAGE.md +1 -0
- package/lib/api-standalone.js +11 -3
- package/lib/api.js +25 -5
- package/lib/client.js +137 -10
- package/lib/config.js +33 -1
- package/lib/continuity.js +11 -3
- package/lib/dream/decisions.js +88 -27
- package/lib/dream/sleep.js +467 -81
- package/lib/dream.js +110 -13
- package/lib/fence.js +24 -0
- package/lib/index.js +15 -6
- package/lib/inject.js +9 -4
- package/lib/lang.js +17 -12
- package/lib/pipeline-nudge-auto.js +3 -0
- package/lib/pipeline-nudge.js +41 -8
- package/lib/pipeline-runner.js +15 -0
- package/lib/pre-inject-gate.js +14 -0
- package/lib/profile-extract.js +4 -1
- package/lib/provenance-http.js +162 -37
- package/lib/service.js +30 -0
- package/lib/session-gate.js +91 -0
- package/lib/settings.js +13 -1
- package/lib/store.js +526 -18
- package/lib/summarize.js +152 -15
- package/lib/tools.js +26 -6
- package/package.json +4 -2
- package/src/api-standalone.js +11 -3
- package/src/api.js +25 -5
- package/src/config.js +33 -1
- package/src/continuity.js +11 -3
- package/src/dream/decisions.js +88 -27
- package/src/dream/sleep.js +467 -81
- package/src/dream.js +110 -13
- package/src/fence.js +24 -0
- package/src/index.js +15 -6
- package/src/inject.js +9 -4
- package/src/lang.js +17 -12
- package/src/pipeline-nudge-auto.js +3 -0
- package/src/pipeline-nudge.js +41 -8
- package/src/pipeline-runner.js +15 -0
- package/src/pre-inject-gate.js +14 -0
- package/src/profile-extract.js +4 -1
- package/src/provenance-http.js +162 -37
- package/src/service.js +30 -0
- package/src/session-gate.js +91 -0
- package/src/settings.js +13 -1
- package/src/store.js +526 -18
- package/src/summarize.js +152 -15
- package/src/tools.js +26 -6
- package/test/api.test.js +153 -4
- package/test/client.test.js +215 -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/dependency-overrides.test.js +78 -0
- package/test/differentiate-feedback.test.js +248 -0
- package/test/dream-decision-evidence.test.js +314 -0
- package/test/dream-fence.test.js +43 -0
- package/test/inject-pools.test.js +2 -1
- package/test/mint-source.test.js +83 -0
- package/test/pipeline-nudge-auto.test.js +26 -2
- package/test/pipeline-nudge.test.js +87 -0
- package/test/pipeline-runner.test.js +26 -0
- package/test/prompt-stance.test.js +128 -0
- package/test/provenance-http.test.js +200 -17
- package/test/release-docs.test.js +7 -1
- package/test/security-doc.test.js +43 -4
- package/test/session-gate.test.js +264 -0
- package/test/settings.test.js +4 -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 +50 -0
- package/test/store.test.js +220 -0
- package/test/summarize.test.js +172 -0
package/src/store.js
CHANGED
|
@@ -76,7 +76,13 @@ CREATE TABLE IF NOT EXISTS dream_runs (
|
|
|
76
76
|
-- 写入就绕过阈值开跑整轮(#291 只持久化了 lastRunAt 那半边)。成功轮才落这两列,
|
|
77
77
|
-- 失败/降级不推进基线的既有语义不变;两列同时有值或同时为 NULL。
|
|
78
78
|
store_count INTEGER,
|
|
79
|
-
store_chars INTEGER
|
|
79
|
+
store_chars INTEGER,
|
|
80
|
+
-- #534:失败轮(拿得到模型输出,但解析或校验没过)的原始输出截断副本。
|
|
81
|
+
-- 此前失败轮只留一行 logger.warn,进程一退现场就没了——#534 实测 19 轮里
|
|
82
|
+
-- 17 轮整轮无效,事后一条模型原文都查不到。做成独立列而不是塞进 decisions:
|
|
83
|
+
-- 那一列的语义是「可重放的决策数组」,失败轮已被 _validationFailed 哨兵占着,
|
|
84
|
+
-- 塞原文进去会污染按决策数组消费的下游(重放、面板)。
|
|
85
|
+
raw_output TEXT
|
|
80
86
|
);
|
|
81
87
|
CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
|
|
82
88
|
|
|
@@ -180,6 +186,51 @@ CREATE TABLE IF NOT EXISTS conflict_pending (
|
|
|
180
186
|
);
|
|
181
187
|
CREATE INDEX IF NOT EXISTS idx_conflict_pending_unresolved ON conflict_pending(resolved_at);
|
|
182
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
|
+
|
|
183
234
|
-- scope_changes: v0.8.1 底座(issue #170)scope 归属的人工修正审计。显式声明
|
|
184
235
|
-- (memory_save/memory_update 的 scope 参数、面板编辑)每次改变某条记忆的
|
|
185
236
|
-- agent/workspace 归属都落一行:改动前后值 + 来源 + actor(tool=模型侧 /
|
|
@@ -300,6 +351,77 @@ CREATE INDEX IF NOT EXISTS idx_memory_provenance_session ON memory_provenance(se
|
|
|
300
351
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_provenance_link
|
|
301
352
|
ON memory_provenance(memory_id, session_id, seq_start, seq_end);
|
|
302
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
|
+
|
|
303
425
|
-- entity gene (v0.3.0): named entities mentioned across memories, with
|
|
304
426
|
-- time-boxed attributes (valid_from → valid_until) and typed relations.
|
|
305
427
|
-- Attributes follow the snapshot style: saveAttr invalidates the previous
|
|
@@ -410,6 +532,10 @@ export const TYPES = new Set(["preference", "project", "decision", "history", "s
|
|
|
410
532
|
// 截断上限(continuity.js)是两件事。
|
|
411
533
|
const MAX_CONTINUITY_PENDING = 200;
|
|
412
534
|
|
|
535
|
+
// dream_runs.run_type 的取值域(schema 注释同款)。既是白名单也是下推条件的护栏:
|
|
536
|
+
// listDreamRuns 的 excludeRunType 会拼进 SQL 的 != 比较,非白名单值一律忽略。
|
|
537
|
+
const DREAM_RUN_TYPES = ["auto", "sleep", "organize"];
|
|
538
|
+
|
|
413
539
|
// Epistemic status: what kind of evidence a memory rests on. Defaults to
|
|
414
540
|
// 'subjective' so legacy rows (and rows without any signal) stay compatible.
|
|
415
541
|
const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]);
|
|
@@ -590,7 +716,29 @@ function toDreamRun(row) {
|
|
|
590
716
|
skipped: row.skipped ? JSON.parse(row.skipped) : undefined,
|
|
591
717
|
// Issue #89(基线半边):成功轮落下的阈值基线(NULL = 该轮没有推进基线)。
|
|
592
718
|
store_count: row.store_count ?? undefined,
|
|
593
|
-
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
|
|
594
742
|
};
|
|
595
743
|
}
|
|
596
744
|
|
|
@@ -628,6 +776,20 @@ function toConflictPending(row) {
|
|
|
628
776
|
};
|
|
629
777
|
}
|
|
630
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
|
+
|
|
631
793
|
function toScopeChange(row) {
|
|
632
794
|
if (!row) return undefined;
|
|
633
795
|
return {
|
|
@@ -962,6 +1124,9 @@ export function createStore(path) {
|
|
|
962
1124
|
// 成功轮之前播种端拿不到基线,行为与升级前一致;跑过一轮之后闸门即跨重启生效。
|
|
963
1125
|
addColumn("dream_runs", "store_count", "ALTER TABLE dream_runs ADD COLUMN store_count INTEGER");
|
|
964
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");
|
|
965
1130
|
|
|
966
1131
|
// Legacy mirror_state without v0.3.6 generation columns → add each missing
|
|
967
1132
|
// column idempotently (old DBs open cleanly, no data loss).
|
|
@@ -976,6 +1141,14 @@ export function createStore(path) {
|
|
|
976
1141
|
addColumn("llm_audit_logs", "session_key", "ALTER TABLE llm_audit_logs ADD COLUMN session_key TEXT");
|
|
977
1142
|
db.exec("CREATE INDEX IF NOT EXISTS idx_llm_audit_session ON llm_audit_logs(session_key);");
|
|
978
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
|
+
|
|
979
1152
|
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
|
|
980
1153
|
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
|
|
981
1154
|
// enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
|
|
@@ -1080,6 +1253,59 @@ export function createStore(path) {
|
|
|
1080
1253
|
return getDistillCursor(sessionId);
|
|
1081
1254
|
}
|
|
1082
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
|
+
|
|
1083
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 } = {}) {
|
|
1084
1310
|
const clauses = [];
|
|
1085
1311
|
const params = [];
|
|
@@ -1406,6 +1632,8 @@ export function createStore(path) {
|
|
|
1406
1632
|
// 只有这一条挡得住。记录本身(distill_records)不删——删掉一条派生记忆
|
|
1407
1633
|
// 不该让整个窗口的原文凭证消失。
|
|
1408
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);
|
|
1409
1637
|
db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
1410
1638
|
// Mirror sync must reflect the deletion; bump desired generation so a
|
|
1411
1639
|
// crash between the delete and syncMirror leaves a recoverable debt.
|
|
@@ -1424,7 +1652,8 @@ export function createStore(path) {
|
|
|
1424
1652
|
* 这个包」与「收起一个从来没导入过的包」都该是这个答案;早退也不 bump generation:
|
|
1425
1653
|
* 什么都没删就别记一笔镜像债(remove() 每次必 bump,那是它的既有行为,不在这里扩大)。
|
|
1426
1654
|
*
|
|
1427
|
-
* side table 清理口径 = remove() 的口径(#399):显式同事务清 memory_provenance
|
|
1655
|
+
* side table 清理口径 = remove() 的口径(#399 / #512):显式同事务清 memory_provenance
|
|
1656
|
+
* 与 memory_mints。
|
|
1428
1657
|
* 这条是双保险,不是「FK 没开所以必须手写」:memory_provenance 挂着 ON DELETE CASCADE,
|
|
1429
1658
|
* 而 node:sqlite 默认就把 foreign_keys 开成 1(实测 PRAGMA foreign_keys = 1),今天删行
|
|
1430
1659
|
* 确实另有级联兜底;显式清理的存在是为了不依赖这份没人钉过的驱动默认,同时跟 remove()
|
|
@@ -1466,6 +1695,7 @@ export function createStore(path) {
|
|
|
1466
1695
|
const chunk = ids.slice(start, start + DELETE_ID_CHUNK);
|
|
1467
1696
|
const marks = chunk.map(() => "?").join(", ");
|
|
1468
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);
|
|
1469
1699
|
deleted += db.prepare(`DELETE FROM memories WHERE id IN (${marks})`).run(...chunk).changes;
|
|
1470
1700
|
}
|
|
1471
1701
|
incrementGeneration();
|
|
@@ -2130,18 +2360,22 @@ export function createStore(path) {
|
|
|
2130
2360
|
Number.isInteger(run.store_chars) && run.store_chars >= 0;
|
|
2131
2361
|
const storeCount = baselineKnown ? run.store_count : null;
|
|
2132
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;
|
|
2133
2366
|
db.prepare(
|
|
2134
2367
|
`INSERT INTO dream_runs (id, created_at, status, error, provider, model, snapshot_hash,
|
|
2135
2368
|
input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch, run_type, skipped,
|
|
2136
|
-
store_count, store_chars)
|
|
2137
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2369
|
+
store_count, store_chars, raw_output)
|
|
2370
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2138
2371
|
ON CONFLICT(id) DO UPDATE SET
|
|
2139
2372
|
created_at=excluded.created_at, status=excluded.status, error=excluded.error,
|
|
2140
2373
|
provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
|
|
2141
2374
|
input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
|
|
2142
2375
|
outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
|
|
2143
2376
|
receipt=excluded.receipt, policy_epoch=excluded.policy_epoch, run_type=excluded.run_type,
|
|
2144
|
-
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`
|
|
2145
2379
|
).run(
|
|
2146
2380
|
id,
|
|
2147
2381
|
run.created_at ?? now,
|
|
@@ -2161,7 +2395,8 @@ export function createStore(path) {
|
|
|
2161
2395
|
runType,
|
|
2162
2396
|
skipped,
|
|
2163
2397
|
storeCount,
|
|
2164
|
-
storeChars
|
|
2398
|
+
storeChars,
|
|
2399
|
+
rawOutput
|
|
2165
2400
|
);
|
|
2166
2401
|
return getDreamRun(id);
|
|
2167
2402
|
}
|
|
@@ -2171,11 +2406,16 @@ export function createStore(path) {
|
|
|
2171
2406
|
return toDreamRun(row);
|
|
2172
2407
|
}
|
|
2173
2408
|
|
|
2174
|
-
|
|
2409
|
+
// #495:excludeRunType 是可选下推条件,默认不过滤——审计面(面板的最近运行、
|
|
2410
|
+
// 回放脚本、各测试的 `listDreamRuns()` 无参调用)本来就该看全量,过滤只在
|
|
2411
|
+
// 「最近一次真·巩固」这种需要排除 agent 主动整理的场合由调用方显式指定。
|
|
2412
|
+
// 值走白名单:这个参数会拼进 SQL 的 != 条件,不接受任意字符串。
|
|
2413
|
+
function listDreamRuns({ limit = 50, offset = 0, excludeRunType = null } = {}) {
|
|
2175
2414
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
2415
|
+
const excluded = DREAM_RUN_TYPES.includes(excludeRunType) ? excludeRunType : null;
|
|
2176
2416
|
const rows = db.prepare(
|
|
2177
|
-
"SELECT * FROM dream_runs ORDER BY created_at DESC, id LIMIT ? OFFSET ?"
|
|
2178
|
-
).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);
|
|
2179
2419
|
return rows.map(toDreamRun);
|
|
2180
2420
|
}
|
|
2181
2421
|
|
|
@@ -2455,7 +2695,7 @@ export function createStore(path) {
|
|
|
2455
2695
|
const now = nowIso();
|
|
2456
2696
|
const inTokens = Number.isFinite(entry.input_tokens) ? entry.input_tokens : 0;
|
|
2457
2697
|
const outTokens = Number.isFinite(entry.output_tokens) ? entry.output_tokens : 0;
|
|
2458
|
-
db.prepare(
|
|
2698
|
+
const info = db.prepare(
|
|
2459
2699
|
`INSERT INTO llm_audit_logs (timestamp, trigger_source, operation_type, model_id,
|
|
2460
2700
|
input_tokens, output_tokens, total_tokens, cost_usd, duration_ms, status,
|
|
2461
2701
|
error_message, related_memory_ids, metadata, session_key)
|
|
@@ -2478,7 +2718,38 @@ export function createStore(path) {
|
|
|
2478
2718
|
: null,
|
|
2479
2719
|
entry.session_key ?? null
|
|
2480
2720
|
);
|
|
2481
|
-
|
|
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));
|
|
2482
2753
|
}
|
|
2483
2754
|
|
|
2484
2755
|
function listLlmAudits({ limit = 50, offset = 0, source, sessionKey } = {}) {
|
|
@@ -2641,22 +2912,101 @@ export function createStore(path) {
|
|
|
2641
2912
|
* 重跑的重复链接(否则幂等测试锁会挂)。
|
|
2642
2913
|
* @returns {number} 实际插入行数
|
|
2643
2914
|
*/
|
|
2644
|
-
function saveMemoryProvenance({ memoryId, sessionId, seqStart, seqEnd, createdAt }) {
|
|
2915
|
+
function saveMemoryProvenance({ memoryId, sessionId, seqStart, seqEnd, createdAt, distillRunId }) {
|
|
2916
|
+
// #512:distill_run_id 是「这条链接由哪一轮蒸馏写下」的可选归因。缺省落 NULL,
|
|
2917
|
+
// 且**不回填**——存量链接没有第二轮真相可查,反推一个合成 id 比留空更坏。
|
|
2645
2918
|
return db.prepare(
|
|
2646
|
-
`INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at)
|
|
2647
|
-
VALUES (?, ?, ?, ?, ?)
|
|
2919
|
+
`INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at, distill_run_id)
|
|
2920
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2648
2921
|
ON CONFLICT DO NOTHING`
|
|
2649
|
-
).run(memoryId, sessionId, seqStart, seqEnd, createdAt).changes;
|
|
2922
|
+
).run(memoryId, sessionId, seqStart, seqEnd, createdAt, distillRunId ?? null).changes;
|
|
2650
2923
|
}
|
|
2651
2924
|
|
|
2652
|
-
/** 一条记忆的全部溯源链接(#399 面板/CLI
|
|
2925
|
+
/** 一条记忆的全部溯源链接(#399 面板/CLI 读取面;#512 起带 distill_run_id)。 */
|
|
2653
2926
|
function provenanceFor(memoryId) {
|
|
2654
2927
|
return db.prepare(
|
|
2655
|
-
`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
|
|
2656
2929
|
FROM memory_provenance WHERE memory_id = ? ORDER BY created_at, session_id, seq_start`
|
|
2657
2930
|
).all(memoryId);
|
|
2658
2931
|
}
|
|
2659
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
|
+
|
|
2660
3010
|
/**
|
|
2661
3011
|
* 一个 (session, seq 区间) 的蒸馏原文行(#399 写入、#421 读出口)。
|
|
2662
3012
|
*
|
|
@@ -2673,6 +3023,40 @@ export function createStore(path) {
|
|
|
2673
3023
|
).all(sessionId, seqStart, seqEnd);
|
|
2674
3024
|
}
|
|
2675
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
|
+
|
|
2676
3060
|
// --- 存储生命周期:无损回收(#275 第一批)--------------------------------
|
|
2677
3061
|
//
|
|
2678
3062
|
// 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。
|
|
@@ -2888,6 +3272,108 @@ export function createStore(path) {
|
|
|
2888
3272
|
).get().c;
|
|
2889
3273
|
}
|
|
2890
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
|
+
|
|
2891
3377
|
/**
|
|
2892
3378
|
* v0.8.1 底座(issue #170):scope 归属人工修正的审计行。record 描述「一次
|
|
2893
3379
|
* 决策后的完整状态」(prev_* 为改前、next_* 为改后,NULL=global/未标注),
|
|
@@ -3646,8 +4132,14 @@ export function createStore(path) {
|
|
|
3646
4132
|
getRecallEval,
|
|
3647
4133
|
listRecallEvals,
|
|
3648
4134
|
saveLlmAudit,
|
|
4135
|
+
annotateLlmAudit,
|
|
3649
4136
|
getDistillCursor,
|
|
3650
4137
|
setDistillCursor,
|
|
4138
|
+
// #493 补充 §②:解析失败计数跨进程持久化(读侧带失效窗口)。单独成表,
|
|
4139
|
+
// 不并进 distill_cursors——那句 UPSERT 是单调的,归零语义会打架。
|
|
4140
|
+
getDistillParseFailureStreak,
|
|
4141
|
+
bumpDistillParseFailureStreak,
|
|
4142
|
+
clearDistillParseFailureStreak,
|
|
3651
4143
|
listLlmAudits,
|
|
3652
4144
|
countLlmAudits,
|
|
3653
4145
|
getLlmAuditStats,
|
|
@@ -3659,6 +4151,15 @@ export function createStore(path) {
|
|
|
3659
4151
|
saveMemoryProvenance,
|
|
3660
4152
|
provenanceFor,
|
|
3661
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,
|
|
3662
4163
|
// 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。
|
|
3663
4164
|
dreamRunInputStats,
|
|
3664
4165
|
clearDreamRunInputs,
|
|
@@ -3674,6 +4175,13 @@ export function createStore(path) {
|
|
|
3674
4175
|
listConflictPending,
|
|
3675
4176
|
resolveConflictPending,
|
|
3676
4177
|
countConflictPending,
|
|
4178
|
+
saveDifferentiatedPair,
|
|
4179
|
+
findDifferentiatedPair,
|
|
4180
|
+
listDifferentiatedPairs,
|
|
4181
|
+
// #529:机器侧裁决台账(与上面四条人工队列的函数刻意分开,见 schema 注释)。
|
|
4182
|
+
saveConflictReviewed,
|
|
4183
|
+
loadConflictReviewed,
|
|
4184
|
+
listConflictReviewed,
|
|
3677
4185
|
saveScopeChange,
|
|
3678
4186
|
listScopeChanges,
|
|
3679
4187
|
// 政策轴(issue #471):按下/松开一个作用域。纯 bookkeeping,不调
|