@modusensus/dsh-mneme 0.8.11 → 0.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -5
  2. package/assets/icon.svg +3 -3
  3. package/lib/config.js +47 -1
  4. package/lib/dream/decisions.js +16 -0
  5. package/lib/dream/sleep.js +3 -0
  6. package/lib/dream.js +57 -9
  7. package/lib/entities/extractor.js +4 -1
  8. package/lib/graph/anchoring.js +180 -0
  9. package/lib/index.js +5 -0
  10. package/lib/inject.js +15 -3
  11. package/lib/lang.js +10 -0
  12. package/lib/service.js +164 -23
  13. package/lib/settings.js +24 -2
  14. package/lib/store.js +155 -11
  15. package/lib/summarize.js +104 -7
  16. package/locale/en.json +6 -6
  17. package/locale/zh.json +6 -6
  18. package/package.json +7 -6
  19. package/scripts/sync-test-badge.mjs +9 -7
  20. package/scripts/test-count-sync.mjs +44 -0
  21. package/src/config.js +47 -1
  22. package/src/dream/decisions.js +16 -0
  23. package/src/dream/sleep.js +3 -0
  24. package/src/dream.js +57 -9
  25. package/src/entities/extractor.js +4 -1
  26. package/src/graph/anchoring.js +180 -0
  27. package/src/index.js +5 -0
  28. package/src/inject.js +15 -3
  29. package/src/lang.js +10 -0
  30. package/src/service.js +164 -23
  31. package/src/settings.js +24 -2
  32. package/src/store.js +155 -11
  33. package/src/summarize.js +104 -7
  34. package/test/api.test.js +7 -2
  35. package/test/baseline-persist.test.js +248 -0
  36. package/test/dream.test.js +75 -0
  37. package/test/graph-anchoring.test.js +185 -0
  38. package/test/graph-inject-budget.test.js +104 -0
  39. package/test/graph-passive-confirm.test.js +92 -0
  40. package/test/graph-weight-evolution.test.js +105 -0
  41. package/test/scope-inject-soft.test.js +106 -0
  42. package/test/summarize.test.js +177 -0
  43. package/test/test-count-sync.test.js +60 -0
