@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 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.29",
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
+ }
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 { buildLedger } from './ledger.js'
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
- const ledger = buildLedger(store.root)
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 { buildLedger } from './ledger.js'
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
- return rankBlend(hits, buildLedger(store.root)).slice(0, limit)
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()