@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.
- package/LICENSE +21 -21
- package/README.md +585 -585
- package/STATUS.md +287 -287
- package/cordis.patch.yml +69 -69
- package/package.json +1 -1
- package/scripts/check-package.mjs +76 -76
- package/src/SYSTEM.template.md +311 -311
- package/src/capture.js +242 -242
- package/src/client/index.js +886 -886
- package/src/config.js +57 -57
- package/src/consolidate-run.js +132 -132
- package/src/consolidate.js +330 -330
- package/src/context.js +88 -88
- package/src/decisions.js +106 -106
- package/src/defaults.js +93 -93
- package/src/digest.js +255 -255
- package/src/entry.js +179 -179
- package/src/eval.js +72 -72
- package/src/files.js +60 -60
- package/src/fixture.js +56 -56
- package/src/forget.js +71 -71
- package/src/freshness.js +65 -65
- package/src/fts.js +166 -166
- package/src/git.js +53 -53
- package/src/history.js +226 -226
- package/src/host.js +866 -857
- package/src/index.js +115 -94
- package/src/inject.js +108 -108
- package/src/layers.js +178 -178
- package/src/ledger.js +19 -19
- package/src/map.js +184 -184
- package/src/models.js +136 -136
- package/src/queue.js +602 -602
- package/src/rank.js +31 -31
- package/src/recover.js +91 -91
- package/src/refresh.js +80 -80
- package/src/remember.js +58 -58
- package/src/rerank.js +90 -90
- package/src/search.js +75 -75
- package/src/select.js +57 -57
- package/src/session.js +60 -60
- package/src/state.js +240 -240
- package/src/store.js +128 -128
- package/src/summarize.js +195 -195
- package/src/system-doc.js +171 -171
- package/src/tasks.js +617 -617
- package/src/telemetry.js +122 -122
- package/src/tools.js +828 -818
- package/src/working.js +288 -288
- package/src/workspace-key.js +68 -68
- 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
|
+
}
|