@try-works/dsh-memory 0.1.30 → 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/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
|
+
}
|