@try-works/dsh-memory 0.1.29 → 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/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.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/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()