@try-works/dsh-memory 0.1.18 → 0.1.20

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.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +287 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/src/SYSTEM.template.md +311 -311
  8. package/src/capture.js +242 -242
  9. package/src/client/index.js +886 -886
  10. package/src/config.js +57 -57
  11. package/src/consolidate-run.js +132 -132
  12. package/src/consolidate.js +330 -330
  13. package/src/context.js +88 -88
  14. package/src/decisions.js +106 -106
  15. package/src/defaults.js +93 -93
  16. package/src/digest.js +255 -255
  17. package/src/entry.js +179 -179
  18. package/src/eval.js +72 -72
  19. package/src/files.js +60 -60
  20. package/src/fixture.js +56 -56
  21. package/src/forget.js +71 -71
  22. package/src/freshness.js +65 -65
  23. package/src/fts.js +166 -166
  24. package/src/git.js +53 -53
  25. package/src/history.js +226 -226
  26. package/src/host.js +866 -857
  27. package/src/index.js +115 -94
  28. package/src/inject.js +108 -108
  29. package/src/layers.js +178 -178
  30. package/src/ledger.js +19 -19
  31. package/src/map.js +184 -184
  32. package/src/models.js +136 -136
  33. package/src/queue.js +602 -602
  34. package/src/rank.js +31 -31
  35. package/src/recover.js +91 -91
  36. package/src/refresh.js +80 -80
  37. package/src/remember.js +58 -58
  38. package/src/rerank.js +90 -90
  39. package/src/search.js +75 -75
  40. package/src/select.js +57 -57
  41. package/src/session.js +60 -60
  42. package/src/state.js +240 -240
  43. package/src/store.js +128 -128
  44. package/src/summarize.js +195 -195
  45. package/src/system-doc.js +171 -171
  46. package/src/tasks.js +617 -617
  47. package/src/telemetry.js +122 -122
  48. package/src/tools.js +828 -818
  49. package/src/working.js +288 -288
  50. package/src/workspace-key.js +68 -68
  51. package/src/workspace.js +87 -87
