@try-works/dsh-memory 0.1.29 → 0.1.31
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/STATUS.md +20 -3
- package/package.json +1 -1
- package/src/capture.js +6 -0
- package/src/compact.js +126 -0
- package/src/inject.js +28 -3
- package/src/ledger.js +47 -19
- package/src/search.js +4 -2
package/STATUS.md
CHANGED
|
@@ -3,9 +3,26 @@
|
|
|
3
3
|
A tracker and planner: what exists, what is verified working, what is written but not
|
|
4
4
|
loaded, and what is left. Kept as a record of MEASURED state, not intention.
|
|
5
5
|
|
|
6
|
-
**Last measured: 2026-
|
|
7
|
-
byte-identical
|
|
8
|
-
the
|
|
6
|
+
**Last measured: 2026-11-01** (409 tests passing; plugin at 0.1.30, published and installed
|
|
7
|
+
byte-identical on harness 0.2.1-rc.2; queue terminal-clean; the Memory tab reads the published
|
|
8
|
+
map under the new event name; the FTS index is 148.6 MB after a rebuild that used to be 436 MB,
|
|
9
|
+
search measures 46-274 ms and an injection 3.3 s).
|
|
10
|
+
|
|
11
|
+
## Store performance, 2026-11-01
|
|
12
|
+
|
|
13
|
+
Three measurements that shaped 0.1.29/0.1.30, kept because each one is a design constraint
|
|
14
|
+
rather than a one-off number:
|
|
15
|
+
|
|
16
|
+
| what | before | after | why it mattered |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| full FTS rebuild | did not finish in 10 min | 43-50 s | every row was its own transaction (~292,000 commits). Batching made "the index is derived, rebuild it" safe |
|
|
19
|
+
| `memory_search` | ~15,000 ms | 46-274 ms | `search()` built the WHOLE ledger (~8,000 files) just to rank the few files that matched; it now reads only the hit files |
|
|
20
|
+
| injection | ~17,000 ms | 3,300 ms | the card quotes only learnings and decisions, but built the ledger for every layer including 425 history shards and 3,010 semantic entries |
|
|
21
|
+
| index file | 436.2 MB | 148.6 MB | free-page bloat from incremental deletes; a rebuild reclaims it and the trigram table (345 MB of the old file) is mandatory, so this is the floor, not a saving to chase |
|
|
22
|
+
|
|
23
|
+
Reading this store costs ~2.4 ms per file (8.5 MB takes ~19 s), so any code path that walks all
|
|
24
|
+
~8,000 files is a multi-second operation by construction. That is the rule the two fixes above
|
|
25
|
+
came from.
|
|
9
26
|
|
|
10
27
|
## How to read this
|
|
11
28
|
|
package/package.json
CHANGED
package/src/capture.js
CHANGED
|
@@ -10,6 +10,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync,
|
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import { projectDigest, classifyJunk, extractCandidates, renderDigestHeader, totalToolCalls, dateOf } from './digest.js'
|
|
12
12
|
import { summarizeSession, renderSummarySection } from './summarize.js'
|
|
13
|
+
import { compactDigest } from './compact.js'
|
|
13
14
|
import { advance, fail, isSettled, readQueue, REASONS, seed, STATES } from './queue.js'
|
|
14
15
|
|
|
15
16
|
/** Digest file format. A stored state with a different value forces a full re-capture. */
|
|
@@ -127,6 +128,11 @@ export async function captureSession(store, services, session, options = {}) {
|
|
|
127
128
|
: { bullets: [], source: "thin" })
|
|
128
129
|
if (summary.bullets.length > 0) {
|
|
129
130
|
appendFileSync(path, '\n' + renderSummarySection(summary.bullets, { stamp: meta.capturedAt, source: summary.source }), 'utf8')
|
|
131
|
+
// A long-lived session adds a section on every pass, and consolidation reads only the first
|
|
132
|
+
// and last 20 KB of a digest - so without this the middle grew forever and was never read.
|
|
133
|
+
// Folding is safe: entries already written are durable, and the recap keeps one line of every
|
|
134
|
+
// folded section. Bounded to the config budget so a raise is a deliberate choice.
|
|
135
|
+
try { compactDigest(path, { keepBytes: cfg.compactKeepBytes ?? 14000, maxRecapBytes: cfg.compactMaxRecapBytes ?? 4000 }) } catch {}
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
// SETTLED versus FAILED. Nothing durable written is only a conclusion when the model SAID
|
package/src/compact.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Fold the OLD summary sections of one digest into a bounded recap.
|
|
2
|
+
//
|
|
3
|
+
// A digest is append-only by nature: every capture pass adds one `## <stamp> - summary (llm)`
|
|
4
|
+
// section, because each pass summarizes only the new events. A long-lived session therefore
|
|
5
|
+
// accumulates hundreds of sections - one was measured at 381 sections / 572 KB - while
|
|
6
|
+
// consolidation reads at most `maxDigestBytes` (20 KB) of it, kept as 60% head and 40% tail. So
|
|
7
|
+
// the middle of a large digest was NEVER read, and every later pass re-read the same head.
|
|
8
|
+
//
|
|
9
|
+
// Compaction keeps the header, folds the older sections into one recap of sampled bullets, and
|
|
10
|
+
// leaves the newest sections intact, so a digest fits in ONE consolidation pass. It never grows
|
|
11
|
+
// the file and never drops a section without keeping a line of it in the recap.
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
/** A summary section heading. */
|
|
15
|
+
const SUMMARY_HEADING = /^## .+ - summary \(.+\)$/
|
|
16
|
+
/** The recap this module writes, which is part of the header, never a foldable section. */
|
|
17
|
+
const RECAP_BLOCK = /^## compacted recap[^\n]*\n(?:- [^\n]*\n?)*/m
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Split a digest into its header and its summary sections.
|
|
21
|
+
*
|
|
22
|
+
* The recap is deliberately part of the header: a recap is already the compressed form of older
|
|
23
|
+
* sections, so folding it again would re-compress it on every pass and the file would never settle.
|
|
24
|
+
* @returns header (everything before the first summary section) and the summary sections.
|
|
25
|
+
*/
|
|
26
|
+
function splitSections(text) {
|
|
27
|
+
const lines = text.split('\n')
|
|
28
|
+
const starts = []
|
|
29
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
30
|
+
if (SUMMARY_HEADING.test(lines[i])) starts.push(i)
|
|
31
|
+
}
|
|
32
|
+
if (starts.length === 0) return { header: text, sections: [] }
|
|
33
|
+
const header = lines.slice(0, starts[0]).join('\n') + (starts[0] > 0 ? '\n' : '')
|
|
34
|
+
const sections = starts.map((start, index) => {
|
|
35
|
+
const end = index + 1 < starts.length ? starts[index + 1] : lines.length
|
|
36
|
+
return { text: lines.slice(start, end).join('\n') }
|
|
37
|
+
})
|
|
38
|
+
return { header, sections }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The timestamp a section is stamped with: `## <stamp> - summary`, or a recap's own range. */
|
|
42
|
+
function stampOf(section) {
|
|
43
|
+
const line = section.text.split('\n', 1)[0]
|
|
44
|
+
const match = /^## (.+?) - summary \(/.exec(line)
|
|
45
|
+
if (match) return match[1].trim()
|
|
46
|
+
const range = /^## compacted recap[^(]*\(([^.]*)\.\./.exec(line)
|
|
47
|
+
return range ? range[1].trim() : ''
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The first bullet of a section, which is the line most likely to carry its point. */
|
|
51
|
+
function firstBullet(section) {
|
|
52
|
+
for (const line of section.text.split('\n')) {
|
|
53
|
+
if (line.startsWith('- ')) return line.slice(2).trim()
|
|
54
|
+
}
|
|
55
|
+
return ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render the recap that replaces the folded sections.
|
|
60
|
+
*
|
|
61
|
+
* Bullets are SAMPLED evenly rather than taken from the front, so the recap spans the whole
|
|
62
|
+
* folded range instead of over-representing its first few summaries. A previous recap, when one
|
|
63
|
+
* exists, contributes its bullets first: they describe older material than the sections being
|
|
64
|
+
* folded now, and keeping them is what makes repeated compactions accumulate context.
|
|
65
|
+
* @param folded - the sections being replaced, oldest first.
|
|
66
|
+
* @param options.maxRecapBytes - hard ceiling for the rendered recap.
|
|
67
|
+
* @param options.prior - the previous recap block, if the header had one.
|
|
68
|
+
* @returns the recap block.
|
|
69
|
+
*/
|
|
70
|
+
function renderRecap(folded, { maxRecapBytes, prior = '' }) {
|
|
71
|
+
const first = stampOf(folded[0])
|
|
72
|
+
const last = stampOf(folded[folded.length - 1])
|
|
73
|
+
let out = '## compacted recap - ' + folded.length + ' earlier summaries' + (first || last ? ' (' + first + '..' + last + ')' : '') + '\n'
|
|
74
|
+
if (prior) {
|
|
75
|
+
for (const row of prior.split('\n')) {
|
|
76
|
+
if (!row.startsWith('- ')) continue
|
|
77
|
+
if (out.length + row.length + 1 > maxRecapBytes) break
|
|
78
|
+
out += row + '\n'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const step = Math.max(1, Math.ceil(folded.length / 80))
|
|
82
|
+
for (let i = 0; i < folded.length; i += step) {
|
|
83
|
+
const bullet = firstBullet(folded[i])
|
|
84
|
+
if (!bullet) continue
|
|
85
|
+
const row = '- ' + bullet.slice(0, 180) + '\n'
|
|
86
|
+
if (out.length + row.length > maxRecapBytes) break
|
|
87
|
+
out += row
|
|
88
|
+
}
|
|
89
|
+
return out + '\n'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compact one digest in place.
|
|
94
|
+
* @param path - the digest file.
|
|
95
|
+
* @param options.keepBytes - byte budget for the newest sections kept verbatim.
|
|
96
|
+
* @param options.maxRecapBytes - ceiling for the recap that replaces the folded ones.
|
|
97
|
+
* @returns what it did: `folded` is 0 when the digest already fits.
|
|
98
|
+
*/
|
|
99
|
+
export function compactDigest(path, { keepBytes = 14000, maxRecapBytes = 4000 } = {}) {
|
|
100
|
+
if (!existsSync(path)) return { folded: 0 }
|
|
101
|
+
const before = readFileSync(path, 'utf8')
|
|
102
|
+
const { header, sections } = splitSections(before)
|
|
103
|
+
if (sections.length === 0) return { folded: 0 }
|
|
104
|
+
// An earlier recap lives in the header. Its bullets are carried into the new recap rather than
|
|
105
|
+
// dropped, so repeated compactions accumulate context instead of erasing it one level at a time.
|
|
106
|
+
const priorMatch = RECAP_BLOCK.exec(header)
|
|
107
|
+
const prior = priorMatch ? priorMatch[0] : ''
|
|
108
|
+
const headerBase = prior ? header.replace(prior, '').replace(/\n{3,}/g, '\n\n') : header
|
|
109
|
+
// Keep the NEWEST sections that fit the budget, and always at least one.
|
|
110
|
+
const kept = []
|
|
111
|
+
let keptBytes = 0
|
|
112
|
+
for (let i = sections.length - 1; i >= 0; i -= 1) {
|
|
113
|
+
const size = sections[i].text.length
|
|
114
|
+
if (kept.length > 0 && keptBytes + size > keepBytes) break
|
|
115
|
+
kept.unshift(sections[i])
|
|
116
|
+
keptBytes += size
|
|
117
|
+
}
|
|
118
|
+
const folded = sections.slice(0, sections.length - kept.length)
|
|
119
|
+
if (folded.length === 0) return { folded: 0, kept: kept.length }
|
|
120
|
+
const recap = renderRecap(folded, { maxRecapBytes, prior })
|
|
121
|
+
const after = headerBase + recap + kept.map((s) => s.text).join('')
|
|
122
|
+
// A compaction that does not shrink the file is not worth the write; leave it alone.
|
|
123
|
+
if (after.length >= before.length) return { folded: 0, kept: kept.length }
|
|
124
|
+
writeFileSync(path, after, 'utf8')
|
|
125
|
+
return { folded: folded.length, kept: kept.length, bytesBefore: before.length, bytesAfter: after.length }
|
|
126
|
+
}
|
package/src/inject.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Session-start injection text: goal + working head + recent learnings + footer,
|
|
2
2
|
// char-capped and scope-aware.
|
|
3
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { layerDir } from './store.js'
|
|
6
|
-
import {
|
|
6
|
+
import { buildLedgerFor } from './ledger.js'
|
|
7
7
|
import { readLayerTopics } from './layers.js'
|
|
8
8
|
import { freshnessMarker, readState } from './state.js'
|
|
9
9
|
import { readWorking, workingMarker } from './working.js'
|
|
@@ -12,7 +12,11 @@ import { relevanceQuery, selectEntries } from './select.js'
|
|
|
12
12
|
export function buildInjectionText(store, { scope = 'global', key, goal, maxChars = 3600, subagent = false } = {}) {
|
|
13
13
|
const dir = layerDir(store.root, scope, key)
|
|
14
14
|
// Working memory is the AGENT's intent (task, recent activity, next steps); repository
|
|
15
|
-
|
|
15
|
+
//
|
|
16
|
+
// Only the two layers this card actually quotes are read. Building the whole ledger here meant
|
|
17
|
+
// walking every indexed file - ~8,000 of them, history shards included - on every injection,
|
|
18
|
+
// which measured ~17 s and dwarfed the text it produced.
|
|
19
|
+
const ledger = buildLedgerFor(store.root, layerFilesFor(dir, ['learnings', 'decisions']))
|
|
16
20
|
const prefix = scope === 'global' ? 'global/' : 'workspaces/' + key + '/'
|
|
17
21
|
// The pools are LISTED here and CHOSEN later, because choosing needs the question, and the
|
|
18
22
|
// question comes from working memory - which is read below.
|
|
@@ -106,3 +110,24 @@ export function buildInjectionText(store, { scope = 'global', key, goal, maxChar
|
|
|
106
110
|
const body = parts.join('\n\n')
|
|
107
111
|
return (body.length <= maxChars ? body : body.slice(0, maxChars)) + '\n\n' + footer
|
|
108
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The absolute file paths of the named layer directories in one scope.
|
|
116
|
+
*
|
|
117
|
+
* Layer directories are flat (one topic file per entry), so listing them is cheap. This exists
|
|
118
|
+
* because the injection card quotes only learnings and decisions: reading every OTHER layer -
|
|
119
|
+
* semantic, episodic, procedural, codebase, history - was the expensive part of the old
|
|
120
|
+
* whole-store ledger, and none of it could ever appear in the card.
|
|
121
|
+
* @param dir - the scope directory (a layer dir already), from {@link layerDir}.
|
|
122
|
+
* @param layers - directory names under it, e.g. 'learnings' and 'decisions'.
|
|
123
|
+
* @returns absolute paths, in directory order.
|
|
124
|
+
*/
|
|
125
|
+
function layerFilesFor(dir, layers) {
|
|
126
|
+
const files = []
|
|
127
|
+
for (const name of layers) {
|
|
128
|
+
const layerPath = join(dir, name)
|
|
129
|
+
if (!existsSync(layerPath)) continue
|
|
130
|
+
for (const file of readdirSync(layerPath)) if (file.endsWith('.md')) files.push(join(layerPath, file))
|
|
131
|
+
}
|
|
132
|
+
return files
|
|
133
|
+
}
|
package/src/ledger.js
CHANGED
|
@@ -1,19 +1,47 @@
|
|
|
1
|
-
// Derived entry ledger: per-entry metadata (file, layer, line span, importance,
|
|
2
|
-
// dates) rebuilt from the markdown in one step. Feeds ranking + freshness.
|
|
3
|
-
import { readFileSync } from 'node:fs'
|
|
4
|
-
import { relative } from 'node:path'
|
|
5
|
-
import { indexedFiles, layerOfFile } from './files.js'
|
|
6
|
-
import { parseEntries } from './entry.js'
|
|
7
|
-
|
|
8
|
-
export function buildLedger(root) {
|
|
9
|
-
const entries = []
|
|
10
|
-
for (const file of indexedFiles(root)) {
|
|
11
|
-
const rel = relative(root, file).replace(/\\/g, '/')
|
|
12
|
-
const layer = layerOfFile(root, file)
|
|
13
|
-
if (!layer) continue
|
|
14
|
-
for (const e of parseEntries(readFileSync(file, 'utf8'))) {
|
|
15
|
-
entries.push({ ...e, file: rel, layer })
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
return entries
|
|
19
|
-
}
|
|
1
|
+
// Derived entry ledger: per-entry metadata (file, layer, line span, importance,
|
|
2
|
+
// dates) rebuilt from the markdown in one step. Feeds ranking + freshness.
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
5
|
+
import { indexedFiles, layerOfFile } from './files.js'
|
|
6
|
+
import { parseEntries } from './entry.js'
|
|
7
|
+
|
|
8
|
+
export function buildLedger(root) {
|
|
9
|
+
const entries = []
|
|
10
|
+
for (const file of indexedFiles(root)) {
|
|
11
|
+
const rel = relative(root, file).replace(/\\/g, '/')
|
|
12
|
+
const layer = layerOfFile(root, file)
|
|
13
|
+
if (!layer) continue
|
|
14
|
+
for (const e of parseEntries(readFileSync(file, 'utf8'))) {
|
|
15
|
+
entries.push({ ...e, file: rel, layer })
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return entries
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build entries for a KNOWN SET of files instead of the whole store.
|
|
23
|
+
*
|
|
24
|
+
* The full ledger reads every indexed file, and on this store that is ~8,000 files and ~17 s —
|
|
25
|
+
* far too expensive for a call that only needs a handful. Both callers below know which files
|
|
26
|
+
* can possibly matter: ranking needs the files that produced a hit, and the injection card needs
|
|
27
|
+
* only the two layers it quotes. Reading the rest was pure I/O.
|
|
28
|
+
* @param files - paths, absolute or root-relative with forward slashes; duplicates are ignored.
|
|
29
|
+
* @returns the same entry shape as {@link buildLedger}, for the files that resolved to a layer.
|
|
30
|
+
*/
|
|
31
|
+
export function buildLedgerFor(root, files) {
|
|
32
|
+
const entries = []
|
|
33
|
+
const seen = new Set()
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
if (!file) continue
|
|
36
|
+
const abs = isAbsolute(file) ? file : join(root, String(file).split('/').join(sep))
|
|
37
|
+
const rel = relative(root, abs).replace(/\\/g, '/')
|
|
38
|
+
if (seen.has(rel)) continue
|
|
39
|
+
seen.add(rel)
|
|
40
|
+
const layer = layerOfFile(root, abs)
|
|
41
|
+
if (!layer) continue
|
|
42
|
+
let text
|
|
43
|
+
try { text = readFileSync(abs, 'utf8') } catch { continue }
|
|
44
|
+
for (const e of parseEntries(text)) entries.push({ ...e, file: rel, layer })
|
|
45
|
+
}
|
|
46
|
+
return entries
|
|
47
|
+
}
|
package/src/search.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { existsSync } from 'node:fs'
|
|
8
8
|
import { DatabaseSync } from 'node:sqlite'
|
|
9
9
|
import { buildFtsQuery, buildIndex, buildLikeQuery, buildTrigramQuery, indexPath, needsSubstring, SUBSTRING_TABLE } from './fts.js'
|
|
10
|
-
import {
|
|
10
|
+
import { buildLedgerFor } from './ledger.js'
|
|
11
11
|
import { rankBlend } from './rank.js'
|
|
12
12
|
|
|
13
13
|
/** Rows pulled from the word index before ranking. */
|
|
@@ -67,7 +67,9 @@ export function search(store, query, { limit = 10, scope, key } = {}) {
|
|
|
67
67
|
if (scope === 'global') filtered = rows.filter((r) => r.file.startsWith('global/'))
|
|
68
68
|
else if (scope === 'workspace') filtered = rows.filter((r) => r.file.startsWith('workspaces/' + (key ?? '') + '/'))
|
|
69
69
|
const hits = filtered.map((r) => ({ file: r.file, line: r.line, section: r.section, snippet: String(r.content).trim() }))
|
|
70
|
-
|
|
70
|
+
// Ranking needs importance and recency only for the files that matched. Building the whole
|
|
71
|
+
// ledger here read ~8,000 files for a handful of hits and made one query cost ~15 s.
|
|
72
|
+
return rankBlend(hits, buildLedgerFor(store.root, hits.map((h) => h.file))).slice(0, limit)
|
|
71
73
|
.map((h) => ({ file: h.file, line: h.line, section: h.section ?? '', snippet: h.snippet, id: h.id ?? '', score: Math.round(h.score * 1000) / 1000 }))
|
|
72
74
|
} finally {
|
|
73
75
|
db.close()
|