dsh-memory-dba 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +147 -3
- package/bin/cli.mjs +199 -0
- package/package.json +34 -4
- package/src/assemble.mjs +80 -0
- package/src/embed.mjs +35 -0
- package/src/index.mjs +240 -0
- package/src/search.mjs +258 -0
- package/src/store.mjs +171 -0
- package/src/stream.mjs +74 -0
- package/src/tokenize.mjs +73 -0
- package/src/vector-worker.mjs +37 -0
- package/src/vector.mjs +121 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-memory-dba — a file-based, git-portable memory plugin for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Three layers, mirroring how the notes are actually maintained:
|
|
5
|
+
* core.md injected in full every turn, budget-capped
|
|
6
|
+
* stream.md a timeline, retrieved only
|
|
7
|
+
* ws/*.md detail notes, retrieved only
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* Failure policy: memory is an enhancement. A broken layer, a missing directory,
|
|
13
|
+
* or a corrupt index degrades to an empty section and never fails the turn.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { buildCore, buildHits } from './assemble.mjs'
|
|
17
|
+
import { fitHits, loadCorpus, search } from './search.mjs'
|
|
18
|
+
import { bytes, ensureLayout, fingerprint, resolveHome } from './store.mjs'
|
|
19
|
+
import { appendTurn } from './stream.mjs'
|
|
20
|
+
import { queryTerms, tokenize } from './tokenize.mjs'
|
|
21
|
+
import { embed } from './embed.mjs'
|
|
22
|
+
import { docKey, semanticRecall } from './vector.mjs'
|
|
23
|
+
|
|
24
|
+
export const name = 'memory-dba'
|
|
25
|
+
|
|
26
|
+
/** The plugin reads and writes prompt sections through this service. */
|
|
27
|
+
export const inject = ['systemPrompt']
|
|
28
|
+
|
|
29
|
+
/** Config defaults; every field is overridable from cordis.yml. */
|
|
30
|
+
export const defaults = {
|
|
31
|
+
home: '',
|
|
32
|
+
coreLimitKB: 15,
|
|
33
|
+
hitBudgetKB: 6,
|
|
34
|
+
topK: 5,
|
|
35
|
+
timeDecay: true,
|
|
36
|
+
halfLifeDays: 30,
|
|
37
|
+
writeStream: true,
|
|
38
|
+
tag: '',
|
|
39
|
+
exclude: [],
|
|
40
|
+
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' },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Text of one message's content blocks, ignoring tool calls and images. */
|
|
46
|
+
function textOf(blocks) {
|
|
47
|
+
if (typeof blocks === 'string') return blocks.trim()
|
|
48
|
+
if (!Array.isArray(blocks)) return ''
|
|
49
|
+
return blocks
|
|
50
|
+
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
|
51
|
+
.map(block => block.text)
|
|
52
|
+
.join('\n')
|
|
53
|
+
.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Coerce a number, falling back when the config value is missing or invalid. */
|
|
57
|
+
function num(value, fallback, { min = -Infinity, max = Infinity } = {}) {
|
|
58
|
+
const parsed = Number(value)
|
|
59
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
60
|
+
return Math.min(max, Math.max(min, parsed))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Merge user config over defaults, rejecting unusable values instead of propagating NaN. */
|
|
64
|
+
export function normalizeConfig(config = {}) {
|
|
65
|
+
const cfg = { ...defaults, ...config }
|
|
66
|
+
const vector = cfg.vector && typeof cfg.vector === 'object' ? cfg.vector : {}
|
|
67
|
+
const timeoutMs = vector.timeoutMs == null ? NaN : Number(vector.timeoutMs)
|
|
68
|
+
const minScore = vector.minScore == null ? NaN : Number(vector.minScore)
|
|
69
|
+
const keepAlive = typeof vector.keepAlive === 'string' && vector.keepAlive.trim() ? vector.keepAlive.trim() : defaults.vector.keepAlive
|
|
70
|
+
let url = defaults.vector.url
|
|
71
|
+
try {
|
|
72
|
+
const parsed = new URL(vector.url)
|
|
73
|
+
if (['http:', 'https:'].includes(parsed.protocol)) url = parsed.href.replace(/\/$/, '')
|
|
74
|
+
} catch {}
|
|
75
|
+
return {
|
|
76
|
+
...cfg,
|
|
77
|
+
home: typeof cfg.home === 'string' ? cfg.home : '',
|
|
78
|
+
coreLimitKB: num(cfg.coreLimitKB, defaults.coreLimitKB, { min: 1, max: 512 }),
|
|
79
|
+
hitBudgetKB: num(cfg.hitBudgetKB, defaults.hitBudgetKB, { min: 1, max: 128 }),
|
|
80
|
+
topK: num(cfg.topK, defaults.topK, { min: 1, max: 50 }),
|
|
81
|
+
halfLifeDays: num(cfg.halfLifeDays, defaults.halfLifeDays, { min: 1, max: 3650 }),
|
|
82
|
+
coreOrder: num(cfg.coreOrder, defaults.coreOrder),
|
|
83
|
+
hitsOrder: num(cfg.hitsOrder, defaults.hitsOrder),
|
|
84
|
+
timeDecay: cfg.timeDecay !== false,
|
|
85
|
+
writeStream: cfg.writeStream !== false,
|
|
86
|
+
tag: typeof cfg.tag === 'string' ? cfg.tag : '',
|
|
87
|
+
exclude: Array.isArray(cfg.exclude) ? cfg.exclude.filter(name => typeof name === 'string') : [],
|
|
88
|
+
vector: {
|
|
89
|
+
enabled: vector.enabled !== false,
|
|
90
|
+
url,
|
|
91
|
+
model: typeof vector.model === 'string' && vector.model.trim() ? vector.model : defaults.vector.model,
|
|
92
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs >= 1 ? Math.floor(timeoutMs) : defaults.vector.timeoutMs,
|
|
93
|
+
minScore: Number.isFinite(minScore) && minScore >= 0 && minScore <= 1 ? minScore : defaults.vector.minScore,
|
|
94
|
+
keepAlive,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function recall(docs, query, options = {}) {
|
|
100
|
+
const first = search(docs, query, options)
|
|
101
|
+
const terms = queryTerms(query)
|
|
102
|
+
const shared = docs.some(doc => tokenize(doc.text).some(term => terms.has(term)))
|
|
103
|
+
if (!shared && (!first.hits.length || first.hits[0].score < 6)) {
|
|
104
|
+
const reduced = tokenize(query)
|
|
105
|
+
.filter(term => !/^[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u3040-\u30FF\uAC00-\uD7AF]{2}$/.test(term))
|
|
106
|
+
.join(' ')
|
|
107
|
+
|| [...query].filter(char => /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/.test(char)).join(' ')
|
|
108
|
+
const second = reduced ? search(docs, reduced, options) : { hits: [] }
|
|
109
|
+
if (second.hits.length) return { ...second, relaxed: true }
|
|
110
|
+
}
|
|
111
|
+
return { ...first, relaxed: false }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
116
|
+
* @param {Partial<typeof defaults>} config
|
|
117
|
+
*/
|
|
118
|
+
export function apply(ctx, config = {}) {
|
|
119
|
+
const cfg = normalizeConfig(config)
|
|
120
|
+
const home = resolveHome(cfg.home)
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
ensureLayout(home)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
// An unwritable home disables persistence but must not break startup.
|
|
126
|
+
console.error(`[memory-dba] cannot initialize ${home}: ${error.message}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 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.
|
|
131
|
+
if (cfg.vector.enabled) {
|
|
132
|
+
embed('warmup', { url: cfg.vector.url, model: cfg.vector.model, timeoutMs: 8000, keepAlive: cfg.vector.keepAlive })
|
|
133
|
+
.catch(() => {})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let corpus = null
|
|
137
|
+
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.
|
|
141
|
+
let latestHits = ''
|
|
142
|
+
const pending = new Map()
|
|
143
|
+
|
|
144
|
+
const docs = () => {
|
|
145
|
+
const key = fingerprint(home)
|
|
146
|
+
if (corpus && key === corpusKey) return corpus
|
|
147
|
+
corpus = loadCorpus(home, { exclude: cfg.exclude })
|
|
148
|
+
corpusKey = key
|
|
149
|
+
return corpus
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const recallQuery = query => {
|
|
153
|
+
if (!query) return { hits: [], applied: false, clipped: false }
|
|
154
|
+
const corpus = docs()
|
|
155
|
+
const semantic = cfg.vector.enabled
|
|
156
|
+
? semanticRecall(home, query, corpus.map(doc => docKey(doc.text)), { ...cfg.vector, topK: cfg.topK })
|
|
157
|
+
: null
|
|
158
|
+
const result = recall(corpus, query, {
|
|
159
|
+
topK: cfg.topK,
|
|
160
|
+
timeDecay: cfg.timeDecay,
|
|
161
|
+
halfLifeDays: cfg.halfLifeDays,
|
|
162
|
+
budgetBytes: cfg.hitBudgetKB * 1024,
|
|
163
|
+
semantic,
|
|
164
|
+
semanticMinScore: cfg.vector.minScore,
|
|
165
|
+
})
|
|
166
|
+
const budget = Math.max(1, cfg.hitBudgetKB) * 1024
|
|
167
|
+
const keyword = result.hits.filter(hit => !hit.semantic)
|
|
168
|
+
const semanticHits = result.hits.filter(hit => hit.semantic)
|
|
169
|
+
// Keyword hits own the prompt; semantic excerpts get a reserved tail so a long
|
|
170
|
+
// workspace section cannot crowd them out entirely.
|
|
171
|
+
const keptKeyword = fitHits(keyword, semanticHits.length ? Math.floor(budget * 0.7) : budget)
|
|
172
|
+
const used = keptKeyword.reduce((total, hit) => total + bytes(hit.text) + 48, 0)
|
|
173
|
+
const hits = used < budget ? keptKeyword.concat(fitHits(semanticHits, budget - used)) : keptKeyword
|
|
174
|
+
return { ...result, hits }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const renderHits = query => {
|
|
178
|
+
const result = recallQuery(query)
|
|
179
|
+
return buildHits(result.hits, { budgetKB: cfg.hitBudgetKB })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
ctx.on('session/disposed', session => {
|
|
183
|
+
pending.delete(session?.id)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
ctx.on('session/event', (session, event) => {
|
|
187
|
+
try {
|
|
188
|
+
const sessionId = session?.id
|
|
189
|
+
if (!sessionId) return
|
|
190
|
+
if (event?.type === 'user/message') {
|
|
191
|
+
if (event.data?.source?.kind !== 'user') return
|
|
192
|
+
pending.delete(sessionId)
|
|
193
|
+
const user = textOf(event.data?.content)
|
|
194
|
+
if (!user) return
|
|
195
|
+
pending.set(sessionId, { user, assistant: '', at: new Date() })
|
|
196
|
+
latestHits = renderHits(user).text
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (event?.type === 'assistant/message') {
|
|
200
|
+
const turn = pending.get(sessionId)
|
|
201
|
+
if (!turn) return
|
|
202
|
+
const text = textOf(event.data?.message?.content)
|
|
203
|
+
if (!text) return
|
|
204
|
+
turn.assistant = turn.assistant ? `${turn.assistant}\n${text}` : text
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if (event?.type === 'turn/end') {
|
|
208
|
+
const turn = pending.get(sessionId)
|
|
209
|
+
if (!turn) return
|
|
210
|
+
pending.delete(sessionId)
|
|
211
|
+
if (!cfg.writeStream) return
|
|
212
|
+
const outcome = appendTurn(home, { ...turn, tag: cfg.tag })
|
|
213
|
+
if (outcome.written) corpus = null
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.error(`[memory-dba] session event failed: ${error.message}`)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
ctx.systemPrompt.section({
|
|
221
|
+
name: 'memory-dba:core',
|
|
222
|
+
order: cfg.coreOrder,
|
|
223
|
+
text: () => {
|
|
224
|
+
try {
|
|
225
|
+
return buildCore(home, { coreLimitKB: cfg.coreLimitKB }).text
|
|
226
|
+
} catch {
|
|
227
|
+
return ''
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
ctx.systemPrompt.section({
|
|
233
|
+
name: 'memory-dba:hits',
|
|
234
|
+
order: cfg.hitsOrder,
|
|
235
|
+
text: () => latestHits,
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Exposed for tests: the retrieval path without a harness context. */
|
|
240
|
+
export { buildCore, buildHits, fitHits, loadCorpus, search }
|
package/src/search.mjs
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BM25 over the three memory layers, with CJK bigrams and optional recency.
|
|
3
|
+
*
|
|
4
|
+
* Retrieval is a two-step ladder that mirrors how the memory is written:
|
|
5
|
+
* 1. rank by relevance only, so the same query yields the same candidates;
|
|
6
|
+
* 2. when the candidate set overflows the prompt budget, rerank with a time
|
|
7
|
+
* weight so recent notes survive the cut.
|
|
8
|
+
* Relevance never gets replaced by recency; the weight only reorders candidates
|
|
9
|
+
* that already matched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { bytes, readLayer } from './store.mjs'
|
|
15
|
+
import { queryTerms, tokenize } from './tokenize.mjs'
|
|
16
|
+
import { docKey } from './vector.mjs'
|
|
17
|
+
|
|
18
|
+
const K1 = 1.5
|
|
19
|
+
const B = 0.75
|
|
20
|
+
export const STREAM_MAX_DOCS = 1000
|
|
21
|
+
export const STREAM_MAX_BYTES = 128 * 1024
|
|
22
|
+
const DATE_RE = /(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {{ source: string, line: number, text: string, date: string|null }} MemoryDoc
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 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
|
+
*
|
|
32
|
+
* @param {string} text Layer contents.
|
|
33
|
+
* @param {string} source Layer label used in results.
|
|
34
|
+
* @returns {MemoryDoc[]} Documents in source order.
|
|
35
|
+
*/
|
|
36
|
+
export function splitDocs(text, source) {
|
|
37
|
+
const docs = []
|
|
38
|
+
const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/)
|
|
39
|
+
let block = []
|
|
40
|
+
let start = 1
|
|
41
|
+
const flush = () => {
|
|
42
|
+
const body = block.join('\n').trim()
|
|
43
|
+
if (body) docs.push({ source, line: start, text: body, date: dateOf(body) })
|
|
44
|
+
block = []
|
|
45
|
+
}
|
|
46
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
47
|
+
const line = lines[i]
|
|
48
|
+
const heading = source.startsWith('ws/') && /^##\s+/.test(line)
|
|
49
|
+
if (!line.trim() || heading) {
|
|
50
|
+
if (block.length) flush()
|
|
51
|
+
}
|
|
52
|
+
if (line.trim()) {
|
|
53
|
+
if (!block.length) start = i + 1
|
|
54
|
+
block.push(line)
|
|
55
|
+
if (!source.startsWith('ws/')) flush()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (block.length) flush()
|
|
59
|
+
return docs
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** First `YYYY-MM-DD[ HH:MM]` in the text, normalized to an ISO date string. */
|
|
63
|
+
export function dateOf(text) {
|
|
64
|
+
const m = DATE_RE.exec(text || '')
|
|
65
|
+
if (!m) return null
|
|
66
|
+
const [, y, mo, d, h, mi] = m
|
|
67
|
+
if (!h) return `${y}-${mo}-${d}`
|
|
68
|
+
return `${y}-${mo}-${d}T${h}:${mi}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Stream retrieval indexes a bounded recent window with original line numbers. */
|
|
72
|
+
function streamDocs(text) {
|
|
73
|
+
const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/)
|
|
74
|
+
const docs = []
|
|
75
|
+
let used = 0
|
|
76
|
+
for (let index = lines.length - 1; index >= 0 && docs.length < STREAM_MAX_DOCS; index -= 1) {
|
|
77
|
+
const body = lines[index].trim()
|
|
78
|
+
if (!body) continue
|
|
79
|
+
const cost = bytes(body)
|
|
80
|
+
if (cost > STREAM_MAX_BYTES) continue
|
|
81
|
+
if (used + cost > STREAM_MAX_BYTES) break
|
|
82
|
+
used += cost
|
|
83
|
+
docs.push({ source: 'stream', line: index + 1, text: body, date: dateOf(body) })
|
|
84
|
+
}
|
|
85
|
+
return docs.reverse()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Unavailable or invalid layers are skipped unless strict diagnostics are requested. */
|
|
89
|
+
export function loadCorpus(home, { exclude = [], strict = false } = {}) {
|
|
90
|
+
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
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const wsDir = join(home, 'ws')
|
|
100
|
+
let names = []
|
|
101
|
+
try {
|
|
102
|
+
names = readdirSync(wsDir).filter(name => name.endsWith('.md')).sort()
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (strict && error?.code !== 'ENOENT') throw error
|
|
105
|
+
}
|
|
106
|
+
const skip = new Set(exclude)
|
|
107
|
+
for (const name of names) {
|
|
108
|
+
if (skip.has(name)) continue
|
|
109
|
+
try {
|
|
110
|
+
docs.push(...splitDocs(readLayer(home, `ws/${name}`), `ws/${name}`))
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (strict && error?.name !== 'MissingLayer') throw error
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return docs
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @typedef {object} SearchOptions
|
|
120
|
+
* @property {boolean} [timeDecay] Apply recency reranking.
|
|
121
|
+
* @property {number} [halfLifeDays] Weight halves every N days.
|
|
122
|
+
* @property {string} [now] Reference date, ISO; defaults to today.
|
|
123
|
+
* @property {number} [budgetBytes] Rerank only when matching candidates exceed this budget.
|
|
124
|
+
* @property {Map<string, number>|null} [semantic] Cosines keyed by document-text SHA-1.
|
|
125
|
+
* @property {number} [semanticMinScore] Cosine floor for appended semantic hits, default 0.45.
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Rank documents for a query.
|
|
130
|
+
*
|
|
131
|
+
* @param {MemoryDoc[]} docs Corpus.
|
|
132
|
+
* @param {string} query User query.
|
|
133
|
+
* @param {SearchOptions & { topK?: number }} [options]
|
|
134
|
+
* @returns {{ hits: Array<MemoryDoc & { score: number, base: number }>, applied: boolean, corpusDates: {dated: number, undated: number} }}
|
|
135
|
+
*/
|
|
136
|
+
export function search(docs, query, options = {}) {
|
|
137
|
+
const topK = Math.max(1, options.topK ?? 5)
|
|
138
|
+
const terms = queryTerms(query)
|
|
139
|
+
const empty = { hits: [], applied: false, corpusDates: { dated: 0, undated: 0 } }
|
|
140
|
+
if ((!terms.size && !options.semantic?.size) || !docs.length) return empty
|
|
141
|
+
|
|
142
|
+
const postings = new Map()
|
|
143
|
+
const lengths = new Array(docs.length)
|
|
144
|
+
for (let i = 0; i < docs.length; i += 1) {
|
|
145
|
+
const tokens = tokenize(docs[i].text)
|
|
146
|
+
lengths[i] = tokens.length || 1
|
|
147
|
+
const tf = new Map()
|
|
148
|
+
for (const token of tokens) tf.set(token, (tf.get(token) || 0) + 1)
|
|
149
|
+
for (const [term, count] of tf) {
|
|
150
|
+
let list = postings.get(term)
|
|
151
|
+
if (!list) postings.set(term, (list = new Map()))
|
|
152
|
+
list.set(i, count)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const total = docs.length
|
|
157
|
+
const avgLen = lengths.reduce((a, b) => a + b, 0) / total
|
|
158
|
+
const scores = new Map()
|
|
159
|
+
for (const [term, boost] of terms) {
|
|
160
|
+
const list = postings.get(term)
|
|
161
|
+
if (!list) continue
|
|
162
|
+
const idf = Math.log(1 + (total - list.size + 0.5) / (list.size + 0.5))
|
|
163
|
+
for (const [i, tf] of list) {
|
|
164
|
+
const denom = tf + K1 * (1 - B + (B * lengths[i]) / avgLen)
|
|
165
|
+
const gain = boost * idf * ((tf * (K1 + 1)) / denom)
|
|
166
|
+
scores.set(i, (scores.get(i) || 0) + gain)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const keys = options.semantic?.size ? docs.map(doc => docKey(doc.text)) : null
|
|
170
|
+
/** Semantic hits extend the keyword list; they never reorder it. */
|
|
171
|
+
const extras = picked => {
|
|
172
|
+
if (!keys) return picked
|
|
173
|
+
const minimum = Number.isFinite(options.semanticMinScore) ? options.semanticMinScore : 0.45
|
|
174
|
+
const seen = new Set(picked.map(hit => docKey(hit.text)))
|
|
175
|
+
const found = []
|
|
176
|
+
for (let index = 0; index < docs.length; index += 1) {
|
|
177
|
+
if (seen.has(keys[index])) continue
|
|
178
|
+
const score = options.semantic.get(keys[index])
|
|
179
|
+
if (Number.isFinite(score) && score >= minimum) found.push({ index, score })
|
|
180
|
+
}
|
|
181
|
+
found.sort((left, right) => right.score - left.score || left.index - right.index)
|
|
182
|
+
return picked.concat(found
|
|
183
|
+
.slice(0, topK)
|
|
184
|
+
.map(({ index, score }) => ({ ...docs[index], score: score * 10, base: score * 10, semantic: true })))
|
|
185
|
+
}
|
|
186
|
+
let ranked = [...scores.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])
|
|
187
|
+
if (!ranked.length) return { ...empty, hits: extras([]) }
|
|
188
|
+
|
|
189
|
+
const base = ranked
|
|
190
|
+
.map(([i, score]) => ({ ...docs[i], score, base: score }))
|
|
191
|
+
|
|
192
|
+
const dated = base.filter(hit => hit.date)
|
|
193
|
+
const corpusDates = { dated: dated.length, undated: base.length - dated.length }
|
|
194
|
+
|
|
195
|
+
const withinBudget = Number.isFinite(options.budgetBytes)
|
|
196
|
+
&& base.reduce((total, hit) => total + bytes(hit.text) + 48, 0) <= options.budgetBytes
|
|
197
|
+
if (!options.timeDecay || !dated.length || withinBudget) {
|
|
198
|
+
return { hits: extras(base.slice(0, topK)), applied: false, corpusDates }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const reference = options.now ? Date.parse(options.now) : Date.now()
|
|
202
|
+
const halfLife = options.halfLifeDays && options.halfLifeDays > 0 ? options.halfLifeDays : 30
|
|
203
|
+
const weights = new Map()
|
|
204
|
+
for (const hit of dated) {
|
|
205
|
+
const ageDays = Math.max(0, (reference - Date.parse(hit.date)) / 86_400_000) || 0
|
|
206
|
+
weights.set(hit, Math.max(0.5, 0.5 ** (ageDays / halfLife)))
|
|
207
|
+
}
|
|
208
|
+
const sortedWeights = [...weights.values()].sort((a, b) => a - b)
|
|
209
|
+
const neutral = sortedWeights[Math.floor(sortedWeights.length / 2)]
|
|
210
|
+
const reranked = base
|
|
211
|
+
.map((hit, index) => ({ ...hit, weight: weights.get(hit) ?? neutral, docIndex: ranked[index][0] }))
|
|
212
|
+
.sort((left, right) => right.base * right.weight - left.base * left.weight || left.docIndex - right.docIndex)
|
|
213
|
+
.map(({ weight, docIndex, ...hit }) => ({ ...hit, score: hit.base * weight }))
|
|
214
|
+
|
|
215
|
+
return { hits: extras(reranked.slice(0, topK)), applied: true, corpusDates, timeWeightNeutral: neutral }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Pick hits that fit a byte budget, keeping rank order. A single oversized hit
|
|
220
|
+
* is kept and reported as truncated by the caller, never silently dropped.
|
|
221
|
+
*
|
|
222
|
+
* @param {Array<MemoryDoc & { score: number }>} hits Ranked hits.
|
|
223
|
+
* @param {number} budgetBytes Prompt budget for the retrieval section.
|
|
224
|
+
*/
|
|
225
|
+
export function fitHits(hits, budgetBytes) {
|
|
226
|
+
const kept = []
|
|
227
|
+
let used = 0
|
|
228
|
+
for (const hit of hits) {
|
|
229
|
+
const cost = bytes(hit.text) + 48
|
|
230
|
+
if (used + cost > budgetBytes && kept.length) break
|
|
231
|
+
kept.push(hit)
|
|
232
|
+
used += cost
|
|
233
|
+
}
|
|
234
|
+
return kept
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Memory-directory stats for the CLI and for doctor output. */
|
|
238
|
+
export function corpusStats(home) {
|
|
239
|
+
const stats = { layers: {}, docs: 0, bytes: 0 }
|
|
240
|
+
for (const name of ['core.md', 'stream.md']) {
|
|
241
|
+
try {
|
|
242
|
+
const size = statSync(join(home, name)).size
|
|
243
|
+
stats.layers[name] = size
|
|
244
|
+
stats.bytes += size
|
|
245
|
+
} catch {
|
|
246
|
+
stats.layers[name] = 0
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
for (const name of readdirSync(join(home, 'ws')).filter(f => f.endsWith('.md'))) {
|
|
251
|
+
const size = statSync(join(home, 'ws', name)).size
|
|
252
|
+
stats.layers[`ws/${name}`] = size
|
|
253
|
+
stats.bytes += size
|
|
254
|
+
}
|
|
255
|
+
} catch { /* no workspace layer yet */ }
|
|
256
|
+
stats.docs = loadCorpus(home).length
|
|
257
|
+
return stats
|
|
258
|
+
}
|
package/src/store.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem layout for the three memory layers.
|
|
3
|
+
*
|
|
4
|
+
* <home>/core.md core memory, injected in full every turn
|
|
5
|
+
* <home>/stream.md one line per completed turn
|
|
6
|
+
* <home>/ws/<area>.md detailed notes, retrieved only
|
|
7
|
+
* <home>/.index/ derived caches (safe to delete)
|
|
8
|
+
*
|
|
9
|
+
* The directory is meant to be versioned as a whole: copy it, commit it, or
|
|
10
|
+
* point two harness installs at the same path on different operating systems.
|
|
11
|
+
* Nothing here stores an absolute path.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { homedir, platform } from 'node:os'
|
|
16
|
+
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
export const CORE_FILE = 'core.md'
|
|
19
|
+
export const STREAM_FILE = 'stream.md'
|
|
20
|
+
export const WS_DIR = 'ws'
|
|
21
|
+
export const INDEX_DIR = '.index'
|
|
22
|
+
|
|
23
|
+
const DEFAULT_HOME = platform() === 'win32'
|
|
24
|
+
? join(homedir(), 'AppData', 'Local', 'dsh-memory-dba')
|
|
25
|
+
: join(homedir(), '.dsh-memory-dba')
|
|
26
|
+
|
|
27
|
+
/** NUL and control bytes make a layer unreadable; keep tabs/newlines and all emoji. */
|
|
28
|
+
const BAD_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g
|
|
29
|
+
|
|
30
|
+
export function resolveHome(home) {
|
|
31
|
+
const chosen = home || process.env.DSH_MEMORY_HOME || DEFAULT_HOME
|
|
32
|
+
return resolve(chosen.replace(/^~(?=$|[\\/])/, homedir()))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create the layer files on first run. Existing files are never touched, so
|
|
37
|
+
* initializing an installed package cannot overwrite a real memory directory.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} home Absolute memory directory.
|
|
40
|
+
* @returns {{created: string[], existed: string[]}} What the call changed.
|
|
41
|
+
*/
|
|
42
|
+
export function ensureLayout(home) {
|
|
43
|
+
const created = []
|
|
44
|
+
const existed = []
|
|
45
|
+
mkdirSync(home, { recursive: true })
|
|
46
|
+
mkdirSync(join(home, WS_DIR), { recursive: true })
|
|
47
|
+
mkdirSync(join(home, INDEX_DIR), { recursive: true })
|
|
48
|
+
|
|
49
|
+
const seeds = [
|
|
50
|
+
[CORE_FILE, '# Core memory\n\n'],
|
|
51
|
+
[STREAM_FILE, '# Memory stream\n\n'],
|
|
52
|
+
]
|
|
53
|
+
for (const [name, body] of seeds) {
|
|
54
|
+
const file = join(home, name)
|
|
55
|
+
if (existsSync(file)) {
|
|
56
|
+
existed.push(name)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
writeFileSync(file, body, 'utf8')
|
|
60
|
+
created.push(name)
|
|
61
|
+
}
|
|
62
|
+
return { created, existed }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** UTF-8 byte length, the unit every budget in this package uses. */
|
|
66
|
+
export function bytes(text) {
|
|
67
|
+
return Buffer.byteLength(text, 'utf8')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class MissingLayer extends Error {
|
|
71
|
+
constructor(file) {
|
|
72
|
+
super(`memory layer missing: ${file}`)
|
|
73
|
+
this.name = 'MissingLayer'
|
|
74
|
+
this.file = file
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read one layer as text. Failure is reported as a distinct error type rather
|
|
80
|
+
* than an empty string, so callers can tell "no memory yet" from "read broke".
|
|
81
|
+
*
|
|
82
|
+
* @param {string} home Absolute memory directory.
|
|
83
|
+
* @param {string} name Layer file name relative to `home`.
|
|
84
|
+
* @returns {string} File contents without a BOM.
|
|
85
|
+
*/
|
|
86
|
+
export function readLayer(home, name) {
|
|
87
|
+
const file = join(home, name)
|
|
88
|
+
let raw
|
|
89
|
+
try {
|
|
90
|
+
raw = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(file))
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error?.code === 'ENOENT') throw new MissingLayer(file)
|
|
93
|
+
throw error
|
|
94
|
+
}
|
|
95
|
+
return raw.replace(/^\uFEFF/, '')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Atomic-ish replacement: write a sibling temp file, then rename. */
|
|
99
|
+
export function writeLayer(home, name, text) {
|
|
100
|
+
const file = join(home, name)
|
|
101
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
102
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
103
|
+
writeFileSync(tmp, text.replace(BAD_CHARS, ''), 'utf8')
|
|
104
|
+
renameSync(tmp, file)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Append a single line; creates the file when absent. */
|
|
108
|
+
export function appendLayer(home, name, line) {
|
|
109
|
+
const file = join(home, name)
|
|
110
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
111
|
+
let needsNewline = false
|
|
112
|
+
if (existsSync(file)) {
|
|
113
|
+
const fd = openSync(file, 'r')
|
|
114
|
+
try {
|
|
115
|
+
const { size } = fstatSync(fd)
|
|
116
|
+
if (size > 0) {
|
|
117
|
+
const last = Buffer.alloc(1)
|
|
118
|
+
readSync(fd, last, 0, 1, size - 1)
|
|
119
|
+
needsNewline = last[0] !== 0x0a
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
closeSync(fd)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const clean = line.replace(BAD_CHARS, '')
|
|
126
|
+
writeFileSync(file, `${needsNewline ? '\n' : ''}${clean}\n`, { encoding: 'utf8', flag: 'a' })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Cut text to a byte budget without splitting a UTF-8 sequence or a lone
|
|
131
|
+
* surrogate, and mark the truncation so a reader never mistakes a prefix for
|
|
132
|
+
* the whole layer.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} text Source text.
|
|
135
|
+
* @param {number} limitBytes Maximum UTF-8 bytes.
|
|
136
|
+
* @param {string} [marker] Suffix appended when text was cut.
|
|
137
|
+
*/
|
|
138
|
+
export function clipBytes(text, limitBytes, marker = '\n\n[...clipped to fit the memory budget]') {
|
|
139
|
+
limitBytes = Number.isFinite(limitBytes) ? Math.max(0, Math.floor(limitBytes)) : 0
|
|
140
|
+
if (bytes(text) <= limitBytes) return { text, clipped: false }
|
|
141
|
+
if (bytes(marker) > limitBytes) marker = clipBytes(marker, limitBytes, '').text
|
|
142
|
+
const reserve = bytes(marker)
|
|
143
|
+
const budget = Math.max(0, limitBytes - reserve)
|
|
144
|
+
let used = 0
|
|
145
|
+
let out = ''
|
|
146
|
+
for (const ch of text) {
|
|
147
|
+
const size = bytes(ch)
|
|
148
|
+
if (used + size > budget) break
|
|
149
|
+
out += ch
|
|
150
|
+
used += size
|
|
151
|
+
}
|
|
152
|
+
return { text: out + marker, clipped: true }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Snapshot used to invalidate derived indexes without watching files. */
|
|
156
|
+
export function fingerprint(home) {
|
|
157
|
+
const parts = []
|
|
158
|
+
let names = []
|
|
159
|
+
try {
|
|
160
|
+
names = readdirSync(join(home, WS_DIR)).filter(name => name.endsWith('.md')).sort()
|
|
161
|
+
} catch { /* An unavailable workspace has no cacheable entries. */ }
|
|
162
|
+
for (const name of [CORE_FILE, STREAM_FILE, ...names.map(name => `${WS_DIR}/${name}`)]) {
|
|
163
|
+
try {
|
|
164
|
+
const st = statSync(join(home, name))
|
|
165
|
+
parts.push(JSON.stringify([name, st.size, st.mtimeMs]))
|
|
166
|
+
} catch {
|
|
167
|
+
parts.push(`${name}:-`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return parts.join('|')
|
|
171
|
+
}
|