@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.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +294 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/scripts/inspect-log.ts +40 -0
  8. package/scripts/repair-ignorable.ts +130 -0
  9. package/scripts/scan-sessions.ts +43 -0
  10. package/scripts/verify-log.ts +26 -0
  11. package/src/SYSTEM.template.md +311 -311
  12. package/src/capture.js +242 -242
  13. package/src/client/index.js +886 -886
  14. package/src/config.js +57 -57
  15. package/src/consolidate-run.js +132 -132
  16. package/src/consolidate.js +330 -330
  17. package/src/context.js +88 -88
  18. package/src/decisions.js +106 -106
  19. package/src/defaults.js +93 -93
  20. package/src/digest.js +255 -255
  21. package/src/entry.js +179 -179
  22. package/src/eval.js +72 -72
  23. package/src/files.js +60 -60
  24. package/src/fixture.js +56 -56
  25. package/src/forget.js +71 -71
  26. package/src/freshness.js +65 -65
  27. package/src/fts.js +166 -166
  28. package/src/git.js +53 -53
  29. package/src/history.js +226 -226
  30. package/src/host.js +884 -866
  31. package/src/index.js +115 -94
  32. package/src/inject.js +108 -108
  33. package/src/layers.js +178 -178
  34. package/src/ledger.js +19 -19
  35. package/src/map.js +184 -184
  36. package/src/models.js +136 -136
  37. package/src/queue.js +602 -602
  38. package/src/rank.js +31 -31
  39. package/src/recover.js +91 -91
  40. package/src/refresh.js +80 -80
  41. package/src/remember.js +58 -58
  42. package/src/rerank.js +90 -90
  43. package/src/search.js +75 -75
  44. package/src/select.js +57 -57
  45. package/src/session.js +60 -60
  46. package/src/state.js +240 -240
  47. package/src/store.js +128 -128
  48. package/src/summarize.js +195 -195
  49. package/src/system-doc.js +171 -171
  50. package/src/tasks.js +617 -617
  51. package/src/telemetry.js +122 -122
  52. package/src/tools.js +828 -818
  53. package/src/working.js +288 -288
  54. package/src/workspace-key.js +68 -68
  55. 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
+ }