@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 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-10-17** (408 tests passing; plugin at 0.1.28, published and installed
7
- byte-identical and confirmed LOADED by the running harness at 0.2.1-rc.2; queue terminal-clean;
8
- the Memory tab reads the published map under the new event name).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "Git-backed, workspace-scoped memory for the DeepSeek Harness (global layer + per-workspace layers)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
+ }