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/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
+ }