@@ -0,0 +1,92 @@
1
+ // issue #24 块4 被动确认(graphPassiveConfirm)测试。
2
+ // 覆盖:默认关=零行为变化 / 开启=命中记忆的关联边 bump / heat 关但 passive
3
+ // 开仍 bump(独立闸门)/ bump 只加不减封顶 / 无关联边的记忆触达安全降级。
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { createStore } from "../src/store.js";
7
+ import { createService } from "../src/service.js";
8
+
9
+ function setup(config = {}) {
10
+ const store = createStore(":memory:");
11
+ const service = createService({ store, mirror: null, config });
12
+ return { store, service };
13
+ }
14
+
15
+ function putMem(store, service, { title, content, importance = 3 }) {
16
+ return service.saveWithDedupe({ type: "project", title, content, importance }).memory;
17
+ }
18
+
19
+ function linkRelToMem(store, { fromName, toName, memoryId, source = "llm" }) {
20
+ const a = store.findEntityByName(fromName) ?? store.createEntity({ name: fromName, type: "technology" });
21
+ const b = store.findEntityByName(toName) ?? store.createEntity({ name: toName, type: "technology" });
22
+ return store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", memory_id: memoryId, source });
23
+ }
24
+
25
+ /** 跑一次检索(内部 touchRecalled 触发触达),返回 rows。 */
26
+ async function searchOnce(service, query) {
27
+ return service.searchMemories(query, { mode: "auto" });
28
+ }
29
+
30
+ test("graphPassiveConfirm=false (default): touched memory does NOT bump relation weight", async () => {
31
+ const { store, service } = setup();
32
+ const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
33
+ const rel = linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
34
+ assert.equal(rel.weight, 0.4);
35
+ await searchOnce(service, "数据库迁移");
36
+ // 默认关:检索触达不 bump(weight 保持 0.4)。
37
+ assert.equal(store.getRelationsByMemory(mem.id)[0].weight, 0.4, "weight unchanged by default");
38
+ store.close();
39
+ });
40
+
41
+ test("graphPassiveConfirm=true: touched memory bumps its linked relation weight", async () => {
42
+ const { store, service } = setup({ graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1 });
43
+ const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
44
+ const rel = linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
45
+ assert.equal(rel.weight, 0.4);
46
+ const rows = await searchOnce(service, "数据库迁移");
47
+ assert.ok(rows.some((r) => r.id === mem.id), "memory surfaced");
48
+ assert.ok(store.getRelationsByMemory(mem.id)[0].weight > 0.4, "weight bumped after touch");
49
+ store.close();
50
+ });
51
+
52
+ test("heatEnabled=false does NOT gate passive confirm (independent gates)", async () => {
53
+ const { store, service } = setup({ heatEnabled: false, graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1 });
54
+ const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
55
+ linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
56
+ await searchOnce(service, "数据库迁移");
57
+ assert.ok(store.getRelationsByMemory(mem.id)[0].weight > 0.4, "bumped even with heat off");
58
+ // 但 touchLastAccess 不写(heat 关的既有语义保留:last_accessed_at 仍为空)。
59
+ assert.equal(store.getById(mem.id).last_accessed_at, undefined);
60
+ store.close();
61
+ });
62
+
63
+ test("repeated touches cap at 1.0 (never exceeds)", () => {
64
+ const { store } = setup();
65
+ const a = store.createEntity({ name: "A", type: "technology" });
66
+ const b = store.createEntity({ name: "B", type: "technology" });
67
+ const rel = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm" }); // 0.4
68
+ for (let i = 0; i < 5; i++) {
69
+ store.bumpRelationWeight(rel.id, 0.3); // 0.4→0.7→1.0→1.0…
70
+ }
71
+ assert.equal(store.getRelations(a.id)[0].weight, 1.0, "capped at 1.0");
72
+ store.close();
73
+ });
74
+
75
+ // 总闸(块2 的 graphWeightEnabled)关着时被动确认不许生效——单开通道键就能演化
76
+ // 边权的话,块2 立的「演化默认关」总闸形同虚设。
77
+ test("graphWeightEnabled=false gates passive confirm (master switch)", async () => {
78
+ const { store, service } = setup({ graphPassiveConfirm: true, graphWeightDelta: 0.1 });
79
+ const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
80
+ linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
81
+ await searchOnce(service, "数据库迁移");
82
+ assert.equal(store.getRelationsByMemory(mem.id)[0].weight, 0.4, "master switch off → weight untouched");
83
+ store.close();
84
+ });
85
+
86
+ test("memory without relations survives touch (no crash)", async () => {
87
+ const { store, service } = setup({ graphPassiveConfirm: true });
88
+ const mem = putMem(store, service, { title: "孤立条", content: "无关联边" });
89
+ await searchOnce(service, "孤立条");
90
+ assert.ok(store.getById(mem.id), "memory still present");
91
+ store.close();
92
+ });
@@ -0,0 +1,105 @@
1
+ // issue #24 块2 权重演化(entity_relations weight + source + bump)测试。
2
+ // 覆盖:来源→初值映射(manual/tag/llm)/ weight 显式优先 / toRelation 回读 /
3
+ // bumpRelationWeight 只加不减封顶 / extractor 建 llm 边 / 四来源全映射落表。
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { createStore, RELATION_SOURCE_DEFAULTS } from "../src/store.js";
7
+
8
+ function makeStore() {
9
+ return createStore(":memory:");
10
+ }
11
+
12
+ test("RELATION_SOURCE_DEFAULTS matches issue #24 mapping", () => {
13
+ assert.equal(RELATION_SOURCE_DEFAULTS.manual, 1.0);
14
+ assert.equal(RELATION_SOURCE_DEFAULTS.confirmed, 1.0);
15
+ assert.equal(RELATION_SOURCE_DEFAULTS.tag, 0.3);
16
+ assert.equal(RELATION_SOURCE_DEFAULTS.llm, 0.4);
17
+ });
18
+
19
+ test("saveRelation maps source to initial weight", () => {
20
+ const store = makeStore();
21
+ const a = store.createEntity({ name: "A", type: "technology" });
22
+ const b = store.createEntity({ name: "B", type: "technology" });
23
+ const manual = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "manual" });
24
+ const tag = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "y", source: "tag" });
25
+ const llm = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "z", source: "llm" });
26
+ assert.equal(manual.weight, 1.0);
27
+ assert.equal(manual.source, "manual");
28
+ assert.equal(tag.weight, 0.3);
29
+ assert.equal(tag.source, "tag");
30
+ assert.equal(llm.weight, 0.4);
31
+ assert.equal(llm.source, "llm");
32
+ });
33
+
34
+ test("explicit weight overrides source mapping", () => {
35
+ const store = makeStore();
36
+ const a = store.createEntity({ name: "A", type: "technology" });
37
+ const b = store.createEntity({ name: "B", type: "technology" });
38
+ const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm", weight: 0.7 });
39
+ assert.equal(r.weight, 0.7);
40
+ });
41
+
42
+ test("toRelation round-trips weight and source via getRelations", () => {
43
+ const store = makeStore();
44
+ const a = store.createEntity({ name: "A", type: "technology" });
45
+ const b = store.createEntity({ name: "B", type: "technology" });
46
+ store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "tag" });
47
+ const rels = store.getRelations(a.id);
48
+ assert.equal(rels.length, 1);
49
+ assert.equal(rels[0].weight, 0.3);
50
+ assert.equal(rels[0].source, "tag");
51
+ });
52
+
53
+ test("bumpRelationWeight raises weight, caps at 1.0, never lowers", () => {
54
+ const store = makeStore();
55
+ const a = store.createEntity({ name: "A", type: "technology" });
56
+ const b = store.createEntity({ name: "B", type: "technology" });
57
+ const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm" });
58
+ assert.equal(store.bumpRelationWeight(r.id, 0.2), true);
59
+ assert.equal(store.bumpRelationWeight(r.id, 0.2), true);
60
+ assert.equal(store.bumpRelationWeight(r.id, 0.2), true); // 0.4→0.6→0.8→1.0
61
+ assert.equal(store.bumpRelationWeight(r.id, 0.2), true);
62
+ assert.equal(store.getRelations(a.id)[0].weight, 1.0, "capped");
63
+ // 不存在的关系:幂等 false
64
+ assert.equal(store.bumpRelationWeight("no-such-id"), false);
65
+ });
66
+
67
+ test("legacy relation rows migrate with manual weight default (no crash)", () => {
68
+ const store = makeStore();
69
+ const a = store.createEntity({ name: "A", type: "technology" });
70
+ const b = store.createEntity({ name: "B", type: "technology" });
71
+ // 直接走底层 SQL 模拟旧库行(不带 weight/source 列 —— 但新 schema 已含列,
72
+ // 所以这条主要验证 migration 幂等路径不崩 + 默认映射成立)。
73
+ const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x" });
74
+ assert.equal(r.weight, 1.0, "default source manual → weight 1.0");
75
+ assert.equal(r.source, "manual");
76
+ });
77
+
78
+ // ---- #24 复审回归(review findings 逐条落锁)--------------------------------
79
+
80
+ // 「只加不减」是存储接口自己的契约:负/非法增量必须在存储边界被拒(false +
81
+ // 权值不动),不能把下界托付给调用方的配置范围——旧写法 bumpRelationWeight(id,
82
+ // -0.2) 能把 0.4 敲到 0.2。
83
+ test("bumpRelationWeight rejects negative and non-finite increments (只加不减)", () => {
84
+ const store = makeStore();
85
+ const a = store.createEntity({ name: "A", type: "technology" });
86
+ const b = store.createEntity({ name: "B", type: "technology" });
87
+ const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm" });
88
+ for (const bad of [-0.2, -1, Number.NaN, Number.POSITIVE_INFINITY, "0.2"]) {
89
+ assert.equal(store.bumpRelationWeight(r.id, bad), false, `increment ${String(bad)} must be refused`);
90
+ assert.equal(store.getRelations(a.id)[0].weight, 0.4, `weight must not move on increment ${String(bad)}`);
91
+ }
92
+ assert.equal(store.bumpRelationWeight(r.id, 0.2), true, "a valid increment still lands");
93
+ assert.ok(Math.abs(store.getRelations(a.id)[0].weight - 0.6) < 1e-9, "0.4 + 0.2 lands at 0.6 (float-safe)");
94
+ });
95
+
96
+ // 来源查询只认映射表的自有键:src="constructor"/"toString" 会命中原型链上的函数,
97
+ // 旧写法把它当权重绑进 SQL(函数不可绑定 → 整条写入抛错)而不是回退默认 1.0。
98
+ test("an inherited source name falls back to the default weight", () => {
99
+ const store = makeStore();
100
+ const a = store.createEntity({ name: "A", type: "technology" });
101
+ const b = store.createEntity({ name: "B", type: "technology" });
102
+ const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "constructor" });
103
+ assert.equal(r.weight, 1.0, "prototype-chain names must fall back to 1.0, not a function");
104
+ assert.equal(r.source, "constructor", "source is recorded verbatim");
105
+ });
@@ -0,0 +1,106 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+ import { createService } from "../src/service.js";
5
+
6
+ // issue #339 / E7 考卷补齐:A2 软加权(foreign ×0.5 / 命中 ×1.25)此前只作用于
7
+ // searchMemories——E7 实测「explicit 标注 + 软档」的注入集与无标注逐条相同
8
+ // (80/80),主泄露面上软档形同虚设。本文件锁注入通道的软加权语义:
9
+ // - softScope 激活(scopeEnabled + scope 至少一维可解析)→ foreign 行在层内
10
+ // 数值积上 ×0.5,压不过同档自己行;未标注行同列吃 BOOST 不被压制;
11
+ // - flag 关 / scope 两维全空 → 注入序与改动前逐字节一致(锁平价);
12
+ // - strictScope 硬过滤先行,被滤行不会被二次降权;
13
+ // - 加权后的序流入 pin 池选取(pin 拿到的是加权后次序)。
14
+
15
+ function setup(config) {
16
+ const store = createStore(":memory:");
17
+ const service = createService({ store, mirror: null, config });
18
+ return { store, service };
19
+ }
20
+
21
+ const SCOPE_ME = { agent_scope: "me", workspace_scope: null };
22
+
23
+ test("injectCandidates soft weight demotes explicit foreign rows below same-importance own rows", () => {
24
+ const { store, service } = setup({ scopeEnabled: true, strictScope: false });
25
+ // 同 importance 档(E7 的同档设计):无软加权时全靠插入序,foreign 在前。
26
+ store.save({ type: "decision", title: "foreign-a", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
27
+ store.save({ type: "decision", title: "mine-a", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
28
+ store.save({ type: "decision", title: "foreign-b", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
29
+
30
+ const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME });
31
+ const titles = injected.map((m) => m.title);
32
+ // foreign ×0.5 = 2.0 < 自己 ×1.25 = 5.0:自己行第一;两条 foreign 同分,
33
+ // 相对序随 store.list 的同分序(updated_at),不锁。
34
+ assert.equal(titles[0], "mine-a");
35
+ assert.deepEqual(titles.slice(1).sort(), ["foreign-a", "foreign-b"]);
36
+ });
37
+
38
+ test("injectCandidates soft tier keeps unlabeled rows ranked with matches, not suppressed", () => {
39
+ const { store, service } = setup({ scopeEnabled: true, strictScope: false });
40
+ store.save({ type: "decision", title: "global", content: "x", importance: 4 });
41
+ store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
42
+
43
+ const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME });
44
+ // 未标注 = 全局可见,与命中行同列(×1.25)而非被压到 foreign 之后。
45
+ assert.deepEqual(injected.map((m) => m.title), ["global", "foreign"]);
46
+ });
47
+
48
+ test("injectCandidates order is byte-identical when scopeEnabled is off or scope is anonymous", () => {
49
+ const seed = (store) => {
50
+ store.save({ type: "decision", title: "foreign-a", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
51
+ store.save({ type: "decision", title: "mine-a", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
52
+ store.save({ type: "decision", title: "global", content: "x", importance: 3 });
53
+ };
54
+ // flag 关:不传 scope(改动前后必须同序——锁平价)。
55
+ const off = setup({ scopeEnabled: false, strictScope: false });
56
+ seed(off.store);
57
+ // flag 开但身份两维全空:软加权门不激活,也不得改序。
58
+ const anon = setup({ scopeEnabled: true, strictScope: false });
59
+ seed(anon.store);
60
+
61
+ // 平价锁:两种情况的注入序必须一致(比较器与改动前逐字节相同)。
62
+ const baseline = off.service.injectCandidates({ maxItems: 5, threshold: 3 }).map((m) => m.title);
63
+ assert.deepEqual(
64
+ anon.service.injectCandidates({ maxItems: 5, threshold: 3, scope: { agent_scope: null, workspace_scope: null } }).map((m) => m.title),
65
+ baseline
66
+ );
67
+ });
68
+
69
+ test("strictScope hard filter runs before soft weighting: filtered rows are not double-demoted", () => {
70
+ const { store, service } = setup({ scopeEnabled: true, strictScope: true });
71
+ store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
72
+ store.save({ type: "decision", title: "global", content: "x", importance: 3 });
73
+
74
+ const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME });
75
+ // 硬墙先删 foreign;剩下的 global(未标注)吃 BOOST 保留——若软加权先跑会把
76
+ // foreign 压到后面再被删,成员虽同但语义不同;此处锁成员与顺序。
77
+ assert.deepEqual(injected.map((m) => m.title), ["global"]);
78
+ });
79
+
80
+ test("soft-weighted order feeds the pin pool (pin picks the weighted-first candidates)", () => {
81
+ const { store, service } = setup({ scopeEnabled: true, strictScope: false, pinnedInjectBudget: 1 });
82
+ // pin 池只收 constraint/preference——两条都用 preference,同档靠软加权分序。
83
+ store.save({ type: "preference", title: "foreign-pin", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
84
+ store.save({ type: "preference", title: "mine", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
85
+
86
+ const pinnedStats = {};
87
+ const injected = service.injectCandidates({
88
+ maxItems: 5, threshold: 3, scope: SCOPE_ME, pinnedStats,
89
+ });
90
+ // 加权后 mine 在前:pin 池取的是加权后次序的第一条。
91
+ assert.equal(pinnedStats.shown, 1);
92
+ assert.equal(injected[0].title, "mine");
93
+ });
94
+
95
+ test("first-round BM25 fallback applies the soft weight (no query vector yet)", () => {
96
+ const { store, service } = setup({ scopeEnabled: true, strictScope: false });
97
+ // 同内容同长度(标题各 1 token 且不在查询词表)→ BM25 原始分相同;
98
+ // 未加权时同分序随 store.list(updated desc,foreign 后存先出)。
99
+ store.save({ type: "decision", title: "alpha", content: "plan the dinner party menu with care", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
100
+ store.save({ type: "decision", title: "bravo", content: "plan the dinner party menu with care", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
101
+
102
+ const injected = service.injectCandidates({
103
+ query: "dinner party menu", maxItems: 5, threshold: 3, scope: SCOPE_ME,
104
+ });
105
+ assert.equal(injected[0].title, "alpha", "BM25 hits are soft-weighted before the semantic-first merge");
106
+ });
@@ -110,6 +110,25 @@ function toolResultEvent(text, isError, seq) {
110
110
  };
111
111
  }
112
112
 
113
+ // DSH 0.2.x 内核对工具结果的实际投递形状:内核给的是 `role:"tool"` 消息本身,
114
+ // content 里只有 `type:"text"` 块、isError 挂在消息上,不再有 tool-result 块。
115
+ // 蒸馏曾只认旧块形状,于是真实会话里的工具输出与工具报错全部被静默丢弃。
116
+ function toolRoleResultEvent(text, isError, seq) {
117
+ return {
118
+ seq,
119
+ type: "tool/result",
120
+ data: {
121
+ message: {
122
+ role: "tool",
123
+ source: { kind: "tool", callId: `call-${seq}` },
124
+ toolCallId: `call-${seq}`,
125
+ content: [{ type: "text", text }],
126
+ isError
127
+ }
128
+ }
129
+ };
130
+ }
131
+
113
132
  function codeDispatchEvent(text, isError, seq) {
114
133
  return {
115
134
  seq,
@@ -143,6 +162,32 @@ test("parseSummaryJson extracts valid entries and skips malformed ones", () => {
143
162
  assert.equal(parsed[1].type, "preference");
144
163
  });
145
164
 
165
+ // --- issue #339 / E8 考卷:JSON 崩溃窗口的 salvage(失败路径修复,默认开)-----
166
+ // E8 实测 10-20% 的窗口输出了含约束的完整对象,只因中段一处语法错误被整窗
167
+ // JSON.parse 拒收 → 生产行为 = 游标卡死 + 温度 0 重试同错 = 静默丢失。
168
+
169
+ test("parseSummaryJson salvages complete objects from a malformed array (issue #339)", () => {
170
+ // E8 现场同款:闭合数组、中段对象含杂散引号(stray quote)。
171
+ const raw = `[
172
+ {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
173
+ { " "type": "history", "title": "坏对象", "content": "语法错误" },
174
+ {"type":"preference","title":"硬件","content":"只要 brushed brass"}
175
+ ]`;
176
+ const parsed = parseSummaryJson(raw);
177
+ assert.equal(parsed.length, 2, "the broken middle object is dropped, both survivors kept");
178
+ assert.equal(parsed[0].content, "总额 $12,400");
179
+ assert.equal(parsed[1].title, "硬件");
180
+ });
181
+
182
+ test("parseSummaryJson salvage finding nothing stays a failure (no fake empty success)", () => {
183
+ // 只有一个语法坏掉的对象 → 救回 0 条:不得按「显式空数组」放行消费窗口。
184
+ const raw = `[
185
+ { " "type": "history", "title": "坏", "content": "语法错误" }
186
+ ]`;
187
+ const parsed = parseSummaryJson(raw);
188
+ assert.deepEqual(parsed, []);
189
+ });
190
+
146
191
  test("subscribes to session/event when autoSummarize enabled", () => {
147
192
  const { events } = setup();
148
193
  assert.ok(events.some((e) => e.name === "session/event"));
@@ -673,6 +718,78 @@ test("invalid summary JSON leaves the seq window retryable", async () => {
673
718
  assert.equal(store.count(), 0, "the valid empty retry remains a no-op");
674
719
  });
675
720
 
721
+ test("a JSON-crashed window with salvageable objects is recovered, consumed once, and audited (issue #339)", async (t) => {
722
+ const malformed = `[
723
+ {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
724
+ { " "type": "history", "title": "坏对象", "content": "语法错误" },
725
+ {"type":"preference","title":"硬件","content":"只要 brushed brass"}
726
+ ]`;
727
+ const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
728
+ stream() {
729
+ return (async function* () {
730
+ yield { type: "block-start", block: { type: "text" } };
731
+ yield { type: "text-delta", delta: malformed };
732
+ yield { type: "finish", kind: "ok" };
733
+ })();
734
+ }
735
+ });
736
+ t.after(() => { summarizer.dispose(); store.close(); });
737
+ const handler = events.find((e) => e.name === "session/event").fn;
738
+ const session = {
739
+ id: "s-salvage",
740
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
741
+ events: [userMessage("带语法错误的蒸馏窗口", 1), { seq: 2, type: "turn/end" }]
742
+ };
743
+
744
+ await handler(session, { seq: 2, type: "turn/end" });
745
+ assert.equal(service.count(), 2, "the two intact objects are written, the broken one dropped");
746
+ const titles = service.list().map((m) => m.title).sort();
747
+ assert.deepEqual(titles, ["硬件", "预算"]);
748
+ assert.equal(calls.length, 1, "salvage consumed the window: no same-window retry");
749
+ const audit = service.listLlmAudits().find((r) => r.status === "success");
750
+ assert.ok(audit, "salvaged run is audited as a success");
751
+ assert.equal(audit.metadata?.json_salvaged, true, "salvage is observable in the audit trail");
752
+
753
+ await handler(session, { seq: 2, type: "turn/end" });
754
+ assert.equal(calls.length, 1, "the consumed window is not re-distilled");
755
+ assert.equal(service.count(), 2, "no duplicate writes on the second turn/end");
756
+ });
757
+
758
+ test("a JSON-crashed window with nothing salvageable stays retryable (issue #339)", async (t) => {
759
+ const hopeless = `[
760
+ { " "type": "history", "title": "坏", "content": "语法错误" }
761
+ ]`;
762
+ let attempt = 0;
763
+ const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
764
+ stream() {
765
+ const output = attempt++ === 0 ? hopeless : `[
766
+ {"type":"history","title":"重试成功","content":"干净窗口","importance":3}
767
+ ]`;
768
+ return (async function* () {
769
+ yield { type: "block-start", block: { type: "text" } };
770
+ yield { type: "text-delta", delta: output };
771
+ yield { type: "finish", kind: "ok" };
772
+ })();
773
+ }
774
+ });
775
+ t.after(() => { summarizer.dispose(); store.close(); });
776
+ const handler = events.find((e) => e.name === "session/event").fn;
777
+ const session = {
778
+ id: "s-salvage-none",
779
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
780
+ events: [userMessage("救不回的窗口", 1), { seq: 2, type: "turn/end" }]
781
+ };
782
+
783
+ await handler(session, { seq: 2, type: "turn/end" });
784
+ assert.equal(service.count(), 0, "zero salvage is not a fake success");
785
+ await handler(session, { seq: 2, type: "turn/end" });
786
+ assert.equal(calls.length, 2, "the window stays retryable");
787
+ assert.equal(service.count(), 1);
788
+ assert.ok(service.listLlmAudits().some(
789
+ (r) => r.status === "error" && r.error_message === "invalid summary JSON"
790
+ ), "zero-salvage run keeps the error audit");
791
+ });
792
+
676
793
  test("an all-invalid summary leaves the same window retryable until valid memories are saved", async (t) => {
677
794
  for (const invalid of [
678
795
  [{}],
@@ -960,6 +1077,47 @@ test("collects real tool result and code dispatch payloads with failure status",
960
1077
  assert.match(transcript, /代码执行(成功):代码执行结果/);
961
1078
  });
962
1079
 
1080
+ test("collects tool results delivered as role:tool messages, keeping the legacy block shape", async () => {
1081
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1082
+ const handler = events.find((e) => e.name === "session/event").fn;
1083
+ const session = {
1084
+ id: "s-tool-role-shape",
1085
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1086
+ events: [
1087
+ userMessage("检查工具结果两种形状", 1),
1088
+ toolResultEvent("旧形状载荷", false, 2),
1089
+ toolRoleResultEvent("新形状载荷", false, 3),
1090
+ toolRoleResultEvent("TypeError: tool crashed", true, 4),
1091
+ { seq: 5, type: "turn/end" }
1092
+ ]
1093
+ };
1094
+
1095
+ await handler(session, { seq: 5, type: "turn/end" });
1096
+ const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1097
+ assert.match(transcript, /工具结果(成功):旧形状载荷/);
1098
+ assert.match(transcript, /工具结果(成功):新形状载荷/);
1099
+ assert.match(transcript, /工具结果(失败):TypeError: tool crashed/);
1100
+ });
1101
+
1102
+ test("keeps prefix trimming for tool results delivered as role:tool messages", async () => {
1103
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1104
+ const handler = events.find((e) => e.name === "session/event").fn;
1105
+ const session = {
1106
+ id: "s-tool-role-trim",
1107
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1108
+ events: [
1109
+ userMessage("检查新形状截断", 1),
1110
+ toolRoleResultEvent(`OUT_START ${"c".repeat(550)} OUT_END`, false, 2),
1111
+ { seq: 3, type: "turn/end" }
1112
+ ]
1113
+ };
1114
+
1115
+ await handler(session, { seq: 3, type: "turn/end" });
1116
+ const transcript = JSON.stringify(calls[0].messages);
1117
+ assert.ok(transcript.includes("OUT_START"));
1118
+ assert.ok(!transcript.includes("OUT_END"), "role:tool 结果同样走 500 字符前缀截断");
1119
+ });
1120
+
963
1121
  test("collects subagent delivery once across inbox and user-message views", async () => {
964
1122
  const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
965
1123
  const handler = events.find((e) => e.name === "session/event").fn;
@@ -1286,3 +1444,22 @@ test("issue#127: a dedupe lookup that yields nothing still lands the entry (neve
1286
1444
  assert.equal(store.count(), 1, "the entry is stored through the normal write path");
1287
1445
  assert.equal(store.all()[0].title, "必须落库");
1288
1446
  });
1447
+
1448
+ test("parseSummaryJson salvage only takes direct children of the outer array (issue #339 review)", () => {
1449
+ // 嵌套子数组里的对象不捞(模型没把它当顶层记忆产出),坏对象照旧丢弃。
1450
+ const nested = `[[{"type":"history","title":"nested","content":"kept"}], { " "type":"history"}]`;
1451
+ assert.deepEqual(parseSummaryJson(nested), []);
1452
+ });
1453
+
1454
+ test("parseSummaryJson salvage does not turn string-embedded objects into memories (issue #339 review)", () => {
1455
+ // 全数组解析失败(第二对象坏)+ 第一对象的 content 字符串里嵌着假记忆形状:
1456
+ // 盲扫不得把属性值对象当独立记忆——外层对象整体才是直接子项。
1457
+ const raw = `[
1458
+ {"type":"history","title":"good","content":"note { \\"type\\": \\"history\\", \\"title\\": \\"fake\\" }"},
1459
+ { " "type": "history", "title": "坏", "content": "x" }
1460
+ ]`;
1461
+ const parsed = parseSummaryJson(raw);
1462
+ assert.equal(parsed.length, 1);
1463
+ assert.equal(parsed[0].title, "good");
1464
+ assert.match(parsed[0].content, /fake/);
1465
+ });
@@ -0,0 +1,60 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { applyTestCount, readTestCounts } from "../scripts/test-count-sync.mjs";
7
+
8
+ const HERE = dirname(fileURLToPath(import.meta.url));
9
+ const PKG_ROOT = join(HERE, "..");
10
+ const REPO_ROOT = join(PKG_ROOT, "..");
11
+ const READMES = [join(REPO_ROOT, "README.md"), join(PKG_ROOT, "README.md")];
12
+
13
+ // 双 README 是发布门面,同一个数字在里面重复出现三到五次(一个 shields.io 徽章 +
14
+ // 每条开发命令注释一处,root 是双语所以中英各一条)。它们必须永远同数——否则
15
+ // 「这个项目有多少测试」取决于读者恰好看了哪一行。
16
+ //
17
+ // v0.8.11 实际漂移过:徽章 1437、四条注释留在 1431。本用例就是那次漂移的回归锁,
18
+ // 跑在真实 README 上(不是 fixture),所以它能在发版前拦住同类漂移。
19
+ test("双 README 的测试数处处一致(徽章与所有注释同数)", () => {
20
+ for (const f of READMES) {
21
+ const counts = readTestCounts(readFileSync(f, "utf8"));
22
+ const shapes = new Set(counts.map((c) => c.shape));
23
+ assert.ok(counts.length >= 2, `${f} 抽到的测试数出现处太少:${JSON.stringify(counts)}`);
24
+ assert.ok(shapes.has("badge"), `${f} 没抽到徽章:${JSON.stringify(counts)}`);
25
+ const values = [...new Set(counts.map((c) => c.value))];
26
+ assert.equal(values.length, 1, `${f} 测试数不一致:${JSON.stringify(counts)}`);
27
+ }
28
+ });
29
+
30
+ // v0.8.11 的真实漂移样本(徽章已 1437、注释仍 1431)。锁住「徽章 + 四种注释形状」
31
+ // 全覆盖:少任何一种,就会有一处静默落后——release-prep.mjs 原先只覆盖徽章 URL,
32
+ // 自动发版路径因此反倒成了漂移来源。
33
+ test("applyTestCount 覆盖徽章与全部四种注释形状(真实漂移样本)", () => {
34
+ const drifted = [
35
+ '<img src="https://img.shields.io/badge/tests-1431%20passed-3E63DD?style=flat-square" alt="tests">',
36
+ "[![tests](https://img.shields.io/badge/tests-1431%20passed-success)](https://github.com/slow-stack/mneme)",
37
+ "npm test # 1431 个测试",
38
+ "npm test # 运行 1431 个测试",
39
+ "test/ # 1431 个 node:test 测试(审计与三轴线压测不变量)",
40
+ "npm test # 1431 tests"
41
+ ].join("\n");
42
+ const fixed = applyTestCount(drifted, 1437);
43
+ assert.ok(!/\b1431\b/.test(fixed), "仍有 1431 未被替换:\n" + fixed);
44
+ const counts = readTestCounts(fixed);
45
+ assert.equal(counts.length, 6, "六处出现都该被认出来:" + JSON.stringify(counts));
46
+ assert.deepEqual([...new Set(counts.map((c) => c.value))], [1437]);
47
+ });
48
+
49
+ test("applyTestCount 幂等:同一个数重复施加不再产生变化", () => {
50
+ const once = applyTestCount("tests-9%20passed-3E63DD\n# 9 个测试\n# 9 tests", 12);
51
+ assert.equal(applyTestCount(once, 12), once);
52
+ });
53
+
54
+ // 历史版本表里的「745 测试全绿」「790 通过」是各版本当时的真实记录(sync-test-badge
55
+ // 的注释也写明了刻意不改它们)。替换规则只认徽章 URL 与 `#` 前缀注释两种形状,
56
+ // 这条用例锁住那个边界,免得将来放宽正则把历史改写掉。
57
+ test("applyTestCount 不碰历史版本表里的「N 测试全绿」", () => {
58
+ const history = "| **v0.8.0** | 745 测试全绿 |\n| **v0.7.9** | 790 通过 |\n| v0.2.7 | 263 |";
59
+ assert.equal(applyTestCount(history, 1437), history);
60
+ });