@try-works/dsh-memory 0.1.19 → 0.1.21
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 +294 -287
- package/cordis.patch.yml +69 -69
- package/package.json +1 -1
- package/scripts/check-package.mjs +76 -76
- package/scripts/inspect-log.ts +40 -0
- package/scripts/repair-ignorable.ts +130 -0
- package/scripts/scan-sessions.ts +43 -0
- package/scripts/verify-log.ts +26 -0
- 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 +884 -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/rank.js
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
// Search ranking: importance x recency-decay x bm25-position (pure).
|
|
2
|
-
export const DECAY_HALF_LIFE_DAYS = 30
|
|
3
|
-
|
|
4
|
-
export function recencyDecay(days, halfLife = DECAY_HALF_LIFE_DAYS) {
|
|
5
|
-
return Math.pow(2, -days / halfLife)
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const DAY_MS = 86400000
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Re-rank FTS hits using the ledger. Each hit maps to its enclosing entry by
|
|
12
|
-
* (file, line) -> line span; unmapped hits keep importance 2 and "now" recency.
|
|
13
|
-
*/
|
|
14
|
-
export function rankBlend(hits, ledger, nowMs = Date.now()) {
|
|
15
|
-
const byFile = new Map()
|
|
16
|
-
for (const e of ledger) {
|
|
17
|
-
const arr = byFile.get(e.file) ?? []
|
|
18
|
-
arr.push(e)
|
|
19
|
-
byFile.set(e.file, arr)
|
|
20
|
-
}
|
|
21
|
-
const scored = hits.map((h, i) => {
|
|
22
|
-
const entries = byFile.get(h.file) ?? []
|
|
23
|
-
const e = entries.find((x) => h.line >= x.startLine && h.line <= x.endLine)
|
|
24
|
-
const importance = e?.importance ?? 2
|
|
25
|
-
const updatedAt = e?.updated ? Date.parse(e.updated) : nowMs
|
|
26
|
-
const days = Math.max(0, (nowMs - updatedAt) / DAY_MS)
|
|
27
|
-
const score = importance * recencyDecay(days) * (1 / (i + 1))
|
|
28
|
-
return { ...h, id: e?.id, importance, score }
|
|
29
|
-
})
|
|
30
|
-
return scored.sort((a, b) => b.score - a.score)
|
|
31
|
-
}
|
|
1
|
+
// Search ranking: importance x recency-decay x bm25-position (pure).
|
|
2
|
+
export const DECAY_HALF_LIFE_DAYS = 30
|
|
3
|
+
|
|
4
|
+
export function recencyDecay(days, halfLife = DECAY_HALF_LIFE_DAYS) {
|
|
5
|
+
return Math.pow(2, -days / halfLife)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 86400000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Re-rank FTS hits using the ledger. Each hit maps to its enclosing entry by
|
|
12
|
+
* (file, line) -> line span; unmapped hits keep importance 2 and "now" recency.
|
|
13
|
+
*/
|
|
14
|
+
export function rankBlend(hits, ledger, nowMs = Date.now()) {
|
|
15
|
+
const byFile = new Map()
|
|
16
|
+
for (const e of ledger) {
|
|
17
|
+
const arr = byFile.get(e.file) ?? []
|
|
18
|
+
arr.push(e)
|
|
19
|
+
byFile.set(e.file, arr)
|
|
20
|
+
}
|
|
21
|
+
const scored = hits.map((h, i) => {
|
|
22
|
+
const entries = byFile.get(h.file) ?? []
|
|
23
|
+
const e = entries.find((x) => h.line >= x.startLine && h.line <= x.endLine)
|
|
24
|
+
const importance = e?.importance ?? 2
|
|
25
|
+
const updatedAt = e?.updated ? Date.parse(e.updated) : nowMs
|
|
26
|
+
const days = Math.max(0, (nowMs - updatedAt) / DAY_MS)
|
|
27
|
+
const score = importance * recencyDecay(days) * (1 / (i + 1))
|
|
28
|
+
return { ...h, id: e?.id, importance, score }
|
|
29
|
+
})
|
|
30
|
+
return scored.sort((a, b) => b.score - a.score)
|
|
31
|
+
}
|
package/src/recover.js
CHANGED
|
@@ -1,91 +1,91 @@
|
|
|
1
|
-
// Recovering a candidate whose material is gone.
|
|
2
|
-
//
|
|
3
|
-
// An ORPHANED entry is one whose digest no longer exists - normally because pruning removed it.
|
|
4
|
-
// The digest is DERIVED, and the source session survives, so the fix is to rebuild it. That is
|
|
5
|
-
// exactly what was done by hand for the session that became global-sem-0033, and this is that
|
|
6
|
-
// procedure written down.
|
|
7
|
-
//
|
|
8
|
-
// Recovery reads the session through sessionQuery.readSession, which the harness documents as
|
|
9
|
-
// reading a LIVE OR PERSISTED session without making it live. An earlier plan had this walking
|
|
10
|
-
// ~/.dsh/sessions and decompressing multi-frame zstd by hand; the service already does both, and
|
|
11
|
-
// depending on a storage path would have been a dependency on an implementation detail.
|
|
12
|
-
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
13
|
-
import { homedir } from 'node:os'
|
|
14
|
-
import { join } from 'node:path'
|
|
15
|
-
import { captureSession } from './capture.js'
|
|
16
|
-
import { advance, readQueue, requeue, STATES } from './queue.js'
|
|
17
|
-
|
|
18
|
-
/** Where DSH keeps session logs, used only to ESTIMATE cost. Never used to read them. */
|
|
19
|
-
export function sessionsRoot(home = homedir()) { return join(home, '.dsh', 'sessions') }
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Bytes of a session's stored log, or undefined when it cannot be located.
|
|
23
|
-
*
|
|
24
|
-
* Undefined is treated as 'too big to do unasked' by the caller, so an unlocatable log asks for
|
|
25
|
-
* approval rather than proceeding on a guess. Failing closed is the right default for a cost gate.
|
|
26
|
-
*/
|
|
27
|
-
export function sessionLogBytes(id, home = homedir()) {
|
|
28
|
-
const base = sessionsRoot(home)
|
|
29
|
-
try {
|
|
30
|
-
for (const project of readdirSync(base)) {
|
|
31
|
-
const inner = join(base, project, id)
|
|
32
|
-
if (!existsSync(inner)) continue
|
|
33
|
-
for (const file of readdirSync(inner)) {
|
|
34
|
-
if (!file.endsWith('.zstd')) continue
|
|
35
|
-
return statSync(join(inner, file)).size
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
} catch { /* an unreadable tree is the same as an unknown size */ }
|
|
39
|
-
return undefined
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* What recovery WOULD do, without doing any of it.
|
|
44
|
-
*
|
|
45
|
-
* The dry run exists because the cost is real: the orphaned sessions measured 8-88MB compressed,
|
|
46
|
-
* so recovering every one would decompress hundreds of megabytes and spend a model call each.
|
|
47
|
-
* Reporting first turns that into a decision.
|
|
48
|
-
* @returns { total, recoverable, oversized, missing, entries }
|
|
49
|
-
*/
|
|
50
|
-
export function planRecovery(root, options = {}) {
|
|
51
|
-
const threshold = options.thresholdBytes ?? 8 * 1024 * 1024
|
|
52
|
-
const bytesOf = options.bytesOf ?? ((id) => sessionLogBytes(id, options.home));
|
|
53
|
-
const entries = [];
|
|
54
|
-
let recoverable = 0
|
|
55
|
-
let oversized = 0
|
|
56
|
-
let missing = 0
|
|
57
|
-
for (const entry of readQueue(root).values()) {
|
|
58
|
-
if (entry.state !== STATES.ORPHANED) continue
|
|
59
|
-
const bytes = bytesOf(entry.id);
|
|
60
|
-
const known = typeof bytes === 'number'
|
|
61
|
-
const needsApproval = !known || bytes >= threshold
|
|
62
|
-
if (!known) missing += 1;
|
|
63
|
-
else if (needsApproval) oversized += 1;
|
|
64
|
-
else recoverable += 1;
|
|
65
|
-
entries.push({ id: entry.id, bytes, needsApproval, reason: entry.reason });
|
|
66
|
-
}
|
|
67
|
-
return { total: entries.length, recoverable, oversized, missing, threshold, entries };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Rebuild one orphaned candidate from its source session.
|
|
72
|
-
*
|
|
73
|
-
* The digest is regenerated by CAPTURE, not by a bespoke reader: capture already projects,
|
|
74
|
-
* filters injected context, chooses a model and writes a digest, and every one of those was a
|
|
75
|
-
* bug fixed this session. A second implementation would drift from it immediately.
|
|
76
|
-
*
|
|
77
|
-
* The watermark is reset to -1 first, because the material is gone: whatever capture last
|
|
78
|
-
* recorded about how far it had read no longer describes anything on disk.
|
|
79
|
-
*/
|
|
80
|
-
export async function recoverOne(store, services, id, options = {}) {
|
|
81
|
-
const entry = readQueue(store.root).get(id)
|
|
82
|
-
if (!entry) throw new Error('memory_recover: ' + id + ' is not in the queue')
|
|
83
|
-
if (entry.state !== STATES.ORPHANED && entry.state !== STATES.ABANDONED && entry.state !== STATES.FAILED) {
|
|
84
|
-
throw new Error('memory_recover: ' + id + ' is ' + entry.state + ', which is not work that stopped');
|
|
85
|
-
}
|
|
86
|
-
// Back to waiting with NO watermark, so capture re-reads the whole session.
|
|
87
|
-
requeue(store.root, id, { detail: { lastSeq: -1, recovering: true } });
|
|
88
|
-
const snapshot = await services.sessionQuery.readSession(id)
|
|
89
|
-
const header = snapshot?.session ?? {}
|
|
90
|
-
return captureSession(store, services, { id, header }, options)
|
|
91
|
-
}
|
|
1
|
+
// Recovering a candidate whose material is gone.
|
|
2
|
+
//
|
|
3
|
+
// An ORPHANED entry is one whose digest no longer exists - normally because pruning removed it.
|
|
4
|
+
// The digest is DERIVED, and the source session survives, so the fix is to rebuild it. That is
|
|
5
|
+
// exactly what was done by hand for the session that became global-sem-0033, and this is that
|
|
6
|
+
// procedure written down.
|
|
7
|
+
//
|
|
8
|
+
// Recovery reads the session through sessionQuery.readSession, which the harness documents as
|
|
9
|
+
// reading a LIVE OR PERSISTED session without making it live. An earlier plan had this walking
|
|
10
|
+
// ~/.dsh/sessions and decompressing multi-frame zstd by hand; the service already does both, and
|
|
11
|
+
// depending on a storage path would have been a dependency on an implementation detail.
|
|
12
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { captureSession } from './capture.js'
|
|
16
|
+
import { advance, readQueue, requeue, STATES } from './queue.js'
|
|
17
|
+
|
|
18
|
+
/** Where DSH keeps session logs, used only to ESTIMATE cost. Never used to read them. */
|
|
19
|
+
export function sessionsRoot(home = homedir()) { return join(home, '.dsh', 'sessions') }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bytes of a session's stored log, or undefined when it cannot be located.
|
|
23
|
+
*
|
|
24
|
+
* Undefined is treated as 'too big to do unasked' by the caller, so an unlocatable log asks for
|
|
25
|
+
* approval rather than proceeding on a guess. Failing closed is the right default for a cost gate.
|
|
26
|
+
*/
|
|
27
|
+
export function sessionLogBytes(id, home = homedir()) {
|
|
28
|
+
const base = sessionsRoot(home)
|
|
29
|
+
try {
|
|
30
|
+
for (const project of readdirSync(base)) {
|
|
31
|
+
const inner = join(base, project, id)
|
|
32
|
+
if (!existsSync(inner)) continue
|
|
33
|
+
for (const file of readdirSync(inner)) {
|
|
34
|
+
if (!file.endsWith('.zstd')) continue
|
|
35
|
+
return statSync(join(inner, file)).size
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch { /* an unreadable tree is the same as an unknown size */ }
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What recovery WOULD do, without doing any of it.
|
|
44
|
+
*
|
|
45
|
+
* The dry run exists because the cost is real: the orphaned sessions measured 8-88MB compressed,
|
|
46
|
+
* so recovering every one would decompress hundreds of megabytes and spend a model call each.
|
|
47
|
+
* Reporting first turns that into a decision.
|
|
48
|
+
* @returns { total, recoverable, oversized, missing, entries }
|
|
49
|
+
*/
|
|
50
|
+
export function planRecovery(root, options = {}) {
|
|
51
|
+
const threshold = options.thresholdBytes ?? 8 * 1024 * 1024
|
|
52
|
+
const bytesOf = options.bytesOf ?? ((id) => sessionLogBytes(id, options.home));
|
|
53
|
+
const entries = [];
|
|
54
|
+
let recoverable = 0
|
|
55
|
+
let oversized = 0
|
|
56
|
+
let missing = 0
|
|
57
|
+
for (const entry of readQueue(root).values()) {
|
|
58
|
+
if (entry.state !== STATES.ORPHANED) continue
|
|
59
|
+
const bytes = bytesOf(entry.id);
|
|
60
|
+
const known = typeof bytes === 'number'
|
|
61
|
+
const needsApproval = !known || bytes >= threshold
|
|
62
|
+
if (!known) missing += 1;
|
|
63
|
+
else if (needsApproval) oversized += 1;
|
|
64
|
+
else recoverable += 1;
|
|
65
|
+
entries.push({ id: entry.id, bytes, needsApproval, reason: entry.reason });
|
|
66
|
+
}
|
|
67
|
+
return { total: entries.length, recoverable, oversized, missing, threshold, entries };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Rebuild one orphaned candidate from its source session.
|
|
72
|
+
*
|
|
73
|
+
* The digest is regenerated by CAPTURE, not by a bespoke reader: capture already projects,
|
|
74
|
+
* filters injected context, chooses a model and writes a digest, and every one of those was a
|
|
75
|
+
* bug fixed this session. A second implementation would drift from it immediately.
|
|
76
|
+
*
|
|
77
|
+
* The watermark is reset to -1 first, because the material is gone: whatever capture last
|
|
78
|
+
* recorded about how far it had read no longer describes anything on disk.
|
|
79
|
+
*/
|
|
80
|
+
export async function recoverOne(store, services, id, options = {}) {
|
|
81
|
+
const entry = readQueue(store.root).get(id)
|
|
82
|
+
if (!entry) throw new Error('memory_recover: ' + id + ' is not in the queue')
|
|
83
|
+
if (entry.state !== STATES.ORPHANED && entry.state !== STATES.ABANDONED && entry.state !== STATES.FAILED) {
|
|
84
|
+
throw new Error('memory_recover: ' + id + ' is ' + entry.state + ', which is not work that stopped');
|
|
85
|
+
}
|
|
86
|
+
// Back to waiting with NO watermark, so capture re-reads the whole session.
|
|
87
|
+
requeue(store.root, id, { detail: { lastSeq: -1, recovering: true } });
|
|
88
|
+
const snapshot = await services.sessionQuery.readSession(id)
|
|
89
|
+
const header = snapshot?.session ?? {}
|
|
90
|
+
return captureSession(store, services, { id, header }, options)
|
|
91
|
+
}
|
package/src/refresh.js
CHANGED
|
@@ -1,80 +1,80 @@
|
|
|
1
|
-
// Refresh pass: flag stale/time-bounded entries (freshness), ask the LLM for
|
|
2
|
-
// keep/update/supersede decisions, then apply them deterministically in place.
|
|
3
|
-
import { readFileSync, writeFileSync } from 'node:fs'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import { buildLedger } from './ledger.js'
|
|
6
|
-
import { scanFreshness } from './freshness.js'
|
|
7
|
-
import { setEntryBody, setEntryStatus } from './entry.js'
|
|
8
|
-
import { updateIndex } from './fts.js'
|
|
9
|
-
import { layerIndexPath, rebuildLayerIndexes, scopeOfFile } from './layers.js'
|
|
10
|
-
import { layersForScope } from './entry.js'
|
|
11
|
-
|
|
12
|
-
export function buildRefreshPrompt(entries) {
|
|
13
|
-
const lines = [
|
|
14
|
-
'You are reviewing stale or time-bounded memory entries. For each, decide keep, update, or supersede.',
|
|
15
|
-
'Return ONLY a JSON object: {"decisions":[{"id":"<entry id>","action":"keep|update|supersede","body":"<new body if update>","reason":"..."}]}',
|
|
16
|
-
'Rules: update rewrites the body with current facts; supersede marks the entry obsolete (history kept); keep does nothing.',
|
|
17
|
-
'',
|
|
18
|
-
'Entries:',
|
|
19
|
-
]
|
|
20
|
-
for (const e of entries) {
|
|
21
|
-
lines.push('### ' + e.id + ' (' + e.file + ')')
|
|
22
|
-
lines.push('Verdict: ' + e.verdict)
|
|
23
|
-
lines.push(e.content)
|
|
24
|
-
lines.push('')
|
|
25
|
-
}
|
|
26
|
-
return lines.join('\n')
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function parseRefreshDecisions(text) {
|
|
30
|
-
const src = String(text ?? '')
|
|
31
|
-
const FENCE = '\u0060\u0060\u0060'
|
|
32
|
-
const fenced = src.match(new RegExp(FENCE + '(?:json)?\\s*([\\s\\S]*?)' + FENCE, 'i'))
|
|
33
|
-
let candidate = fenced ? fenced[1] : src
|
|
34
|
-
const obj = candidate.match(/\{[\s\S]*\}/)
|
|
35
|
-
if (obj) candidate = obj[0]
|
|
36
|
-
try {
|
|
37
|
-
const parsed = JSON.parse(candidate)
|
|
38
|
-
return Array.isArray(parsed.decisions) ? parsed.decisions : []
|
|
39
|
-
} catch { return [] }
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export async function runRefresh(store, { llm, limit = 3, scope, key, nowMs = Date.now() } = {}) {
|
|
43
|
-
const ledger = buildLedger(store.root)
|
|
44
|
-
const scoped = scope === 'workspace' && key
|
|
45
|
-
? ledger.filter((e) => e.file.startsWith('workspaces/' + key + '/'))
|
|
46
|
-
: ledger
|
|
47
|
-
const flagged = scanFreshness(scoped, nowMs).slice(0, limit)
|
|
48
|
-
if (flagged.length === 0) return { refreshed: 0, updated: 0, superseded: 0 }
|
|
49
|
-
const text = await llm(buildRefreshPrompt(flagged))
|
|
50
|
-
const decisions = parseRefreshDecisions(text)
|
|
51
|
-
let updated = 0
|
|
52
|
-
let superseded = 0
|
|
53
|
-
const edited = new Set()
|
|
54
|
-
for (const d of decisions) {
|
|
55
|
-
const entry = flagged.find((e) => e.id === d.id)
|
|
56
|
-
if (!entry) continue
|
|
57
|
-
const file = join(store.root, entry.file)
|
|
58
|
-
const cur = readFileSync(file, 'utf8')
|
|
59
|
-
if (d.action === 'update' && d.body) {
|
|
60
|
-
writeFileSync(file, setEntryBody(cur, d.id, d.body), 'utf8')
|
|
61
|
-
edited.add(file)
|
|
62
|
-
updated++
|
|
63
|
-
} else if (d.action === 'supersede') {
|
|
64
|
-
writeFileSync(file, setEntryStatus(cur, d.id, 'superseded', d.reason || 'refresh'), 'utf8')
|
|
65
|
-
edited.add(file)
|
|
66
|
-
superseded++
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
// An in-place edit changes a topic file, so its layer index is stale until rebuilt.
|
|
70
|
-
if (updated || superseded) {
|
|
71
|
-
const scopes = new Set(flagged.map((e) => JSON.stringify(scopeOfFile(e.file))).filter((s) => s !== undefined))
|
|
72
|
-
for (const raw of scopes) {
|
|
73
|
-
const { scope: s, key } = JSON.parse(raw)
|
|
74
|
-
rebuildLayerIndexes(store.root, s, key)
|
|
75
|
-
for (const l of layersForScope(s)) edited.add(layerIndexPath(store.root, s, key, l))
|
|
76
|
-
}
|
|
77
|
-
updateIndex(store.root, edited)
|
|
78
|
-
}
|
|
79
|
-
return { refreshed: decisions.length, updated, superseded }
|
|
80
|
-
}
|
|
1
|
+
// Refresh pass: flag stale/time-bounded entries (freshness), ask the LLM for
|
|
2
|
+
// keep/update/supersede decisions, then apply them deterministically in place.
|
|
3
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { buildLedger } from './ledger.js'
|
|
6
|
+
import { scanFreshness } from './freshness.js'
|
|
7
|
+
import { setEntryBody, setEntryStatus } from './entry.js'
|
|
8
|
+
import { updateIndex } from './fts.js'
|
|
9
|
+
import { layerIndexPath, rebuildLayerIndexes, scopeOfFile } from './layers.js'
|
|
10
|
+
import { layersForScope } from './entry.js'
|
|
11
|
+
|
|
12
|
+
export function buildRefreshPrompt(entries) {
|
|
13
|
+
const lines = [
|
|
14
|
+
'You are reviewing stale or time-bounded memory entries. For each, decide keep, update, or supersede.',
|
|
15
|
+
'Return ONLY a JSON object: {"decisions":[{"id":"<entry id>","action":"keep|update|supersede","body":"<new body if update>","reason":"..."}]}',
|
|
16
|
+
'Rules: update rewrites the body with current facts; supersede marks the entry obsolete (history kept); keep does nothing.',
|
|
17
|
+
'',
|
|
18
|
+
'Entries:',
|
|
19
|
+
]
|
|
20
|
+
for (const e of entries) {
|
|
21
|
+
lines.push('### ' + e.id + ' (' + e.file + ')')
|
|
22
|
+
lines.push('Verdict: ' + e.verdict)
|
|
23
|
+
lines.push(e.content)
|
|
24
|
+
lines.push('')
|
|
25
|
+
}
|
|
26
|
+
return lines.join('\n')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseRefreshDecisions(text) {
|
|
30
|
+
const src = String(text ?? '')
|
|
31
|
+
const FENCE = '\u0060\u0060\u0060'
|
|
32
|
+
const fenced = src.match(new RegExp(FENCE + '(?:json)?\\s*([\\s\\S]*?)' + FENCE, 'i'))
|
|
33
|
+
let candidate = fenced ? fenced[1] : src
|
|
34
|
+
const obj = candidate.match(/\{[\s\S]*\}/)
|
|
35
|
+
if (obj) candidate = obj[0]
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(candidate)
|
|
38
|
+
return Array.isArray(parsed.decisions) ? parsed.decisions : []
|
|
39
|
+
} catch { return [] }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runRefresh(store, { llm, limit = 3, scope, key, nowMs = Date.now() } = {}) {
|
|
43
|
+
const ledger = buildLedger(store.root)
|
|
44
|
+
const scoped = scope === 'workspace' && key
|
|
45
|
+
? ledger.filter((e) => e.file.startsWith('workspaces/' + key + '/'))
|
|
46
|
+
: ledger
|
|
47
|
+
const flagged = scanFreshness(scoped, nowMs).slice(0, limit)
|
|
48
|
+
if (flagged.length === 0) return { refreshed: 0, updated: 0, superseded: 0 }
|
|
49
|
+
const text = await llm(buildRefreshPrompt(flagged))
|
|
50
|
+
const decisions = parseRefreshDecisions(text)
|
|
51
|
+
let updated = 0
|
|
52
|
+
let superseded = 0
|
|
53
|
+
const edited = new Set()
|
|
54
|
+
for (const d of decisions) {
|
|
55
|
+
const entry = flagged.find((e) => e.id === d.id)
|
|
56
|
+
if (!entry) continue
|
|
57
|
+
const file = join(store.root, entry.file)
|
|
58
|
+
const cur = readFileSync(file, 'utf8')
|
|
59
|
+
if (d.action === 'update' && d.body) {
|
|
60
|
+
writeFileSync(file, setEntryBody(cur, d.id, d.body), 'utf8')
|
|
61
|
+
edited.add(file)
|
|
62
|
+
updated++
|
|
63
|
+
} else if (d.action === 'supersede') {
|
|
64
|
+
writeFileSync(file, setEntryStatus(cur, d.id, 'superseded', d.reason || 'refresh'), 'utf8')
|
|
65
|
+
edited.add(file)
|
|
66
|
+
superseded++
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// An in-place edit changes a topic file, so its layer index is stale until rebuilt.
|
|
70
|
+
if (updated || superseded) {
|
|
71
|
+
const scopes = new Set(flagged.map((e) => JSON.stringify(scopeOfFile(e.file))).filter((s) => s !== undefined))
|
|
72
|
+
for (const raw of scopes) {
|
|
73
|
+
const { scope: s, key } = JSON.parse(raw)
|
|
74
|
+
rebuildLayerIndexes(store.root, s, key)
|
|
75
|
+
for (const l of layersForScope(s)) edited.add(layerIndexPath(store.root, s, key, l))
|
|
76
|
+
}
|
|
77
|
+
updateIndex(store.root, edited)
|
|
78
|
+
}
|
|
79
|
+
return { refreshed: decisions.length, updated, superseded }
|
|
80
|
+
}
|
package/src/remember.js
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
// Deterministic write path: append a Format-2 entry to a layer file, then rebuild the index.
|
|
2
|
-
import { relative } from 'node:path'
|
|
3
|
-
import { allocateId, formatEntry, isWorkspaceOnly, LAYER_CODES, scopeIdFor, nowIso } from './entry.js'
|
|
4
|
-
import { updateIndex } from './fts.js'
|
|
5
|
-
import { isSuppressed } from './forget.js'
|
|
6
|
-
import { associate } from './workspace.js'
|
|
7
|
-
import { sanitizeKey } from './workspace-key.js'
|
|
8
|
-
import { appendToTopic, layerIndexPath, layerText, rebuildLayerIndex } from './layers.js'
|
|
9
|
-
import { decisionHeading, renderDecisionBody } from './decisions.js'
|
|
10
|
-
|
|
11
|
-
export function defaultHeading(text) {
|
|
12
|
-
const first = String(text ?? '').split('\n').map((s) => s.trim()).find((s) => s.length > 0)
|
|
13
|
-
if (!first) return 'untitled'
|
|
14
|
-
const clean = first.replace(/^[#*>-]+/, '').trim()
|
|
15
|
-
const h = clean.length > 64 ? clean.slice(0, 61) + '…' : clean
|
|
16
|
-
return h || 'untitled'
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function remember(store, { scope, key, layer, text, topic, decision, source = 'manual', importance = 2, until, volatility, verified } = {}) {
|
|
20
|
-
if (!LAYER_CODES[layer]) throw new Error('unknown layer: ' + layer)
|
|
21
|
-
if (isWorkspaceOnly(layer) && scope !== 'workspace') {
|
|
22
|
-
throw new Error('the ' + layer + ' map describes one repository; write it to a workspace')
|
|
23
|
-
}
|
|
24
|
-
// Sanitize ONCE, up front. associate() sanitizes for the directory it creates, so using
|
|
25
|
-
// the raw key afterwards built a path with characters a filesystem rejects: the workspace
|
|
26
|
-
// directory appeared and the write then failed, silently losing every entry.
|
|
27
|
-
const safeKey = scope === 'global' ? undefined : sanitizeKey(key)
|
|
28
|
-
if (scope !== 'global' && !safeKey) throw new Error('workspace scope requires a key')
|
|
29
|
-
const scopeId = scopeIdFor(scope, safeKey)
|
|
30
|
-
// A decision gets its own shape: the choice is the heading (so the layer index reads as a
|
|
31
|
-
// list of decisions) and the reasoning lives in fixed sections. `topic` still wins when the
|
|
32
|
-
// caller names one explicitly.
|
|
33
|
-
if (isSuppressed(store.root, scopeId, text)) throw new Error('suppressed: this content matches a forgotten entry')
|
|
34
|
-
if (scope !== 'global' && safeKey) {
|
|
35
|
-
// A write can introduce a workspace. Make it first-class - seeded layer files and a
|
|
36
|
-
// registry entry, exactly as an explicit associate would - instead of leaving behind a
|
|
37
|
-
// directory that holds only the file this one write happened to touch.
|
|
38
|
-
const known = store.readMemoryJson().workspaces ?? {}
|
|
39
|
-
if (!known[safeKey]) associate(store, { key: safeKey, title: key ?? safeKey })
|
|
40
|
-
}
|
|
41
|
-
// Layered storage: the entry goes into its own topic file, the layer file becomes an
|
|
42
|
-
// index over those files, and id allocation spans the whole layer rather than one file.
|
|
43
|
-
const heading = layer === 'decision'
|
|
44
|
-
? decisionHeading(topic ?? text)
|
|
45
|
-
: (topic ?? defaultHeading(text))
|
|
46
|
-
const body = layer === 'decision'
|
|
47
|
-
? renderDecisionBody(decision ?? { context: text })
|
|
48
|
-
: String(text)
|
|
49
|
-
const id = allocateId(layerText(store.root, scope, safeKey, layer), scopeId, layer)
|
|
50
|
-
const block = '## ' + heading + '\n\n'
|
|
51
|
-
+ formatEntry({ id, source, date: nowIso(), importance, status: 'active', until, volatility, verified, content: body })
|
|
52
|
-
const file = appendToTopic(store.root, scope, safeKey, layer, heading, block)
|
|
53
|
-
rebuildLayerIndex(store.root, scope, safeKey, layer)
|
|
54
|
-
// Index just what changed. A full rebuild is seconds once the store is large, and a
|
|
55
|
-
// single write touches exactly two files.
|
|
56
|
-
updateIndex(store.root, [file, layerIndexPath(store.root, scope, safeKey, layer)])
|
|
57
|
-
return { id, file: relative(store.root, file).replace(/\\/g, '/'), layer, scopeId }
|
|
58
|
-
}
|
|
1
|
+
// Deterministic write path: append a Format-2 entry to a layer file, then rebuild the index.
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import { allocateId, formatEntry, isWorkspaceOnly, LAYER_CODES, scopeIdFor, nowIso } from './entry.js'
|
|
4
|
+
import { updateIndex } from './fts.js'
|
|
5
|
+
import { isSuppressed } from './forget.js'
|
|
6
|
+
import { associate } from './workspace.js'
|
|
7
|
+
import { sanitizeKey } from './workspace-key.js'
|
|
8
|
+
import { appendToTopic, layerIndexPath, layerText, rebuildLayerIndex } from './layers.js'
|
|
9
|
+
import { decisionHeading, renderDecisionBody } from './decisions.js'
|
|
10
|
+
|
|
11
|
+
export function defaultHeading(text) {
|
|
12
|
+
const first = String(text ?? '').split('\n').map((s) => s.trim()).find((s) => s.length > 0)
|
|
13
|
+
if (!first) return 'untitled'
|
|
14
|
+
const clean = first.replace(/^[#*>-]+/, '').trim()
|
|
15
|
+
const h = clean.length > 64 ? clean.slice(0, 61) + '…' : clean
|
|
16
|
+
return h || 'untitled'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function remember(store, { scope, key, layer, text, topic, decision, source = 'manual', importance = 2, until, volatility, verified } = {}) {
|
|
20
|
+
if (!LAYER_CODES[layer]) throw new Error('unknown layer: ' + layer)
|
|
21
|
+
if (isWorkspaceOnly(layer) && scope !== 'workspace') {
|
|
22
|
+
throw new Error('the ' + layer + ' map describes one repository; write it to a workspace')
|
|
23
|
+
}
|
|
24
|
+
// Sanitize ONCE, up front. associate() sanitizes for the directory it creates, so using
|
|
25
|
+
// the raw key afterwards built a path with characters a filesystem rejects: the workspace
|
|
26
|
+
// directory appeared and the write then failed, silently losing every entry.
|
|
27
|
+
const safeKey = scope === 'global' ? undefined : sanitizeKey(key)
|
|
28
|
+
if (scope !== 'global' && !safeKey) throw new Error('workspace scope requires a key')
|
|
29
|
+
const scopeId = scopeIdFor(scope, safeKey)
|
|
30
|
+
// A decision gets its own shape: the choice is the heading (so the layer index reads as a
|
|
31
|
+
// list of decisions) and the reasoning lives in fixed sections. `topic` still wins when the
|
|
32
|
+
// caller names one explicitly.
|
|
33
|
+
if (isSuppressed(store.root, scopeId, text)) throw new Error('suppressed: this content matches a forgotten entry')
|
|
34
|
+
if (scope !== 'global' && safeKey) {
|
|
35
|
+
// A write can introduce a workspace. Make it first-class - seeded layer files and a
|
|
36
|
+
// registry entry, exactly as an explicit associate would - instead of leaving behind a
|
|
37
|
+
// directory that holds only the file this one write happened to touch.
|
|
38
|
+
const known = store.readMemoryJson().workspaces ?? {}
|
|
39
|
+
if (!known[safeKey]) associate(store, { key: safeKey, title: key ?? safeKey })
|
|
40
|
+
}
|
|
41
|
+
// Layered storage: the entry goes into its own topic file, the layer file becomes an
|
|
42
|
+
// index over those files, and id allocation spans the whole layer rather than one file.
|
|
43
|
+
const heading = layer === 'decision'
|
|
44
|
+
? decisionHeading(topic ?? text)
|
|
45
|
+
: (topic ?? defaultHeading(text))
|
|
46
|
+
const body = layer === 'decision'
|
|
47
|
+
? renderDecisionBody(decision ?? { context: text })
|
|
48
|
+
: String(text)
|
|
49
|
+
const id = allocateId(layerText(store.root, scope, safeKey, layer), scopeId, layer)
|
|
50
|
+
const block = '## ' + heading + '\n\n'
|
|
51
|
+
+ formatEntry({ id, source, date: nowIso(), importance, status: 'active', until, volatility, verified, content: body })
|
|
52
|
+
const file = appendToTopic(store.root, scope, safeKey, layer, heading, block)
|
|
53
|
+
rebuildLayerIndex(store.root, scope, safeKey, layer)
|
|
54
|
+
// Index just what changed. A full rebuild is seconds once the store is large, and a
|
|
55
|
+
// single write touches exactly two files.
|
|
56
|
+
updateIndex(store.root, [file, layerIndexPath(store.root, scope, safeKey, layer)])
|
|
57
|
+
return { id, file: relative(store.root, file).replace(/\\/g, '/'), layer, scopeId }
|
|
58
|
+
}
|