@try-works/dsh-memory 0.1.19 → 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 -866
- 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/consolidate.js
CHANGED
|
@@ -1,330 +1,330 @@
|
|
|
1
|
-
// Consolidation: prompt builder + lenient summary parser + deterministic apply.
|
|
2
|
-
|
|
3
|
-
/** Coerce a model-supplied importance into the 1-3 range. */
|
|
4
|
-
function normalizeImportance(value) {
|
|
5
|
-
const n = Number(value)
|
|
6
|
-
if (!Number.isFinite(n)) return 2
|
|
7
|
-
return Math.min(3, Math.max(1, Math.round(n)))
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Pull every complete top-level JSON object out of a reply. Models routinely emit a
|
|
12
|
-
* valid prefix, echo a placeholder, or get cut off at the token limit; the complete
|
|
13
|
-
* objects are still worth keeping instead of failing the whole pass.
|
|
14
|
-
*/
|
|
15
|
-
/** The first complete top-level {...} object, so trailing commentary cannot break a parse. */
|
|
16
|
-
function firstObject(source) {
|
|
17
|
-
let depth = 0
|
|
18
|
-
let start = -1
|
|
19
|
-
let inString = false
|
|
20
|
-
let escaped = false
|
|
21
|
-
for (let i = 0; i < source.length; i += 1) {
|
|
22
|
-
const ch = source[i]
|
|
23
|
-
if (inString) {
|
|
24
|
-
if (escaped) escaped = false
|
|
25
|
-
else if (ch === '\\') escaped = true
|
|
26
|
-
else if (ch === '"') inString = false
|
|
27
|
-
continue
|
|
28
|
-
}
|
|
29
|
-
if (ch === '"') { inString = true; continue }
|
|
30
|
-
if (ch === '{') { if (depth === 0) start = i; depth += 1 }
|
|
31
|
-
else if (ch === '}') {
|
|
32
|
-
depth -= 1
|
|
33
|
-
if (depth === 0 && start >= 0) return source.slice(start, i + 1)
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return undefined
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function salvageObjects(source) {
|
|
40
|
-
const out = []
|
|
41
|
-
const starts = []
|
|
42
|
-
let inString = false
|
|
43
|
-
let escaped = false
|
|
44
|
-
for (let i = 0; i < source.length; i += 1) {
|
|
45
|
-
const ch = source[i]
|
|
46
|
-
if (inString) {
|
|
47
|
-
if (escaped) escaped = false
|
|
48
|
-
else if (ch === '\\') escaped = true
|
|
49
|
-
else if (ch === '"') inString = false
|
|
50
|
-
continue
|
|
51
|
-
}
|
|
52
|
-
if (ch === '"') { inString = true; continue }
|
|
53
|
-
if (ch === '{') { starts.push(i); continue }
|
|
54
|
-
if (ch === '}') {
|
|
55
|
-
const start = starts.pop()
|
|
56
|
-
if (start !== undefined) {
|
|
57
|
-
try { out.push({ value: JSON.parse(source.slice(start, i + 1)), start }) } catch {}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return out
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Repair the model-authored JSON defects seen in practice. Applied only after an
|
|
66
|
-
* exact parse has already failed, so a valid reply is never touched.
|
|
67
|
-
*/
|
|
68
|
-
function repairJson(text) {
|
|
69
|
-
return String(text ?? '')
|
|
70
|
-
.replace(/,\s*([}\]])/g, '$1')
|
|
71
|
-
.replace(/("importance"\s*:\s*)([^",}\]\s]+)/g, (match, prefix, value) => {
|
|
72
|
-
const n = Number(String(value).trim())
|
|
73
|
-
const safe = Number.isFinite(n) ? Math.min(3, Math.max(1, Math.round(n))) : 2
|
|
74
|
-
return prefix + String(safe)
|
|
75
|
-
})
|
|
76
|
-
}
|
|
77
|
-
// The LLM returns a JSON change summary; the writer applies it via remember() so the
|
|
78
|
-
// write path stays deterministic (the LLM decides, the plugin writes).
|
|
79
|
-
import { remember } from './remember.js'
|
|
80
|
-
|
|
81
|
-
export const FORMAT_SPEC = [
|
|
82
|
-
'Entry format (Format 2), one entry per layer file:',
|
|
83
|
-
'## <heading>',
|
|
84
|
-
'**Id:** <scopeId>-<layer>-<NNNN>',
|
|
85
|
-
'**Source:** <source>',
|
|
86
|
-
'**Date:** <iso>',
|
|
87
|
-
'**Updated:** <iso>',
|
|
88
|
-
'**Importance:** 1-3',
|
|
89
|
-
'**Status:** active',
|
|
90
|
-
'**Format:** 2',
|
|
91
|
-
'',
|
|
92
|
-
'<content>',
|
|
93
|
-
'',
|
|
94
|
-
'- The writer allocates Id; you only name scope/key/layer/topic/text.',
|
|
95
|
-
'- scopeId is "global" or the workspace key; layer is semantic|episodic|procedural|learning.',
|
|
96
|
-
'- Source cites the originating session.',
|
|
97
|
-
'- Importance 1=routine, 2=notable, 3=critical/standing.',
|
|
98
|
-
'- Optional **Valid-until:** <iso> for facts that expire.',
|
|
99
|
-
'- Never delete history; supersede instead of delete.',
|
|
100
|
-
'- Omit trivia, junk, filler, and duplicates; provenance is required.',
|
|
101
|
-
].join('\n')
|
|
102
|
-
|
|
103
|
-
/** The reply protocol the model is asked to follow. */
|
|
104
|
-
export const REPLY_SPEC = [
|
|
105
|
-
'Reply with ONE LINE PER FACT. No JSON, no prose, no preamble, no code fences.',
|
|
106
|
-
'',
|
|
107
|
-
'Each fact line looks exactly like:',
|
|
108
|
-
'- [<digest id> | global | <layer> | <importance> | <volatility>] <topic> :: <text>',
|
|
109
|
-
'- [<digest id> | workspace:<key> | <layer> | <importance> | <volatility>] <topic> :: <text>',
|
|
110
|
-
'',
|
|
111
|
-
'Then one line per digest you were given:',
|
|
112
|
-
'DONE <digest id>',
|
|
113
|
-
'(include digests that held nothing durable)',
|
|
114
|
-
'',
|
|
115
|
-
'Rules:',
|
|
116
|
-
'- Copy the digest id EXACTLY as it appears in the heading below; every fact line carries one.',
|
|
117
|
-
'- A workspace key is a SHORT SLUG (letters, digits and dashes), never a filesystem path.',
|
|
118
|
-
'- Use the workspace key named in a digest heading for the facts of that digest.',
|
|
119
|
-
' Write global when the heading names no workspace, or when the fact is not repo-specific.',
|
|
120
|
-
'- layer is semantic (durable facts), episodic (what happened), procedural (how to do things),',
|
|
121
|
-
' learning (a lesson) or decision (a settled choice: dependencies, conventions, architecture).',
|
|
122
|
-
'- A decision is a CHOICE plus why it won, not a fact about how something works. Write one when',
|
|
123
|
-
' the session settled a question future work must respect.',
|
|
124
|
-
'- importance is 1 (routine), 2 (notable) or 3 (critical).',
|
|
125
|
-
' volatility is how fast the fact decays:',
|
|
126
|
-
' stable (years - a language, a license, a convention),',
|
|
127
|
-
' normal (months - how a subsystem works),',
|
|
128
|
-
' volatile (days - a port, a version in flight, anything that will be untrue soon).',
|
|
129
|
-
'- <topic> is a short heading; <text> is the fact itself. Separate them with " :: ".',
|
|
130
|
-
'- layer codebase maps the workspace: what a module or subsystem does. Its <topic> is the file',
|
|
131
|
-
' path or subsystem name, and it needs a workspace key (never global).',
|
|
132
|
-
'- Never delete history: supersede instead of delete. Omit trivia, junk, filler and duplicates.',
|
|
133
|
-
'- Writing NO fact lines is a correct and expected reply. Many sessions hold nothing durable;',
|
|
134
|
-
' in that case reply with just the DONE lines and nothing else.',
|
|
135
|
-
' Never invent a fact, and never restate something already in the store merely to produce output.',
|
|
136
|
-
'- Never output a <...> placeholder literally, and never repeat these instructions back.',
|
|
137
|
-
].join('\n')
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Guidance for a BACKFILL of historical sessions.
|
|
141
|
-
*
|
|
142
|
-
* A backfill is not a live pass. Live consolidation keeps up with work as it happens, so it can
|
|
143
|
-
* afford detail. A backfill reconstructs months at once, where completeness would flood the
|
|
144
|
-
* store with facts that were only interesting at the time. The bar is raised instead: keep the
|
|
145
|
-
* few things that would still change a decision later, and let the rest go.
|
|
146
|
-
*/
|
|
147
|
-
export const BACKFILL_GUIDANCE = [
|
|
148
|
-
'This pass is a BACKFILL of historical sessions, not live work.',
|
|
149
|
-
'Keep only what would still change a decision months from now: a constraint, a settled',
|
|
150
|
-
' choice, an environment fact, or how a subsystem works.',
|
|
151
|
-
'Prefer 1-3 facts per digest. Skipping narrative, progress and anything superseded since.',
|
|
152
|
-
'Returning no facts at all is correct when a digest holds nothing at that bar.',
|
|
153
|
-
].join(String.fromCharCode(10))
|
|
154
|
-
|
|
155
|
-
export function buildConsolidatePrompt({ digests = [], indexHeads = [], guidance } = {}) {
|
|
156
|
-
return [
|
|
157
|
-
'You are consolidating new session activity into a durable memory store.',
|
|
158
|
-
' Most of what happens in a session is NOT memory. Find the small durable part, or correctly',
|
|
159
|
-
' conclude that there is none.',
|
|
160
|
-
'You decide WHAT is durable and name the fields. The writer renders the markdown entry,',
|
|
161
|
-
'allocates its id and dates, and routes it to the right layer file.',
|
|
162
|
-
'',
|
|
163
|
-
// Callers may tighten the bar for a specific pass - a backfill of historical sessions,
|
|
164
|
-
// for instance, should add the few facts that still matter rather than reconstruct every
|
|
165
|
-
// session in full. Absent guidance, the standing contract applies unchanged.
|
|
166
|
-
...(guidance ? [guidance, ''] : []),
|
|
167
|
-
REPLY_SPEC,
|
|
168
|
-
'',
|
|
169
|
-
'## Existing index (heads only, for dedup + routing)',
|
|
170
|
-
indexHeads.length ? indexHeads.join('\n') : '(none)',
|
|
171
|
-
'',
|
|
172
|
-
'## New session digests',
|
|
173
|
-
digests.map((d) => '### ' + (d.title || d.sessionId)
|
|
174
|
-
+ (d.workspace ? ' (workspace key: ' + d.workspace + ')' : ' (no workspace)')
|
|
175
|
-
+ '\n' + d.text).join('\n\n'),
|
|
176
|
-
'',
|
|
177
|
-
'Now reply with the fact lines and the DONE lines. Nothing else.',
|
|
178
|
-
].join('\n')
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Lenient JSON parse: strips an optional code fence, then grabs the first {…} object. */
|
|
182
|
-
/**
|
|
183
|
-
* Read the line protocol: one "[digest | scope | layer | importance] topic :: text" per fact,
|
|
184
|
-
* plus "DONE <digest>" lines. Per line, so one malformed fact cannot kill the batch, and
|
|
185
|
-
* prose is ignored rather than mined.
|
|
186
|
-
* @returns the summary, or null when the reply carries nothing usable.
|
|
187
|
-
*/
|
|
188
|
-
export function parseFactLines(text) {
|
|
189
|
-
const entries = []
|
|
190
|
-
const learnings = []
|
|
191
|
-
const digests = new Set()
|
|
192
|
-
for (const raw of String(text ?? '').split('\n')) {
|
|
193
|
-
const line = raw.trim()
|
|
194
|
-
const done = /^DONE\s+(\S+)\s*$/i.exec(line)
|
|
195
|
-
if (done) { digests.add(done[1]); continue }
|
|
196
|
-
const fact = /^[-*]\s*\[\s*([^|\]]+?)\s*\|\s*([^|\]]+?)\s*\|\s*([^|\]]+?)\s*\|\s*(\d+)\s*(?:\|\s*([a-z]+)\s*)?\]\s*(.+)$/.exec(line)
|
|
197
|
-
if (!fact) continue
|
|
198
|
-
const digest = fact[1].trim()
|
|
199
|
-
const scopeToken = fact[2].trim()
|
|
200
|
-
const layer = fact[3].trim().toLowerCase()
|
|
201
|
-
const volatility = fact[5] ? String(fact[5]).trim().toLowerCase() : undefined
|
|
202
|
-
const rest = fact[6].trim()
|
|
203
|
-
const split = rest.indexOf('::')
|
|
204
|
-
const topic = (split >= 0 ? rest.slice(0, split) : rest).trim()
|
|
205
|
-
const body = (split >= 0 ? rest.slice(split + 2) : rest).trim()
|
|
206
|
-
if (body === '' || looksLikePlaceholder(body)) continue
|
|
207
|
-
const item = {
|
|
208
|
-
digest, layer,
|
|
209
|
-
topic: topic === '' ? undefined : topic,
|
|
210
|
-
text: body,
|
|
211
|
-
importance: normalizeImportance(fact[4]),
|
|
212
|
-
...(volatility ? { volatility } : {}),
|
|
213
|
-
scope: scopeToken.startsWith('workspace:') ? 'workspace' : 'global',
|
|
214
|
-
...scopeToken.startsWith('workspace:') ? { key: scopeToken.slice('workspace:'.length).trim() } : {},
|
|
215
|
-
}
|
|
216
|
-
digests.add(digest)
|
|
217
|
-
if (layer === 'learning') learnings.push(item)
|
|
218
|
-
else entries.push(item)
|
|
219
|
-
}
|
|
220
|
-
if (entries.length === 0 && learnings.length === 0 && digests.size === 0) return null
|
|
221
|
-
return {
|
|
222
|
-
entries, learnings, digests: [...digests],
|
|
223
|
-
skipped: 0, junk: 0, filler: 0, contradictions: 0, protocol: 'lines',
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** An item still carrying <...> markers is the instructions echoed back, not data. */
|
|
228
|
-
function looksLikePlaceholder(text) {
|
|
229
|
-
return /[<>]/.test(String(text ?? ''))
|
|
230
|
-
}
|
|
231
|
-
export function parseChangeSummary(text) {
|
|
232
|
-
const src = String(text ?? '')
|
|
233
|
-
const FENCE = '\u0060\u0060\u0060' // three backticks, without literal backticks in source
|
|
234
|
-
const fenced = src.match(new RegExp(FENCE + '(?:json)?\\s*([\\s\\S]*?)' + FENCE, 'i'))
|
|
235
|
-
const body = (fenced ? fenced[1] : src).trim()
|
|
236
|
-
if (body === '') return null
|
|
237
|
-
// A JSON object is still accepted, because some models volunteer one. The documented
|
|
238
|
-
// reply is the line protocol, which needs no brace matching at all. Anything else is
|
|
239
|
-
// prose: earlier this mined any brace-delimited text, which turned a reasoning preamble
|
|
240
|
-
// (and the echoed prompt example) into real entries.
|
|
241
|
-
if (!body.startsWith('{')) return parseFactLines(body)
|
|
242
|
-
const candidate = firstObject(body) ?? body
|
|
243
|
-
const normalized = (parsed) => {
|
|
244
|
-
const usable = (item) => item && !looksLikePlaceholder(item.text) && !looksLikePlaceholder(item.topic)
|
|
245
|
-
const entries = (Array.isArray(parsed.entries) ? parsed.entries : []).filter(usable)
|
|
246
|
-
const learnings = (Array.isArray(parsed.learnings) ? parsed.learnings : []).filter(usable)
|
|
247
|
-
// Attribution: a digest is only finished when the model either produced entries
|
|
248
|
-
// from it or explicitly reported handling it. Anything else is retried.
|
|
249
|
-
const digests = new Set(Array.isArray(parsed.handledDigests) ? parsed.handledDigests.map(String) : [])
|
|
250
|
-
for (const item of [...entries, ...learnings]) if (item && item.digest) digests.add(String(item.digest))
|
|
251
|
-
return {
|
|
252
|
-
entries, learnings, digests: [...digests],
|
|
253
|
-
skipped: parsed.skipped ?? 0,
|
|
254
|
-
junk: parsed.junk ?? 0,
|
|
255
|
-
filler: parsed.filler ?? 0,
|
|
256
|
-
contradictions: parsed.contradictions ?? 0,
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Escalating recovery: exact, then repaired, then whatever complete objects remain -
|
|
261
|
-
// but only when the reply is actually JSON-shaped. Prose that merely contains braces
|
|
262
|
-
// (an echoed example, a reasoning preamble) must not be mined for entries.
|
|
263
|
-
const jsonShaped = candidate.trim().startsWith('{')
|
|
264
|
-
|
|
265
|
-
for (const attempt of [candidate, repairJson(candidate)]) {
|
|
266
|
-
try { return normalized(JSON.parse(attempt)) } catch {}
|
|
267
|
-
}
|
|
268
|
-
const repaired = repairJson(candidate)
|
|
269
|
-
// Learnings may omit `layer`, so fall back to position: anything after the
|
|
270
|
-
// "learnings" key came from that array.
|
|
271
|
-
const learningsAt = repaired.indexOf('"learnings"')
|
|
272
|
-
const usable = jsonShaped ? salvageObjects(repaired).filter((o) => {
|
|
273
|
-
const value = o.value
|
|
274
|
-
if (!value || typeof value.text !== 'string' || value.text.trim() === '') return false
|
|
275
|
-
// An object still carrying <...> markers is the prompt example, not data.
|
|
276
|
-
return !/[<>]/.test(value.text) && !/[<>]/.test(String(value.topic === undefined ? '' : value.topic))
|
|
277
|
-
}) : []
|
|
278
|
-
if (usable.length === 0) return null
|
|
279
|
-
const isLearning = (o) => o.value.layer === 'learning' || (learningsAt >= 0 && o.start > learningsAt)
|
|
280
|
-
const learnings = usable.filter(isLearning).map((o) => o.value)
|
|
281
|
-
const entries = usable.filter((o) => !isLearning(o)).map((o) => o.value)
|
|
282
|
-
const digests = new Set()
|
|
283
|
-
for (const item of usable) if (item.value.digest) digests.add(String(item.value.digest))
|
|
284
|
-
return {
|
|
285
|
-
entries, learnings, digests: [...digests],
|
|
286
|
-
skipped: 0, junk: 0, filler: 0, contradictions: 0, salvaged: true,
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/** Deterministically write a parsed change summary into the store. Returns written ids. */
|
|
291
|
-
/**
|
|
292
|
-
* Deterministically write a parsed change summary into the store.
|
|
293
|
-
* @param summary - parsed reply; entries may carry the digest they came from.
|
|
294
|
-
* @param options.fallbackSource - provenance when an item names no digest.
|
|
295
|
-
* @returns the written ids; a per-item failure is recorded on `failed`, never thrown.
|
|
296
|
-
*/
|
|
297
|
-
export function applyConsolidation(store, summary, options = {}) {
|
|
298
|
-
const written = []
|
|
299
|
-
const failed = []
|
|
300
|
-
const write = (item, layer) => {
|
|
301
|
-
// Cite the digest the fact came from, so the entry is traceable back to the session
|
|
302
|
-
// instead of being stamped "manual" like a hand-written one.
|
|
303
|
-
const source = item.digest ? 'digest:' + String(item.digest) : (options.fallbackSource ?? 'manual')
|
|
304
|
-
const key = item.key === null ? undefined : item.key
|
|
305
|
-
// A workspace request without a usable key cannot be honoured; keep the fact in
|
|
306
|
-
// global rather than throwing away the whole entry.
|
|
307
|
-
const scope = item.scope === 'workspace' && key ? 'workspace' : 'global'
|
|
308
|
-
try {
|
|
309
|
-
const r = remember(store, {
|
|
310
|
-
scope,
|
|
311
|
-
key: scope === 'workspace' ? key : undefined,
|
|
312
|
-
layer,
|
|
313
|
-
text: item.text,
|
|
314
|
-
topic: item.topic,
|
|
315
|
-
importance: normalizeImportance(item.importance),
|
|
316
|
-
volatility: item.volatility,
|
|
317
|
-
until: item.until === null ? undefined : item.until,
|
|
318
|
-
source,
|
|
319
|
-
})
|
|
320
|
-
written.push(r.id)
|
|
321
|
-
} catch (error) {
|
|
322
|
-
// One malformed item must not fail the whole consolidation pass.
|
|
323
|
-
failed.push(String((error && error.message) || error).slice(0, 160))
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
for (const e of summary.entries ?? []) write(e, e.layer)
|
|
327
|
-
for (const l of summary.learnings ?? []) write(l, 'learning')
|
|
328
|
-
written.failed = failed
|
|
329
|
-
return written
|
|
330
|
-
}
|
|
1
|
+
// Consolidation: prompt builder + lenient summary parser + deterministic apply.
|
|
2
|
+
|
|
3
|
+
/** Coerce a model-supplied importance into the 1-3 range. */
|
|
4
|
+
function normalizeImportance(value) {
|
|
5
|
+
const n = Number(value)
|
|
6
|
+
if (!Number.isFinite(n)) return 2
|
|
7
|
+
return Math.min(3, Math.max(1, Math.round(n)))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pull every complete top-level JSON object out of a reply. Models routinely emit a
|
|
12
|
+
* valid prefix, echo a placeholder, or get cut off at the token limit; the complete
|
|
13
|
+
* objects are still worth keeping instead of failing the whole pass.
|
|
14
|
+
*/
|
|
15
|
+
/** The first complete top-level {...} object, so trailing commentary cannot break a parse. */
|
|
16
|
+
function firstObject(source) {
|
|
17
|
+
let depth = 0
|
|
18
|
+
let start = -1
|
|
19
|
+
let inString = false
|
|
20
|
+
let escaped = false
|
|
21
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
22
|
+
const ch = source[i]
|
|
23
|
+
if (inString) {
|
|
24
|
+
if (escaped) escaped = false
|
|
25
|
+
else if (ch === '\\') escaped = true
|
|
26
|
+
else if (ch === '"') inString = false
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
if (ch === '"') { inString = true; continue }
|
|
30
|
+
if (ch === '{') { if (depth === 0) start = i; depth += 1 }
|
|
31
|
+
else if (ch === '}') {
|
|
32
|
+
depth -= 1
|
|
33
|
+
if (depth === 0 && start >= 0) return source.slice(start, i + 1)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function salvageObjects(source) {
|
|
40
|
+
const out = []
|
|
41
|
+
const starts = []
|
|
42
|
+
let inString = false
|
|
43
|
+
let escaped = false
|
|
44
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
45
|
+
const ch = source[i]
|
|
46
|
+
if (inString) {
|
|
47
|
+
if (escaped) escaped = false
|
|
48
|
+
else if (ch === '\\') escaped = true
|
|
49
|
+
else if (ch === '"') inString = false
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (ch === '"') { inString = true; continue }
|
|
53
|
+
if (ch === '{') { starts.push(i); continue }
|
|
54
|
+
if (ch === '}') {
|
|
55
|
+
const start = starts.pop()
|
|
56
|
+
if (start !== undefined) {
|
|
57
|
+
try { out.push({ value: JSON.parse(source.slice(start, i + 1)), start }) } catch {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Repair the model-authored JSON defects seen in practice. Applied only after an
|
|
66
|
+
* exact parse has already failed, so a valid reply is never touched.
|
|
67
|
+
*/
|
|
68
|
+
function repairJson(text) {
|
|
69
|
+
return String(text ?? '')
|
|
70
|
+
.replace(/,\s*([}\]])/g, '$1')
|
|
71
|
+
.replace(/("importance"\s*:\s*)([^",}\]\s]+)/g, (match, prefix, value) => {
|
|
72
|
+
const n = Number(String(value).trim())
|
|
73
|
+
const safe = Number.isFinite(n) ? Math.min(3, Math.max(1, Math.round(n))) : 2
|
|
74
|
+
return prefix + String(safe)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
// The LLM returns a JSON change summary; the writer applies it via remember() so the
|
|
78
|
+
// write path stays deterministic (the LLM decides, the plugin writes).
|
|
79
|
+
import { remember } from './remember.js'
|
|
80
|
+
|
|
81
|
+
export const FORMAT_SPEC = [
|
|
82
|
+
'Entry format (Format 2), one entry per layer file:',
|
|
83
|
+
'## <heading>',
|
|
84
|
+
'**Id:** <scopeId>-<layer>-<NNNN>',
|
|
85
|
+
'**Source:** <source>',
|
|
86
|
+
'**Date:** <iso>',
|
|
87
|
+
'**Updated:** <iso>',
|
|
88
|
+
'**Importance:** 1-3',
|
|
89
|
+
'**Status:** active',
|
|
90
|
+
'**Format:** 2',
|
|
91
|
+
'',
|
|
92
|
+
'<content>',
|
|
93
|
+
'',
|
|
94
|
+
'- The writer allocates Id; you only name scope/key/layer/topic/text.',
|
|
95
|
+
'- scopeId is "global" or the workspace key; layer is semantic|episodic|procedural|learning.',
|
|
96
|
+
'- Source cites the originating session.',
|
|
97
|
+
'- Importance 1=routine, 2=notable, 3=critical/standing.',
|
|
98
|
+
'- Optional **Valid-until:** <iso> for facts that expire.',
|
|
99
|
+
'- Never delete history; supersede instead of delete.',
|
|
100
|
+
'- Omit trivia, junk, filler, and duplicates; provenance is required.',
|
|
101
|
+
].join('\n')
|
|
102
|
+
|
|
103
|
+
/** The reply protocol the model is asked to follow. */
|
|
104
|
+
export const REPLY_SPEC = [
|
|
105
|
+
'Reply with ONE LINE PER FACT. No JSON, no prose, no preamble, no code fences.',
|
|
106
|
+
'',
|
|
107
|
+
'Each fact line looks exactly like:',
|
|
108
|
+
'- [<digest id> | global | <layer> | <importance> | <volatility>] <topic> :: <text>',
|
|
109
|
+
'- [<digest id> | workspace:<key> | <layer> | <importance> | <volatility>] <topic> :: <text>',
|
|
110
|
+
'',
|
|
111
|
+
'Then one line per digest you were given:',
|
|
112
|
+
'DONE <digest id>',
|
|
113
|
+
'(include digests that held nothing durable)',
|
|
114
|
+
'',
|
|
115
|
+
'Rules:',
|
|
116
|
+
'- Copy the digest id EXACTLY as it appears in the heading below; every fact line carries one.',
|
|
117
|
+
'- A workspace key is a SHORT SLUG (letters, digits and dashes), never a filesystem path.',
|
|
118
|
+
'- Use the workspace key named in a digest heading for the facts of that digest.',
|
|
119
|
+
' Write global when the heading names no workspace, or when the fact is not repo-specific.',
|
|
120
|
+
'- layer is semantic (durable facts), episodic (what happened), procedural (how to do things),',
|
|
121
|
+
' learning (a lesson) or decision (a settled choice: dependencies, conventions, architecture).',
|
|
122
|
+
'- A decision is a CHOICE plus why it won, not a fact about how something works. Write one when',
|
|
123
|
+
' the session settled a question future work must respect.',
|
|
124
|
+
'- importance is 1 (routine), 2 (notable) or 3 (critical).',
|
|
125
|
+
' volatility is how fast the fact decays:',
|
|
126
|
+
' stable (years - a language, a license, a convention),',
|
|
127
|
+
' normal (months - how a subsystem works),',
|
|
128
|
+
' volatile (days - a port, a version in flight, anything that will be untrue soon).',
|
|
129
|
+
'- <topic> is a short heading; <text> is the fact itself. Separate them with " :: ".',
|
|
130
|
+
'- layer codebase maps the workspace: what a module or subsystem does. Its <topic> is the file',
|
|
131
|
+
' path or subsystem name, and it needs a workspace key (never global).',
|
|
132
|
+
'- Never delete history: supersede instead of delete. Omit trivia, junk, filler and duplicates.',
|
|
133
|
+
'- Writing NO fact lines is a correct and expected reply. Many sessions hold nothing durable;',
|
|
134
|
+
' in that case reply with just the DONE lines and nothing else.',
|
|
135
|
+
' Never invent a fact, and never restate something already in the store merely to produce output.',
|
|
136
|
+
'- Never output a <...> placeholder literally, and never repeat these instructions back.',
|
|
137
|
+
].join('\n')
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Guidance for a BACKFILL of historical sessions.
|
|
141
|
+
*
|
|
142
|
+
* A backfill is not a live pass. Live consolidation keeps up with work as it happens, so it can
|
|
143
|
+
* afford detail. A backfill reconstructs months at once, where completeness would flood the
|
|
144
|
+
* store with facts that were only interesting at the time. The bar is raised instead: keep the
|
|
145
|
+
* few things that would still change a decision later, and let the rest go.
|
|
146
|
+
*/
|
|
147
|
+
export const BACKFILL_GUIDANCE = [
|
|
148
|
+
'This pass is a BACKFILL of historical sessions, not live work.',
|
|
149
|
+
'Keep only what would still change a decision months from now: a constraint, a settled',
|
|
150
|
+
' choice, an environment fact, or how a subsystem works.',
|
|
151
|
+
'Prefer 1-3 facts per digest. Skipping narrative, progress and anything superseded since.',
|
|
152
|
+
'Returning no facts at all is correct when a digest holds nothing at that bar.',
|
|
153
|
+
].join(String.fromCharCode(10))
|
|
154
|
+
|
|
155
|
+
export function buildConsolidatePrompt({ digests = [], indexHeads = [], guidance } = {}) {
|
|
156
|
+
return [
|
|
157
|
+
'You are consolidating new session activity into a durable memory store.',
|
|
158
|
+
' Most of what happens in a session is NOT memory. Find the small durable part, or correctly',
|
|
159
|
+
' conclude that there is none.',
|
|
160
|
+
'You decide WHAT is durable and name the fields. The writer renders the markdown entry,',
|
|
161
|
+
'allocates its id and dates, and routes it to the right layer file.',
|
|
162
|
+
'',
|
|
163
|
+
// Callers may tighten the bar for a specific pass - a backfill of historical sessions,
|
|
164
|
+
// for instance, should add the few facts that still matter rather than reconstruct every
|
|
165
|
+
// session in full. Absent guidance, the standing contract applies unchanged.
|
|
166
|
+
...(guidance ? [guidance, ''] : []),
|
|
167
|
+
REPLY_SPEC,
|
|
168
|
+
'',
|
|
169
|
+
'## Existing index (heads only, for dedup + routing)',
|
|
170
|
+
indexHeads.length ? indexHeads.join('\n') : '(none)',
|
|
171
|
+
'',
|
|
172
|
+
'## New session digests',
|
|
173
|
+
digests.map((d) => '### ' + (d.title || d.sessionId)
|
|
174
|
+
+ (d.workspace ? ' (workspace key: ' + d.workspace + ')' : ' (no workspace)')
|
|
175
|
+
+ '\n' + d.text).join('\n\n'),
|
|
176
|
+
'',
|
|
177
|
+
'Now reply with the fact lines and the DONE lines. Nothing else.',
|
|
178
|
+
].join('\n')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Lenient JSON parse: strips an optional code fence, then grabs the first {…} object. */
|
|
182
|
+
/**
|
|
183
|
+
* Read the line protocol: one "[digest | scope | layer | importance] topic :: text" per fact,
|
|
184
|
+
* plus "DONE <digest>" lines. Per line, so one malformed fact cannot kill the batch, and
|
|
185
|
+
* prose is ignored rather than mined.
|
|
186
|
+
* @returns the summary, or null when the reply carries nothing usable.
|
|
187
|
+
*/
|
|
188
|
+
export function parseFactLines(text) {
|
|
189
|
+
const entries = []
|
|
190
|
+
const learnings = []
|
|
191
|
+
const digests = new Set()
|
|
192
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
193
|
+
const line = raw.trim()
|
|
194
|
+
const done = /^DONE\s+(\S+)\s*$/i.exec(line)
|
|
195
|
+
if (done) { digests.add(done[1]); continue }
|
|
196
|
+
const fact = /^[-*]\s*\[\s*([^|\]]+?)\s*\|\s*([^|\]]+?)\s*\|\s*([^|\]]+?)\s*\|\s*(\d+)\s*(?:\|\s*([a-z]+)\s*)?\]\s*(.+)$/.exec(line)
|
|
197
|
+
if (!fact) continue
|
|
198
|
+
const digest = fact[1].trim()
|
|
199
|
+
const scopeToken = fact[2].trim()
|
|
200
|
+
const layer = fact[3].trim().toLowerCase()
|
|
201
|
+
const volatility = fact[5] ? String(fact[5]).trim().toLowerCase() : undefined
|
|
202
|
+
const rest = fact[6].trim()
|
|
203
|
+
const split = rest.indexOf('::')
|
|
204
|
+
const topic = (split >= 0 ? rest.slice(0, split) : rest).trim()
|
|
205
|
+
const body = (split >= 0 ? rest.slice(split + 2) : rest).trim()
|
|
206
|
+
if (body === '' || looksLikePlaceholder(body)) continue
|
|
207
|
+
const item = {
|
|
208
|
+
digest, layer,
|
|
209
|
+
topic: topic === '' ? undefined : topic,
|
|
210
|
+
text: body,
|
|
211
|
+
importance: normalizeImportance(fact[4]),
|
|
212
|
+
...(volatility ? { volatility } : {}),
|
|
213
|
+
scope: scopeToken.startsWith('workspace:') ? 'workspace' : 'global',
|
|
214
|
+
...scopeToken.startsWith('workspace:') ? { key: scopeToken.slice('workspace:'.length).trim() } : {},
|
|
215
|
+
}
|
|
216
|
+
digests.add(digest)
|
|
217
|
+
if (layer === 'learning') learnings.push(item)
|
|
218
|
+
else entries.push(item)
|
|
219
|
+
}
|
|
220
|
+
if (entries.length === 0 && learnings.length === 0 && digests.size === 0) return null
|
|
221
|
+
return {
|
|
222
|
+
entries, learnings, digests: [...digests],
|
|
223
|
+
skipped: 0, junk: 0, filler: 0, contradictions: 0, protocol: 'lines',
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** An item still carrying <...> markers is the instructions echoed back, not data. */
|
|
228
|
+
function looksLikePlaceholder(text) {
|
|
229
|
+
return /[<>]/.test(String(text ?? ''))
|
|
230
|
+
}
|
|
231
|
+
export function parseChangeSummary(text) {
|
|
232
|
+
const src = String(text ?? '')
|
|
233
|
+
const FENCE = '\u0060\u0060\u0060' // three backticks, without literal backticks in source
|
|
234
|
+
const fenced = src.match(new RegExp(FENCE + '(?:json)?\\s*([\\s\\S]*?)' + FENCE, 'i'))
|
|
235
|
+
const body = (fenced ? fenced[1] : src).trim()
|
|
236
|
+
if (body === '') return null
|
|
237
|
+
// A JSON object is still accepted, because some models volunteer one. The documented
|
|
238
|
+
// reply is the line protocol, which needs no brace matching at all. Anything else is
|
|
239
|
+
// prose: earlier this mined any brace-delimited text, which turned a reasoning preamble
|
|
240
|
+
// (and the echoed prompt example) into real entries.
|
|
241
|
+
if (!body.startsWith('{')) return parseFactLines(body)
|
|
242
|
+
const candidate = firstObject(body) ?? body
|
|
243
|
+
const normalized = (parsed) => {
|
|
244
|
+
const usable = (item) => item && !looksLikePlaceholder(item.text) && !looksLikePlaceholder(item.topic)
|
|
245
|
+
const entries = (Array.isArray(parsed.entries) ? parsed.entries : []).filter(usable)
|
|
246
|
+
const learnings = (Array.isArray(parsed.learnings) ? parsed.learnings : []).filter(usable)
|
|
247
|
+
// Attribution: a digest is only finished when the model either produced entries
|
|
248
|
+
// from it or explicitly reported handling it. Anything else is retried.
|
|
249
|
+
const digests = new Set(Array.isArray(parsed.handledDigests) ? parsed.handledDigests.map(String) : [])
|
|
250
|
+
for (const item of [...entries, ...learnings]) if (item && item.digest) digests.add(String(item.digest))
|
|
251
|
+
return {
|
|
252
|
+
entries, learnings, digests: [...digests],
|
|
253
|
+
skipped: parsed.skipped ?? 0,
|
|
254
|
+
junk: parsed.junk ?? 0,
|
|
255
|
+
filler: parsed.filler ?? 0,
|
|
256
|
+
contradictions: parsed.contradictions ?? 0,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Escalating recovery: exact, then repaired, then whatever complete objects remain -
|
|
261
|
+
// but only when the reply is actually JSON-shaped. Prose that merely contains braces
|
|
262
|
+
// (an echoed example, a reasoning preamble) must not be mined for entries.
|
|
263
|
+
const jsonShaped = candidate.trim().startsWith('{')
|
|
264
|
+
|
|
265
|
+
for (const attempt of [candidate, repairJson(candidate)]) {
|
|
266
|
+
try { return normalized(JSON.parse(attempt)) } catch {}
|
|
267
|
+
}
|
|
268
|
+
const repaired = repairJson(candidate)
|
|
269
|
+
// Learnings may omit `layer`, so fall back to position: anything after the
|
|
270
|
+
// "learnings" key came from that array.
|
|
271
|
+
const learningsAt = repaired.indexOf('"learnings"')
|
|
272
|
+
const usable = jsonShaped ? salvageObjects(repaired).filter((o) => {
|
|
273
|
+
const value = o.value
|
|
274
|
+
if (!value || typeof value.text !== 'string' || value.text.trim() === '') return false
|
|
275
|
+
// An object still carrying <...> markers is the prompt example, not data.
|
|
276
|
+
return !/[<>]/.test(value.text) && !/[<>]/.test(String(value.topic === undefined ? '' : value.topic))
|
|
277
|
+
}) : []
|
|
278
|
+
if (usable.length === 0) return null
|
|
279
|
+
const isLearning = (o) => o.value.layer === 'learning' || (learningsAt >= 0 && o.start > learningsAt)
|
|
280
|
+
const learnings = usable.filter(isLearning).map((o) => o.value)
|
|
281
|
+
const entries = usable.filter((o) => !isLearning(o)).map((o) => o.value)
|
|
282
|
+
const digests = new Set()
|
|
283
|
+
for (const item of usable) if (item.value.digest) digests.add(String(item.value.digest))
|
|
284
|
+
return {
|
|
285
|
+
entries, learnings, digests: [...digests],
|
|
286
|
+
skipped: 0, junk: 0, filler: 0, contradictions: 0, salvaged: true,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Deterministically write a parsed change summary into the store. Returns written ids. */
|
|
291
|
+
/**
|
|
292
|
+
* Deterministically write a parsed change summary into the store.
|
|
293
|
+
* @param summary - parsed reply; entries may carry the digest they came from.
|
|
294
|
+
* @param options.fallbackSource - provenance when an item names no digest.
|
|
295
|
+
* @returns the written ids; a per-item failure is recorded on `failed`, never thrown.
|
|
296
|
+
*/
|
|
297
|
+
export function applyConsolidation(store, summary, options = {}) {
|
|
298
|
+
const written = []
|
|
299
|
+
const failed = []
|
|
300
|
+
const write = (item, layer) => {
|
|
301
|
+
// Cite the digest the fact came from, so the entry is traceable back to the session
|
|
302
|
+
// instead of being stamped "manual" like a hand-written one.
|
|
303
|
+
const source = item.digest ? 'digest:' + String(item.digest) : (options.fallbackSource ?? 'manual')
|
|
304
|
+
const key = item.key === null ? undefined : item.key
|
|
305
|
+
// A workspace request without a usable key cannot be honoured; keep the fact in
|
|
306
|
+
// global rather than throwing away the whole entry.
|
|
307
|
+
const scope = item.scope === 'workspace' && key ? 'workspace' : 'global'
|
|
308
|
+
try {
|
|
309
|
+
const r = remember(store, {
|
|
310
|
+
scope,
|
|
311
|
+
key: scope === 'workspace' ? key : undefined,
|
|
312
|
+
layer,
|
|
313
|
+
text: item.text,
|
|
314
|
+
topic: item.topic,
|
|
315
|
+
importance: normalizeImportance(item.importance),
|
|
316
|
+
volatility: item.volatility,
|
|
317
|
+
until: item.until === null ? undefined : item.until,
|
|
318
|
+
source,
|
|
319
|
+
})
|
|
320
|
+
written.push(r.id)
|
|
321
|
+
} catch (error) {
|
|
322
|
+
// One malformed item must not fail the whole consolidation pass.
|
|
323
|
+
failed.push(String((error && error.message) || error).slice(0, 160))
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const e of summary.entries ?? []) write(e, e.layer)
|
|
327
|
+
for (const l of summary.learnings ?? []) write(l, 'learning')
|
|
328
|
+
written.failed = failed
|
|
329
|
+
return written
|
|
330
|
+
}
|