package/src/entry.js CHANGED
@@ -1,179 +1,179 @@
1
- // Entry format (Format 2), parsing, and id allocation. Pure (no fs) — unit-testable.
2
- export const LAYER_CODES = { semantic: 'sem', episodic: 'epi', procedural: 'pro', learning: 'learning', codebase: 'code', history: 'hist', decision: 'dec' }
3
- export const LAYER_FILES = { semantic: 'semantic.md', episodic: 'episodic.md', procedural: 'procedural.md', learning: 'learnings.md', codebase: 'codebase.md', history: 'history.md', decision: 'decisions.md' }
4
- export const LAYERS = Object.keys(LAYER_CODES)
5
-
6
- /** Sub-directory holding the per-topic files for each layered kind. */
7
- export const LAYER_DIRS = { semantic: 'semantic', episodic: 'episodic', procedural: 'procedural', learning: 'learnings', codebase: 'codebase', history: 'history', decision: 'decisions' }
8
-
9
- /** Layered kinds, in a stable order. */
10
- export const LAYERED = Object.keys(LAYER_DIRS)
11
-
12
- /**
13
- * Kinds that only make sense inside a repository. The codebase map describes ONE repo, so a
14
- * global map would mix unrelated projects together - the same reason working memory is
15
- * per-workspace.
16
- */
17
- export const WORKSPACE_ONLY_LAYERS = ['codebase', 'history']
18
-
19
- /** Layered kinds available in a scope. */
20
- export const GLOBAL_LAYERED = LAYERED.filter((layer) => !WORKSPACE_ONLY_LAYERS.includes(layer))
21
-
22
- /** The layered kinds that exist in a given scope. */
23
- export function layersForScope(scope) { return scope === 'global' ? GLOBAL_LAYERED : LAYERED }
24
-
25
- /** True when this kind may only be written to a workspace. */
26
- export function isWorkspaceOnly(layer) { return WORKSPACE_ONLY_LAYERS.includes(layer) }
27
-
28
- /**
29
- * How fast an entry's content decays. This is a property OF THE ENTRY, not a global setting:
30
- * a language choice stays true for years, a port number can change tomorrow, and only the
31
- * writer knows which it is. Freshness thresholds are derived from this rather than assumed.
32
- */
33
- export const VOLATILITY = { stable: 'stable', normal: 'normal', volatile: 'volatile' }
34
-
35
- /** Days after which an entry of each volatility is worth re-checking. */
36
- export const VOLATILITY_DAYS = { stable: 365, normal: 30, volatile: 3 }
37
-
38
- /** Valid volatility values, in increasing decay order. */
39
- export const VOLATILITIES = Object.keys(VOLATILITY_DAYS)
40
-
41
- /** Coerce a volatility to a known value; unknown or absent becomes normal. */
42
- export function normalizeVolatility(value) {
43
- const text = String(value ?? '').trim().toLowerCase()
44
- return Object.hasOwn(VOLATILITY_DAYS, text) ? text : VOLATILITY.normal
45
- }
46
-
47
- /** Scope id embedded in an entry Id: 'global' or the workspace key. */
48
- export function scopeIdFor(scope, key) {
49
- return scope === 'global' ? 'global' : (key ?? 'global')
50
- }
51
-
52
- export function nowIso() { return new Date().toISOString() }
53
-
54
- /**
55
- * Allocate the next NNNN for a (scopeId, layer) pair by scanning existing **Id:** lines.
56
- * scopeId + layer are already slug-safe (alnum + hyphen), so no regex escaping is needed.
57
- */
58
- export function allocateId(text, scopeId, layer) {
59
- const code = LAYER_CODES[layer] ?? layer
60
- const re = new RegExp('\\*\\*Id:\\*\\*\\s*' + scopeId + '-' + code + '-(\\d+)', 'g')
61
- let max = 0
62
- let m
63
- while ((m = re.exec(text)) !== null) max = Math.max(max, Number(m[1]))
64
- return scopeId + '-' + code + '-' + String(max + 1).padStart(4, '0')
65
- }
66
-
67
- /** Build the metadata block for a new entry (one field per line; no heading — the writer adds it). */
68
- export function formatEntry({ id, source, date, importance = 2, status = 'active', until, volatility, verified, content }) {
69
- const lines = [
70
- '**Id:** ' + id,
71
- '**Source:** ' + source,
72
- '**Date:** ' + date,
73
- '**Updated:** ' + date,
74
- '**Importance:** ' + importance,
75
- '**Status:** ' + status,
76
- ]
77
- // Self-describing freshness: the entry states how fast it decays and when it was last
78
- // confirmed true, so staleness is determinable from the file alone - no external clock,
79
- // no global assumption, and after a clone the metadata travels with the text.
80
- if (volatility) lines.push('**Volatility:** ' + normalizeVolatility(volatility))
81
- if (verified) lines.push('**Verified:** ' + verified)
82
- if (until) lines.push('**Valid-until:** ' + until)
83
- lines.push('**Format:** 2')
84
- return lines.join('\n') + '\n\n' + content
85
- }
86
-
87
- /**
88
- * Parse a layer file into entries: each '## heading' starts an entry, following
89
- * '**Key:** value' lines populate its metadata and the rest is the body. Returns
90
- * entries with stable line ranges (for ledger -> line-span mapping) and body content
91
- * (for freshness time-bounded detection and the context card).
92
- */
93
- export function parseEntries(text) {
94
- const entries = []
95
- const lines = String(text).split('\n')
96
- let current = null
97
- const push = () => { if (current) { current.endLine = current.lastLine ?? current.startLine; entries.push(current) } }
98
- for (let i = 0; i < lines.length; i++) {
99
- const line = lines[i]
100
- const h = line.match(/^##\s+(.+)$/)
101
- if (h) { push(); current = { heading: h[1].trim(), startLine: i + 1, endLine: undefined, lastLine: i + 1, meta: {}, body: [] }; continue }
102
- if (!current) continue
103
- current.lastLine = i + 1
104
- const m = line.match(/^\*\*([A-Za-z-]+):\*\*\s*(.*)$/)
105
- if (m) current.meta[m[1]] = m[2].trim()
106
- else current.body.push(line)
107
- }
108
- push()
109
- return entries
110
- .filter((e) => e.meta['Id'])
111
- .map((e) => ({
112
- id: e.meta['Id'],
113
- heading: e.heading,
114
- importance: e.meta['Importance'] ? Number(e.meta['Importance']) : 2,
115
- status: e.meta['Status'] ?? 'active',
116
- date: e.meta['Date'],
117
- updated: e.meta['Updated'] ?? e.meta['Date'],
118
- until: e.meta['Valid-until'],
119
- volatility: e.meta['Volatility'] ? normalizeVolatility(e.meta['Volatility']) : undefined,
120
- verified: e.meta['Verified'],
121
- source: e.meta['Source'],
122
- content: e.body.join('\n').trim(),
123
- startLine: e.startLine,
124
- endLine: e.endLine,
125
- }))
126
- }
127
-
128
- /**
129
- * Flip an entry's Status (and optionally add a SupersededBy pointer) in place.
130
- * Pure: returns the new file text; used by forget.
131
- */
132
- export function setEntryStatus(text, id, status, supersededBy) {
133
- const lines = String(text).split('\n')
134
- const idIdx = lines.findIndex((l) => l.includes('**Id:** ' + id))
135
- if (idIdx < 0) return String(text)
136
- let end = lines.length
137
- for (let i = idIdx + 1; i < lines.length; i++) { if (/^##\s+/.test(lines[i])) { end = i; break } }
138
- for (let i = idIdx; i < end; i++) {
139
- if (/^\*\*Status:\*\*/.test(lines[i])) {
140
- lines[i] = '**Status:** ' + status
141
- if (supersededBy) lines.splice(i + 1, 0, '**SupersededBy:** ' + supersededBy)
142
- break
143
- }
144
- }
145
- return lines.join('\n')
146
- }
147
-
148
- /**
149
- * Replace an entry's body (below the metadata block) and bump its Updated stamp.
150
- * Pure: returns the new file text; used by the refresh pass.
151
- */
152
- export function setEntryBody(text, id, newBody) {
153
- const lines = String(text).split('\n')
154
- const idIdx = lines.findIndex((l) => l.includes('**Id:** ' + id))
155
- if (idIdx < 0) return String(text)
156
- let end = lines.length
157
- for (let i = idIdx + 1; i < lines.length; i++) { if (/^##\s+/.test(lines[i])) { end = i; break } }
158
- // A refresh rewrites the body because the fact was RE-CONFIRMED, so it must reset the
159
- // freshness clock too. Verified is the age basis, so bumping only Updated would leave a
160
- // rewritten entry permanently stale - the refresh pass could never cure anything.
161
- const stamp = nowIso()
162
- let sawVerified = false
163
- for (let i = idIdx; i < end; i++) {
164
- if (/^\*\*Updated:\*\*/.test(lines[i])) lines[i] = '**Updated:** ' + stamp
165
- else if (/^\*\*Verified:\*\*/.test(lines[i])) { lines[i] = '**Verified:** ' + stamp; sawVerified = true }
166
- }
167
- if (!sawVerified) {
168
- // Entries written before the field existed still need their clock reset.
169
- const updatedAt = lines.findIndex((l, i) => i > idIdx && i < end && /^\*\*Updated:\*\*/.test(l))
170
- if (updatedAt >= 0) lines.splice(updatedAt + 1, 0, '**Verified:** ' + stamp)
171
- }
172
- let bodyStart = end
173
- for (let i = idIdx; i < end; i++) {
174
- if (/^\*\*Format:\*\*/.test(lines[i])) { bodyStart = i + 1; break }
175
- }
176
- while (bodyStart < end && lines[bodyStart].trim() === '') bodyStart++
177
- lines.splice(bodyStart, end - bodyStart, ...String(newBody).split('\n'))
178
- return lines.join('\n')
179
- }
1
+ // Entry format (Format 2), parsing, and id allocation. Pure (no fs) — unit-testable.
2
+ export const LAYER_CODES = { semantic: 'sem', episodic: 'epi', procedural: 'pro', learning: 'learning', codebase: 'code', history: 'hist', decision: 'dec' }
3
+ export const LAYER_FILES = { semantic: 'semantic.md', episodic: 'episodic.md', procedural: 'procedural.md', learning: 'learnings.md', codebase: 'codebase.md', history: 'history.md', decision: 'decisions.md' }
4
+ export const LAYERS = Object.keys(LAYER_CODES)
5
+
6
+ /** Sub-directory holding the per-topic files for each layered kind. */
7
+ export const LAYER_DIRS = { semantic: 'semantic', episodic: 'episodic', procedural: 'procedural', learning: 'learnings', codebase: 'codebase', history: 'history', decision: 'decisions' }
8
+
9
+ /** Layered kinds, in a stable order. */
10
+ export const LAYERED = Object.keys(LAYER_DIRS)
11
+
12
+ /**
13
+ * Kinds that only make sense inside a repository. The codebase map describes ONE repo, so a
14
+ * global map would mix unrelated projects together - the same reason working memory is
15
+ * per-workspace.
16
+ */
17
+ export const WORKSPACE_ONLY_LAYERS = ['codebase', 'history']
18
+
19
+ /** Layered kinds available in a scope. */
20
+ export const GLOBAL_LAYERED = LAYERED.filter((layer) => !WORKSPACE_ONLY_LAYERS.includes(layer))
21
+
22
+ /** The layered kinds that exist in a given scope. */
23
+ export function layersForScope(scope) { return scope === 'global' ? GLOBAL_LAYERED : LAYERED }
24
+
25
+ /** True when this kind may only be written to a workspace. */
26
+ export function isWorkspaceOnly(layer) { return WORKSPACE_ONLY_LAYERS.includes(layer) }
27
+
28
+ /**
29
+ * How fast an entry's content decays. This is a property OF THE ENTRY, not a global setting:
30
+ * a language choice stays true for years, a port number can change tomorrow, and only the
31
+ * writer knows which it is. Freshness thresholds are derived from this rather than assumed.
32
+ */
33
+ export const VOLATILITY = { stable: 'stable', normal: 'normal', volatile: 'volatile' }
34
+
35
+ /** Days after which an entry of each volatility is worth re-checking. */
36
+ export const VOLATILITY_DAYS = { stable: 365, normal: 30, volatile: 3 }
37
+
38
+ /** Valid volatility values, in increasing decay order. */
39
+ export const VOLATILITIES = Object.keys(VOLATILITY_DAYS)
40
+
41
+ /** Coerce a volatility to a known value; unknown or absent becomes normal. */
42
+ export function normalizeVolatility(value) {
43
+ const text = String(value ?? '').trim().toLowerCase()
44
+ return Object.hasOwn(VOLATILITY_DAYS, text) ? text : VOLATILITY.normal
45
+ }
46
+
47
+ /** Scope id embedded in an entry Id: 'global' or the workspace key. */
48
+ export function scopeIdFor(scope, key) {
49
+ return scope === 'global' ? 'global' : (key ?? 'global')
50
+ }
51
+
52
+ export function nowIso() { return new Date().toISOString() }
53
+
54
+ /**
55
+ * Allocate the next NNNN for a (scopeId, layer) pair by scanning existing **Id:** lines.
56
+ * scopeId + layer are already slug-safe (alnum + hyphen), so no regex escaping is needed.
57
+ */
58
+ export function allocateId(text, scopeId, layer) {
59
+ const code = LAYER_CODES[layer] ?? layer
60
+ const re = new RegExp('\\*\\*Id:\\*\\*\\s*' + scopeId + '-' + code + '-(\\d+)', 'g')
61
+ let max = 0
62
+ let m
63
+ while ((m = re.exec(text)) !== null) max = Math.max(max, Number(m[1]))
64
+ return scopeId + '-' + code + '-' + String(max + 1).padStart(4, '0')
65
+ }
66
+
67
+ /** Build the metadata block for a new entry (one field per line; no heading — the writer adds it). */
68
+ export function formatEntry({ id, source, date, importance = 2, status = 'active', until, volatility, verified, content }) {
69
+ const lines = [
70
+ '**Id:** ' + id,
71
+ '**Source:** ' + source,
72
+ '**Date:** ' + date,
73
+ '**Updated:** ' + date,
74
+ '**Importance:** ' + importance,
75
+ '**Status:** ' + status,
76
+ ]
77
+ // Self-describing freshness: the entry states how fast it decays and when it was last
78
+ // confirmed true, so staleness is determinable from the file alone - no external clock,
79
+ // no global assumption, and after a clone the metadata travels with the text.
80
+ if (volatility) lines.push('**Volatility:** ' + normalizeVolatility(volatility))
81
+ if (verified) lines.push('**Verified:** ' + verified)
82
+ if (until) lines.push('**Valid-until:** ' + until)
83
+ lines.push('**Format:** 2')
84
+ return lines.join('\n') + '\n\n' + content
85
+ }
86
+
87
+ /**
88
+ * Parse a layer file into entries: each '## heading' starts an entry, following
89
+ * '**Key:** value' lines populate its metadata and the rest is the body. Returns
90
+ * entries with stable line ranges (for ledger -> line-span mapping) and body content
91
+ * (for freshness time-bounded detection and the context card).
92
+ */
93
+ export function parseEntries(text) {
94
+ const entries = []
95
+ const lines = String(text).split('\n')
96
+ let current = null
97
+ const push = () => { if (current) { current.endLine = current.lastLine ?? current.startLine; entries.push(current) } }
98
+ for (let i = 0; i < lines.length; i++) {
99
+ const line = lines[i]
100
+ const h = line.match(/^##\s+(.+)$/)
101
+ if (h) { push(); current = { heading: h[1].trim(), startLine: i + 1, endLine: undefined, lastLine: i + 1, meta: {}, body: [] }; continue }
102
+ if (!current) continue
103
+ current.lastLine = i + 1
104
+ const m = line.match(/^\*\*([A-Za-z-]+):\*\*\s*(.*)$/)
105
+ if (m) current.meta[m[1]] = m[2].trim()
106
+ else current.body.push(line)
107
+ }
108
+ push()
109
+ return entries
110
+ .filter((e) => e.meta['Id'])
111
+ .map((e) => ({
112
+ id: e.meta['Id'],
113
+ heading: e.heading,
114
+ importance: e.meta['Importance'] ? Number(e.meta['Importance']) : 2,
115
+ status: e.meta['Status'] ?? 'active',
116
+ date: e.meta['Date'],
117
+ updated: e.meta['Updated'] ?? e.meta['Date'],
118
+ until: e.meta['Valid-until'],
119
+ volatility: e.meta['Volatility'] ? normalizeVolatility(e.meta['Volatility']) : undefined,
120
+ verified: e.meta['Verified'],
121
+ source: e.meta['Source'],
122
+ content: e.body.join('\n').trim(),
123
+ startLine: e.startLine,
124
+ endLine: e.endLine,
125
+ }))
126
+ }
127
+
128
+ /**
129
+ * Flip an entry's Status (and optionally add a SupersededBy pointer) in place.
130
+ * Pure: returns the new file text; used by forget.
131
+ */
132
+ export function setEntryStatus(text, id, status, supersededBy) {
133
+ const lines = String(text).split('\n')
134
+ const idIdx = lines.findIndex((l) => l.includes('**Id:** ' + id))
135
+ if (idIdx < 0) return String(text)
136
+ let end = lines.length
137
+ for (let i = idIdx + 1; i < lines.length; i++) { if (/^##\s+/.test(lines[i])) { end = i; break } }
138
+ for (let i = idIdx; i < end; i++) {
139
+ if (/^\*\*Status:\*\*/.test(lines[i])) {
140
+ lines[i] = '**Status:** ' + status
141
+ if (supersededBy) lines.splice(i + 1, 0, '**SupersededBy:** ' + supersededBy)
142
+ break
143
+ }
144
+ }
145
+ return lines.join('\n')
146
+ }
147
+
148
+ /**
149
+ * Replace an entry's body (below the metadata block) and bump its Updated stamp.
150
+ * Pure: returns the new file text; used by the refresh pass.
151
+ */
152
+ export function setEntryBody(text, id, newBody) {
153
+ const lines = String(text).split('\n')
154
+ const idIdx = lines.findIndex((l) => l.includes('**Id:** ' + id))
155
+ if (idIdx < 0) return String(text)
156
+ let end = lines.length
157
+ for (let i = idIdx + 1; i < lines.length; i++) { if (/^##\s+/.test(lines[i])) { end = i; break } }
158
+ // A refresh rewrites the body because the fact was RE-CONFIRMED, so it must reset the
159
+ // freshness clock too. Verified is the age basis, so bumping only Updated would leave a
160
+ // rewritten entry permanently stale - the refresh pass could never cure anything.
161
+ const stamp = nowIso()
162
+ let sawVerified = false
163
+ for (let i = idIdx; i < end; i++) {
164
+ if (/^\*\*Updated:\*\*/.test(lines[i])) lines[i] = '**Updated:** ' + stamp
165
+ else if (/^\*\*Verified:\*\*/.test(lines[i])) { lines[i] = '**Verified:** ' + stamp; sawVerified = true }
166
+ }
167
+ if (!sawVerified) {
168
+ // Entries written before the field existed still need their clock reset.
169
+ const updatedAt = lines.findIndex((l, i) => i > idIdx && i < end && /^\*\*Updated:\*\*/.test(l))
170
+ if (updatedAt >= 0) lines.splice(updatedAt + 1, 0, '**Verified:** ' + stamp)
171
+ }
172
+ let bodyStart = end
173
+ for (let i = idIdx; i < end; i++) {
174
+ if (/^\*\*Format:\*\*/.test(lines[i])) { bodyStart = i + 1; break }
175
+ }
176
+ while (bodyStart < end && lines[bodyStart].trim() === '') bodyStart++
177
+ lines.splice(bodyStart, end - bodyStart, ...String(newBody).split('\n'))
178
+ return lines.join('\n')
179
+ }
package/src/eval.js CHANGED
@@ -1,72 +1,72 @@
1
- // Sufficiency eval + validation: search probes (fact recall), a judge layer
2
- // (answerability from retrieved context), and deterministic anchor checks.
3
- import { readFileSync } from 'node:fs'
4
- import { indexedFiles } from './files.js'
5
- import { search } from './search.js'
6
-
7
- export function runProbe(store, probe) {
8
- const hits = search(store, probe.question, { limit: probe.limit ?? 10, scope: probe.scope, key: probe.key })
9
- const required = probe.requiredFacts ?? []
10
- let found = 0
11
- const missing = []
12
- for (const f of required) {
13
- const ok = hits.some((h) => (f.id && h.id === f.id) || (f.file && h.file === f.file))
14
- if (ok) found++
15
- else missing.push(f.id ?? f.file)
16
- }
17
- const recall = required.length ? found / required.length : 1
18
- return { id: probe.id, found, required: required.length, recall, sufficient: recall >= 1, missing }
19
- }
20
-
21
- export function evalProbes(store, probes) {
22
- const results = probes.map((p) => runProbe(store, p))
23
- const factRecall = results.length ? results.reduce((a, r) => a + r.recall, 0) / results.length : 1
24
- const sufficient = results.filter((r) => r.sufficient).length
25
- return { factRecall, sufficient, total: results.length, results }
26
- }
27
-
28
- const ANCHOR_PATTERNS = [
29
- /\b[a-z]+-[a-z]{2,8}-\d{2,4}\b/gi, // entry ids: global-sem-0001
30
- /\b\d{6}_[a-z0-9_]+\b/gi, // experiment ids: 123456_abc
31
- /\b[a-z_][\w]*\.[a-z_][\w]*\b/gi, // qualified names: table.column
32
- /\b\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?)?\b/g, // ISO dates
33
- /\bhttps?:\/\/[^\s]+\b/gi, // URLs
34
- /\b\d+(?:\.\d+)?\s*(?:GB|MB|KB|TB|ms|s|%)\b/gi, // numbers with units
35
- ]
36
-
37
- /** Extract stable, greppable anchors from free text (for validated consolidation). */
38
- export function extractAnchors(text) {
39
- const set = new Set()
40
- for (const re of ANCHOR_PATTERNS) {
41
- for (const m of String(text ?? '').matchAll(re)) set.add(m[0])
42
- }
43
- return [...set]
44
- }
45
-
46
- /** Which anchors are present (vs missing) in the committed layer files. */
47
- export function checkAnchors(anchors, store) {
48
- const all = indexedFiles(store.root).map((f) => readFileSync(f, 'utf8')).join('\n')
49
- const found = []
50
- const missing = []
51
- for (const a of anchors) (all.includes(a) ? found : missing).push(a)
52
- return { found, missing }
53
- }
54
-
55
- /**
56
- * Judge layer: for each probe, retrieve the top-k context, then ask a judge LLM
57
- * whether the question is answerable from that context alone. Sufficiency = the
58
- * share of probes the judge deems answerable (not just a single hit landing).
59
- */
60
- export async function judgeSufficiency(store, probes, llm) {
61
- const verdicts = []
62
- for (const probe of probes) {
63
- const hits = search(store, probe.question, { limit: 8, scope: probe.scope, key: probe.key })
64
- const context = hits.map((h) => h.file + ':' + h.line + ' ' + h.snippet).join('\n')
65
- const prompt = 'Question: ' + probe.question + '\n\nRetrieved context:\n' + (context || '(none)')
66
- + '\n\nUsing ONLY the retrieved context, can you answer the question? Reply exactly "yes" or "no".'
67
- const answer = String(await llm(prompt)).trim().toLowerCase()
68
- verdicts.push({ id: probe.id, answerable: answer.startsWith('yes') })
69
- }
70
- const sufficiency = verdicts.length ? verdicts.filter((v) => v.answerable).length / verdicts.length : 1
71
- return { sufficiency, verdicts }
72
- }
1
+ // Sufficiency eval + validation: search probes (fact recall), a judge layer
2
+ // (answerability from retrieved context), and deterministic anchor checks.
3
+ import { readFileSync } from 'node:fs'
4
+ import { indexedFiles } from './files.js'
5
+ import { search } from './search.js'
6
+
7
+ export function runProbe(store, probe) {
8
+ const hits = search(store, probe.question, { limit: probe.limit ?? 10, scope: probe.scope, key: probe.key })
9
+ const required = probe.requiredFacts ?? []
10
+ let found = 0
11
+ const missing = []
12
+ for (const f of required) {
13
+ const ok = hits.some((h) => (f.id && h.id === f.id) || (f.file && h.file === f.file))
14
+ if (ok) found++
15
+ else missing.push(f.id ?? f.file)
16
+ }
17
+ const recall = required.length ? found / required.length : 1
18
+ return { id: probe.id, found, required: required.length, recall, sufficient: recall >= 1, missing }
19
+ }
20
+
21
+ export function evalProbes(store, probes) {
22
+ const results = probes.map((p) => runProbe(store, p))
23
+ const factRecall = results.length ? results.reduce((a, r) => a + r.recall, 0) / results.length : 1
24
+ const sufficient = results.filter((r) => r.sufficient).length
25
+ return { factRecall, sufficient, total: results.length, results }
26
+ }
27
+
28
+ const ANCHOR_PATTERNS = [
29
+ /\b[a-z]+-[a-z]{2,8}-\d{2,4}\b/gi, // entry ids: global-sem-0001
30
+ /\b\d{6}_[a-z0-9_]+\b/gi, // experiment ids: 123456_abc
31
+ /\b[a-z_][\w]*\.[a-z_][\w]*\b/gi, // qualified names: table.column
32
+ /\b\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?)?\b/g, // ISO dates
33
+ /\bhttps?:\/\/[^\s]+\b/gi, // URLs
34
+ /\b\d+(?:\.\d+)?\s*(?:GB|MB|KB|TB|ms|s|%)\b/gi, // numbers with units
35
+ ]
36
+
37
+ /** Extract stable, greppable anchors from free text (for validated consolidation). */
38
+ export function extractAnchors(text) {
39
+ const set = new Set()
40
+ for (const re of ANCHOR_PATTERNS) {
41
+ for (const m of String(text ?? '').matchAll(re)) set.add(m[0])
42
+ }
43
+ return [...set]
44
+ }
45
+
46
+ /** Which anchors are present (vs missing) in the committed layer files. */
47
+ export function checkAnchors(anchors, store) {
48
+ const all = indexedFiles(store.root).map((f) => readFileSync(f, 'utf8')).join('\n')
49
+ const found = []
50
+ const missing = []
51
+ for (const a of anchors) (all.includes(a) ? found : missing).push(a)
52
+ return { found, missing }
53
+ }
54
+
55
+ /**
56
+ * Judge layer: for each probe, retrieve the top-k context, then ask a judge LLM
57
+ * whether the question is answerable from that context alone. Sufficiency = the
58
+ * share of probes the judge deems answerable (not just a single hit landing).
59
+ */
60
+ export async function judgeSufficiency(store, probes, llm) {
61
+ const verdicts = []
62
+ for (const probe of probes) {
63
+ const hits = search(store, probe.question, { limit: 8, scope: probe.scope, key: probe.key })
64
+ const context = hits.map((h) => h.file + ':' + h.line + ' ' + h.snippet).join('\n')
65
+ const prompt = 'Question: ' + probe.question + '\n\nRetrieved context:\n' + (context || '(none)')
66
+ + '\n\nUsing ONLY the retrieved context, can you answer the question? Reply exactly "yes" or "no".'
67
+ const answer = String(await llm(prompt)).trim().toLowerCase()
68
+ verdicts.push({ id: probe.id, answerable: answer.startsWith('yes') })
69
+ }
70
+ const sufficiency = verdicts.length ? verdicts.filter((v) => v.answerable).length / verdicts.length : 1
71
+ return { sufficiency, verdicts }
72
+ }
package/src/files.js CHANGED
@@ -1,60 +1,60 @@
1
- // Store file discovery for indexing/ledger. Node built-ins only.
2
- import { existsSync, readdirSync, statSync } from 'node:fs'
3
- import { join, relative } from 'node:path'
4
- import { globalDir, workspacesDir } from './store.js'
5
- import { LAYER_DIRS, LAYER_FILES } from './entry.js'
6
-
7
- // Which files are searchable truth. junk.md (archaeology) and working.md (transient
8
- // current-state) are deliberately excluded from FTS + the ledger. A layered kind is
9
- // represented by BOTH its index file and the per-topic files it points at.
10
- export const INDEXED_FILES = new Set(['INDEX.md', ...Object.values(LAYER_FILES)])
11
-
12
- /** Depth cap for discovery: root scope dir -> layer dir -> topic file. */
13
- const MAX_DEPTH = 4
14
-
15
- /** Every markdown file under global/ and the workspace scopes. */
16
- export function listLayerFiles(root) {
17
- const out = []
18
- const walk = (dir, depth) => {
19
- if (depth > MAX_DEPTH || !existsSync(dir)) return
20
- let names = []
21
- try { names = readdirSync(dir) } catch { return }
22
- for (const name of names) {
23
- const full = join(dir, name)
24
- let stat
25
- try { stat = statSync(full) } catch { continue }
26
- if (stat.isDirectory()) walk(full, depth + 1)
27
- else if (name.endsWith('.md')) out.push(full)
28
- }
29
- }
30
- walk(globalDir(root), 1)
31
- walk(workspacesDir(root), 1)
32
- return out.sort()
33
- }
34
-
35
- /**
36
- * Which layer a file belongs to: its name when it is a layer index, otherwise the layer
37
- * directory that holds it. Returns undefined for INDEX.md, junk.md, working.md, meta.json.
38
- */
39
- export function layerOfFile(root, file) {
40
- const rel = relative(root, file).replace(/\\/g, '/')
41
- const parts = rel.split('/')
42
- const base = parts[parts.length - 1]
43
- for (const [layer, name] of Object.entries(LAYER_FILES)) if (base === name) return layer
44
- const parent = parts.length >= 2 ? parts[parts.length - 2] : undefined
45
- for (const [layer, dir] of Object.entries(LAYER_DIRS)) if (parent === dir) return layer
46
- return undefined
47
- }
48
-
49
- /** Files that feed the FTS index and the ledger. */
50
- export function indexedFiles(root) {
51
- return listLayerFiles(root).filter((file) => {
52
- const base = file.replace(/\\/g, '/').split('/').pop()
53
- return INDEXED_FILES.has(base) || layerOfFile(root, file) !== undefined
54
- })
55
- }
56
-
57
- /** The store-level topic map, excluded from entries but kept for navigation. */
58
- export function isTopicMap(root, file) {
59
- return file.replace(/\\/g, '/').split('/').pop() === 'INDEX.md'
60
- }
1
+ // Store file discovery for indexing/ledger. Node built-ins only.
2
+ import { existsSync, readdirSync, statSync } from 'node:fs'
3
+ import { join, relative } from 'node:path'
4
+ import { globalDir, workspacesDir } from './store.js'
5
+ import { LAYER_DIRS, LAYER_FILES } from './entry.js'
6
+
7
+ // Which files are searchable truth. junk.md (archaeology) and working.md (transient
8
+ // current-state) are deliberately excluded from FTS + the ledger. A layered kind is
9
+ // represented by BOTH its index file and the per-topic files it points at.
10
+ export const INDEXED_FILES = new Set(['INDEX.md', ...Object.values(LAYER_FILES)])
11
+
12
+ /** Depth cap for discovery: root scope dir -> layer dir -> topic file. */
13
+ const MAX_DEPTH = 4
14
+
15
+ /** Every markdown file under global/ and the workspace scopes. */
16
+ export function listLayerFiles(root) {
17
+ const out = []
18
+ const walk = (dir, depth) => {
19
+ if (depth > MAX_DEPTH || !existsSync(dir)) return
20
+ let names = []
21
+ try { names = readdirSync(dir) } catch { return }
22
+ for (const name of names) {
23
+ const full = join(dir, name)
24
+ let stat
25
+ try { stat = statSync(full) } catch { continue }
26
+ if (stat.isDirectory()) walk(full, depth + 1)
27
+ else if (name.endsWith('.md')) out.push(full)
28
+ }
29
+ }
30
+ walk(globalDir(root), 1)
31
+ walk(workspacesDir(root), 1)
32
+ return out.sort()
33
+ }
34
+
35
+ /**
36
+ * Which layer a file belongs to: its name when it is a layer index, otherwise the layer
37
+ * directory that holds it. Returns undefined for INDEX.md, junk.md, working.md, meta.json.
38
+ */
39
+ export function layerOfFile(root, file) {
40
+ const rel = relative(root, file).replace(/\\/g, '/')
41
+ const parts = rel.split('/')
42
+ const base = parts[parts.length - 1]
43
+ for (const [layer, name] of Object.entries(LAYER_FILES)) if (base === name) return layer
44
+ const parent = parts.length >= 2 ? parts[parts.length - 2] : undefined
45
+ for (const [layer, dir] of Object.entries(LAYER_DIRS)) if (parent === dir) return layer
46
+ return undefined
47
+ }
48
+
49
+ /** Files that feed the FTS index and the ledger. */
50
+ export function indexedFiles(root) {
51
+ return listLayerFiles(root).filter((file) => {
52
+ const base = file.replace(/\\/g, '/').split('/').pop()
53
+ return INDEXED_FILES.has(base) || layerOfFile(root, file) !== undefined
54
+ })
55
+ }
56
+
57
+ /** The store-level topic map, excluded from entries but kept for navigation. */
58
+ export function isTopicMap(root, file) {
59
+ return file.replace(/\\/g, '/').split('/').pop() === 'INDEX.md'
60
+ }