dsh-memory-dba 0.1.4 → 0.3.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/src/index.mjs CHANGED
@@ -6,17 +6,26 @@
6
6
  * stream.md a timeline, retrieved only
7
7
  * ws/*.md detail notes, retrieved only
8
8
  *
9
- * The plugin owns injection and turn recording; retrieval is BM25 over all three
10
- * layers, so the package has no runtime outside Node itself.
9
+ * Placement decides whether the prompt cache survives. `core.md` goes in as a
10
+ * system-prompt section, which is the stable head every request shares. Retrieval
11
+ * hits change on every user turn, so they are contributed as runtime context
12
+ * instead: the harness materializes that as a trailing user snapshot, leaving every
13
+ * earlier message byte-identical. The same text returned from a system-prompt
14
+ * section would land near the head and invalidate the whole conversation after it.
15
+ *
16
+ * The plugin owns injection and turn recording; retrieval is BM25 over the
17
+ * stream and workspace names, so the package has no runtime outside Node itself.
11
18
  *
12
19
  * Failure policy: memory is an enhancement. A broken layer, a missing directory,
13
20
  * or a corrupt index degrades to an empty section and never fails the turn.
14
21
  */
15
22
 
23
+ import { join } from 'node:path'
16
24
  import { buildCore, buildHits } from './assemble.mjs'
17
25
  import { fitHits, loadCorpus, search } from './search.mjs'
18
26
  import { bytes, ensureLayout, fingerprint, resolveHome } from './store.mjs'
19
- import { appendTurn } from './stream.mjs'
27
+ import { publishEvent } from './stream.mjs'
28
+ import { completeEvent, createPendingEvent, interruptEvent, listOutbox, readEvent, updateEventAssistant } from './events.mjs'
20
29
  import { queryTerms, tokenize } from './tokenize.mjs'
21
30
  import { embed } from './embed.mjs'
22
31
  import { docKey, semanticRecall } from './vector.mjs'
@@ -37,9 +46,39 @@ export const defaults = {
37
46
  writeStream: true,
38
47
  tag: '',
39
48
  exclude: [],
49
+ /**
50
+ * Full text of the level-1 workspace hint. Empty means derive the command from
51
+ * `home` (`<home>/../scripts/nb_search.py`); set it for non-Windows installs or
52
+ * a different Python launcher.
53
+ */
54
+ detailHint: '',
55
+ /**
56
+ * Text contributed when level-1 recall has no hits. Empty derives a sentence
57
+ * naming the Python index; `'off'` (or an explicit `false`) disables it.
58
+ */
59
+ coldHint: '',
60
+ /**
61
+ * `session` snapshots core.md on first assembly per session (the stable head
62
+ * must not change mid-session); `always` re-reads every assembly for edits
63
+ * that must apply immediately.
64
+ */
65
+ coreRefresh: 'session',
40
66
  coreOrder: -50,
41
- hitsOrder: 200,
42
- vector: { enabled: true, url: 'http://127.0.0.1:11434', model: 'bge-m3', timeoutMs: 1500, minScore: 0.45, keepAlive: '30m' },
67
+ /**
68
+ * Order of the retrieval-hits runtime context. Above the harness's own contexts
69
+ * (repository 100–200, web surface 210) so memory stays the last thing the model
70
+ * reads in the snapshot.
71
+ */
72
+ hitsOrder: 300,
73
+ /**
74
+ * Semantic recall is off by default. Level 1 is already narrow: core is
75
+ * injected in full, workspace documents are names only, and stream entries
76
+ * are short lines. The synchronous 1500 ms worker/GPU cost bought a measured
77
+ * 0/143 non-core coverage (core 67/74, stream 0/124, workspace 0/19), while
78
+ * core no longer belongs in the recall corpus. Use the Python CLI for semantic
79
+ * search over full text; enable this only on a machine with a warm bge-m3.
80
+ */
81
+ vector: { enabled: false, url: 'http://127.0.0.1:11434', model: 'bge-m3', timeoutMs: 1500, minScore: 0.45, keepAlive: '30m' },
43
82
  }
44
83
 
45
84
  /** Text of one message's content blocks, ignoring tool calls and images. */
