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/CHANGELOG.md +137 -0
- package/MEMORY.md +83 -0
- package/README.md +76 -35
- package/README.zh-CN.md +38 -14
- package/bin/cli.mjs +44 -1
- package/package.json +47 -46
- package/src/assemble.mjs +67 -12
- package/src/catalog.mjs +114 -0
- package/src/events.mjs +138 -0
- package/src/index.mjs +192 -28
- package/src/search.mjs +88 -12
- package/src/store.mjs +18 -3
- package/src/stream.mjs +66 -25
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
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
/**
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
}
|