@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/map.js
CHANGED
|
@@ -1,184 +1,184 @@
|
|
|
1
|
-
// The UI snapshot behind memory_map: one structured view of the store, and of what THIS
|
|
2
|
-
// session has received from memory and written back to it.
|
|
3
|
-
//
|
|
4
|
-
// Three rules shape this module.
|
|
5
|
-
//
|
|
6
|
-
// Every number is DERIVED, never restated. Freshness comes from freshnessVerdict, the queue
|
|
7
|
-
// from the queue reader, and the RECEIVED panel from the injection's own rendered text. A
|
|
8
|
-
// second set of rules here would drift from the first, and the UI would then lie about the
|
|
9
|
-
// store it exists to describe.
|
|
10
|
-
//
|
|
11
|
-
// The snapshot is LOSSLESS JSON - no undefined, no Date, no Map. It crosses the tool boundary
|
|
12
|
-
// and a browser parses it, so one undefined rejects the whole result.
|
|
13
|
-
//
|
|
14
|
-
// Store and session are separate scopes because they answer different questions and have
|
|
15
|
-
// different lifetimes. The store is what EXISTS; the session is what THIS conversation saw
|
|
16
|
-
// and wrote. A tab can show either without pretending the other is live.
|
|
17
|
-
|
|
18
|
-
import { buildLedger } from './ledger.js'
|
|
19
|
-
import { LAYERS } from './entry.js'
|
|
20
|
-
import { freshnessVerdict } from './freshness.js'
|
|
21
|
-
import { buildInjectionText } from './inject.js'
|
|
22
|
-
import { problems, summarize } from './queue.js'
|
|
23
|
-
|
|
24
|
-
/** How many scopes and written entries the snapshot carries before it summarises instead. */
|
|
25
|
-
const SCOPE_LIMIT = 6
|
|
26
|
-
const WRITTEN_LIMIT = 8
|
|
27
|
-
|
|
28
|
-
/** Doubt, worst first: a group reports the most doubtful thing inside it, not the best. */
|
|
29
|
-
const SEVERITY = { fresh: 0, empty: 1, aging: 2, unverified: 3, stale: 4 }
|
|
30
|
-
|
|
31
|
-
function worstVerdict(verdicts) {
|
|
32
|
-
let worst = 'fresh'
|
|
33
|
-
for (const verdict of verdicts) {
|
|
34
|
-
if ((SEVERITY[verdict] ?? 0) > (SEVERITY[worst] ?? 0)) worst = verdict
|
|
35
|
-
}
|
|
36
|
-
return worst
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** The workspace key a ledger file belongs to, or 'global' for the shared layer. */
|
|
40
|
-
export function scopeKeyOf(file) {
|
|
41
|
-
const normalised = String(file ?? '').replace(/\\/g, '/')
|
|
42
|
-
const match = normalised.match(/workspaces\/([^/]+)\//)
|
|
43
|
-
return match ? match[1] : 'global'
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function tally(values) {
|
|
47
|
-
const out = {}
|
|
48
|
-
for (const value of values) out[value] = (out[value] ?? 0) + 1
|
|
49
|
-
return out
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Whole-store composition: entries per layer, how fresh they are, which workspaces hold
|
|
54
|
-
* them, and what the ingest queue is doing.
|
|
55
|
-
* @param store - the memory store.
|
|
56
|
-
* @param options.ledger - a prebuilt ledger, so a caller needing two scopes reads the disk once.
|
|
57
|
-
*/
|
|
58
|
-
export function buildStoreSnapshot(store, { ledger, now = Date.now(), scopeLimit = SCOPE_LIMIT } = {}) {
|
|
59
|
-
const entries = ledger ?? buildLedger(store.root)
|
|
60
|
-
// The verdict vocabulary belongs to the freshness module. An unrecognised verdict counts as
|
|
61
|
-
// fresh, so a new one upstream shows up as a total that does not add up - visible - rather
|
|
62
|
-
// than as a crash or a silent zero.
|
|
63
|
-
const freshness = { fresh: 0, stale: 0, expired: 0, skip: 0 }
|
|
64
|
-
for (const entry of entries) {
|
|
65
|
-
const verdict = freshnessVerdict(entry, now).verdict
|
|
66
|
-
freshness[Object.hasOwn(freshness, verdict) ? verdict : 'fresh'] += 1
|
|
67
|
-
}
|
|
68
|
-
const layerCounts = tally(entries.map((e) => e.layer))
|
|
69
|
-
const scopeCounts = tally(entries.map((e) => scopeKeyOf(e.file)))
|
|
70
|
-
const scopes = Object.keys(scopeCounts)
|
|
71
|
-
.map((key) => ({ key, count: scopeCounts[key] }))
|
|
72
|
-
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
|
|
73
|
-
const states = summarize(store.root)
|
|
74
|
-
// Group problem rows by state and reason: 76 orphaned candidates are ONE fact about the
|
|
75
|
-
// store, not 76 lines of UI.
|
|
76
|
-
const grouped = new Map()
|
|
77
|
-
for (const problem of problems(store.root)) {
|
|
78
|
-
const reason = problem.reason ?? 'unclassified'
|
|
79
|
-
const key = problem.state + '|' + reason
|
|
80
|
-
const seen = grouped.get(key)
|
|
81
|
-
if (seen) seen.count += 1
|
|
82
|
-
else grouped.set(key, { state: problem.state, reason, count: 1 })
|
|
83
|
-
}
|
|
84
|
-
return {
|
|
85
|
-
root: store.root,
|
|
86
|
-
total: entries.length,
|
|
87
|
-
layers: LAYERS.map((name) => ({ name, count: layerCounts[name] ?? 0 })),
|
|
88
|
-
freshness,
|
|
89
|
-
scopes: scopes.slice(0, scopeLimit),
|
|
90
|
-
scopeTotal: scopes.length,
|
|
91
|
-
queue: {
|
|
92
|
-
states,
|
|
93
|
-
waiting: (states.pending ?? 0) + (states.summarized ?? 0),
|
|
94
|
-
recoverable: states.orphaned ?? 0,
|
|
95
|
-
attention: [...grouped.values()].sort((a, b) => b.count - a.count),
|
|
96
|
-
},
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** The marker vocabulary the injection actually emits. Matching only these keeps a bracketed
|
|
101
|
-
* word inside an entry's prose from being read as a freshness verdict. */
|
|
102
|
-
const MARKER = /\[(STALE|AGING|UNVERIFIED|NOT STARTED)\b[^\]]*\]/g
|
|
103
|
-
|
|
104
|
-
function verdictOfMarker(marker) {
|
|
105
|
-
if (marker.startsWith('[STALE')) return 'stale'
|
|
106
|
-
if (marker.startsWith('[AGING')) return 'aging'
|
|
107
|
-
if (marker.startsWith('[UNVERIFIED')) return 'unverified'
|
|
108
|
-
if (marker.startsWith('[NOT STARTED')) return 'empty'
|
|
109
|
-
return 'fresh'
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Read the sections back out of the injection's OWN rendered text.
|
|
114
|
-
*
|
|
115
|
-
* Parsing text is usually the wrong instinct, and here it is the right one: the question the
|
|
116
|
-
* panel answers is 'what was actually put in front of this session', and the only artefact
|
|
117
|
-
* that can answer it without the possibility of disagreement is the text itself. Recomputing
|
|
118
|
-
* the sections from the same inputs would be a second implementation of one rule.
|
|
119
|
-
*/
|
|
120
|
-
export function parseInjectionSections(text) {
|
|
121
|
-
const sections = []
|
|
122
|
-
for (const raw of String(text ?? '').split('\n')) {
|
|
123
|
-
const heading = raw.match(/^###\s+(.+?)\s*$/)
|
|
124
|
-
if (heading) {
|
|
125
|
-
const found = heading[1].match(MARKER) ?? []
|
|
126
|
-
// The heading is written for the agent ('Working memory - yours'); the panel labels the
|
|
127
|
-
// same thing more briefly, so the owner suffix is dropped at the boundary.
|
|
128
|
-
const name = heading[1].replace(MARKER, '').replace(/\s+\u2014\s+yours\s*$/, '').trim()
|
|
129
|
-
sections.push({ name, markers: [...found] })
|
|
130
|
-
continue
|
|
131
|
-
}
|
|
132
|
-
if (sections.length === 0) continue
|
|
133
|
-
const found = raw.match(MARKER)
|
|
134
|
-
if (found) sections[sections.length - 1].markers.push(...found)
|
|
135
|
-
}
|
|
136
|
-
return sections.map((section) => {
|
|
137
|
-
const verdict = worstVerdict(section.markers.map(verdictOfMarker))
|
|
138
|
-
const marker = section.markers.find((m) => verdictOfMarker(m) === verdict) ?? ''
|
|
139
|
-
return { name: section.name, verdict, marker }
|
|
140
|
-
})
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** What memory put in front of this session, sized and broken down as it was sent. */
|
|
144
|
-
export function buildReceived(store, { memoryScope = 'global', key, goal, maxChars } = {}) {
|
|
145
|
-
const text = buildInjectionText(store, { scope: memoryScope, key, goal, maxChars })
|
|
146
|
-
return { chars: text.length, sections: parseInjectionSections(text) }
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* What THIS session wrote back. Attribution is exact rather than inferred from timestamps:
|
|
151
|
-
* every write already records source 'agent:<sessionId>', so two sessions writing in the same
|
|
152
|
-
* minute are still told apart.
|
|
153
|
-
*/
|
|
154
|
-
export function buildWritten(store, { ledger, sessionId, limit = WRITTEN_LIMIT } = {}) {
|
|
155
|
-
if (!sessionId) return { total: 0, byLayer: {}, entries: [] }
|
|
156
|
-
const marker = 'agent:' + sessionId
|
|
157
|
-
const written = (ledger ?? buildLedger(store.root)).filter((entry) => entry.source === marker)
|
|
158
|
-
const ordered = [...written].sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? '')))
|
|
159
|
-
return {
|
|
160
|
-
total: written.length,
|
|
161
|
-
byLayer: tally(written.map((e) => e.layer)),
|
|
162
|
-
entries: ordered.slice(0, limit).map((e) => ({ id: e.id, layer: e.layer, heading: e.heading ?? '' })),
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* One snapshot, either scope or both.
|
|
168
|
-
* @param options.scope - 'store' (what exists), 'session' (what this conversation saw and wrote), or 'all'.
|
|
169
|
-
* @param options.memoryScope - 'global' or 'workspace': which memory layer this session belongs to.
|
|
170
|
-
*/
|
|
171
|
-
export function buildMap(store, { scope = 'all', sessionId, memoryScope = 'global', key, goal, now = Date.now(), maxChars } = {}) {
|
|
172
|
-
const ledger = buildLedger(store.root)
|
|
173
|
-
const snapshot = { scope, generatedAt: new Date(now).toISOString() }
|
|
174
|
-
if (scope === 'store' || scope === 'all') snapshot.store = buildStoreSnapshot(store, { ledger, now })
|
|
175
|
-
if (scope === 'session' || scope === 'all') {
|
|
176
|
-
snapshot.session = {
|
|
177
|
-
sessionId: sessionId ?? null,
|
|
178
|
-
workspace: memoryScope === 'workspace' ? (key ?? null) : null,
|
|
179
|
-
received: buildReceived(store, { memoryScope, key, goal, maxChars }),
|
|
180
|
-
written: buildWritten(store, { ledger, sessionId }),
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return snapshot
|
|
184
|
-
}
|
|
1
|
+
// The UI snapshot behind memory_map: one structured view of the store, and of what THIS
|
|
2
|
+
// session has received from memory and written back to it.
|
|
3
|
+
//
|
|
4
|
+
// Three rules shape this module.
|
|
5
|
+
//
|
|
6
|
+
// Every number is DERIVED, never restated. Freshness comes from freshnessVerdict, the queue
|
|
7
|
+
// from the queue reader, and the RECEIVED panel from the injection's own rendered text. A
|
|
8
|
+
// second set of rules here would drift from the first, and the UI would then lie about the
|
|
9
|
+
// store it exists to describe.
|
|
10
|
+
//
|
|
11
|
+
// The snapshot is LOSSLESS JSON - no undefined, no Date, no Map. It crosses the tool boundary
|
|
12
|
+
// and a browser parses it, so one undefined rejects the whole result.
|
|
13
|
+
//
|
|
14
|
+
// Store and session are separate scopes because they answer different questions and have
|
|
15
|
+
// different lifetimes. The store is what EXISTS; the session is what THIS conversation saw
|
|
16
|
+
// and wrote. A tab can show either without pretending the other is live.
|
|
17
|
+
|
|
18
|
+
import { buildLedger } from './ledger.js'
|
|
19
|
+
import { LAYERS } from './entry.js'
|
|
20
|
+
import { freshnessVerdict } from './freshness.js'
|
|
21
|
+
import { buildInjectionText } from './inject.js'
|
|
22
|
+
import { problems, summarize } from './queue.js'
|
|
23
|
+
|
|
24
|
+
/** How many scopes and written entries the snapshot carries before it summarises instead. */
|
|
25
|
+
const SCOPE_LIMIT = 6
|
|
26
|
+
const WRITTEN_LIMIT = 8
|
|
27
|
+
|
|
28
|
+
/** Doubt, worst first: a group reports the most doubtful thing inside it, not the best. */
|
|
29
|
+
const SEVERITY = { fresh: 0, empty: 1, aging: 2, unverified: 3, stale: 4 }
|
|
30
|
+
|
|
31
|
+
function worstVerdict(verdicts) {
|
|
32
|
+
let worst = 'fresh'
|
|
33
|
+
for (const verdict of verdicts) {
|
|
34
|
+
if ((SEVERITY[verdict] ?? 0) > (SEVERITY[worst] ?? 0)) worst = verdict
|
|
35
|
+
}
|
|
36
|
+
return worst
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The workspace key a ledger file belongs to, or 'global' for the shared layer. */
|
|
40
|
+
export function scopeKeyOf(file) {
|
|
41
|
+
const normalised = String(file ?? '').replace(/\\/g, '/')
|
|
42
|
+
const match = normalised.match(/workspaces\/([^/]+)\//)
|
|
43
|
+
return match ? match[1] : 'global'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tally(values) {
|
|
47
|
+
const out = {}
|
|
48
|
+
for (const value of values) out[value] = (out[value] ?? 0) + 1
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whole-store composition: entries per layer, how fresh they are, which workspaces hold
|
|
54
|
+
* them, and what the ingest queue is doing.
|
|
55
|
+
* @param store - the memory store.
|
|
56
|
+
* @param options.ledger - a prebuilt ledger, so a caller needing two scopes reads the disk once.
|
|
57
|
+
*/
|
|
58
|
+
export function buildStoreSnapshot(store, { ledger, now = Date.now(), scopeLimit = SCOPE_LIMIT } = {}) {
|
|
59
|
+
const entries = ledger ?? buildLedger(store.root)
|
|
60
|
+
// The verdict vocabulary belongs to the freshness module. An unrecognised verdict counts as
|
|
61
|
+
// fresh, so a new one upstream shows up as a total that does not add up - visible - rather
|
|
62
|
+
// than as a crash or a silent zero.
|
|
63
|
+
const freshness = { fresh: 0, stale: 0, expired: 0, skip: 0 }
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const verdict = freshnessVerdict(entry, now).verdict
|
|
66
|
+
freshness[Object.hasOwn(freshness, verdict) ? verdict : 'fresh'] += 1
|
|
67
|
+
}
|
|
68
|
+
const layerCounts = tally(entries.map((e) => e.layer))
|
|
69
|
+
const scopeCounts = tally(entries.map((e) => scopeKeyOf(e.file)))
|
|
70
|
+
const scopes = Object.keys(scopeCounts)
|
|
71
|
+
.map((key) => ({ key, count: scopeCounts[key] }))
|
|
72
|
+
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
|
|
73
|
+
const states = summarize(store.root)
|
|
74
|
+
// Group problem rows by state and reason: 76 orphaned candidates are ONE fact about the
|
|
75
|
+
// store, not 76 lines of UI.
|
|
76
|
+
const grouped = new Map()
|
|
77
|
+
for (const problem of problems(store.root)) {
|
|
78
|
+
const reason = problem.reason ?? 'unclassified'
|
|
79
|
+
const key = problem.state + '|' + reason
|
|
80
|
+
const seen = grouped.get(key)
|
|
81
|
+
if (seen) seen.count += 1
|
|
82
|
+
else grouped.set(key, { state: problem.state, reason, count: 1 })
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
root: store.root,
|
|
86
|
+
total: entries.length,
|
|
87
|
+
layers: LAYERS.map((name) => ({ name, count: layerCounts[name] ?? 0 })),
|
|
88
|
+
freshness,
|
|
89
|
+
scopes: scopes.slice(0, scopeLimit),
|
|
90
|
+
scopeTotal: scopes.length,
|
|
91
|
+
queue: {
|
|
92
|
+
states,
|
|
93
|
+
waiting: (states.pending ?? 0) + (states.summarized ?? 0),
|
|
94
|
+
recoverable: states.orphaned ?? 0,
|
|
95
|
+
attention: [...grouped.values()].sort((a, b) => b.count - a.count),
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The marker vocabulary the injection actually emits. Matching only these keeps a bracketed
|
|
101
|
+
* word inside an entry's prose from being read as a freshness verdict. */
|
|
102
|
+
const MARKER = /\[(STALE|AGING|UNVERIFIED|NOT STARTED)\b[^\]]*\]/g
|
|
103
|
+
|
|
104
|
+
function verdictOfMarker(marker) {
|
|
105
|
+
if (marker.startsWith('[STALE')) return 'stale'
|
|
106
|
+
if (marker.startsWith('[AGING')) return 'aging'
|
|
107
|
+
if (marker.startsWith('[UNVERIFIED')) return 'unverified'
|
|
108
|
+
if (marker.startsWith('[NOT STARTED')) return 'empty'
|
|
109
|
+
return 'fresh'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read the sections back out of the injection's OWN rendered text.
|
|
114
|
+
*
|
|
115
|
+
* Parsing text is usually the wrong instinct, and here it is the right one: the question the
|
|
116
|
+
* panel answers is 'what was actually put in front of this session', and the only artefact
|
|
117
|
+
* that can answer it without the possibility of disagreement is the text itself. Recomputing
|
|
118
|
+
* the sections from the same inputs would be a second implementation of one rule.
|
|
119
|
+
*/
|
|
120
|
+
export function parseInjectionSections(text) {
|
|
121
|
+
const sections = []
|
|
122
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
123
|
+
const heading = raw.match(/^###\s+(.+?)\s*$/)
|
|
124
|
+
if (heading) {
|
|
125
|
+
const found = heading[1].match(MARKER) ?? []
|
|
126
|
+
// The heading is written for the agent ('Working memory - yours'); the panel labels the
|
|
127
|
+
// same thing more briefly, so the owner suffix is dropped at the boundary.
|
|
128
|
+
const name = heading[1].replace(MARKER, '').replace(/\s+\u2014\s+yours\s*$/, '').trim()
|
|
129
|
+
sections.push({ name, markers: [...found] })
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (sections.length === 0) continue
|
|
133
|
+
const found = raw.match(MARKER)
|
|
134
|
+
if (found) sections[sections.length - 1].markers.push(...found)
|
|
135
|
+
}
|
|
136
|
+
return sections.map((section) => {
|
|
137
|
+
const verdict = worstVerdict(section.markers.map(verdictOfMarker))
|
|
138
|
+
const marker = section.markers.find((m) => verdictOfMarker(m) === verdict) ?? ''
|
|
139
|
+
return { name: section.name, verdict, marker }
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What memory put in front of this session, sized and broken down as it was sent. */
|
|
144
|
+
export function buildReceived(store, { memoryScope = 'global', key, goal, maxChars } = {}) {
|
|
145
|
+
const text = buildInjectionText(store, { scope: memoryScope, key, goal, maxChars })
|
|
146
|
+
return { chars: text.length, sections: parseInjectionSections(text) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What THIS session wrote back. Attribution is exact rather than inferred from timestamps:
|
|
151
|
+
* every write already records source 'agent:<sessionId>', so two sessions writing in the same
|
|
152
|
+
* minute are still told apart.
|
|
153
|
+
*/
|
|
154
|
+
export function buildWritten(store, { ledger, sessionId, limit = WRITTEN_LIMIT } = {}) {
|
|
155
|
+
if (!sessionId) return { total: 0, byLayer: {}, entries: [] }
|
|
156
|
+
const marker = 'agent:' + sessionId
|
|
157
|
+
const written = (ledger ?? buildLedger(store.root)).filter((entry) => entry.source === marker)
|
|
158
|
+
const ordered = [...written].sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? '')))
|
|
159
|
+
return {
|
|
160
|
+
total: written.length,
|
|
161
|
+
byLayer: tally(written.map((e) => e.layer)),
|
|
162
|
+
entries: ordered.slice(0, limit).map((e) => ({ id: e.id, layer: e.layer, heading: e.heading ?? '' })),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One snapshot, either scope or both.
|
|
168
|
+
* @param options.scope - 'store' (what exists), 'session' (what this conversation saw and wrote), or 'all'.
|
|
169
|
+
* @param options.memoryScope - 'global' or 'workspace': which memory layer this session belongs to.
|
|
170
|
+
*/
|
|
171
|
+
export function buildMap(store, { scope = 'all', sessionId, memoryScope = 'global', key, goal, now = Date.now(), maxChars } = {}) {
|
|
172
|
+
const ledger = buildLedger(store.root)
|
|
173
|
+
const snapshot = { scope, generatedAt: new Date(now).toISOString() }
|
|
174
|
+
if (scope === 'store' || scope === 'all') snapshot.store = buildStoreSnapshot(store, { ledger, now })
|
|
175
|
+
if (scope === 'session' || scope === 'all') {
|
|
176
|
+
snapshot.session = {
|
|
177
|
+
sessionId: sessionId ?? null,
|
|
178
|
+
workspace: memoryScope === 'workspace' ? (key ?? null) : null,
|
|
179
|
+
received: buildReceived(store, { memoryScope, key, goal, maxChars }),
|
|
180
|
+
written: buildWritten(store, { ledger, sessionId }),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return snapshot
|
|
184
|
+
}
|
package/src/models.js
CHANGED
|
@@ -1,136 +1,136 @@
|
|
|
1
|
-
// Model resolution for memory work: a fallback ladder over the models that are actually
|
|
2
|
-
// routable RIGHT NOW.
|
|
3
|
-
//
|
|
4
|
-
// Memory work used to run on one model - the session's, or a configured override - and a model
|
|
5
|
-
// that answers with no text block at all silently produced nothing. That was not hypothetical:
|
|
6
|
-
// 7 of 11 consolidation passes returned an empty reply on one reasoning-heavy model, while the
|
|
7
|
-
// next model wrote 35 entries from the same prompt and the same digests.
|
|
8
|
-
//
|
|
9
|
-
// The model list is deployment state, not a constant: providers come and go, a session switches
|
|
10
|
-
// model mid-conversation, and a subscription can lapse. So the ladder is resolved at call time
|
|
11
|
-
// from the live registry, and the winner is REMEMBERED so the normal case still costs one call.
|
|
12
|
-
|
|
13
|
-
/** How many models one call may try before giving up. Bounds the cost of a bad deployment. */
|
|
14
|
-
export const LADDER_MAX = 4
|
|
15
|
-
|
|
16
|
-
/** A model that returned no text is benched for this long before being tried again. */
|
|
17
|
-
export const BENCH_MS = 15 * 60 * 1000
|
|
18
|
-
|
|
19
|
-
/** Stable identity for a (provider, model) pair. */
|
|
20
|
-
export function modelKey(target) {
|
|
21
|
-
if (!target || !target.provider || !target.model) return ''
|
|
22
|
-
return target.provider + '/' + target.model
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Order the models to try, most preferred first.
|
|
27
|
-
*
|
|
28
|
-
* The configured memory model wins outright when set, because that is an explicit instruction.
|
|
29
|
-
* The session's own model is next: it is already known to be routable for this user, and it
|
|
30
|
-
* keeps memory consistent with the work being remembered. Everything else routable follows, so
|
|
31
|
-
* a deployment with no configuration still has somewhere to fall back to.
|
|
32
|
-
*
|
|
33
|
-
* @param options.preferred - the configured memory model, if any.
|
|
34
|
-
* @param options.session - the session's current route, if any.
|
|
35
|
-
* @param options.available - every routable target, in registry order.
|
|
36
|
-
* @returns a de-duplicated ladder, at most LADDER_MAX long.
|
|
37
|
-
*/
|
|
38
|
-
export function buildModelLadder({ preferred, session, available } = {}) {
|
|
39
|
-
const ladder = []
|
|
40
|
-
const seen = new Set()
|
|
41
|
-
const push = (target) => {
|
|
42
|
-
const key = modelKey(target)
|
|
43
|
-
if (key === '' || seen.has(key)) return
|
|
44
|
-
seen.add(key)
|
|
45
|
-
ladder.push({ provider: target.provider, model: target.model })
|
|
46
|
-
}
|
|
47
|
-
push(preferred)
|
|
48
|
-
push(session)
|
|
49
|
-
for (const target of available ?? []) push(target)
|
|
50
|
-
return ladder.slice(0, LADDER_MAX)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Track which model last worked, and which ones recently returned nothing.
|
|
55
|
-
*
|
|
56
|
-
* This is what keeps a ladder cheap. Without it every call would re-walk from the top; with it
|
|
57
|
-
* the working model is tried first every time, and a model is only re-tried after its bench
|
|
58
|
-
* expires, so a temporary outage costs a bounded number of calls rather than one per write.
|
|
59
|
-
*/
|
|
60
|
-
export function createModelMemory({ now = () => Date.now(), benchMs = BENCH_MS } = {}) {
|
|
61
|
-
let winner
|
|
62
|
-
const benched = new Map()
|
|
63
|
-
return {
|
|
64
|
-
/** The model that last produced text, if any. */
|
|
65
|
-
preferred() { return winner },
|
|
66
|
-
/** Record that a model worked; it becomes the new first choice. */
|
|
67
|
-
succeeded(target) {
|
|
68
|
-
winner = { provider: target.provider, model: target.model }
|
|
69
|
-
benched.delete(modelKey(target))
|
|
70
|
-
},
|
|
71
|
-
/** Record that a model returned nothing usable. */
|
|
72
|
-
failed(target) {
|
|
73
|
-
benched.set(modelKey(target), now())
|
|
74
|
-
if (modelKey(winner) === modelKey(target)) winner = undefined
|
|
75
|
-
},
|
|
76
|
-
/** True when a model is still benched for returning nothing. */
|
|
77
|
-
isBenched(target, at = now()) {
|
|
78
|
-
const since = benched.get(modelKey(target))
|
|
79
|
-
if (since === undefined) return false
|
|
80
|
-
if (at - since >= benchMs) { benched.delete(modelKey(target)); return false }
|
|
81
|
-
return true
|
|
82
|
-
},
|
|
83
|
-
/** Benched models, for diagnostics. */
|
|
84
|
-
snapshot() { return { winner, benched: [...benched.keys()] } },
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Order a ladder for one call: previously-working model first, benched models last.
|
|
90
|
-
* Benched models stay in the ladder - they are a last resort, not a permanent exclusion,
|
|
91
|
-
* so a deployment where every model is benched still attempts something.
|
|
92
|
-
*/
|
|
93
|
-
export function orderLadder(ladder, memory) {
|
|
94
|
-
if (!memory) return ladder
|
|
95
|
-
const winner = memory.preferred()
|
|
96
|
-
const rank = (target) => {
|
|
97
|
-
if (modelKey(target) === modelKey(winner)) return 0
|
|
98
|
-
return memory.isBenched(target) ? 2 : 1
|
|
99
|
-
}
|
|
100
|
-
return ladder.map((t, i) => ({ t, i })).sort((a, b) => (rank(a.t) - rank(b.t)) || (a.i - b.i)).map((x) => x.t)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Run one prompt down the ladder until a model returns text.
|
|
105
|
-
*
|
|
106
|
-
* @param options.ladder - ordered targets.
|
|
107
|
-
* @param options.attempt - (target) => Promise<string>; resolves to the reply text, '' for none.
|
|
108
|
-
* @param options.memory - optional createModelMemory() to learn from the outcome.
|
|
109
|
-
* @param options.onAttempt - (target, text) => void, for telemetry.
|
|
110
|
-
* @returns { text, target, tried } - text is '' when every model returned nothing.
|
|
111
|
-
*/
|
|
112
|
-
export async function runLadder({ ladder, attempt, memory, onAttempt } = {}) {
|
|
113
|
-
const ordered = orderLadder(ladder ?? [], memory)
|
|
114
|
-
const tried = []
|
|
115
|
-
for (const target of ordered) {
|
|
116
|
-
let text = ''
|
|
117
|
-
try {
|
|
118
|
-
text = String((await attempt(target)) ?? '')
|
|
119
|
-
} catch (error) {
|
|
120
|
-
// A model that throws is unavailable, not a verdict about the material: bench it and
|
|
121
|
-
// let the next rung try the SAME prompt.
|
|
122
|
-
tried.push({ target, error: String((error && error.message) || error).slice(0, 160) })
|
|
123
|
-
if (memory) memory.failed(target)
|
|
124
|
-
if (onAttempt) onAttempt(target, '', error)
|
|
125
|
-
continue
|
|
126
|
-
}
|
|
127
|
-
tried.push({ target, text: text.length })
|
|
128
|
-
if (onAttempt) onAttempt(target, text)
|
|
129
|
-
if (text.trim() !== '') {
|
|
130
|
-
if (memory) memory.succeeded(target)
|
|
131
|
-
return { text, target, tried }
|
|
132
|
-
}
|
|
133
|
-
if (memory) memory.failed(target)
|
|
134
|
-
}
|
|
135
|
-
return { text: '', target: undefined, tried }
|
|
136
|
-
}
|
|
1
|
+
// Model resolution for memory work: a fallback ladder over the models that are actually
|
|
2
|
+
// routable RIGHT NOW.
|
|
3
|
+
//
|
|
4
|
+
// Memory work used to run on one model - the session's, or a configured override - and a model
|
|
5
|
+
// that answers with no text block at all silently produced nothing. That was not hypothetical:
|
|
6
|
+
// 7 of 11 consolidation passes returned an empty reply on one reasoning-heavy model, while the
|
|
7
|
+
// next model wrote 35 entries from the same prompt and the same digests.
|
|
8
|
+
//
|
|
9
|
+
// The model list is deployment state, not a constant: providers come and go, a session switches
|
|
10
|
+
// model mid-conversation, and a subscription can lapse. So the ladder is resolved at call time
|
|
11
|
+
// from the live registry, and the winner is REMEMBERED so the normal case still costs one call.
|
|
12
|
+
|
|
13
|
+
/** How many models one call may try before giving up. Bounds the cost of a bad deployment. */
|
|
14
|
+
export const LADDER_MAX = 4
|
|
15
|
+
|
|
16
|
+
/** A model that returned no text is benched for this long before being tried again. */
|
|
17
|
+
export const BENCH_MS = 15 * 60 * 1000
|
|
18
|
+
|
|
19
|
+
/** Stable identity for a (provider, model) pair. */
|
|
20
|
+
export function modelKey(target) {
|
|
21
|
+
if (!target || !target.provider || !target.model) return ''
|
|
22
|
+
return target.provider + '/' + target.model
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Order the models to try, most preferred first.
|
|
27
|
+
*
|
|
28
|
+
* The configured memory model wins outright when set, because that is an explicit instruction.
|
|
29
|
+
* The session's own model is next: it is already known to be routable for this user, and it
|
|
30
|
+
* keeps memory consistent with the work being remembered. Everything else routable follows, so
|
|
31
|
+
* a deployment with no configuration still has somewhere to fall back to.
|
|
32
|
+
*
|
|
33
|
+
* @param options.preferred - the configured memory model, if any.
|
|
34
|
+
* @param options.session - the session's current route, if any.
|
|
35
|
+
* @param options.available - every routable target, in registry order.
|
|
36
|
+
* @returns a de-duplicated ladder, at most LADDER_MAX long.
|
|
37
|
+
*/
|
|
38
|
+
export function buildModelLadder({ preferred, session, available } = {}) {
|
|
39
|
+
const ladder = []
|
|
40
|
+
const seen = new Set()
|
|
41
|
+
const push = (target) => {
|
|
42
|
+
const key = modelKey(target)
|
|
43
|
+
if (key === '' || seen.has(key)) return
|
|
44
|
+
seen.add(key)
|
|
45
|
+
ladder.push({ provider: target.provider, model: target.model })
|
|
46
|
+
}
|
|
47
|
+
push(preferred)
|
|
48
|
+
push(session)
|
|
49
|
+
for (const target of available ?? []) push(target)
|
|
50
|
+
return ladder.slice(0, LADDER_MAX)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Track which model last worked, and which ones recently returned nothing.
|
|
55
|
+
*
|
|
56
|
+
* This is what keeps a ladder cheap. Without it every call would re-walk from the top; with it
|
|
57
|
+
* the working model is tried first every time, and a model is only re-tried after its bench
|
|
58
|
+
* expires, so a temporary outage costs a bounded number of calls rather than one per write.
|
|
59
|
+
*/
|
|
60
|
+
export function createModelMemory({ now = () => Date.now(), benchMs = BENCH_MS } = {}) {
|
|
61
|
+
let winner
|
|
62
|
+
const benched = new Map()
|
|
63
|
+
return {
|
|
64
|
+
/** The model that last produced text, if any. */
|
|
65
|
+
preferred() { return winner },
|
|
66
|
+
/** Record that a model worked; it becomes the new first choice. */
|
|
67
|
+
succeeded(target) {
|
|
68
|
+
winner = { provider: target.provider, model: target.model }
|
|
69
|
+
benched.delete(modelKey(target))
|
|
70
|
+
},
|
|
71
|
+
/** Record that a model returned nothing usable. */
|
|
72
|
+
failed(target) {
|
|
73
|
+
benched.set(modelKey(target), now())
|
|
74
|
+
if (modelKey(winner) === modelKey(target)) winner = undefined
|
|
75
|
+
},
|
|
76
|
+
/** True when a model is still benched for returning nothing. */
|
|
77
|
+
isBenched(target, at = now()) {
|
|
78
|
+
const since = benched.get(modelKey(target))
|
|
79
|
+
if (since === undefined) return false
|
|
80
|
+
if (at - since >= benchMs) { benched.delete(modelKey(target)); return false }
|
|
81
|
+
return true
|
|
82
|
+
},
|
|
83
|
+
/** Benched models, for diagnostics. */
|
|
84
|
+
snapshot() { return { winner, benched: [...benched.keys()] } },
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Order a ladder for one call: previously-working model first, benched models last.
|
|
90
|
+
* Benched models stay in the ladder - they are a last resort, not a permanent exclusion,
|
|
91
|
+
* so a deployment where every model is benched still attempts something.
|
|
92
|
+
*/
|
|
93
|
+
export function orderLadder(ladder, memory) {
|
|
94
|
+
if (!memory) return ladder
|
|
95
|
+
const winner = memory.preferred()
|
|
96
|
+
const rank = (target) => {
|
|
97
|
+
if (modelKey(target) === modelKey(winner)) return 0
|
|
98
|
+
return memory.isBenched(target) ? 2 : 1
|
|
99
|
+
}
|
|
100
|
+
return ladder.map((t, i) => ({ t, i })).sort((a, b) => (rank(a.t) - rank(b.t)) || (a.i - b.i)).map((x) => x.t)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Run one prompt down the ladder until a model returns text.
|
|
105
|
+
*
|
|
106
|
+
* @param options.ladder - ordered targets.
|
|
107
|
+
* @param options.attempt - (target) => Promise<string>; resolves to the reply text, '' for none.
|
|
108
|
+
* @param options.memory - optional createModelMemory() to learn from the outcome.
|
|
109
|
+
* @param options.onAttempt - (target, text) => void, for telemetry.
|
|
110
|
+
* @returns { text, target, tried } - text is '' when every model returned nothing.
|
|
111
|
+
*/
|
|
112
|
+
export async function runLadder({ ladder, attempt, memory, onAttempt } = {}) {
|
|
113
|
+
const ordered = orderLadder(ladder ?? [], memory)
|
|
114
|
+
const tried = []
|
|
115
|
+
for (const target of ordered) {
|
|
116
|
+
let text = ''
|
|
117
|
+
try {
|
|
118
|
+
text = String((await attempt(target)) ?? '')
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// A model that throws is unavailable, not a verdict about the material: bench it and
|
|
121
|
+
// let the next rung try the SAME prompt.
|
|
122
|
+
tried.push({ target, error: String((error && error.message) || error).slice(0, 160) })
|
|
123
|
+
if (memory) memory.failed(target)
|
|
124
|
+
if (onAttempt) onAttempt(target, '', error)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
tried.push({ target, text: text.length })
|
|
128
|
+
if (onAttempt) onAttempt(target, text)
|
|
129
|
+
if (text.trim() !== '') {
|
|
130
|
+
if (memory) memory.succeeded(target)
|
|
131
|
+
return { text, target, tried }
|
|
132
|
+
}
|
|
133
|
+
if (memory) memory.failed(target)
|
|
134
|
+
}
|
|
135
|
+
return { text: '', target: undefined, tried }
|
|
136
|
+
}
|