@try-works/dsh-memory 0.1.28 → 0.1.30

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,8 +3,9 @@
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.27, published and installed
7
- byte-identical and confirmed LOADED by the running host; queue terminal-clean).
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).
8
9
 
9
10
  ## How to read this
10
11
 
@@ -67,6 +68,7 @@ Observed in the running host. Each row names the effect that was seen, not a cla
67
68
  | Boot tasks backup + restore ran live | at boot (2026-10-10T22:03:14Z) the log records `tasks-backup ok:true written:0 refreshed:5` and `tasks-restore ok:true owner:session-6e8d9a10 present:5 added:0 paused:0` — the restore reached the schedule service, found everything present, and duplicated nothing |
68
69
  | Git sync works live | after the 0.1.22+ boot the log shows real `sync` events, `state/sync.json` records `ok:true remote:https://github.com/try-works/dsh-memory-store` (2026-10-11T10:34:54Z), and `git log` in the store holds `memory(store): update [dsh-memory]` commits — the first syncs this plugin has ever performed |
69
70
  | History ingest works live | `history-check ok=true` entries after the boot, including `count=1` at 09:37Z — a commit was actually ingested; the hourly `null.run` error era is over |
71
+ | Map publishing works on harness 0.2.1 | after the 0.1.28 boot the log shows `map-publish ok:true` on every cadence, the live session log carries `plugin:memory/map` rows with `ignorable:true`, the harness's own `validateStoredEvents` accepts the log, and the Memory tab renders (confirmed in the UI). The 0.2.1 contract is different from what 0.1.27 assumed: `Session.append` takes options only for surface event types, so an out-of-repo record is written with `appendPluginRecord` under the `plugin:` namespace |
70
72
 
71
73
  ## Shipped and LOADED
