dsh-memu-memory 1.2.1 → 1.2.2

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 (87) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +12 -5
  3. package/README.zh.md +4 -2
  4. package/docs/contributing.md +16 -3
  5. package/docs/host-api.md +3 -1
  6. package/docs/memory-semantics.md +21 -2
  7. package/docs/troubleshooting.md +42 -4
  8. package/lib/backup.js +24 -2
  9. package/lib/binding-diagnostics.js +2 -2
  10. package/lib/client.js +1361 -146
  11. package/lib/consolidation-worker.js +30 -8
  12. package/lib/curator-evidence.js +131 -0
  13. package/lib/curator-policy.js +81 -0
  14. package/lib/curator.js +1252 -91
  15. package/lib/cycle.js +3 -0
  16. package/lib/embedding-download.js +39 -4
  17. package/lib/engine.js +6 -3
  18. package/lib/global-graph.js +282 -0
  19. package/lib/graph.js +51 -9
  20. package/lib/inbound.js +19 -0
  21. package/lib/inject.js +277 -17
  22. package/lib/lexicon.js +1115 -0
  23. package/lib/local-vector-service.js +8 -6
  24. package/lib/managed-runtime.js +33 -2
  25. package/lib/memory.js +282 -17
  26. package/lib/memu.js +2 -2
  27. package/lib/migrate.js +83 -2
  28. package/lib/private-links.js +587 -0
  29. package/lib/private.js +32 -3
  30. package/lib/prompts/global-curator.md +76 -19
  31. package/lib/prompts/private-consolidation.coding.md +4 -1
  32. package/lib/prompts/private-consolidation.md +3 -1
  33. package/lib/prompts/private-consolidation.mixed.md +3 -1
  34. package/lib/prompts/private-consolidation.rp.md +3 -1
  35. package/lib/prompts.js +1 -1
  36. package/lib/rpc.js +577 -9
  37. package/lib/state.js +4 -1
  38. package/lib/text-retrieval.js +661 -0
  39. package/lib/text-similarity.js +36 -18
  40. package/lib/tools.js +168 -10
  41. package/lib/webui/standalone.css +17 -0
  42. package/lib/webui/standalone.js +2 -0
  43. package/package.json +1 -1
  44. package/scripts/acceptance-local.mjs +8 -5
  45. package/scripts/curator-pair-calibrate.mjs +259 -0
  46. package/scripts/lexicon-calibrate.mjs +230 -0
  47. package/scripts/lexicon-dedupe.mjs +318 -0
  48. package/tests/absorb-review.test.mjs +353 -0
  49. package/tests/appendix-b2.test.mjs +2 -2
  50. package/tests/client-export.test.mjs +520 -0
  51. package/tests/client-render.test.mjs +19 -3
  52. package/tests/consolidation-fork.test.mjs +14 -12
  53. package/tests/consolidation-snapshot.test.mjs +39 -0
  54. package/tests/curator-absorb.test.mjs +443 -0
  55. package/tests/curator-calibrate.test.mjs +173 -0
  56. package/tests/curator-clusters.test.mjs +281 -0
  57. package/tests/curator-commit.test.mjs +52 -5
  58. package/tests/curator-evidence.test.mjs +102 -0
  59. package/tests/curator-link.test.mjs +316 -0
  60. package/tests/curator-pairing.test.mjs +252 -0
  61. package/tests/curator-probe.test.mjs +15 -6
  62. package/tests/curator-review-mode.test.mjs +314 -0
  63. package/tests/disclosure.test.mjs +2 -1
  64. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  65. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  66. package/tests/fixtures/lexicon-stub.mjs +284 -0
  67. package/tests/graph-cascade.test.mjs +35 -2
  68. package/tests/graph.test.mjs +96 -0
  69. package/tests/help-page.test.mjs +415 -0
  70. package/tests/inbound.test.mjs +6 -3
  71. package/tests/inject-guide.test.mjs +28 -0
  72. package/tests/inject.test.mjs +157 -5
  73. package/tests/lexicon-calibrate.test.mjs +48 -0
  74. package/tests/lexicon-dedupe.test.mjs +171 -0
  75. package/tests/lexicon-frequency.test.mjs +179 -0
  76. package/tests/lexicon-inject.test.mjs +410 -0
  77. package/tests/lexicon-perf.test.mjs +436 -0
  78. package/tests/lexicon-ui.test.mjs +547 -0
  79. package/tests/lexicon.test.mjs +922 -0
  80. package/tests/managed-runtime.test.mjs +32 -0
  81. package/tests/private-links.test.mjs +445 -0
  82. package/tests/prompts.test.mjs +35 -0
  83. package/tests/recall-dedup.test.mjs +33 -4
  84. package/tests/tool-schema.test.mjs +1 -1
  85. package/tests/tools.test.mjs +112 -3
  86. package/tests/webui-auth.test.mjs +348 -2
  87. package/tests/webui.test.mjs +188 -0