@@ -85,8 +124,13 @@ export function normalizeConfig(config = {}) {
85
124
  writeStream: cfg.writeStream !== false,
86
125
  tag: typeof cfg.tag === 'string' ? cfg.tag : '',
87
126
  exclude: Array.isArray(cfg.exclude) ? cfg.exclude.filter(name => typeof name === 'string') : [],
127
+ detailHint: typeof cfg.detailHint === 'string' ? cfg.detailHint : '',
128
+ coldHint: typeof cfg.coldHint === 'string' ? cfg.coldHint : '',
129
+ coreRefresh: cfg.coreRefresh === 'always' ? 'always' : 'session',
88
130
  vector: {
89
- enabled: vector.enabled !== false,
131
+ // Explicit opt-in only: the default is false so a missing/partial vector
132
+ // config cannot silently re-enable the synchronous worker path.
133
+ enabled: vector.enabled === true,
90
134
  url,
91
135
  model: typeof vector.model === 'string' && vector.model.trim() ? vector.model : defaults.vector.model,
92
136
  timeoutMs: Number.isFinite(timeoutMs) && timeoutMs >= 1 ? Math.floor(timeoutMs) : defaults.vector.timeoutMs,
@@ -114,10 +158,36 @@ export function recall(docs, query, options = {}) {
114
158
  /**
115
159
  * @param {import('@deepseek-ai/cordis').Context} ctx
116
160
  * @param {Partial<typeof defaults>} config
161
+ * @param {{
162
+ * buildCore?: typeof buildCore,
163
+ * vector?: { embed?: typeof embed, semanticRecall?: typeof semanticRecall },
164
+ * }} [runtime] Test seam; production callers omit it. Lets tests prove that the
165
+ * default config never calls the vector runtime and that core reads are
166
+ * per-session.
117
167
  */
118
- export function apply(ctx, config = {}) {
168
+ export function apply(ctx, config = {}, runtime = {}) {
119
169
  const cfg = normalizeConfig(config)
120
170
  const home = resolveHome(cfg.home)
171
+ const coreBuilder = runtime.buildCore || buildCore
172
+ const vectorRuntime = { embed, semanticRecall, ...(runtime.vector || {}) }
173
+ // Derived once per mount: the hint names the Python DBA script that performs
174
+ // the level-2 read. `home` is the memory directory, so the script lives in its
175
+ // sibling `scripts/`; an explicit `detailHint` replaces the whole line for
176
+ // installs that use another launcher or a different tree layout.
177
+ const scriptPath = join(home, '..', 'scripts', 'nb_search.py')
178
+ const detailHint = cfg.detailHint || `工作区册只给文件名;详情用:py -3.14 ${scriptPath} "查询" --file <区名>`
179
+ // Cold hint follows detailHint's convention (empty derives, a string wins),
180
+ // but an explicitly supplied empty value turns it off. `Object.hasOwn` is what
181
+ // distinguishes "not configured" from "configured empty" — normalizeConfig
182
+ // cannot, because its default is also ''.
183
+ const defaultColdHint = `本地召回本轮无命中;全量历史(含更早的 stream 记录)用 py -3.14 ${scriptPath} "查询" 查(工作区册正文用 --file <区名>)。`
184
+ const coldHint = (() => {
185
+ if (!Object.hasOwn(config || {}, 'coldHint')) return defaultColdHint
186
+ const raw = config.coldHint
187
+ if (raw === '' || raw === false || raw === null) return ''
188
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return ''
189
+ return typeof raw === 'string' && raw.trim() ? raw : defaultColdHint
190
+ })()
121
191
 
122
192
  try {
123
193
  ensureLayout(home)
@@ -127,20 +197,37 @@ export function apply(ctx, config = {}) {
127
197
  }
128
198
 
129
199
  // A cold Ollama takes longer than the synchronous recall budget, so warm it in the
130
- // background; the first turn would otherwise lose semantic recall.
200
+ // background; the first turn would otherwise lose semantic recall. With the
201
+ // default `vector.enabled: false` this branch never runs, so no fetch/worker.
131
202
  if (cfg.vector.enabled) {
132
- embed('warmup', { url: cfg.vector.url, model: cfg.vector.model, timeoutMs: 8000, keepAlive: cfg.vector.keepAlive })
203
+ vectorRuntime.embed('warmup', { url: cfg.vector.url, model: cfg.vector.model, timeoutMs: 8000, keepAlive: cfg.vector.keepAlive })
133
204
  .catch(() => {})
134
205
  }
135
206
 
136
207
  let corpus = null
137
208
  let corpusKey = ''
138
- // The assembly context carries only a scope key, not a session id, so recall text
139
- // is single-slot: the most recent user turn wins. Concurrent sessions (subagents)
140
- // therefore render the last query, not their own.
209
+ /** One slot for contexts without a session id (diagnostics/tests/subagents). */
141
210
  let latestHits = ''
211
+ /** Per-session recall text; a shared slot would leak one session's hits into another. */
212
+ const hitsBySession = new Map()
213
+ /**
214
+ * Core snapshots keyed by session id. `{text, bytes, clipped}` is already the
215
+ * clipped section, so the model sees the same head for the whole session.
216
+ * Snapshots live for the session lifetime and are released on session/disposed;
217
+ * retaining every active session is required to keep its prompt head stable.
218
+ */
219
+ const coreSnapshots = new Map()
220
+ /** Last read for the no-session-id fallback; never used to skip a read. */
221
+ let fallbackCore = null
142
222
  const pending = new Map()
143
223
 
224
+ // Repair complete archives whose stream publication was interrupted. Scanning
225
+ // event files here is recovery only; recall indexing remains stream-only.
226
+ if (cfg.writeStream) {
227
+ try { for (const id of listOutbox(home)) { try { const event = readEvent(home, id); if (event?.status === 'complete') publishEvent(home, id) } catch (error) { console.error(`[memory-dba] event recovery skipped ${id}: ${error.message}`) } } }
228
+ catch (error) { console.error(`[memory-dba] event recovery failed: ${error.message}`) }
229
+ }
230
+
144
231
  const docs = () => {
145
232
  const key = fingerprint(home)
146
233
  if (corpus && key === corpusKey) return corpus
@@ -153,7 +240,7 @@ export function apply(ctx, config = {}) {
153
240
  if (!query) return { hits: [], applied: false, clipped: false }
154
241
  const corpus = docs()
155
242
  const semantic = cfg.vector.enabled
156
- ? semanticRecall(home, query, corpus.map(doc => docKey(doc.text)), { ...cfg.vector, topK: cfg.topK })
243
+ ? vectorRuntime.semanticRecall(home, query, corpus.map(doc => docKey(doc.text)), { ...cfg.vector, topK: cfg.topK })
157
244
  : null
158
245
  const result = recall(corpus, query, {
159
246
  topK: cfg.topK,
@@ -176,11 +263,60 @@ export function apply(ctx, config = {}) {
176
263
 
177
264
  const renderHits = query => {
178
265
  const result = recallQuery(query)
179
- return buildHits(result.hits, { budgetKB: cfg.hitBudgetKB })
266
+ return buildHits(result.hits, { budgetKB: cfg.hitBudgetKB, detailHint, coldHint })
267
+ }
268
+
269
+ /** Read + clip core now, and record the bytes/clipped flags with the snapshot. */
270
+ const readCore = () => {
271
+ const snapshot = coreBuilder(home, { coreLimitKB: cfg.coreLimitKB })
272
+ const text = snapshot?.text ?? ''
273
+ return { text, bytes: bytes(text), clipped: Boolean(snapshot?.clipped) }
274
+ }
275
+
276
+ /**
277
+ * Core section text, resolved per assembly.
278
+ *
279
+ * The system prompt must stay byte-identical for a session or prefix caching
280
+ * loses the whole head; therefore the default `coreRefresh: 'session'` reads
281
+ * core.md once per session id and replays that snapshot. A new session gets a
282
+ * new id and reads the latest file. `coreRefresh: 'always'` opts out for
283
+ * edits that must be visible immediately.
284
+ *
285
+ * Without `context.agent.id` (diagnostics, tests, non-agent assembly) there is
286
+ * no safe cache key: a single global snapshot would leak one session's core
287
+ * into another. That path reads on every assembly and keeps only the last
288
+ * read in `fallbackCore` for the section contract.
289
+ */
290
+ const coreText = context => {
291
+ try {
292
+ if (cfg.coreRefresh === 'always') {
293
+ fallbackCore = readCore()
294
+ return fallbackCore.text
295
+ }
296
+ const id = context?.agent?.id
297
+ if (!id) {
298
+ fallbackCore = readCore()
299
+ return fallbackCore.text
300
+ }
301
+ const cached = coreSnapshots.get(id)
302
+ if (cached) return cached.text
303
+ const snapshot = readCore()
304
+ coreSnapshots.set(id, snapshot)
305
+ return snapshot.text
306
+ } catch {
307
+ // A broken core must not fail assembly; the next assembly retries.
308
+ return ''
309
+ }
180
310
  }
181
311
 
182
312
  ctx.on('session/disposed', session => {
183
- pending.delete(session?.id)
313
+ const sessionId = session?.id
314
+ if (!sessionId) return
315
+ pending.delete(sessionId)
316
+ // Session-scoped prompt text must not outlive the session: otherwise a
317
+ // later id reuse (or a long process's map) serves stale hits/core.
318
+ hitsBySession.delete(sessionId)
319
+ coreSnapshots.delete(sessionId)
184
320
  })
185
321
 
186
322
  ctx.on('session/event', (session, event) => {
@@ -189,11 +325,27 @@ export function apply(ctx, config = {}) {
189
325
  if (!sessionId) return
190
326
  if (event?.type === 'user/message') {
191
327
  if (event.data?.source?.kind !== 'user') return
328
+ const previous = pending.get(sessionId)
329
+ if (previous?.eventId) {
330
+ try { interruptEvent(home, previous.eventId) }
331
+ catch (error) { console.error(`[memory-dba] cannot interrupt prior archive: ${error.message}`) }
332
+ }
192
333
  pending.delete(sessionId)
193
334
  const user = textOf(event.data?.content)
194
335
  if (!user) return
195
- pending.set(sessionId, { user, assistant: '', at: new Date() })
196
- latestHits = renderHits(user).text
336
+ const at = new Date()
337
+ let archive = null
338
+ if (cfg.writeStream) {
339
+ try { archive = createPendingEvent(home, { session_id: sessionId, user, at, tag: cfg.tag }) }
340
+ catch (error) { console.error(`[memory-dba] cannot archive pending event: ${error.message}`) }
341
+ }
342
+ pending.set(sessionId, { user, assistant: '', at, eventId: archive?.id })
343
+ let text = ''
344
+ try { text = renderHits(user).text }
345
+ catch (error) { console.error(`[memory-dba] recall failed: ${error.message}`) }
346
+ hitsBySession.set(sessionId, text)
347
+ // Keep the no-session-id fallback in sync with the latest turn.
348
+ latestHits = text
197
349
  return
198
350
  }
199
351
  if (event?.type === 'assistant/message') {
@@ -202,6 +354,10 @@ export function apply(ctx, config = {}) {
202
354
  const text = textOf(event.data?.message?.content)
203
355
  if (!text) return
204
356
  turn.assistant = turn.assistant ? `${turn.assistant}\n${text}` : text
357
+ if (turn.eventId) {
358
+ try { updateEventAssistant(home, turn.eventId, text) }
359
+ catch (error) { console.error(`[memory-dba] cannot update event archive: ${error.message}`) }
360
+ }
205
361
  return
206
362
  }
207
363
  if (event?.type === 'turn/end') {
@@ -209,7 +365,13 @@ export function apply(ctx, config = {}) {
209
365
  if (!turn) return
210
366
  pending.delete(sessionId)
211
367
  if (!cfg.writeStream) return
212
- const outcome = appendTurn(home, { ...turn, tag: cfg.tag })
368
+ if (!turn.eventId) return
369
+ if (!turn.assistant.trim()) {
370
+ interruptEvent(home, turn.eventId)
371
+ return
372
+ }
373
+ completeEvent(home, turn.eventId, turn)
374
+ const outcome = publishEvent(home, turn.eventId, { tag: cfg.tag })
213
375
  if (outcome.written) corpus = null
214
376
  }
215
377
  } catch (error) {
@@ -220,21 +382,23 @@ export function apply(ctx, config = {}) {
220
382
  ctx.systemPrompt.section({
221
383
  name: 'memory-dba:core',
222
384
  order: cfg.coreOrder,
223
- text: () => {
224
- try {
225
- return buildCore(home, { coreLimitKB: cfg.coreLimitKB }).text
226
- } catch {
227
- return ''
228
- }
229
- },
385
+ text: coreText,
230
386
  })
231
387
 
232
- ctx.systemPrompt.section({
388
+ // Retrieval hits change per turn, so they must not touch the cacheable head:
389
+ // runtime context is materialized after the conversation history, where a change
390
+ // costs only the tokens that follow it. Resolve by the assembly's agent id;
391
+ // contexts without one fall back to the latest single slot.
392
+ ctx.systemPrompt.context({
233
393
  name: 'memory-dba:hits',
234
394
  order: cfg.hitsOrder,
235
- text: () => latestHits,
395
+ text: context => {
396
+ const id = context?.agent?.id
397
+ if (id) return hitsBySession.get(id) ?? ''
398
+ return latestHits
399
+ },
236
400
  })
237
401
  }
238
402
 
239
403
  /** Exposed for tests: the retrieval path without a harness context. */
240
- export { buildCore, buildHits, fitHits, loadCorpus, search }
404
+ export { buildCore, buildHits, fitHits, loadCorpus, search, readEvent }
package/src/search.mjs CHANGED
@@ -9,11 +9,12 @@
9
9
  * that already matched.
10
10
  */
11
11
 
12
- import { readdirSync, statSync } from 'node:fs'
12
+ import { lstatSync, readdirSync, statSync } from 'node:fs'
13
13
  import { join } from 'node:path'
14
14
  import { bytes, readLayer } from './store.mjs'
15
15
  import { queryTerms, tokenize } from './tokenize.mjs'
16
16
  import { docKey } from './vector.mjs'
17
+ import { readCatalog, effectiveFacts } from './catalog.mjs'
17
18
 
18
19
  const K1 = 1.5
19
20
  const B = 0.75
@@ -27,7 +28,12 @@ const DATE_RE = /(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/
27
28
 
28
29
  /**
29
30
  * Split one layer into retrievable units. Core and stream split per non-empty
30
- * line; a workspace file splits per `##` heading so a long note is one hit.
31
+ * line; a workspace source still splits per `##` heading for callers that pass
32
+ * one explicitly.
33
+ *
34
+ * Retrieval does NOT use this for workspace files: level-1 workspace documents
35
+ * are name-only (see `wsDoc`), because a section of workspace text in the
36
+ * corpus is exactly what the two-level design keeps out of the prompt.
31
37
  *
32
38
  * @param {string} text Layer contents.
33
39
  * @param {string} source Layer label used in results.
@@ -59,6 +65,41 @@ export function splitDocs(text, source) {
59
65
  return docs
60
66
  }
61
67
 
68
+ /**
69
+ * First-line title of a workspace file, or `''` when the first line is not a
70
+ * level-1 heading (`# ...`).
71
+ *
72
+ * Only the first physical line counts. Workspace bodies contain `#` lines in
73
+ * quotes and code blocks; scanning the whole file for something that looks
74
+ * like a title would put body text back into the level-1 document.
75
+ *
76
+ * @param {string} text Workspace file contents.
77
+ * @returns {string} Title line, or empty string.
78
+ */
79
+ export function wsTitle(text) {
80
+ const first = String(text || '').replace(/^\uFEFF/, '').split(/\r?\n/, 1)[0].trim()
81
+ return first.startsWith('# ') && !first.startsWith('## ') ? first : ''
82
+ }
83
+
84
+ /**
85
+ * Level-1 workspace document: file name plus first-line title, never the body.
86
+ *
87
+ * Rationale: at level 1 retrieval may answer "which workspace note might be
88
+ * relevant", not "what does it say". Indexing the body means any unrelated
89
+ * query can pull a whole workspace section into the prompt. Reading the body
90
+ * is an explicit second step (`nb_search.py --file <area>`), which is also why
91
+ * `date` is null here: without a body there is no section date to weight.
92
+ *
93
+ * @param {string} name Workspace file name, e.g. `记忆系统.md`.
94
+ * @param {string} text Workspace file contents.
95
+ * @returns {MemoryDoc} Name-only document.
96
+ */
97
+ export function wsDoc(name, text) {
98
+ const source = `ws/${name}`
99
+ const title = wsTitle(text)
100
+ return { source, line: 1, text: title ? `${source}\n${title}` : source, date: null }
101
+ }
102
+
62
103
  /** First `YYYY-MM-DD[ HH:MM]` in the text, normalized to an ISO date string. */
63
104
  export function dateOf(text) {
64
105
  const m = DATE_RE.exec(text || '')
@@ -85,16 +126,22 @@ function streamDocs(text) {
85
126
  return docs.reverse()
86
127
  }
87
128
 
88
- /** Unavailable or invalid layers are skipped unless strict diagnostics are requested. */
89
- export function loadCorpus(home, { exclude = [], strict = false } = {}) {
129
+ /**
130
+ * Unavailable or invalid layers are skipped unless strict diagnostics are requested.
131
+ *
132
+ * `core.md` is deliberately **not** part of the retrieval corpus. It is injected
133
+ * in full every turn by `buildCore`, so indexing it here would let the same text
134
+ * consume the prompt budget twice — the exact rule the Python side follows
135
+ * (`nb_search.py` excludes `scope === "core"` by default). A broken `core.md`
136
+ * is still reported by the core-budget check and by `buildCore` throwing, not by
137
+ * pretending it was a retrievable document.
138
+ */
139
+ export function loadCorpus(home, { exclude = [], strict = false, includeHistory = false } = {}) {
90
140
  const docs = []
91
- for (const [file, label] of [['core.md', 'core'], ['stream.md', 'stream']]) {
92
- try {
93
- const text = readLayer(home, file)
94
- docs.push(...(label === 'stream' ? streamDocs(text) : splitDocs(text, label)))
95
- } catch (error) {
96
- if (strict && error?.name !== 'MissingLayer') throw error
97
- }
141
+ try {
142
+ docs.push(...streamDocs(readLayer(home, 'stream.md')))
143
+ } catch (error) {
144
+ if (strict && error?.name !== 'MissingLayer') throw error
98
145
  }
99
146
  const wsDir = join(home, 'ws')
100
147
  let names = []
@@ -107,11 +154,40 @@ export function loadCorpus(home, { exclude = [], strict = false } = {}) {
107
154
  for (const name of names) {
108
155
  if (skip.has(name)) continue
109
156
  try {
110
- docs.push(...splitDocs(readLayer(home, `ws/${name}`), `ws/${name}`))
157
+ const fileStat = lstatSync(join(wsDir, name))
158
+ if (fileStat.isSymbolicLink() || !fileStat.isFile()) {
159
+ if (strict) throw new Error(`workspace entry must be a regular file: ${name}`)
160
+ continue
161
+ }
162
+ // One name-only document per file. `exclude` stays as an extra guardrail;
163
+ // the real gate is that no workspace body ever enters this corpus.
164
+ const doc = wsDoc(name, readLayer(home, `ws/${name}`))
165
+ docs.push(doc)
111
166
  } catch (error) {
112
167
  if (strict && error?.name !== 'MissingLayer') throw error
113
168
  }
114
169
  }
170
+ let catalog
171
+ try { catalog = readCatalog(home, { strict }) }
172
+ catch (error) { console.error(`[memory-dba] ${error.message}`); if (strict) throw error }
173
+ if (catalog) {
174
+ const cards = new Map(catalog.cards.map(card => [card.file, card]))
175
+ for (const doc of docs) {
176
+ const card = cards.get(doc.source)
177
+ if (!card || exclude.includes(doc.source) || exclude.includes(doc.source.slice(3))) continue
178
+ const labels = [['topics','topics'],['aliases','aliases'],['entities','entities'],['questions','questions']]
179
+ .filter(([key]) => card[key].length).map(([key,label]) => `${label}: ${card[key].join(', ')}`)
180
+ if (labels.length) doc.text += `\n${labels.join('\n')}`
181
+ }
182
+ const superseded = new Set(catalog.facts.flatMap(f => f.supersedes))
183
+ const eff = effectiveFacts(catalog, { history: includeHistory })
184
+ for (const fact of eff) {
185
+ if (!includeHistory && fact.effective_status !== 'active' && fact.effective_status !== 'conflict') continue
186
+ const evidence = fact.source.role === 'user' ? 'user_reported' : 'assistant_reported'
187
+ const text = `key: ${fact.key}\nclaim: ${fact.claim}\nkind: ${fact.kind}\nsource: ${evidence}\nevent: ${fact.source.event_id}\nstatus: ${fact.effective_status}`
188
+ docs.push({ source: `fact/${fact.id}`, line: 1, text, date: fact.observed_at, evidence: { ...fact.source }, source_label: evidence, factStatus: fact.effective_status, effective_status: fact.effective_status, conflict: fact.effective_status === 'conflict', key: fact.key, claim: fact.claim, kind: fact.kind, factKey: fact.key, superseded: superseded.has(fact.id) })
189
+ }
190
+ }
115
191
  return docs
116
192
  }
117
193
 
package/src/store.mjs CHANGED
@@ -14,6 +14,7 @@
14
14
  import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs'
15
15
  import { homedir, platform } from 'node:os'
16
16
  import { dirname, join, resolve } from 'node:path'
17
+ import { createHash } from 'node:crypto'
17
18
 
18
19
  export const CORE_FILE = 'core.md'
19
20
  export const STREAM_FILE = 'stream.md'
@@ -158,14 +159,28 @@ export function fingerprint(home) {
158
159
  let names = []
159
160
  try {
160
161
  names = readdirSync(join(home, WS_DIR)).filter(name => name.endsWith('.md')).sort()
161
- } catch { /* An unavailable workspace has no cacheable entries. */ }
162
+ } catch (error) { parts.push(`ws-dir:${error?.code || 'unreadable'}`) }
162
163
  for (const name of [CORE_FILE, STREAM_FILE, ...names.map(name => `${WS_DIR}/${name}`)]) {
163
164
  try {
164
165
  const st = statSync(join(home, name))
165
166
  parts.push(JSON.stringify([name, st.size, st.mtimeMs]))
166
- } catch {
167
- parts.push(`${name}:-`)
167
+ } catch (error) {
168
+ parts.push(`${name}:${error?.code || 'unreadable'}`)
168
169
  }
169
170
  }
171
+ // Catalog edits and changes to referenced evidence invalidate retrieved facts.
172
+ const catalogPath = join(home, 'catalog.json')
173
+ try {
174
+ const st = statSync(catalogPath)
175
+ const digest = createHash('sha256').update(readFileSync(catalogPath)).digest('hex')
176
+ parts.push(JSON.stringify(['catalog.json', st.size, st.mtimeMs, digest]))
177
+ try {
178
+ const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'))
179
+ for (const id of [...new Set((catalog.facts || []).map(f => f?.source?.event_id).filter(x => typeof x === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(x)))].sort()) {
180
+ try { const path=join(home, 'events-v1', `${id}.json`); const ev = statSync(path); const evHash=createHash('sha256').update(readFileSync(path)).digest('hex'); parts.push(JSON.stringify([`events-v1/${id}.json`, ev.size, ev.mtimeMs, evHash])) }
181
+ catch { parts.push(`events-v1/${id}.json:-`) }
182
+ }
183
+ } catch { parts.push('catalog.json:invalid') }
184
+ } catch (error) { parts.push(`catalog.json:${error?.code || 'unreadable'}`) }
170
185
  return parts.join('|')
171
186
  }
package/src/stream.mjs CHANGED
@@ -7,20 +7,10 @@
7
7
  */
8
8
 
9
9
  import { appendLayer, readLayer } from './store.mjs'
10
-
11
- const SECRET_PATTERNS = [
12
- /sk-[A-Za-z0-9_-]{16,}/g,
13
- /\bBearer\s+[A-Za-z0-9._-]{16,}/gi,
14
- /\b[A-Fa-f0-9]{32,}\b/g,
15
- /\b\d{13,19}\b/g,
16
- ]
10
+ import { completeEvent, createPendingEvent, eventId, markEventPublished, readEvent, sanitizeEventText, saveEvent } from './events.mjs'
17
11
 
18
12
  /** Remove credential-looking runs before text reaches a committable file. */
19
- export function sanitize(text) {
20
- let out = String(text || '')
21
- for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, '[redacted]')
22
- return out
23
- }
13
+ export const sanitize = sanitizeEventText
24
14
 
25
15
  /** Collapse whitespace so one turn stays one line. */
26
16
  export function oneLine(text, maxChars = 600) {
@@ -43,12 +33,42 @@ export function stamp(date = new Date()) {
43
33
  * @param {{user?: string, assistant?: string, tag?: string, at?: Date}} turn
44
34
  * @returns {string|null} Line, or null when the turn is not worth remembering.
45
35
  */
46
- export function formatTurn({ user, assistant, tag, at }) {
36
+ export function formatTurn({ user, assistant, tag, at, eventId: id }) {
47
37
  const u = oneLine(sanitize(user), 300)
48
38
  const a = oneLine(sanitize(assistant), 600)
49
39
  if (!u || !a) return null
50
40
  const label = tag ? ` | tag=${oneLine(sanitize(tag), 100)}` : ''
51
- return `- ${stamp(at)}${label} | 用户:${u} | 助手:${a}`
41
+ return `- ${stamp(at)}${label} | 用户:${u} | 助手:${a}${id ? ` [event=${id}]` : ''}`
42
+ }
43
+
44
+ /** Compact navigation excerpt balanced across both ends of each message. */
45
+ export function summarizeForStream(text, maxChars) {
46
+ const flat = oneLine(sanitize(text), Number.MAX_SAFE_INTEGER)
47
+ if (flat.length <= maxChars) return flat
48
+ let head = Math.floor((maxChars - 1) * 0.58)
49
+ const tail = maxChars - 1 - head
50
+ if (head > 0 && /[\uD800-\uDBFF]/.test(flat[head - 1]) && /[\uDC00-\uDFFF]/.test(flat[head])) head -= 1
51
+ let tailStart = flat.length - tail
52
+ if (tailStart > 0 && /[\uD800-\uDBFF]/.test(flat[tailStart - 1]) && /[\uDC00-\uDFFF]/.test(flat[tailStart])) tailStart += 1
53
+ return `${flat.slice(0, head)}…${flat.slice(tailStart)}`
54
+ }
55
+
56
+ /** Publish one completed archive; the reference makes append recovery idempotent. */
57
+ export function publishEvent(home, id, options = {}) {
58
+ const event = readEvent(home, id)
59
+ if (!event || event.status !== 'complete') return { written: false, reason: 'not-complete' }
60
+ let stream = ''
61
+ try { stream = readLayer(home, 'stream.md') } catch (error) { if (error?.name !== 'MissingLayer') throw error }
62
+ if (stream.includes(`[event=${id}]`)) {
63
+ markEventPublished(home, id)
64
+ return { written: false, reason: 'already-published' }
65
+ }
66
+ const tag = options.tag ?? event.tag ?? ''
67
+ const line = formatTurn({ user: summarizeForStream(event.user, 300), assistant: summarizeForStream(event.assistant, 600), tag, at: new Date(event.started_at), eventId: id })
68
+ if (!line) return { written: false, reason: 'empty-turn' }
69
+ appendLayer(home, 'stream.md', line)
70
+ markEventPublished(home, id)
71
+ return { written: true, line }
52
72
  }
53
73
 
54
74
  /**
@@ -59,16 +79,37 @@ export function formatTurn({ user, assistant, tag, at }) {
59
79
  * @returns {{written: boolean, reason?: string, line?: string}}
60
80
  */
61
81
  export function appendTurn(home, turn) {
62
- const line = formatTurn(turn)
63
- if (!line) return { written: false, reason: 'empty-turn' }
64
- let last = ''
65
- try {
66
- const stream = readLayer(home, 'stream.md').trimEnd()
67
- last = stream.slice(stream.lastIndexOf('\n') + 1)
68
- } catch (error) {
69
- if (error?.name !== 'MissingLayer') throw error
82
+ if (!turn?.user || !turn?.assistant) return { written: false, reason: 'empty-turn' }
83
+ const at = turn.at ? new Date(turn.at) : new Date()
84
+ const digest = String(turn.eventId || eventId())
85
+ if (!turn.eventId) {
86
+ const wanted = formatTurn({ ...turn, at, user: summarizeForStream(turn.user, 300), assistant: summarizeForStream(turn.assistant, 600) }).replace(/\s+$/, '')
87
+ try {
88
+ const stream = readLayer(home, 'stream.md').trimEnd()
89
+ const lastLine = stream.slice(stream.lastIndexOf('\n') + 1)
90
+ const priorId = / \[event=([A-Za-z0-9_-]+)\]$/.exec(lastLine)?.[1]
91
+ const last = lastLine.replace(/ \[event=[A-Za-z0-9_-]+\]$/, '')
92
+ if (last === wanted) {
93
+ const prior = priorId ? readEvent(home, priorId) : null
94
+ const sameArchive = prior && prior.user === sanitize(turn.user) && prior.assistant === sanitize(turn.assistant)
95
+ const legacyShort = !priorId && String(turn.user).length <= 300 && String(turn.assistant).length <= 600
96
+ if (sameArchive || legacyShort) return { written: false, reason: 'duplicate' }
97
+ }
98
+ } catch (error) { if (error?.name !== 'MissingLayer') throw error }
70
99
  }
71
- if (last === line) return { written: false, reason: 'duplicate' }
72
- appendLayer(home, 'stream.md', line)
73
- return { written: true, line }
100
+ let event = readEvent(home, digest)
101
+ if (!event) {
102
+ event = createPendingEvent(home, { id: digest, session_id: turn.session_id || 'cli', user: turn.user, at, tag: turn.tag })
103
+ event.assistant = sanitize(turn.assistant)
104
+ event.updated_at = new Date().toISOString()
105
+ saveEvent(home, event)
106
+ completeEvent(home, event.id)
107
+ } else if (event.status !== 'complete') {
108
+ event.assistant = sanitize(turn.assistant)
109
+ event.updated_at = new Date().toISOString()
110
+ saveEvent(home, event)
111
+ completeEvent(home, event.id)
112
+ }
113
+ const result = publishEvent(home, event.id, { tag: turn.tag })
114
+ return result.reason === 'already-published' ? { ...result, reason: 'duplicate' } : result
74
115
  }