72
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
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/fts.js CHANGED
@@ -54,11 +54,43 @@ export function buildTrigramQuery(input) {
54
54
  /**
55
55
  * Create both indexes when missing. The word table serves normal queries; the trigram table
56
56
  * serves substring queries (no-space scripts), which the word tokenizer cannot express.
57
+ *
58
+ * MANDATORY, both tables. The trigram table is the ONLY route for a no-space script such as
59
+ * Chinese or Japanese, and the fallback for a partial-word query that the word table missed, so
60
+ * failing to create it must not be swallowed: a store without it silently makes a whole class of
61
+ * queries unanswerable, and the caller sees "no results" rather than an error. Throw here, where
62
+ * the cause is still visible, instead of degrading search quietly.
57
63
  */
58
64
  function ensureTables(db) {
59
65
  db.exec('CREATE VIRTUAL TABLE IF NOT EXISTS mem_fts USING fts5(file, line, section, content)')
60
- try { db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS " + SUBSTRING_TABLE + " USING fts5(file, line, section, content, tokenize='trigram')") }
61
- catch { /* a build without trigram still works, just without substring search */ }
66
+ try {
67
+ db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS " + SUBSTRING_TABLE + " USING fts5(file, line, section, content, tokenize='trigram')")
68
+ } catch (error) {
69
+ throw new Error('the substring (trigram) index is required and could not be created: ' + String((error && error.message) || error))
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Run a block of writes inside ONE transaction.
75
+ *
76
+ * Without this, every INSERT is its own implicit transaction, so a row costs a commit and an
77
+ * fsync. A full rebuild of this store writes ~292,000 rows (word table + trigram table), and a
78
+ * measured rebuild did not finish in ten minutes. Batched, the same work is seconds - which is
79
+ * what makes "the index is derived, delete it and rebuild" a safe recovery step instead of an
80
+ * outage for every query.
81
+ * @param run - the work to perform inside the transaction.
82
+ * @returns whatever `run` returned, after the transaction commits.
83
+ */
84
+ function inTransaction(db, run) {
85
+ db.exec('BEGIN')
86
+ try {
87
+ const result = run()
88
+ db.exec('COMMIT')
89
+ return result
90
+ } catch (error) {
91
+ try { db.exec('ROLLBACK') } catch { /* a failed rollback must not mask the real error */ }
92
+ throw error
93
+ }
62
94
  }
63
95
 
64
96
  /**
@@ -122,11 +154,13 @@ export function updateIndex(root, files) {
122
154
  let dropTri
123
155
  try { dropTri = db.prepare('DELETE FROM ' + SUBSTRING_TABLE + ' WHERE file = ?') } catch { dropTri = undefined }
124
156
  let rows = 0
125
- for (const file of list) {
126
- drop.run(relOf(root, file))
127
- if (dropTri) try { dropTri.run(relOf(root, file)) } catch {}
128
- try { rows += indexFile(db, root, file) } catch {}
129
- }
157
+ inTransaction(db, () => {
158
+ for (const file of list) {
159
+ drop.run(relOf(root, file))
160
+ if (dropTri) try { dropTri.run(relOf(root, file)) } catch {}
161
+ try { rows += indexFile(db, root, file) } catch {}
162
+ }
163
+ })
130
164
  db.close()
131
165
  return { rows, files: list.length }
132
166
  }
@@ -159,8 +193,11 @@ export function buildIndex(root) {
159
193
  db.exec('DROP TABLE IF EXISTS mem_fts')
160
194
  db.exec('DROP TABLE IF EXISTS ' + SUBSTRING_TABLE)
161
195
  ensureTables(db)
162
- let rows = 0
163
- for (const file of indexedFiles(root)) rows += indexFile(db, root, file)
196
+ const rows = inTransaction(db, () => {
197
+ let total = 0
198
+ for (const file of indexedFiles(root)) total += indexFile(db, root, file)
199
+ return total
200
+ })
164
201
  db.close()
165
202
  return { dbPath, rows }
166
203
  }
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()
@@ -1,13 +0,0 @@
1
- import { Context } from "@deepseek-ai/cordis"
2
- import { SessionStore } from "@deepseek-ai/dsh-session"
3
- import JsonlSessionPersistence from "@deepseek-ai/dsh-session-persistence-jsonl"
4
- const ctx = new Context()
5
- await ctx.plugin(SessionStore)
6
- await ctx.plugin(JsonlSessionPersistence, { root: "C:/Users/erikb/.dsh/sessions", compression: "zstd" })
7
- const p = ctx.get("sessionPersistence")
8
- const id = "session-b21ecc7e-7743-4b06-b07f-453541dbcde5"
9
- try {
10
- const h = await p.open(id, "read")
11
- const snap = await h.read()
12
- console.log("REAL OPEN OK — events:", snap?.events?.length ?? snap?.log?.length ?? "(shape)")
13
- } catch (e) { console.log("REAL OPEN FAILED:", String(e.message).slice(0, 300)) }
@@ -1,16 +0,0 @@
1
- import { readFileSync } from "node:fs"
2
- import { scanZstdFrames, decompressZstdFrame, decompressZstdPrefix } from "file:///D:/deepseek-harness/packages/session/session-persistence-jsonl/src/zstd.ts"
3
- import { validateStoredEvents } from "file:///D:/deepseek-harness/packages/session/session-persistence/src/storage-contract.ts"
4
- const buf = readFileSync(process.argv[2])
5
- const scan = scanZstdFrames(buf)
6
- let text = ""
7
- for (const f of scan.frames) text += (await decompressZstdFrame(buf.subarray(f.start, f.end))).toString("utf8")
8
- if (scan.tornStart !== undefined) text += (await decompressZstdPrefix(buf.subarray(scan.tornStart))).toString("utf8")
9
- const rows = text.split("\n").filter(Boolean).map(l => JSON.parse(l))
10
- const meta = rows[0]; const events = rows.slice(1)
11
- const dups = new Map(); for (const e of events) dups.set(e.seq, (dups.get(e.seq) ?? 0) + 1)
12
- console.log("events:", events.length, "| duplicate seqs:", [...dups].filter(([,n]) => n > 1).slice(0, 5))
13
- const bad = events.filter(e => e.type === "memory/map" && e.ignorable !== true)
14
- console.log("memory/map without marker:", bad.length)
15
- try { validateStoredEvents(meta, events, { kind: "jsonl", path: process.argv[2] }); console.log("VALIDATOR: OK") }
16
- catch (e) { console.log("VALIDATOR THREW:", String(e.message).slice(0, 300)) }