@@ -0,0 +1,173 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import {
6
+ buildReport,
7
+ confusionMatrix,
8
+ evaluateGates,
9
+ loadFixture,
10
+ MARK,
11
+ scorePairs,
12
+ sweepAt,
13
+ sweepThresholds,
14
+ } from '../scripts/curator-pair-calibrate.mjs'
15
+
16
+ // Calibration harness test (spec §8.1 校准层):fixture 结构、评分确定性、指标数学。
17
+ // The §4.13 gates themselves are a report, not a CI gate (§8.2) — they are
18
+ // asserted here only as data plumbing, never as a pass/fail of the algorithm.
19
+
20
+ const FIXTURE = path.resolve('tests/fixtures/curator-pairs/labeled.v1.jsonl')
21
+
22
+ test('fixture: 90 pairs in three equal bands with unique pair and entry ids', () => {
23
+ const pairs = loadFixture(FIXTURE)
24
+ assert.equal(pairs.length, 90)
25
+ for (const label of ['same-fact', 'related', 'none']) {
26
+ assert.equal(pairs.filter((p) => p.label === label).length, 30, label)
27
+ }
28
+ const pairIds = new Set()
29
+ const entryIds = new Set()
30
+ for (const pair of pairs) {
31
+ assert.ok(!pairIds.has(pair.id), `duplicate pair id ${pair.id}`)
32
+ pairIds.add(pair.id)
33
+ for (const side of ['a', 'b']) {
34
+ const entry = pair[side]
35
+ assert.ok(entry.id, `${pair.id}.${side} carries an id`)
36
+ assert.ok(!entryIds.has(entry.id), `duplicate entry id ${entry.id}`)
37
+ entryIds.add(entry.id)
38
+ assert.ok(String(entry.title || '').length > 0, `${entry.id} has a title`)
39
+ assert.ok(String(entry.content || '').length >= 8, `${entry.id} has content`)
40
+ assert.ok(['memory', 'skill'].includes(entry.track), `${entry.id} has a valid track`)
41
+ assert.ok(Array.isArray(entry.appliesTo), `${entry.id} has an appliesTo array`)
42
+ }
43
+ }
44
+ })
45
+
46
+ test('fixture: covers zh/en/mixed plus length-gap, negation, dates and alias tags', () => {
47
+ const pairs = loadFixture(FIXTURE)
48
+ const tags = new Set(pairs.flatMap((p) => p.tags || []))
49
+ for (const tag of ['length-gap', 'negation-same', 'negation-differ', 'dates-same', 'dates-differ', 'alias', 'punctuation']) {
50
+ assert.ok(tags.has(tag), `missing coverage tag: ${tag}`)
51
+ }
52
+ const langs = new Set(pairs.map((p) => p.lang))
53
+ for (const lang of ['zh', 'en', 'mixed']) assert.ok(langs.has(lang), `missing language coverage: ${lang}`)
54
+ // every declared tag is one of the known ones (typo guard)
55
+ const known = new Set(['paraphrase', 'length-gap', 'containment', 'negation-same', 'negation-differ',
56
+ 'dates-same', 'dates-differ', 'alias', 'alias-related', 'punctuation', 'same-prefix', 'same-topic',
57
+ 'time-shift', 'different-topic', 'different-domain', 'cross-track', 'length-gap-none', 'skill'])
58
+ for (const tag of tags) assert.ok(known.has(tag), `unknown tag: ${tag}`)
59
+ // the length-gap tags must be honest: real gap >0.5 on the raw text pair
60
+ for (const pair of pairs.filter((p) => (p.tags || []).includes('length-gap'))) {
61
+ const aLen = String(pair.a.title + pair.a.content).length
62
+ const bLen = String(pair.b.title + pair.b.content).length
63
+ assert.ok(Math.abs(aLen - bLen) / Math.max(aLen, bLen) > 0.5, `${pair.id} claims length-gap but the gap is small`)
64
+ }
65
+ })
66
+
67
+ test('fixture header marks the data as a synthetic draft, never a gold set', () => {
68
+ assert.equal(MARK, 'synthetic-draft-not-gold')
69
+ const report = buildReport(loadFixture(FIXTURE))
70
+ assert.equal(report.mark, MARK)
71
+ assert.match(report.note, /synthetic draft/i)
72
+ assert.match(report.note, /NOT a production gold set/i)
73
+ })
74
+
75
+ test('scoring is deterministic: two passes agree exactly', () => {
76
+ const pairs = loadFixture(FIXTURE)
77
+ assert.deepEqual(scorePairs(pairs), scorePairs(pairs))
78
+ const report1 = buildReport(pairs)
79
+ const report2 = buildReport(pairs)
80
+ assert.deepEqual(report1.pairs, report2.pairs)
81
+ assert.deepEqual(report1.confusion, report2.confusion)
82
+ assert.deepEqual(report1.candidateRecall, report2.candidateRecall)
83
+ })
84
+
85
+ test('sweepAt: precision/recall/F1 arithmetic on a hand-checked population', () => {
86
+ const results = [
87
+ { label: 'same-fact', score: 0.9 },
88
+ { label: 'same-fact', score: 0.7 },
89
+ { label: 'related', score: 0.6 },
90
+ { label: 'none', score: 0.75 },
91
+ { label: 'none', score: 0.2 },
92
+ ]
93
+ const at082 = sweepAt(results, ['same-fact'], 0.82)
94
+ assert.deepEqual(
95
+ { tp: at082.tp, fp: at082.fp, fn: at082.fn },
96
+ { tp: 1, fp: 0, fn: 1 },
97
+ )
98
+ assert.equal(at082.precision, 1)
99
+ assert.equal(at082.recall, 0.5)
100
+ assert.equal(at082.f1, 0.6667)
101
+ const at055 = sweepAt(results, ['same-fact', 'related'], 0.55)
102
+ assert.deepEqual({ tp: at055.tp, fp: at055.fp, fn: at055.fn }, { tp: 3, fp: 1, fn: 0 })
103
+ assert.equal(at055.precision, 0.75)
104
+ assert.equal(at055.recall, 1)
105
+ // empty predictions → null precision, not NaN
106
+ const empty = sweepAt(results, ['same-fact'], 1.5)
107
+ assert.equal(empty.precision, null)
108
+ assert.equal(empty.recall, 0)
109
+ })
110
+
111
+ test('sweepThresholds walks 0.50→0.95 inclusive with 0.01 steps', () => {
112
+ const rows = sweepThresholds([{ label: 'none', score: 0.6 }], ['same-fact'], {})
113
+ assert.equal(rows.length, 46)
114
+ assert.equal(rows[0].threshold, 0.5)
115
+ assert.equal(rows.at(-1).threshold, 0.95)
116
+ })
117
+
118
+ test('confusionMatrix: label × decision counts sum to the population', () => {
119
+ const results = [
120
+ { label: 'same-fact', decision: 'duplicate' },
121
+ { label: 'same-fact', decision: 'absorb' },
122
+ { label: 'related', decision: 'link' },
123
+ { label: 'none', decision: 'none' },
124
+ ]
125
+ const { matrix } = confusionMatrix(results)
126
+ assert.equal(matrix['same-fact'].duplicate, 1)
127
+ assert.equal(matrix['same-fact'].absorb, 1)
128
+ assert.equal(matrix.related.link, 1)
129
+ assert.equal(matrix.none.none, 1)
130
+ const total = Object.values(matrix).reduce((sum, row) => sum + Object.values(row).reduce((a, b) => a + b, 0), 0)
131
+ assert.equal(total, 4)
132
+ })
133
+
134
+ test('evaluateGates: the four §4.13 thresholds flip pass/fail at their bounds', () => {
135
+ const pass = evaluateGates({
136
+ absorbAt082: { precision: 0.96 },
137
+ linkAt055: { precision: 0.9 },
138
+ noneAt055: { rate: 0.05 },
139
+ recall20: { recall: 0.97 },
140
+ })
141
+ assert.ok(pass.every((gate) => gate.pass))
142
+ const fail = evaluateGates({
143
+ absorbAt082: { precision: 0.94 },
144
+ linkAt055: { precision: 0.84 },
145
+ noneAt055: { rate: 0.11 },
146
+ recall20: { recall: 0.94 },
147
+ })
148
+ assert.ok(fail.every((gate) => !gate.pass))
149
+ // null precision (empty population) never counts as a pass
150
+ const degenerate = evaluateGates({
151
+ absorbAt082: { precision: null },
152
+ linkAt055: { precision: null },
153
+ noneAt055: { rate: null },
154
+ recall20: { recall: null },
155
+ })
156
+ assert.ok(degenerate.every((gate) => !gate.pass))
157
+ })
158
+
159
+ test('candidateRecall: every same-fact partner is reachable through rankPairCandidates', () => {
160
+ const report = buildReport(loadFixture(FIXTURE))
161
+ assert.equal(report.candidateRecall.total, 30)
162
+ assert.equal(report.candidateRecall.limit, 20)
163
+ // data plumbing only — the gate value is reported in the calibration run
164
+ assert.ok(report.candidateRecall.recall >= 0 && report.candidateRecall.recall <= 1)
165
+ })
166
+
167
+ test('fixture file ends with a newline and parses as strict JSONL', () => {
168
+ const raw = fs.readFileSync(FIXTURE, 'utf8')
169
+ assert.match(raw, /\n$/, 'trailing newline (CRLF conversion friendly)')
170
+ const lines = raw.split(/\r?\n/).filter((line) => line.trim())
171
+ assert.equal(lines.length, 90)
172
+ for (const line of lines) JSON.parse(line)
173
+ })
@@ -0,0 +1,281 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { registerHooks } from 'node:module'
7
+
8
+ // Candidate clusters (spec §4.4) + curator context packing (§4.5).
9
+ //
10
+ // buildCandidateClusters is pure (text-retrieval), but packCuratorIndexContext /
11
+ // packCuratorClusterContext live in curator.js, which pulls the module stack —
12
+ // so this file uses the same hermetic rig as tests/curator-commit.test.mjs:
13
+ // './memu.js' is replaced before production modules load and DSH_MEMU_HOME
14
+ // points at a temp root.
15
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-clusters-'))
16
+ process.env.DSH_MEMU_HOME = home
17
+ process.env.DSH_MEMU_MEMORY_HOME = home
18
+
19
+ globalThis.__curatorClusterTest = {
20
+ async commit() { return { ok: true } },
21
+ async forget() { return { deleted: 1, segments: 1 } },
22
+ async retrieve() { return { files: [], segments: [], resources: [], raw: null } },
23
+ async listFiles() { return { recall_files: [], resources: [], raw: null } },
24
+ }
25
+ const memuSource = 'const bridge = globalThis.__curatorClusterTest;'
26
+ + 'export const commit = (args) => bridge.commit(args), forget = (args) => bridge.forget(args),'
27
+ + 'retrieve = (args) => bridge.retrieve(args), listFiles = (args) => bridge.listFiles(args);'
28
+ const hooks = registerHooks({
29
+ resolve(specifier, context, nextResolve) {
30
+ if (specifier === './memu.js') return { url: 'data:text/javascript,' + encodeURIComponent(memuSource), shortCircuit: true }
31
+ return nextResolve(specifier, context)
32
+ },
33
+ })
34
+
35
+ const { buildCandidateClusters } = await import('../lib/text-retrieval.js')
36
+ const curator = await import('../lib/curator.js')
37
+
38
+ test.after(async () => {
39
+ try { hooks.unregister?.() } catch {}
40
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
41
+ })
42
+
43
+ const entryOf = (id, title, content, extra = {}) => ({
44
+ id, track: 'memory', domain: 'general', status: 'active',
45
+ title, content, updatedAt: '2026-01-01T00:00:00.000Z', ...extra,
46
+ })
47
+
48
+ const index = (entries) => ({ version: 1, entries: Object.fromEntries(entries.map((e) => [e.id, e])) })
49
+
50
+ // —— blocking ——
51
+
52
+ // 1440 distinct CJK words; each filler picks 6 with its own PRNG stream, so two
53
+ // fillers share at most a stray bigram (never enough Dice for an edge) and the
54
+ // pair under test is the only real signal in the corpus. The one-char title
55
+ // keeps both probe entries' token sets to their content words ('x' is dropped
56
+ // by tokenize) while the title bigrams keep Dice high.
57
+ const VOCAB = []
58
+ for (const first of '甲乙丙丁戊己庚辛壬癸') {
59
+ for (const second of '子丑寅卯辰巳午未申酉戌亥') {
60
+ for (const third of '東南西北中發白裏乾坤') VOCAB.push(`词${first}${second}${third}`)
61
+ }
62
+ }
63
+ function mulberry32(seed) {
64
+ let state = seed >>> 0
65
+ return () => {
66
+ state = (state + 0x6D2B79F5) | 0
67
+ let t = Math.imul(state ^ (state >>> 15), 1 | state)
68
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
69
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
70
+ }
71
+ }
72
+ function fillers(count, from = 0) {
73
+ const out = []
74
+ for (let i = from + 1; i <= from + count; i++) {
75
+ const random = mulberry32(i * 7919)
76
+ const deck = [...VOCAB.keys()]
77
+ const words = []
78
+ for (let t = 0; t < 6; t++) {
79
+ const pick = Math.floor(random() * deck.length)
80
+ words.push(VOCAB[deck.splice(pick, 1)[0]])
81
+ }
82
+ out.push(entryOf(`filler-${i}`, 'x', words.join('、') + '。'))
83
+ }
84
+ return out
85
+ }
86
+
87
+ test('N=300 exact all-pairs still cluster a high-Dice one-shared-token pair', () => {
88
+ const entries = [
89
+ entryOf('deploy-a', 'x', 'deploy cache'),
90
+ entryOf('deploy-b', 'x', 'redeploy cache'),
91
+ ...fillers(298),
92
+ ]
93
+ const { clusters } = buildCandidateClusters(index(entries))
94
+ const pair = clusters.find((c) => c.members.some((m) => m.id === 'deploy-a'))
95
+ assert.ok(pair, 'at N<=300 the exact pass scores every same-track pair')
96
+ assert.deepEqual(pair.members.map((m) => m.id).sort(), ['deploy-a', 'deploy-b'])
97
+ })
98
+
99
+ test('N=301 switches to inverted blocking: the same pair shares only 1 token and must not cluster', () => {
100
+ const entries = [
101
+ entryOf('deploy-a', '部署', 'deploy cache'),
102
+ entryOf('deploy-b', '锚点', 'redeploy cache'),
103
+ ...fillers(299),
104
+ ]
105
+ const { clusters } = buildCandidateClusters(index(entries))
106
+ assert.equal(clusters.length, 0, 'shared token count 1 < blockMinShared 2 keeps the pair out')
107
+ })
108
+
109
+ // —— all-pairs clique growth ——
110
+
111
+ // A-B ≈0.68, B-C ≈0.82, A-C ≈0.39: whichever edge seeds the cluster, the third
112
+ // member always violates the all-pairs constraint against the opposite side.
113
+ const chainIndex = () => index([
114
+ entryOf('A', '站会要点', '站会先过昨日进展,再排今日任务,最后收集阻塞。'),
115
+ entryOf('B', '站会要点', '站会先过昨日进展,然后直接进入深挖环节,限时十分钟。'),
116
+ entryOf('C', '站会要点', '站会先过昨日进展,然后直接进入深挖环节。'),
117
+ ])
118
+
119
+ test('all-pairs constraint: an A-B-C chain never becomes one cluster', () => {
120
+ const { clusters } = buildCandidateClusters(chainIndex())
121
+ assert.ok(clusters.length >= 1, 'the two strong edges must form at least one cluster')
122
+ for (const cluster of clusters) {
123
+ const ids = cluster.members.map((m) => m.id)
124
+ assert.ok(!ids.includes('A') || !ids.includes('C'), `A and C must never cohabit: ${ids.join(',')}`)
125
+ }
126
+ // every kept edge honours the floor pair-wise, not just transitively
127
+ for (const cluster of clusters) {
128
+ for (const [key, score] of Object.entries(cluster.pair_scores)) {
129
+ assert.ok(score >= 0.55, `in-cluster pair ${key} below floor: ${score}`)
130
+ }
131
+ }
132
+ })
133
+
134
+ test('all-pairs constraint: deterministic across runs', () => {
135
+ assert.deepEqual(buildCandidateClusters(chainIndex()), buildCandidateClusters(chainIndex()))
136
+ })
137
+
138
+ // —— medoid ——
139
+
140
+ test('medoid is the member with the highest average pairwise score; ties break updatedAt desc then id asc', () => {
141
+ const entries = [
142
+ entryOf('t1', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源并写回缓存。', { updatedAt: '2026-01-01T00:00:00.000Z' }),
143
+ entryOf('t2', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源并写回缓存集合。', { updatedAt: '2026-01-02T00:00:00.000Z' }),
144
+ entryOf('t3', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源写回缓存集合。', { updatedAt: '2026-01-03T00:00:00.000Z' }),
145
+ ]
146
+ const { clusters } = buildCandidateClusters(index(entries))
147
+ assert.equal(clusters.length, 1)
148
+ const cluster = clusters[0]
149
+ assert.equal(cluster.members.length, 3)
150
+ // medoid: recompute averages from the published upper-triangle pair scores
151
+ const scores = cluster.pair_scores
152
+ const pairsOf = (id) => Object.entries(scores).filter(([key]) => key.includes(id))
153
+ const avg = (id) => pairsOf(id).reduce((sum, [, value]) => sum + value, 0) / pairsOf(id).length
154
+ const best = cluster.members.map((m) => m.id).sort((x, y) => avg(y) - avg(x) || String(y).localeCompare(String(x)))[0]
155
+ assert.equal(cluster.medoid_id, best)
156
+ assert.ok(cluster.avg_score >= 0.82, `a tight triple must average in the absorb band, got ${cluster.avg_score}`)
157
+ assert.equal(cluster.suggested_op, 'absorb', '3 members, same domain, avg >=0.82')
158
+ assert.equal(cluster.cluster_id, 'cl-1')
159
+ assert.equal(Object.keys(scores).length, 3, 'upper triangle only')
160
+ })
161
+
162
+ test('a 2-member cluster never suggests absorb even at duplicate-level similarity', () => {
163
+ const { clusters } = buildCandidateClusters(index([
164
+ entryOf('p1', '发布清单', '发版前按清单逐项确认版本号、changelog 和回归标记。'),
165
+ entryOf('p2', '发布清单', '发版前按清单逐项确认版本号、changelog与回归标记。'),
166
+ ]))
167
+ assert.equal(clusters.length, 1)
168
+ assert.equal(clusters[0].members.length, 2)
169
+ assert.equal(clusters[0].suggested_op, 'link')
170
+ })
171
+
172
+ test('medoid average below the pair floor drops the whole cluster (defensive, via lowered minPairScore)', () => {
173
+ const { clusters } = buildCandidateClusters(index([
174
+ entryOf('w1', '杂项', '条目甲的内容讲预算审批流程。'),
175
+ entryOf('w2', '杂项', '条目甲的内容讲预算审批与报销流程。'),
176
+ entryOf('w3', '杂项', '条目甲的内容讲报销与发票流程。'),
177
+ ]), { minPairScore: 0.3 })
178
+ for (const cluster of clusters) {
179
+ assert.ok(cluster.avg_score >= 0.55 || cluster.medoid_avg_dropped !== true)
180
+ }
181
+ // with the floor at 0.3 edges form, but a medoid averaging <0.55 must be dropped
182
+ const weak = clusters.filter((c) => c.avg_score < 0.55)
183
+ assert.equal(weak.length, 0, 'no cluster below the 0.55 output floor survives')
184
+ })
185
+
186
+ test('clusters never span tracks', () => {
187
+ const { clusters } = buildCandidateClusters(index([
188
+ entryOf('m1', '发布清单', '发版前按清单逐项确认版本号、changelog 和回归标记。', { track: 'memory' }),
189
+ entryOf('s1', '发布清单', '发版前按清单逐项确认版本号、changelog 与回归标记。', { track: 'skill' }),
190
+ ]))
191
+ assert.equal(clusters.length, 0)
192
+ })
193
+
194
+ test('>12 clusters are truncated by avg_score desc', () => {
195
+ const entries = []
196
+ for (let i = 1; i <= 15; i++) {
197
+ entries.push(entryOf(`pair-${i}-a`, `主题${i}`, `主题甲${i}:独有词${i}${i}的内容陈述。`))
198
+ entries.push(entryOf(`pair-${i}-b`, `主题${i}`, `主题甲${i}:独有词${i}${i}的内容陈述变体。`))
199
+ }
200
+ const { clusters } = buildCandidateClusters(index(entries))
201
+ assert.equal(clusters.length, 12)
202
+ const avgs = clusters.map((c) => c.avg_score)
203
+ assert.deepEqual([...avgs].sort((a, b) => b - a), avgs, 'output stays avg_score-descending')
204
+ for (const cluster of clusters) assert.equal(cluster.suggested_op, 'link')
205
+ })
206
+
207
+ test('recentLimit cuts the pool to the most recent entries and flags truncation', () => {
208
+ const partner = entryOf('partner-old', '旧站会', '站会先过昨日进展,再排今日任务,最后收集阻塞。', { updatedAt: '2025-06-01T00:00:00.000Z' })
209
+ const seed = entryOf('seed-new', '旧站会', '站会先过昨日进展,再排今日任务,最后收集阻塞项。', { updatedAt: '2026-06-01T00:00:00.000Z' })
210
+ const entries = [partner, seed, ...fillers(58)]
211
+ const result = buildCandidateClusters(index(entries), { recentLimit: 30 })
212
+ assert.equal(result.truncated, true)
213
+ assert.equal(result.scanned, 30)
214
+ assert.equal(result.clusters.length, 0, 'the older partner fell outside the recent window')
215
+ })
216
+
217
+ // —— §4.5 context packing ——
218
+
219
+ test('packCuratorIndexContext: deterministic, budgeted, BM25-ordered, archived out / pending in', () => {
220
+ const entries = {
221
+ 'gmem-hot': entryOf('gmem-hot', '部署窗口', '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。'),
222
+ 'gmem-cold': entryOf('gmem-cold', '摄影器材', '用户的相机是全画幅微单,常用 35mm 定焦。'),
223
+ 'gmem-pending': entryOf('gmem-pending', '部署告警', '部署失败要发告警到值班群。', { status: 'pending_review', updatedAt: '2026-02-01T00:00:00.000Z' }),
224
+ 'gmem-archived': entryOf('gmem-archived', '旧部署窗口', '旧窗口已废弃不用。', { status: 'archived' }),
225
+ 'gmem-linked': entryOf('gmem-linked', '回滚流程', '窗口结束前完成回滚。', { links: [{ id: 'lnk_1', to: 'gmem-hot', kind: 'related' }, { id: 'lnk_2', to: 'gmem-x', kind: 'refines' }] }),
226
+ }
227
+ const sessionText = '这次会话都在讨论部署窗口与灰度发布的安排'
228
+ const pack1 = curator.packCuratorIndexContext({ index: { entries }, sessionText })
229
+ const pack2 = curator.packCuratorIndexContext({ index: { entries }, sessionText })
230
+ assert.deepEqual(pack1, pack2)
231
+ const ids = pack1.entries.map((e) => e.id)
232
+ assert.ok(ids.includes('gmem-hot'), 'the BM25-best entry ranks first')
233
+ assert.equal(ids[0], 'gmem-hot')
234
+ assert.ok(ids.includes('gmem-pending'), 'pending_review stays in the pool')
235
+ assert.ok(!ids.includes('gmem-archived'), 'archived entries are filtered')
236
+ const linked = pack1.entries.find((e) => e.id === 'gmem-linked')
237
+ assert.equal(linked.linkCount, 2)
238
+ assert.deepEqual(linked.linkedTo, [
239
+ { to: 'gmem-hot', kind: 'related' },
240
+ { to: 'gmem-x', kind: 'refines' },
241
+ ], 'linkedTo carries the first 5 links without notes')
242
+ const hot = pack1.entries.find((e) => e.id === 'gmem-hot')
243
+ assert.ok(hot.content.includes('灰度发布'), 'content survives truncation at 900/600 chars')
244
+ assert.ok(pack1.tokens <= 4000, `index budget respected, got ${pack1.tokens}`)
245
+ })
246
+
247
+ test('packCuratorIndexContext: a tiny budget drops low-scoring entries instead of truncating JSON', () => {
248
+ const entries = {
249
+ 'gmem-a': entryOf('gmem-a', '部署窗口', '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。'),
250
+ 'gmem-b': entryOf('gmem-b', '摄影器材', '用户的相机是全画幅微单,常用 35mm 定焦。'),
251
+ 'gmem-c': entryOf('gmem-c', '咖啡偏好', '用户下午的咖啡只喝美式,上午喝拿铁。'),
252
+ }
253
+ const packed = curator.packCuratorIndexContext({ index: { entries }, sessionText: '部署窗口 灰度发布', tokenBudget: 120 })
254
+ assert.ok(packed.entries.length < 3, `a 120-token budget must drop entries, kept ${packed.entries.length}`)
255
+ assert.equal(packed.entries[0].id, 'gmem-a', 'the best-scoring entry survives a tiny budget')
256
+ assert.equal(packed.omitted, 3 - packed.entries.length)
257
+ })
258
+
259
+ test('packCuratorClusterContext: budget 2000 keeps whole clusters, drops the lowest scores', () => {
260
+ const clusterResult = buildCandidateClusters(index([
261
+ entryOf('k1', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源并写回缓存。'),
262
+ entryOf('k2', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源并写回缓存集合。'),
263
+ entryOf('k3', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源写回缓存集合。'),
264
+ entryOf('j1', '发布清单', '发版前按清单逐项确认版本号、changelog 和回归标记。'),
265
+ entryOf('j2', '发布清单', '发版前按清单逐项确认版本号、changelog与回归标记。'),
266
+ ]))
267
+ assert.equal(clusterResult.clusters.length, 2)
268
+ const packed = curator.packCuratorClusterContext(clusterResult)
269
+ assert.deepEqual(packed.clusters.map((c) => c.cluster_id), clusterResult.clusters.map((c) => c.cluster_id))
270
+ assert.ok(packed.tokens <= 2000)
271
+ assert.equal(packed.truncated, false)
272
+ const rendered = packed.clusters[0]
273
+ assert.ok(rendered.members[0].content.length <= 301, 'cluster member content is cut to 300 chars')
274
+ for (const score of Object.values(rendered.pair_scores)) {
275
+ assert.equal(Math.round(score * 100) / 100, score, 'pair_scores keep 2 decimals')
276
+ }
277
+ const squeezed = curator.packCuratorClusterContext(clusterResult, { tokenBudget: 100 })
278
+ assert.equal(squeezed.clusters.length, 1, 'a 100-token budget keeps only the best cluster')
279
+ assert.equal(squeezed.omitted, 1)
280
+ assert.equal(squeezed.truncated, true)
281
+ })
@@ -194,7 +194,7 @@ test('a clean create still carries the current-generation stamp and is counted b
194
194
  title: '独立的全新事实',
195
195
  content: '用户倾向于在正式汇报里先给结论再给细节。',
196
196
  confidence: 0.9,
197
- evidence: [{ session_id: 's2', quote: '先说结论' }],
197
+ evidence: [{ session_id: 's2', quote: '汇报时先给结论再讲细节' }],
198
198
  }],
199
199
  }
200
200
  const result = await curator.commitCuratorOutput(output, { job: { step: 'committing' } })
@@ -213,8 +213,8 @@ test('a global-index write failure after the batch commit is compensated name by
213
213
  await activateVectorGeneration()
214
214
  const output = {
215
215
  memories: [
216
- { op: 'create', domain: 'general', track: 'memory', title: '第一条', content: '第一条全新事实的内容,足够长以通过校验。', confidence: 0.9, evidence: [{ session_id: 's3', quote: '一' }] },
217
- { op: 'create', domain: 'general', track: 'memory', title: '第二条', content: '第二条全新事实的内容,与第一条没有关系。', confidence: 0.9, evidence: [{ session_id: 's4', quote: '二' }] },
216
+ { op: 'create', domain: 'general', track: 'memory', title: '第一条', content: '第一条全新事实的内容,足够长以通过校验。', confidence: 0.9, evidence: [{ session_id: 's3', quote: '第一条内容的原话记录' }] },
217
+ { op: 'create', domain: 'general', track: 'memory', title: '第二条', content: '第二条全新事实的内容,与第一条没有关系。', confidence: 0.9, evidence: [{ session_id: 's4', quote: '第二条内容的原话记录' }] },
218
218
  ],
219
219
  }
220
220
  indexHook.failNext = 1
@@ -257,8 +257,8 @@ test('a compensation failure is reported per name and never hides the original e
257
257
  }
258
258
  const output = {
259
259
  memories: [
260
- { op: 'create', domain: 'general', track: 'memory', title: '甲', content: '甲事实的内容,与乙无关。', confidence: 0.9, evidence: [{ session_id: 's5', quote: '甲' }] },
261
- { op: 'create', domain: 'general', track: 'memory', title: '乙', content: '乙事实的内容,与甲无关。', confidence: 0.9, evidence: [{ session_id: 's6', quote: '乙' }] },
260
+ { op: 'create', domain: 'general', track: 'memory', title: '甲', content: '甲事实的内容,与乙无关。', confidence: 0.9, evidence: [{ session_id: 's5', quote: '甲条内容的原话记录' }] },
261
+ { op: 'create', domain: 'general', track: 'memory', title: '乙', content: '乙事实的内容,与甲无关。', confidence: 0.9, evidence: [{ session_id: 's6', quote: '乙条内容的原话记录' }] },
262
262
  ],
263
263
  }
264
264
  indexHook.failNext = 1
@@ -275,3 +275,50 @@ test('a compensation failure is reported per name and never hides the original e
275
275
  assert.match(event.detail.failed[0].error, /engine db is locked/)
276
276
  assert.match(event.message, /残留孤儿行/)
277
277
  })
278
+
279
+ // Package A (spec §4.10): the risk score is an audit trail, so it must survive
280
+ // the two paths that rewrite an entry after the decision was made: the held
281
+ // clone (target changed mid-run) and the activity-log summary.
282
+ test('a held clone from a target-changed conflict keeps the decision risk score for audit', async () => {
283
+ seedGlobalIndex({ [TARGET]: TARGET_ENTRY })
284
+ await activateVectorGeneration()
285
+ // mergeOutput is a clean risky-rewrite candidate: merge 0.20 + single evidence
286
+ // 0.10 → risk 0.30 < 0.70, so auto commits it and the concurrent writer turns
287
+ // the committed row into a held clone.
288
+ backend.onCommit = async () => {
289
+ state.updateGlobalIndex((fresh) => {
290
+ fresh.entries[TARGET] = { ...fresh.entries[TARGET], content: '内容已被并发修改', updatedAt: '2026-02-02T00:00:00.000Z' }
291
+ })
292
+ }
293
+ await curator.commitCuratorOutput(structuredClone(mergeOutput), { job: { step: 'committing' } })
294
+ backend.onCommit = null
295
+ const index = state.loadGlobalIndex()
296
+ const heldId = Object.keys(index.entries).find((id) => index.entries[id]?.reviewReason === 'target changed while curator was running')
297
+ assert.ok(heldId, 'the target-changed merge must be held for review')
298
+ const held = index.entries[heldId]
299
+ assert.equal(held.reviewRisk, 0.3, 'merge 0.20 + single evidence 0.10 survives the clone')
300
+ assert.deepEqual(held.reviewRiskReasons, ['merge', 'single evidence'])
301
+ assert.equal(held.status, 'pending_review')
302
+ })
303
+
304
+ test('the curate activity record carries the batch review-risk summary', async () => {
305
+ seedGlobalIndex({})
306
+ await activateVectorGeneration()
307
+ const output = {
308
+ memories: [
309
+ { op: 'create', domain: 'general', track: 'memory', title: '低风险条目', content: '低风险条目的内容,与另一条无关。', confidence: 0.9, evidence: [{ session_id: 's7', quote: '低风险条目的原话记录' }] },
310
+ { op: 'create', domain: 'general', track: 'memory', title: '高风险条目', content: '高风险条目的内容,模型自己标了待复核。', confidence: 0.9, needs_review: true, evidence: [{ session_id: 's8', quote: '高风险条目的原话记录' }] },
311
+ ],
312
+ }
313
+ const result = await curator.commitCuratorOutput(structuredClone(output), { job: { step: 'committing' } })
314
+ assert.equal(result.created, 2)
315
+ assert.deepEqual(result.reviewRisk, { max: 0.8, flagged: 1, reasons: { 'needs_review': 1, 'single evidence': 2 } })
316
+ // listEvents is newest-first; this run is the only successful curation with
317
+ // exactly two created entries, so match on that instead of list position.
318
+ const event = activity.listEvents({ kind: 'curate' })
319
+ .find((item) => item.ok === true && item.detail?.reviewRisk && item.detail?.created === 2)
320
+ assert.ok(event, 'the successful curation must be recorded')
321
+ assert.equal(event.detail.reviewRisk.max, 0.8)
322
+ assert.equal(event.detail.reviewRisk.flagged, 1)
323
+ assert.equal(event.detail.reviewRisk.reasons.needs_review, 1)
324
+ })
@@ -0,0 +1,102 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { buildEvidenceCorpus, groundEvidence, groundLinkNote, normalizeEvidenceText } from '../lib/curator-evidence.js'
4
+
5
+ const transcript = [
6
+ 'User: 我们把每周的部署窗口定在周四凌晨两点到四点,只允许灰度发布。',
7
+ 'Assistant: 好的,部署窗口已记录:周四 02:00-04:00,灰度 only。',
8
+ 'User: 另外我的 api_key 是 sk-abcdef1234567890abcdef,别外传。',
9
+ ].join('\n')
10
+
11
+ const corpus = buildEvidenceCorpus([{ id: 's1', transcript }])
12
+
13
+ test('normalizeEvidenceText: NFKC + lowercase + strip whitespace/punctuation/symbols', () => {
14
+ assert.equal(normalizeEvidenceText('部署窗口!Api_Key…'), '部署窗口apikey'.replace('api', 'api'))
15
+ assert.equal(normalizeEvidenceText(' Hello, World! '), 'helloworld')
16
+ assert.equal(normalizeEvidenceText('灰度-发布_v2'), '灰度发布v2')
17
+ })
18
+
19
+ test('groundEvidence: exact quote grounds; punctuation differences do not block', () => {
20
+ const exact = groundEvidence([{ session_id: 's1', quote: '周四凌晨两点到四点' }], corpus)
21
+ assert.equal(exact.status, 'grounded')
22
+ assert.equal(exact.valid.length, 1)
23
+ assert.deepEqual(exact.invalid, [])
24
+ const punctuated = groundEvidence([{ session_id: 's1', quote: '部署窗口定在周四凌晨两点到四点,只允许灰度发布。' }], corpus)
25
+ assert.equal(punctuated.valid.length, 1, 'punctuation is stripped on both sides before matching')
26
+ })
27
+
28
+ test('groundEvidence: a fabricated quote fails grounding', () => {
29
+ const fabricated = groundEvidence([{ session_id: 's1', quote: '周五凌晨五点才部署呢' }], corpus)
30
+ assert.equal(fabricated.valid.length, 0)
31
+ assert.match(fabricated.invalid[0].reason, /not found in session/)
32
+ })
33
+
34
+ test('groundEvidence: session_id must belong to this curation', () => {
35
+ const stranger = groundEvidence([{ session_id: 's-other', quote: '周四凌晨两点到四点' }], corpus)
36
+ assert.equal(stranger.valid.length, 0)
37
+ assert.match(stranger.invalid[0].reason, /not part of this curation/)
38
+ })
39
+
40
+ test('groundEvidence: quotes shorter than 6 normalized chars are rejected', () => {
41
+ const short = groundEvidence([{ session_id: 's1', quote: '周四' }], corpus)
42
+ assert.equal(short.valid.length, 0)
43
+ assert.match(short.invalid[0].reason, /shorter than 6/)
44
+ })
45
+
46
+ test('groundEvidence: ellipsis quotes split into 2-3 segments, each >=4 chars and found', () => {
47
+ const ok = groundEvidence([{ session_id: 's1', quote: '周四凌晨两点到四点……只允许灰度发布' }], corpus)
48
+ assert.equal(ok.valid.length, 1, 'a two-segment ellipsis quote with both halves present grounds')
49
+ const missing = groundEvidence([{ session_id: 's1', quote: '周四凌晨两点到四点……周五凌晨五点开工' }], corpus)
50
+ assert.equal(missing.valid.length, 0)
51
+ assert.match(missing.invalid[0].reason, /ellipsis segment not found/)
52
+ const tooShort = groundEvidence([{ session_id: 's1', quote: '周四凌晨两点到四点……灰度' }], corpus)
53
+ assert.equal(tooShort.valid.length, 0)
54
+ assert.match(tooShort.invalid[0].reason, /ellipsis segment shorter than 4/)
55
+ const tooMany = groundEvidence([{ session_id: 's1', quote: '我们……周四凌晨……灰度发布……记录' }], corpus)
56
+ assert.equal(tooMany.valid.length, 0)
57
+ assert.match(tooMany.invalid[0].reason, /2-3 segments/)
58
+ })
59
+
60
+ test('groundEvidence: quotes are redactSecrets-ed before storage', () => {
61
+ // The transcript above contains a raw key; quoting it verbatim must fail the
62
+ // substring check (the corpus is redacted), and the stored quote is redacted.
63
+ const leaked = groundEvidence([{ session_id: 's1', quote: 'sk-abcdef1234567890abcdef' }], corpus)
64
+ assert.equal(leaked.valid.length, 0, 'a raw secret is not in the redacted corpus, so it cannot ground')
65
+ const mixed = groundEvidence([{ session_id: 's1', quote: 'api_key 是 sk-abcdef1234567890abcdef,别外传' }], corpus)
66
+ assert.equal(mixed.valid.length, 0, 'the secret-bearing span cannot ground either')
67
+ // Direct redaction check: groundEvidence never stores an unredacted secret.
68
+ const unverifiedCorpus = buildEvidenceCorpus([{ id: 'x', transcript: 'token sk-zzzz1111222233334444 inside' }])
69
+ const got = groundEvidence([{ session_id: 'x', quote: 'token sk-zzzz1111222233334444 inside' }], unverifiedCorpus)
70
+ assert.equal(got.valid.length, 0, 'even a self-consistent corpus fails when the quote redacts away')
71
+ })
72
+
73
+ test('groundEvidence: no sessionCorpus means shape-checked unverified, not rejected', () => {
74
+ const got = groundEvidence([{ session_id: 's9', quote: '完全没有出处的引用也可以' }], null)
75
+ assert.equal(got.status, 'unverified')
76
+ assert.equal(got.valid.length, 1)
77
+ assert.deepEqual(got.invalid, [])
78
+ const malformed = groundEvidence([{ session_id: '', quote: '完全没有出处的引用也可以' }], null)
79
+ assert.equal(malformed.valid.length, 0)
80
+ assert.match(malformed.invalid[0].reason, /missing session_id/)
81
+ })
82
+
83
+ test('buildEvidenceCorpus: ids and normalized transcripts; empty ids skipped', () => {
84
+ const c = buildEvidenceCorpus([
85
+ { id: 'a', transcript: '部署窗口!' },
86
+ { id: '', transcript: 'ignored' },
87
+ { id: 'b', text: 'second channel' },
88
+ ])
89
+ assert.deepEqual([...c.ids].sort(), ['a', 'b'])
90
+ assert.equal(c.text.get('a'), '部署窗口')
91
+ assert.equal(c.text.get('b'), 'secondchannel')
92
+ })
93
+
94
+ test('groundLinkNote: 8-500 chars and must contain one grounded quote verbatim', () => {
95
+ const evidence = [{ sessionId: 's1', quote: '周四凌晨两点到四点' }]
96
+ assert.equal(groundLinkNote('部署窗口与告警规则相关联', evidence).ok, false, 'note without the quote is rejected')
97
+ const ok = groundLinkNote('两条记忆都源于部署窗口的约定:周四凌晨两点到四点', evidence)
98
+ assert.equal(ok.ok, true, 'note quoting the evidence grounds')
99
+ assert.equal(groundLinkNote('太短了', evidence).ok, false)
100
+ assert.equal(groundLinkNote('x'.repeat(501), evidence).ok, false)
101
+ assert.equal(groundLinkNote('八到五百之间的正常说明文字', []).ok, false, 'no evidence, no note grounding')
102
+ })