@ilaomianyang/dsh-memory-graph 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/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.en.md +466 -0
- package/README.md +459 -0
- package/client.js +1953 -0
- package/cordis.patch.yml +13 -0
- package/docs/auto-settings.png +0 -0
- package/docs/distill-button.png +0 -0
- package/docs/graph-dark.png +0 -0
- package/docs/graph-light.png +0 -0
- package/docs/session-tab-cards.png +0 -0
- package/docs/session-tab-open.png +0 -0
- package/docs/session-tab.png +0 -0
- package/docs/vault-panel.png +0 -0
- package/eval/REPORT.md +437 -0
- package/eval/questions.json +277 -0
- package/eval/run.mjs +460 -0
- package/eval/seed.mjs +489 -0
- package/icon.svg +26 -0
- package/index.js +243 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +77 -0
- package/src/auto.js +194 -0
- package/src/capture.js +237 -0
- package/src/config.js +280 -0
- package/src/distillapi.js +30 -0
- package/src/distillrun.js +106 -0
- package/src/extract.js +660 -0
- package/src/graph.js +1582 -0
- package/src/graphdata.js +475 -0
- package/src/http.js +32 -0
- package/src/pageapi.js +40 -0
- package/src/privacy.js +78 -0
- package/src/schema.js +323 -0
- package/src/search.js +317 -0
- package/src/tools.js +534 -0
- package/src/vault.js +888 -0
- package/test/auto.mjs +295 -0
- package/test/capture.mjs +264 -0
- package/test/client.mjs +1035 -0
- package/test/distillapi.mjs +212 -0
- package/test/extract.mjs +660 -0
- package/test/graphdata.mjs +295 -0
- package/test/live-supersede.mjs +78 -0
- package/test/live.mjs +156 -0
- package/test/page.mjs +116 -0
- package/test/schema.mjs +256 -0
- package/test/smoke.mjs +428 -0
- package/test/tools.mjs +238 -0
package/test/auto.mjs
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The automatic distiller.
|
|
3
|
+
*
|
|
4
|
+
* Two triggers, one tick, and one rule that matters more than the features: a tick
|
|
5
|
+
* *decides*, it does not run. That is what makes this testable at all — the whole
|
|
6
|
+
* decision is a pure function of (config, clock, last attempt, pending episodes),
|
|
7
|
+
* so these checks never wait a minute and never spend a token.
|
|
8
|
+
*
|
|
9
|
+
* The state that survives a restart lives in the vault, so it is faked here the
|
|
10
|
+
* same way the real one behaves: read what was written.
|
|
11
|
+
*
|
|
12
|
+
* node test/auto.mjs
|
|
13
|
+
*/
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
|
|
16
|
+
import { autoDecision, createAutoDistiller, inQuietHours, registerAutoDistill } from '../src/auto.js'
|
|
17
|
+
import { parseQuietHours, resolveConfig } from '../src/config.js'
|
|
18
|
+
|
|
19
|
+
const results = []
|
|
20
|
+
function check(name, fn) {
|
|
21
|
+
return Promise.resolve()
|
|
22
|
+
.then(fn)
|
|
23
|
+
.then(
|
|
24
|
+
() => results.push(` ok ${name}`),
|
|
25
|
+
(error) => {
|
|
26
|
+
results.push(` FAIL ${name}: ${error.message}`)
|
|
27
|
+
process.exitCode = 1
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MINUTE = 60 * 1000
|
|
33
|
+
const AUTO = { enabled: true, minEpisodes: 10, everyMinutes: 0, minGapMinutes: 30, quietHours: '', project: '' }
|
|
34
|
+
|
|
35
|
+
/** A vault that only remembers what `writeAutoState` was told. */
|
|
36
|
+
const makeVault = ({ pending = 0, state = {}, failWrite = false } = {}) => {
|
|
37
|
+
const vault = {
|
|
38
|
+
written: state,
|
|
39
|
+
reads: 0,
|
|
40
|
+
async readUndistilled(limit) {
|
|
41
|
+
vault.reads += 1
|
|
42
|
+
vault.lastLimit = limit
|
|
43
|
+
return Array.from({ length: Math.min(pending, limit) }, (_, i) => ({ file: 'f.jsonl', line: i, record: {} }))
|
|
44
|
+
},
|
|
45
|
+
async readAutoState() {
|
|
46
|
+
return vault.written
|
|
47
|
+
},
|
|
48
|
+
async writeAutoState(patch) {
|
|
49
|
+
if (failWrite) throw new Error('disk full')
|
|
50
|
+
vault.written = { ...vault.written, ...patch }
|
|
51
|
+
return vault.written
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
return vault
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const makeDistiller = ({ auto = AUTO, pending = 0, state = {}, clock = { at: 1_000_000 }, run, failWrite } = {}) => {
|
|
58
|
+
const calls = []
|
|
59
|
+
const logs = []
|
|
60
|
+
const vault = makeVault({ pending, state, failWrite })
|
|
61
|
+
const distiller = createAutoDistiller({
|
|
62
|
+
cfg: { auto, extractor: { maxEpisodesPerRun: 40 } },
|
|
63
|
+
vault,
|
|
64
|
+
now: () => clock.at,
|
|
65
|
+
run: async (options) => {
|
|
66
|
+
calls.push(options)
|
|
67
|
+
return run ? run(options) : { ok: true, episodes: pending, written: 2, model: 'deepseek-chat' }
|
|
68
|
+
},
|
|
69
|
+
log: (level, message) => logs.push(`${level}: ${message}`),
|
|
70
|
+
})
|
|
71
|
+
return { distiller, vault, calls, logs, clock }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* --------------------------------------------------------------- the rules */
|
|
75
|
+
|
|
76
|
+
await check('off by default: a tick does nothing at all', async () => {
|
|
77
|
+
const { distiller, calls, vault } = makeDistiller({ auto: { ...AUTO, enabled: false }, pending: 100 })
|
|
78
|
+
const decision = await distiller.tick()
|
|
79
|
+
assert.equal(decision.due, false)
|
|
80
|
+
assert.equal(decision.reason, 'auto is off')
|
|
81
|
+
assert.equal(vault.reads, 0, 'an off switch does not even read the vault')
|
|
82
|
+
assert.equal(calls.length, 0)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
await check('the threshold rule fires only once enough episodes have piled up', () => {
|
|
86
|
+
const base = { auto: AUTO, now: 5_000_000, lastAttemptAt: null }
|
|
87
|
+
assert.equal(autoDecision({ ...base, pending: 9 }).due, false)
|
|
88
|
+
const hit = autoDecision({ ...base, pending: 10 })
|
|
89
|
+
assert.equal(hit.due, true)
|
|
90
|
+
assert.equal(hit.rule, 'threshold')
|
|
91
|
+
assert.equal(autoDecision({ ...base, pending: 0 }).reason, 'nothing to distill')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
await check('the schedule rule fires on elapsed time, and 0 disables it', () => {
|
|
95
|
+
const auto = { ...AUTO, minEpisodes: 0, everyMinutes: 60 }
|
|
96
|
+
const now = 10 * 60 * MINUTE
|
|
97
|
+
// No clock yet is never "overdue": the ticker starts it, so switching a daily
|
|
98
|
+
// schedule on cannot spend before the first interval has passed.
|
|
99
|
+
assert.equal(autoDecision({ auto, now, lastAttemptAt: null, pending: 1 }).due, false)
|
|
100
|
+
assert.equal(autoDecision({ auto, now, lastAttemptAt: now - 61 * MINUTE, pending: 1 }).due, true)
|
|
101
|
+
assert.equal(autoDecision({ auto, now, lastAttemptAt: now - 59 * MINUTE, pending: 1 }).due, false)
|
|
102
|
+
assert.equal(autoDecision({ auto: { ...auto, everyMinutes: 0 }, now, lastAttemptAt: now - 999 * MINUTE, pending: 1 }).due, false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
await check('a schedule with no clock starts one instead of spending on the spot', async () => {
|
|
106
|
+
const { distiller, calls, vault, clock } = makeDistiller({ auto: { ...AUTO, minEpisodes: 0, everyMinutes: 1440 }, pending: 5 })
|
|
107
|
+
const first = await distiller.tick()
|
|
108
|
+
assert.equal(first.due, false, 'switching a daily schedule on does not run immediately')
|
|
109
|
+
assert.equal(first.reason, 'nothing is due yet')
|
|
110
|
+
assert.equal(calls.length, 0)
|
|
111
|
+
assert.equal(vault.written.startedAt, clock.at, 'the clock starts now')
|
|
112
|
+
assert.equal(vault.written.lastAttemptAt, undefined, 'and it is not an attempt: the gap must not be armed by it')
|
|
113
|
+
clock.at += 24 * 60 * MINUTE
|
|
114
|
+
const next = await distiller.tick()
|
|
115
|
+
assert.equal(next.due, true)
|
|
116
|
+
assert.equal(next.rule, 'schedule')
|
|
117
|
+
assert.equal(calls.length, 1, 'and a day later it runs')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await check('a threshold with no clock still fires at once: that rule is the pile, not the clock', async () => {
|
|
121
|
+
const { distiller, calls } = makeDistiller({ auto: { ...AUTO, minEpisodes: 3, everyMinutes: 1440 }, pending: 5 })
|
|
122
|
+
const decision = await distiller.tick()
|
|
123
|
+
assert.equal(decision.ran, true)
|
|
124
|
+
assert.equal(decision.rule, 'threshold')
|
|
125
|
+
assert.equal(calls.length, 1)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
await check('both rules can be on: whichever is satisfied first runs', () => {
|
|
129
|
+
const auto = { ...AUTO, minEpisodes: 10, everyMinutes: 60 }
|
|
130
|
+
const now = 100 * MINUTE
|
|
131
|
+
// Past the gap, inside the schedule window: only the pile of episodes can fire it.
|
|
132
|
+
assert.equal(autoDecision({ auto, now, lastAttemptAt: now - 31 * MINUTE, pending: 40 }).rule, 'threshold')
|
|
133
|
+
assert.equal(autoDecision({ auto, now, lastAttemptAt: now - 61 * MINUTE, pending: 1 }).rule, 'schedule')
|
|
134
|
+
assert.equal(autoDecision({ auto, now, lastAttemptAt: now - 31 * MINUTE, pending: 1 }).due, false)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
await check('the minimum gap holds whichever rule fired', () => {
|
|
138
|
+
const auto = { ...AUTO, minEpisodes: 1, everyMinutes: 1, minGapMinutes: 30 }
|
|
139
|
+
const now = 100 * MINUTE
|
|
140
|
+
assert.equal(autoDecision({ auto, now, lastAttemptAt: now - 10 * MINUTE, pending: 99 }).due, false)
|
|
141
|
+
assert.equal(autoDecision({ auto, now, lastAttemptAt: now - 31 * MINUTE, pending: 99 }).due, true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
await check('quiet hours save the run for later, including a window past midnight', () => {
|
|
145
|
+
const at = (hour, minute = 0) => new Date(2026, 10, 3, hour, minute).getTime()
|
|
146
|
+
const auto = { ...AUTO, quietHours: '23:00-08:00' }
|
|
147
|
+
assert.equal(inQuietHours('23:00-08:00', at(23, 30)), true)
|
|
148
|
+
assert.equal(inQuietHours('23:00-08:00', at(2)), true)
|
|
149
|
+
assert.equal(inQuietHours('23:00-08:00', at(8)), false)
|
|
150
|
+
assert.equal(inQuietHours('02:00-03:00', at(2, 30)), true)
|
|
151
|
+
assert.equal(inQuietHours('02:00-03:00', at(4)), false)
|
|
152
|
+
assert.equal(inQuietHours('', at(3)), false, 'empty means always')
|
|
153
|
+
assert.equal(inQuietHours('nonsense', at(3)), false, 'unreadable text must not silence it forever')
|
|
154
|
+
assert.equal(autoDecision({ auto, now: at(23, 30), lastAttemptAt: null, pending: 50 }).reason, 'quiet hours')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
/* ------------------------------------------------------------- the ticker */
|
|
158
|
+
|
|
159
|
+
await check('a tick that decides yes runs exactly one distillation and records the attempt', async () => {
|
|
160
|
+
const { distiller, calls, vault, logs } = makeDistiller({ pending: 12 })
|
|
161
|
+
const decision = await distiller.tick()
|
|
162
|
+
assert.equal(decision.due, true)
|
|
163
|
+
assert.equal(decision.ran, true)
|
|
164
|
+
assert.equal(calls.length, 1, 'one tick, one run')
|
|
165
|
+
assert.equal(typeof vault.written.lastAttemptAt, 'number', 'the gap clock is persisted, so a restart cannot lose it')
|
|
166
|
+
assert.ok(logs.some((line) => line.includes('read 12 episode(s) → wrote 2 card(s)')), logs.join('\n'))
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
await check('the same tick right after does not run again: the gap is armed before the call', async () => {
|
|
170
|
+
const { distiller, calls, clock } = makeDistiller({ pending: 12 })
|
|
171
|
+
await distiller.tick()
|
|
172
|
+
clock.at += 5 * MINUTE
|
|
173
|
+
const again = await distiller.tick()
|
|
174
|
+
assert.equal(again.due, false)
|
|
175
|
+
assert.equal(again.reason, 'the gap between runs has not elapsed')
|
|
176
|
+
assert.equal(calls.length, 1)
|
|
177
|
+
clock.at += 30 * MINUTE
|
|
178
|
+
assert.equal((await distiller.tick()).due, true, 'and it comes back after the gap')
|
|
179
|
+
assert.equal(calls.length, 2)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
await check('nor after a restart, which is what the vault state file is for', async () => {
|
|
183
|
+
const first = makeDistiller({ pending: 12 })
|
|
184
|
+
await first.distiller.tick()
|
|
185
|
+
const written = first.vault.written
|
|
186
|
+
const second = makeDistiller({ pending: 12, state: written, clock: { at: 1_000_000 + 6 * MINUTE } })
|
|
187
|
+
const decision = await second.distiller.tick()
|
|
188
|
+
assert.equal(decision.due, false, 'a fresh process reads the attempt clock instead of starting over')
|
|
189
|
+
assert.equal(second.calls.length, 0)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
await check('a run that produced nothing still arms the gap, so a quiet vault is not re-asked', async () => {
|
|
193
|
+
const { distiller, calls, clock } = makeDistiller({
|
|
194
|
+
pending: 12,
|
|
195
|
+
run: async () => ({ ok: true, episodes: 12, written: 0, reason: 'nothing worth keeping' }),
|
|
196
|
+
})
|
|
197
|
+
await distiller.tick()
|
|
198
|
+
clock.at += 2 * MINUTE
|
|
199
|
+
assert.equal((await distiller.tick()).due, false)
|
|
200
|
+
assert.equal(calls.length, 1)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
await check('an automatic run never overlaps one already in flight', async () => {
|
|
204
|
+
const { distiller, calls, logs } = makeDistiller({
|
|
205
|
+
pending: 12,
|
|
206
|
+
run: async () => ({ ok: false, busy: true, reason: 'A distillation is already running.' }),
|
|
207
|
+
})
|
|
208
|
+
const decision = await distiller.tick()
|
|
209
|
+
assert.equal(decision.ran, true)
|
|
210
|
+
assert.equal(calls.length, 1)
|
|
211
|
+
assert.ok(logs.some((line) => line.includes('already running')), logs.join('\n'))
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
await check('a failed run is logged with the reason, and the gap still holds', async () => {
|
|
215
|
+
const { distiller, calls, clock, logs } = makeDistiller({
|
|
216
|
+
pending: 12,
|
|
217
|
+
run: async () => ({ ok: false, reason: 'Extractor is disabled. Set config extractor.enabled = true.' }),
|
|
218
|
+
})
|
|
219
|
+
await distiller.tick()
|
|
220
|
+
assert.ok(logs.some((line) => line.includes('Extractor is disabled')), logs.join('\n'))
|
|
221
|
+
clock.at += 5 * MINUTE
|
|
222
|
+
await distiller.tick()
|
|
223
|
+
assert.equal(calls.length, 1, 'a refusal is not retried on the next tick')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await check('the configured project reaches the run', async () => {
|
|
227
|
+
const { distiller, calls } = makeDistiller({ auto: { ...AUTO, project: 'Article2' }, pending: 12 })
|
|
228
|
+
await distiller.tick()
|
|
229
|
+
assert.equal(calls[0].project, 'Article2', 'automatic cards land in the project the user named')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
await check('an unreadable vault is a logged skip, not a crashed ticker', async () => {
|
|
233
|
+
const { distiller, logs } = makeDistiller({ pending: 12 })
|
|
234
|
+
distiller.state()
|
|
235
|
+
const broken = createAutoDistiller({
|
|
236
|
+
cfg: { auto: AUTO, extractor: { maxEpisodesPerRun: 40 } },
|
|
237
|
+
vault: {
|
|
238
|
+
async readUndistilled() {
|
|
239
|
+
throw new Error('EACCES')
|
|
240
|
+
},
|
|
241
|
+
async readAutoState() {
|
|
242
|
+
return {}
|
|
243
|
+
},
|
|
244
|
+
async writeAutoState() {},
|
|
245
|
+
},
|
|
246
|
+
run: async () => {
|
|
247
|
+
throw new Error('should not run')
|
|
248
|
+
},
|
|
249
|
+
log: (level, message) => logs.push(`${level}: ${message}`),
|
|
250
|
+
})
|
|
251
|
+
const decision = await broken.tick()
|
|
252
|
+
assert.equal(decision.ran, false)
|
|
253
|
+
assert.ok(logs.some((line) => line.includes('EACCES')), logs.join('\n'))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
/* ------------------------------------------------------------- the wiring */
|
|
257
|
+
|
|
258
|
+
await check('the ticker asks for enough episodes to see the threshold', async () => {
|
|
259
|
+
const { distiller, vault } = makeDistiller({ auto: { ...AUTO, minEpisodes: 200 }, pending: 250 })
|
|
260
|
+
await distiller.tick()
|
|
261
|
+
assert.ok(vault.lastLimit >= 200, `asked for ${vault.lastLimit}, which would hide the threshold`)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
await check('registering the ticker returns a stop that clears both timers', () => {
|
|
265
|
+
let ticks = 0
|
|
266
|
+
const stop = registerAutoDistill({
|
|
267
|
+
ctx: {},
|
|
268
|
+
distiller: {
|
|
269
|
+
async tick() {
|
|
270
|
+
ticks += 1
|
|
271
|
+
return { due: false }
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
intervalMs: 5,
|
|
275
|
+
})
|
|
276
|
+
assert.equal(typeof stop, 'function')
|
|
277
|
+
stop()
|
|
278
|
+
assert.equal(typeof stop, 'function', 'stopping twice must not throw')
|
|
279
|
+
stop()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
await check('a malformed quiet window is dropped instead of silencing the feature', () => {
|
|
283
|
+
assert.equal(parseQuietHours(''), null)
|
|
284
|
+
assert.equal(parseQuietHours('25:00-08:00'), null)
|
|
285
|
+
assert.equal(parseQuietHours('later'), null)
|
|
286
|
+
const cfg = resolveConfig({})
|
|
287
|
+
assert.equal(cfg.auto.enabled, false, 'automatic distillation is off unless someone asks for it')
|
|
288
|
+
assert.equal(cfg.auto.quietHours, '')
|
|
289
|
+
const weird = resolveConfig({ auto: { quietHours: 'nonsense', minEpisodes: '12' } })
|
|
290
|
+
assert.equal(weird.auto.quietHours, '', 'the broken window is dropped')
|
|
291
|
+
assert.equal(weird.auto.minEpisodes, 12, 'a numeric string is still a number')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
console.log(results.join('\n'))
|
|
295
|
+
console.log(process.exitCode === 1 ? '\nSOME CHECKS FAILED' : `\nall ${results.length} checks passed`)
|
package/test/capture.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture test: drives the session/event listener with the real event shapes
|
|
3
|
+
* and checks what lands in the vault. node test/capture.mjs
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import fsp from 'node:fs/promises'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
|
|
10
|
+
import { resolveConfig } from '../src/config.js'
|
|
11
|
+
import { createRedactor } from '../src/privacy.js'
|
|
12
|
+
import { createVault } from '../src/vault.js'
|
|
13
|
+
import { registerCapture } from '../src/capture.js'
|
|
14
|
+
|
|
15
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-cap-'))
|
|
16
|
+
const cfg = resolveConfig({ vaultDir: dir })
|
|
17
|
+
const redactor = createRedactor(cfg.privacy)
|
|
18
|
+
const vault = createVault(cfg, redactor)
|
|
19
|
+
|
|
20
|
+
/** Minimal stand-in for the Cordis context. */
|
|
21
|
+
const handlers = new Map()
|
|
22
|
+
const ctx = {
|
|
23
|
+
on(name, fn) {
|
|
24
|
+
if (!handlers.has(name)) handlers.set(name, [])
|
|
25
|
+
handlers.get(name).push(fn)
|
|
26
|
+
return () => {
|
|
27
|
+
const list = handlers.get(name) ?? []
|
|
28
|
+
const at = list.indexOf(fn)
|
|
29
|
+
if (at !== -1) list.splice(at, 1)
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
logger: { info() {}, warn() {} },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function emit(name, ...args) {
|
|
36
|
+
for (const fn of handlers.get(name) ?? []) fn(...args)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const results = []
|
|
40
|
+
function check(name, fn) {
|
|
41
|
+
try {
|
|
42
|
+
fn()
|
|
43
|
+
results.push(` ok ${name}`)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
results.push(` FAIL ${name}: ${error.message}`)
|
|
46
|
+
process.exitCode = 1
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const dispose = registerCapture(ctx, { cfg, vault, redactor })
|
|
51
|
+
const session = { id: 's-abc', header: { cwd: 'G:\\TempProject\\测试\\memory-graph-plugin' } }
|
|
52
|
+
|
|
53
|
+
/* A turn exactly as the harness records it. */
|
|
54
|
+
emit('session/event', session, {
|
|
55
|
+
type: 'user/message',
|
|
56
|
+
seq: 1,
|
|
57
|
+
time: Date.now(),
|
|
58
|
+
data: {
|
|
59
|
+
role: 'user',
|
|
60
|
+
content: [{ type: 'text', text: '把 vault 改成纯文本,顺便 key 是 sk-abcdefghijklmnopqrstuvwxyz012345' }],
|
|
61
|
+
source: { kind: 'user', rpcId: 'r-1', clientTimeZone: 'Asia/Shanghai' },
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
emit('session/event', session, {
|
|
65
|
+
type: 'assistant/message',
|
|
66
|
+
seq: 2,
|
|
67
|
+
time: Date.now(),
|
|
68
|
+
data: {
|
|
69
|
+
turn: 7,
|
|
70
|
+
step: 1,
|
|
71
|
+
message: { role: 'assistant', content: [{ type: 'text', text: '改成纯文本了,因为没有二进制合并问题。' }] },
|
|
72
|
+
stream: null,
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
emit('session/event', session, { type: 'tool/result', seq: 3, time: Date.now(), data: { turn: 7, step: 1, message: {} } })
|
|
76
|
+
emit('session/event', session, { type: 'turn/end', seq: 4, time: Date.now(), data: { turn: 7, reason: { kind: 'completed' } } })
|
|
77
|
+
|
|
78
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
79
|
+
|
|
80
|
+
const files = await vault.episodeFiles()
|
|
81
|
+
check('a closed turn becomes one episode file', () => {
|
|
82
|
+
assert.equal(files.length, 1, files.join(','))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const raw = await fsp.readFile(path.join(dir, 'episodes', files[0]), 'utf8')
|
|
86
|
+
const lines = raw.split('\n').filter((line) => line.trim() !== '')
|
|
87
|
+
check('one line per turn', () => {
|
|
88
|
+
assert.equal(lines.length, 1, raw)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const record = JSON.parse(lines[0])
|
|
92
|
+
check('the episode carries project, turn and both sides', () => {
|
|
93
|
+
assert.equal(record.project, 'memory-graph-plugin')
|
|
94
|
+
assert.equal(record.turn, 7)
|
|
95
|
+
assert.ok(record.user.includes('纯文本'), record.user)
|
|
96
|
+
assert.ok(record.assistant.includes('二进制合并'), record.assistant)
|
|
97
|
+
})
|
|
98
|
+
check('a secret in the user text never reaches the file', () => {
|
|
99
|
+
assert.equal(raw.includes('sk-abcdefghij'), false, raw)
|
|
100
|
+
assert.ok(record.user.includes('[REDACTED]'), record.user)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/* A second turn, flushed by the next user message rather than turn/end. */
|
|
104
|
+
emit('session/event', session, {
|
|
105
|
+
type: 'user/message',
|
|
106
|
+
seq: 5,
|
|
107
|
+
time: Date.now(),
|
|
108
|
+
data: { role: 'user', content: [{ type: 'text', text: '第二个问题:实体页要不要自动合并别名?' }] },
|
|
109
|
+
})
|
|
110
|
+
emit('session/event', session, {
|
|
111
|
+
type: 'assistant/message',
|
|
112
|
+
seq: 6,
|
|
113
|
+
time: Date.now(),
|
|
114
|
+
data: { turn: 8, step: 1, message: { role: 'assistant', content: [{ type: 'text', text: '先按 slug 归一化,别名靠人工。' }] }, stream: null },
|
|
115
|
+
})
|
|
116
|
+
emit('session/event', session, { type: 'turn/end', seq: 7, time: Date.now(), data: { turn: 8, reason: { kind: 'completed' } } })
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
118
|
+
|
|
119
|
+
const after = (await fsp.readFile(path.join(dir, 'episodes', files[0]), 'utf8')).split('\n').filter((l) => l.trim() !== '')
|
|
120
|
+
check('the second turn appends instead of rewriting', () => {
|
|
121
|
+
assert.equal(after.length, 2, String(after.length))
|
|
122
|
+
assert.ok(after[1].includes('实体页'), after[1])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/* Trivial turns are dropped, so the vault does not fill with noise. */
|
|
126
|
+
emit('session/event', session, {
|
|
127
|
+
type: 'user/message',
|
|
128
|
+
seq: 8,
|
|
129
|
+
time: Date.now(),
|
|
130
|
+
data: { role: 'user', content: [{ type: 'text', text: 'ok' }] },
|
|
131
|
+
})
|
|
132
|
+
emit('session/event', session, { type: 'turn/end', seq: 9, time: Date.now(), data: { turn: 9, reason: { kind: 'completed' } } })
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
134
|
+
|
|
135
|
+
const finalLines = (await fsp.readFile(path.join(dir, 'episodes', files[0]), 'utf8')).split('\n').filter((l) => l.trim() !== '')
|
|
136
|
+
check('a one-word turn is not stored', () => {
|
|
137
|
+
assert.equal(finalLines.length, 2, String(finalLines.length))
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
/* An unknown session with no cwd still gets a sensible project tag. */
|
|
141
|
+
emit('session/event', { id: 's-nocwd' }, {
|
|
142
|
+
type: 'user/message',
|
|
143
|
+
seq: 1,
|
|
144
|
+
time: Date.now(),
|
|
145
|
+
data: { role: 'user', content: [{ type: 'text', text: '这个会话没有工作目录,也应该能记下来。' }] },
|
|
146
|
+
})
|
|
147
|
+
emit('session/event', { id: 's-nocwd' }, { type: 'turn/end', seq: 2, time: Date.now(), data: { turn: 1, reason: { kind: 'completed' } } })
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
149
|
+
|
|
150
|
+
const allLines = (await fsp.readFile(path.join(dir, 'episodes', files[0]), 'utf8')).split('\n').filter((l) => l.trim() !== '')
|
|
151
|
+
check('a session without cwd falls back to the default project', () => {
|
|
152
|
+
assert.equal(allLines.length, 3, String(allLines.length))
|
|
153
|
+
assert.equal(JSON.parse(allLines[2]).project, cfg.defaultProject)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
/* Injected context — a compaction checkpoint, a runtime snapshot, a time
|
|
157
|
+
prelude, a skill catalog, an approval notice — arrives as a user message but
|
|
158
|
+
is not a conversation. `source.kind` marks it; the last entry has no
|
|
159
|
+
provenance at all, to exercise the text-pattern fallback. */
|
|
160
|
+
const injections = [
|
|
161
|
+
{ source: { kind: 'compact-checkpoint', form: 'checkpoint' }, text: 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background.' },
|
|
162
|
+
{ source: { kind: 'runtime-context', form: 'snapshot' }, text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.' },
|
|
163
|
+
{ source: { kind: 'time-context', form: 'sampled' }, text: 'Time sampled while preparing turn 3, step 1: 2026-11-02T15:37:18+08:00[Asia/Shanghai]' },
|
|
164
|
+
{ source: { kind: 'skill-catalog', form: 'catalog' }, text: '<system-reminder>\nThe available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session.\n</system-reminder>' },
|
|
165
|
+
{ source: { kind: 'user-approval' }, text: 'The approval policy changed from "ask" to "never" (changed by the user).' },
|
|
166
|
+
{ text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.' },
|
|
167
|
+
// The live bug: a runtime snapshot arriving tagged as if the person typed it.
|
|
168
|
+
{ source: { kind: 'user' }, text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.' },
|
|
169
|
+
]
|
|
170
|
+
for (const [i, { source, text }] of injections.entries()) {
|
|
171
|
+
emit('session/event', session, {
|
|
172
|
+
type: 'user/message',
|
|
173
|
+
seq: 20 + i,
|
|
174
|
+
time: Date.now(),
|
|
175
|
+
data: { role: 'user', content: [{ type: 'text', text }], ...(source ? { source } : {}) },
|
|
176
|
+
})
|
|
177
|
+
emit('session/event', session, {
|
|
178
|
+
type: 'assistant/message',
|
|
179
|
+
seq: 40 + i,
|
|
180
|
+
time: Date.now(),
|
|
181
|
+
data: { turn: 20 + i, step: 1, message: { role: 'assistant', content: [{ type: 'text', text: '收到,继续原来的工作。' }] }, stream: null },
|
|
182
|
+
})
|
|
183
|
+
emit('session/event', session, { type: 'turn/end', seq: 60 + i, time: Date.now(), data: { turn: 20 + i, reason: { kind: 'completed' } } })
|
|
184
|
+
}
|
|
185
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
186
|
+
|
|
187
|
+
const readLines = async () =>
|
|
188
|
+
(await fsp.readFile(path.join(dir, 'episodes', files[0]), 'utf8')).split('\n').filter((l) => l.trim() !== '')
|
|
189
|
+
|
|
190
|
+
const afterInjections = await readLines()
|
|
191
|
+
check('injected context is never stored as a memory', () => {
|
|
192
|
+
assert.equal(afterInjections.length, 3, String(afterInjections.length))
|
|
193
|
+
assert.equal(afterInjections.some((l) => l.includes('automatically generated checkpoint')), false)
|
|
194
|
+
assert.equal(afterInjections.some((l) => l.includes('system-reminder')), false)
|
|
195
|
+
assert.equal(afterInjections.some((l) => l.includes('Time sampled')), false)
|
|
196
|
+
assert.equal(afterInjections.some((l) => l.includes('approval policy')), false)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
/* The filter must not swallow the real turn that follows an injection. */
|
|
200
|
+
emit('session/event', session, {
|
|
201
|
+
type: 'user/message',
|
|
202
|
+
seq: 80,
|
|
203
|
+
time: Date.now(),
|
|
204
|
+
data: { role: 'user', content: [{ type: 'text', text: '压缩之后继续:请检查一下 capture 的 flush 时机。' }], source: { kind: 'user', rpcId: 'r-2' } },
|
|
205
|
+
})
|
|
206
|
+
emit('session/event', session, {
|
|
207
|
+
type: 'assistant/message',
|
|
208
|
+
seq: 81,
|
|
209
|
+
time: Date.now(),
|
|
210
|
+
data: { turn: 30, step: 1, message: { role: 'assistant', content: [{ type: 'text', text: 'flush 现在挂在 turn/end 上。' }] }, stream: null },
|
|
211
|
+
})
|
|
212
|
+
emit('session/event', session, { type: 'turn/end', seq: 82, time: Date.now(), data: { turn: 30, reason: { kind: 'completed' } } })
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
214
|
+
|
|
215
|
+
const afterReal = await readLines()
|
|
216
|
+
check('a real turn after an injection is still stored', () => {
|
|
217
|
+
assert.equal(afterReal.length, 4, String(afterReal.length))
|
|
218
|
+
assert.ok(afterReal[3].includes('flush'), afterReal[3])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
check('an injected turn is dropped even when the model replied to it', () => {
|
|
222
|
+
assert.equal(afterReal.some((l) => l.includes('收到,继续原来的工作')), false, afterReal.join('\n'))
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
/* A real message that carries a reminder keeps its own words: the block is
|
|
226
|
+
stripped, the message is not thrown away with it. */
|
|
227
|
+
emit('session/event', session, {
|
|
228
|
+
type: 'user/message',
|
|
229
|
+
seq: 90,
|
|
230
|
+
time: Date.now(),
|
|
231
|
+
data: {
|
|
232
|
+
role: 'user',
|
|
233
|
+
content: [{ type: 'text', text: '<system-reminder>\nThe available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session.\n</system-reminder>\n\n为什么以前的会话有些没有情节' }],
|
|
234
|
+
source: { kind: 'user' },
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
emit('session/event', session, {
|
|
238
|
+
type: 'assistant/message',
|
|
239
|
+
seq: 91,
|
|
240
|
+
time: Date.now(),
|
|
241
|
+
data: { turn: 31, step: 1, message: { role: 'assistant', content: [{ type: 'text', text: '因为采集是从插件在场那一刻开始的。' }] }, stream: null },
|
|
242
|
+
})
|
|
243
|
+
emit('session/event', session, { type: 'turn/end', seq: 92, time: Date.now(), data: { turn: 31, reason: { kind: 'completed' } } })
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
245
|
+
|
|
246
|
+
const afterReminder = await readLines()
|
|
247
|
+
check('a reminder inside a real message is stripped, not the message', () => {
|
|
248
|
+
assert.equal(afterReminder.length, 5, String(afterReminder.length))
|
|
249
|
+
const last = JSON.parse(afterReminder[4])
|
|
250
|
+
assert.equal(last.user, '为什么以前的会话有些没有情节')
|
|
251
|
+
assert.equal(afterReminder[4].includes('skill catalog'), false)
|
|
252
|
+
})
|
|
253
|
+
check('disposing removes the listeners', () => {
|
|
254
|
+
for (const item of dispose) item()
|
|
255
|
+
assert.equal((handlers.get('session/event') ?? []).length, 0)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
console.log(results.join('\n'))
|
|
259
|
+
if (process.exitCode) {
|
|
260
|
+
console.log('\nSOME CHECKS FAILED')
|
|
261
|
+
} else {
|
|
262
|
+
console.log(`\nall ${results.length} checks passed`)
|
|
263
|
+
await fsp.rm(dir, { recursive: true, force: true })
|
|
264
|
+
}
|