@chatpanel/events 0.102.0 → 0.103.1

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 (138) hide show
  1. package/adapters.js +1 -83
  2. package/adaptive-tool-policy.js +1 -45
  3. package/agent.js +2 -255
  4. package/attribution.js +1 -132
  5. package/backup-envelope.js +1 -221
  6. package/board-tool.js +2 -165
  7. package/budget.js +1 -116
  8. package/capability.js +1 -126
  9. package/citations.js +5 -127
  10. package/client-prefs.js +1 -148
  11. package/context-attachments.js +8 -144
  12. package/cowriter-router.js +1 -83
  13. package/cowriter-writer.js +19 -193
  14. package/cowriter.js +1 -190
  15. package/curate.js +3 -509
  16. package/distance.js +1 -124
  17. package/engine.js +1 -131
  18. package/entitlement.js +1 -332
  19. package/entity.js +1 -354
  20. package/event.js +1 -175
  21. package/extraction.js +4 -293
  22. package/failover.js +1 -93
  23. package/find-tool.js +1 -66
  24. package/flowchart.js +2 -644
  25. package/gate.js +1 -74
  26. package/harness.js +4 -101
  27. package/index.js +1 -345
  28. package/invariants.js +1 -174
  29. package/job.js +3 -147
  30. package/kernel.js +1 -255
  31. package/knowledge-derive.js +1 -267
  32. package/knowledge.js +3 -230
  33. package/library.js +11 -280
  34. package/loop.js +1 -132
  35. package/manifest.js +1 -107
  36. package/markdown-authoring.js +11 -288
  37. package/markdown-render.js +0 -0
  38. package/mcp-client.js +8 -358
  39. package/mcp-dispatch.js +1 -52
  40. package/mcp-errors.js +1 -106
  41. package/mcp-manager.js +1 -127
  42. package/media-transcript.js +8 -531
  43. package/meeting-analyzers.js +1 -83
  44. package/meeting-insights.js +7 -164
  45. package/meeting-shape.js +1 -282
  46. package/meeting-text.js +5 -94
  47. package/memory.js +2 -638
  48. package/model-candidates.js +1 -358
  49. package/model-health.js +1 -182
  50. package/model-ledger.js +1 -228
  51. package/model-picker.js +1 -188
  52. package/note-actions.js +15 -246
  53. package/note-graph.js +1 -158
  54. package/note-links.js +1 -68
  55. package/note-mentions.js +8 -114
  56. package/note-plan.js +9 -107
  57. package/note-research.js +1 -135
  58. package/observability.js +1 -119
  59. package/omni.js +1 -125
  60. package/order.js +1 -78
  61. package/package.json +1 -4
  62. package/pdf-layout.js +7 -253
  63. package/project-run.js +20 -444
  64. package/project.js +1 -170
  65. package/promotion.js +1 -171
  66. package/queue.js +1 -98
  67. package/reach.js +1 -23
  68. package/recipe-tool.js +2 -161
  69. package/recipe.js +1 -264
  70. package/record-list.js +2 -110
  71. package/recruit.js +3 -456
  72. package/redaction-tokens.js +1 -61
  73. package/ref.js +1 -55
  74. package/registry.js +1 -240
  75. package/route-graph.js +1 -115
  76. package/route-strategies.js +2 -232
  77. package/router.js +2 -880
  78. package/rrf.js +1 -89
  79. package/rules.js +1 -142
  80. package/schedule.js +1 -663
  81. package/scm-connection.js +1 -180
  82. package/scopes.js +1 -12
  83. package/scorecard.js +1 -335
  84. package/search-engines.js +1 -81
  85. package/skill-manifest.js +1 -330
  86. package/skill-scan.js +3 -209
  87. package/skill-sources.js +1 -162
  88. package/skill-vars.js +2 -252
  89. package/slash-commands.js +1 -158
  90. package/source-gate.js +1 -92
  91. package/sources-retrieval.js +5 -186
  92. package/sources.js +1 -273
  93. package/store.js +1 -171
  94. package/structured.js +4 -901
  95. package/subject-kinds.js +1 -5
  96. package/subject-name.js +1 -96
  97. package/sync-plan.js +1 -213
  98. package/synthesis.js +3 -123
  99. package/tags.js +1 -203
  100. package/team-board.js +6 -337
  101. package/team-cache.js +2 -63
  102. package/team-observe.js +1 -118
  103. package/team-org.js +1 -442
  104. package/team-plan.js +4 -131
  105. package/team-record.js +1 -225
  106. package/team-run.js +9 -652
  107. package/team-subtask.js +2 -191
  108. package/team-task.js +2 -124
  109. package/team-tool.js +2 -196
  110. package/team-trail.js +2 -73
  111. package/team-worklog.js +2 -149
  112. package/team.js +1 -302
  113. package/text-search.js +1 -155
  114. package/theme.js +17 -154
  115. package/titles.js +4 -285
  116. package/tool-discovery.js +1 -87
  117. package/tool-dispatch.js +5 -230
  118. package/tool-groups.js +1 -81
  119. package/tool-hints.js +10 -185
  120. package/tool-loop-guard.js +4 -182
  121. package/tool-need.js +2 -124
  122. package/tool-result.js +2 -438
  123. package/tool-round.js +0 -0
  124. package/tool-schema.js +1 -155
  125. package/tool-traits.js +1 -182
  126. package/toolset.js +2 -88
  127. package/trajectory.js +1 -530
  128. package/turn-loop.js +4 -440
  129. package/upcast.js +1 -37
  130. package/vault.js +2 -250
  131. package/view.js +1 -88
  132. package/voice-intents.js +2 -1488
  133. package/voice-speaker.js +1 -98
  134. package/weather-tool.js +1 -65
  135. package/weather.js +2 -211
  136. package/web-search-tool.js +14 -102
  137. package/web-search.js +1 -160
  138. package/widget.js +1 -153
@@ -1,358 +1 @@
1
- // WHAT A MODEL IS, GUESSED FROM WHAT THE USER CONFIGUREDone answer for every client.
2
- //
3
- // Routing needs attributes nobody types in: how far a request travels to reach a model,
4
- // what it can probably do, roughly what it costs, how good it is likely to be. The extension
5
- // inferred these from names and URLs for its own endpoint and agent records; the desktop
6
- // needed the same inference over the gateway's model list, and a second copy of a guess is
7
- // two guesses that drift. So the heuristics live here and each client hands in its own
8
- // shape: `inferCandidate(target, kind, { override, health })` takes anything with
9
- // `{ id?, name?, model?, baseUrl?|url?, kind?, enabled?, bridgeAgent? }` and returns a router
10
- // model. Health (rate-limited, observed down) is INJECTED, because measuring it is a host's
11
- // job — the extension keeps a health map, the desktop asks the gateway.
12
- //
13
- // Everything below is a starting point the user corrects (`applyOverride`), never a verdict.
14
-
15
- import { classifySource } from './sources.js';
16
- import { defineModel } from './router.js';
17
-
18
- /** Where a request must travel to reach this target — the only attribute privacy depends on. */
19
- export function reachOf(target) {
20
- // A bridge agent runs a CLI on the user's own machine; the model behind it may still be
21
- // remote, which is why this says 'trusted' rather than 'device'. Claiming otherwise would
22
- // let a device-only request reach a cloud model through a local process.
23
- if (target.kind === 'bridge') return 'trusted';
24
- const url = String(target.baseUrl || target.url || '');
25
- if (/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)(:|\/|$)/i.test(url)) return 'device';
26
- // A .local or on-LAN host is the user's own machine or network — not a third party, but
27
- // not the device either. Same private-address rules as the source classifier, so
28
- // "internal" cannot mean one thing for a page and another for an endpoint.
29
- //
30
- // BUT THE FAIL-SAFE DIRECTION IS OPPOSITE HERE. classifySource fails CLOSED — an
31
- // unreadable URL counts as internal, because a source we cannot identify must not be sent
32
- // out. A DESTINATION we cannot identify is the reverse: calling it 'trusted' would admit
33
- // it to a restricted turn. So an unparseable endpoint is treated as the furthest reach.
34
- const c = classifySource(url);
35
- return c.internal && c.matched !== 'unparseable' ? 'trusted' : 'any';
36
- }
37
-
38
- /**
39
- * The levers a user can pull, and what each one means for routing.
40
- *
41
- * Named rather than free-form: a capability only matters if something asks for it, and a
42
- * typo in a free-text field would silently make a model ineligible forever with no way to
43
- * see why.
44
- */
45
- export const KNOWN_CAPABILITIES = Object.freeze([
46
- { id: 'tools', label: 'Tools', hint: 'Can call functions — needed for page actions, search and MCP.' },
47
- { id: 'vision', label: 'Vision', hint: 'Can read images and screenshots.' },
48
- { id: 'reasoning', label: 'Reasoning', hint: 'Thinks before answering — worth the wait on hard tasks.' },
49
- { id: 'long-context', label: 'Long context', hint: 'Handles large documents and long meetings.' },
50
- { id: 'coding', label: 'Coding', hint: 'Strong at writing and refactoring code.' },
51
- { id: 'json', label: 'Structured output', hint: 'Reliably returns valid JSON.' },
52
- // The media capabilities (architecture-pillars.md §13.2). Never guessed from a name: a
53
- // model earns these by proof (the ledger's `capability` entries) or by the person's word.
54
- { id: 'speech-in', label: 'Speech in', hint: 'Takes audio as input — a meeting, a voice note.' },
55
- { id: 'speech-out', label: 'Speech out', hint: 'Speaks its answer.' },
56
- { id: 'audio', label: 'Audio', hint: 'Understands audio content beyond speech — music, sounds.' },
57
- { id: 'image-out', label: 'Image out', hint: 'Generates images.' },
58
- ]);
59
-
60
- /**
61
- * What a model can probably do, guessed from its name.
62
- *
63
- * Conservative on purpose: an unproven capability claimed here becomes a failed turn, and a
64
- * missing one only means the router does not volunteer it. The user corrects both — these
65
- * are a starting point, not a verdict.
66
- */
67
- export function capabilitiesOf(target) {
68
- const m = String(target.model || '').toLowerCase();
69
- const caps = new Set(['json']);
70
- // Bridge agents relay tools through the bridge's MCP server; API endpoints vary, so tool
71
- // support is assumed only where the user has actually configured a model for it.
72
- if (target.kind === 'bridge' || target.model) caps.add('tools');
73
- if (/gpt-4|gpt-5|claude|gemini|vision|vl\b|llava|pixtral/.test(m)) caps.add('vision');
74
- if (/o1|o3|r1|reason|think|opus|sonnet|deepseek-r/.test(m)) caps.add('reasoning');
75
- if (/200k|1m\b|long|gemini|claude|gpt-4\.1|gpt-5/.test(m)) caps.add('long-context');
76
- if (/code|coder|codex|deepseek|qwen|opus|sonnet/.test(m)) caps.add('coding');
77
- // A CLI coding agent is a coding agent, whatever its model is called.
78
- if (target.kind === 'bridge') { caps.add('coding'); caps.add('reasoning'); }
79
- return [...caps];
80
- }
81
-
82
- /**
83
- * Which provider to prefer when two of them offer the same model. Lower wins.
84
- *
85
- * Ties were breaking alphabetically, which is not a preference — it is the absence of one,
86
- * and it sent every equal choice to whichever provider happened to sort first. The order
87
- * below is a starting point with a reason behind each rung; the user overrides it per model.
88
- *
89
- * FEWER HOPS FIRST. A direct API is one network call to the people who run the model; an
90
- * aggregator adds a hop, its own quotas, and its own outages on top of the provider's. When
91
- * everything else is equal, the shorter path is the more reliable one.
92
- */
93
- const PROVIDER_ORDER = [
94
- // The user's own machine: no quota, no outage, no third party.
95
- /localhost|127\.0\.0\.1|ollama|lm.?studio/i,
96
- // First-party APIs.
97
- /anthropic|openai\.com|api\.deepseek|googleapis|x\.ai/i,
98
- // Local CLI agents — capable, but they spawn a process and run their own loop.
99
- /(^|\W)bridge(\W|$)/i,
100
- // Aggregators and gateways: an extra hop and someone else's quota.
101
- /openrouter|huggingface|together|groq|fireworks|nvidia|replicate/i,
102
- ];
103
-
104
- // Inferred ranks sit ABOVE every number the settings UI can produce (it offers 1..N), so
105
- // an order someone chose by hand always outranks one we guessed. Sharing the range meant
106
- // picking "Order: 1" still lost to a local model we had silently rated 0 — the setting looked
107
- // like the top priority and was not.
108
- const INFERRED_RANK_FLOOR = 1000;
109
-
110
- export function providerRankOf(target, kind) {
111
- const hay = `${target.baseUrl || target.url || ''} ${target.name || ''} ${kind || target.kind || ''}`;
112
- for (let i = 0; i < PROVIDER_ORDER.length; i++) {
113
- if (PROVIDER_ORDER[i].test(hay)) return INFERRED_RANK_FLOOR + i * 10;
114
- }
115
- // Unrecognised: mid-table, so a provider we have no opinion on is not buried.
116
- return INFERRED_RANK_FLOOR + 50;
117
- }
118
-
119
- /**
120
- * Roughly how capable a model is, guessed from its name.
121
- *
122
- * Shipping the quality lever with no default meant every model scored the same, so a
123
- * frontier model that declined was replaced by an 8B instant model with equal standing —
124
- * "same capabilities, cheaper" is what the ranking saw, and it is nonsense. A wrong guess a
125
- * user can correct beats a blank that makes every model interchangeable.
126
- *
127
- * Names are a crude signal and deliberately so: this only has to ORDER models, not score
128
- * them, and the ordering it needs is the obvious one everybody already knows.
129
- */
130
- export function qualityOf(target) {
131
- const m = `${target.model || ''} ${target.name || ''}`.toLowerCase();
132
-
133
- // Parameter count, READ AS A NUMBER rather than pattern-matched. A regex for "any digits
134
- // followed by b" cannot tell 8B from 26B from 405B, and the first version of this scored
135
- // a 26B model as tiny for exactly that reason. Size is a number; treat it as one.
136
- const size = Number(/(\d+(?:\.\d+)?)\s*b\b/.exec(m)?.[1]);
137
- if (Number.isFinite(size)) {
138
- if (size >= 60) return 0.85; // frontier-scale open weights
139
- if (size >= 20) return 0.6; // the solid mid-range most people run locally
140
- return 0.3; // small and fast, never a stand-in for a frontier model
141
- }
142
-
143
- // Named tiers, for hosted models that do not advertise a size.
144
- if (/instant|mini|nano|tiny|lite|-small\b|haiku/.test(m)) return 0.3;
145
- if (/opus|gpt-5|o1|o3|\bpro\b|ultra|deepseek-r|thinking/.test(m)) return 0.9;
146
- if (/sonnet|gpt-4|flash|gemini|deepseek|qwen|mistral|codestral/.test(m)) return 0.6;
147
- // A CLI HARNESS IS NOT AN UNKNOWN MODEL, and the harness's NAME is not its model's name.
148
- //
149
- // Claude Code, Codex and the rest usually carry no `model` string — the CLI picks that
150
- // itself — and 'Claude Code' matches none of the tiers above, so every coding agent landed
151
- // on the "genuinely unknown" 0.5 below. requirementsFor puts a 0.55 quality floor on
152
- // complex, code and structured turns, so 0.5 meant a CLI coding agent was ELIMINATED from
153
- // precisely the tasks it exists for — rejected as "below the quality this task needs" while
154
- // the work went to an API model. A harness running a frontier model behind its own loop is
155
- // not the weakest thing configured.
156
- //
157
- // LAST, not first: an agent that names its model has told us something better than this
158
- // default, and overriding it would make a declared `opus` indistinguishable from a bare
159
- // harness — which is exactly the distance failover ranks by.
160
- if (target.kind === 'bridge') return 0.8;
161
-
162
- return 0.5; // genuinely unknown: mid-table, so it is neither buried nor promoted
163
- }
164
-
165
- /** Rough relative cost — unitless, and only ever compared against its siblings. */
166
- /**
167
- * Roughly how long this model takes, from the two things that actually decide it.
168
- *
169
- * This used to read WHERE a model runs and nothing else — every hosted model 700ms, every
170
- * local one 1500ms — so an 8B and a frontier model at the same provider were equally fast.
171
- * Asking the router for speed could therefore never find the small model, which is the one
172
- * thing "prefer latency" exists to do.
173
- *
174
- * SIZE IS THE OTHER HALF. A frontier model thinks for longer than an 8B wherever it runs,
175
- * and quality is the only size signal available here — it is already inferred from the
176
- * parameter count in the name (see qualityOf), and already correctable by the user, so
177
- * deriving from it keeps one number to fix rather than two.
178
- *
179
- * Still a guess, deliberately crude: this only has to ORDER models. Health can measure the
180
- * real thing later and override it per model, which is exactly why it is a plain field.
181
- */
182
- export function latencyOf(reach, quality) {
183
- // A local model is slower to first token than a hosted one far more often than not: no
184
- // warm pool, and usually a laptop rather than a datacentre.
185
- const base = reach === 'device' ? 1500 : 700;
186
- const q = Number.isFinite(quality) ? quality : 0.5;
187
- // 0.6 + q: an 8B (0.3) is ~0.9x the base, a frontier model (0.9) ~1.5x. A spread of under
188
- // two to one, because the difference is real but not the order of magnitude a bigger
189
- // coefficient would claim.
190
- return Math.round(base * (0.6 + q));
191
- }
192
-
193
- export function costOf(target, reach) {
194
- if (reach === 'device') return 0;
195
- const m = String(target.model || '').toLowerCase();
196
- if (/opus|gpt-4|pro\b/.test(m)) return 5;
197
- if (/sonnet|mini|flash|haiku/.test(m)) return 1;
198
- return 2;
199
- }
200
-
201
- /**
202
- * Everything the router infers about one model, and what the user said instead.
203
- *
204
- * Defaults are guesses — a name matched against a regex, a URL judged local. They are right
205
- * often enough to be useful and wrong often enough that someone who knows their own setup
206
- * must be able to say so. A router that cannot be corrected is one people work around.
207
- *
208
- * OVERRIDES CAN ONLY MOVE REACH OUTWARD. Every other attribute is the user's to set, but
209
- * reach is what privacy depends on, and the two directions are not symmetric:
210
- *
211
- * 'this cloud endpoint is really on my device' — would let a device-only request reach a
212
- * third party, from one typo or one synced settings file. Refused.
213
- * 'this local-looking endpoint actually goes out' — makes FEWER requests eligible for it.
214
- * Always allowed, because a user is entitled to trust their own setup less than we do.
215
- *
216
- * A model that reaches further can serve fewer kinds of request, so outward is the safe
217
- * direction and inward is the one that has to be earned rather than declared.
218
- */
219
- /**
220
- * A number the user actually SET, or null for "cleared — use what we inferred".
221
- *
222
- * CLEARING AN OVERRIDE WAS SETTING IT TO ZERO. The settings selects write `null` for their
223
- * "default" option, and the guard here was `Number.isFinite(Number(v))` — but `Number(null)`
224
- * is 0 and 0 is finite, so every cleared field became a real, extreme value. Picking
225
- * "Speed: default" made a model claim it answers in 0 ms; "Cost: default" made it free;
226
- * "Quality: default" made it worthless; and clearing Order pinned it at position 0, ahead of
227
- * everything, flagged as a deliberate choice.
228
- *
229
- * It stayed invisible while the balanced score multiplied cost by latency — every free model
230
- * scored 0 anyway. The moment a request could ask for SPEED, a model with a cleared speed
231
- * field beat everything that had a real one, and "hi" went to the most expensive model
232
- * configured. An unset field must read as unset.
233
- */
234
- export function numericOverride(v) {
235
- if (v === null || v === undefined || v === '' || typeof v === 'boolean') return null;
236
- const n = Number(v);
237
- return Number.isFinite(n) ? n : null;
238
- }
239
-
240
- export function applyOverride(inferred, override = {}) {
241
- if (!override || typeof override !== 'object') return inferred;
242
- const out = { ...inferred };
243
- const rank = numericOverride(override.providerRank);
244
- if (rank !== null) {
245
- out.providerRank = rank;
246
- // Flagged as chosen, not guessed: the router honours a hand-set order outright between
247
- // two routes to one model, and treats the order we inferred as a tie-break only.
248
- out.orderPinned = true;
249
- }
250
- if (Array.isArray(override.capabilities)) out.capabilities = [...override.capabilities];
251
- for (const key of ['costPer1k', 'latencyMs', 'quality']) {
252
- const n = numericOverride(override[key]);
253
- if (n !== null) out[key] = n;
254
- }
255
- if (typeof override.available === 'boolean') out.available = override.available;
256
- if (override.reach && REACH_RANK[override.reach] > REACH_RANK[inferred.reach]) {
257
- // Outward only. See the note above.
258
- out.reach = override.reach;
259
- }
260
- return out;
261
- }
262
-
263
- // ── The engine card over the guess (model-ledger.js, architecture-pillars.md §13.2) ──────
264
-
265
- import { DEFAULT_MIN_CALLS, cardOverride } from './model-ledger.js';
266
- export { DEFAULT_MIN_CALLS };
267
-
268
- export { cardOverride } from './model-ledger.js';
269
-
270
- /**
271
- * A router model with its card applied: `applyOverride` with what the card observed, then
272
- * withdrawn capabilities removed (a proof beats a guess in both directions). A person's own
273
- * override (`userOverride`) is applied LAST — what they said outranks what was observed —
274
- * except reach, which `applyOverride` already keeps outward-only. The result carries
275
- * `observed: [...]` so the Context Ledger can say guess or observed per field.
276
- */
277
- export function applyCard(inferred, card, { minCalls = DEFAULT_MIN_CALLS, jobKind = null, userOverride = null } = {}) {
278
- const { override, observed } = cardOverride(card, { minCalls, jobKind });
279
- let out = applyOverride(inferred, override);
280
- const withdrawn = new Set(card?.capabilities?.withdrawn || []);
281
- if (withdrawn.size && Array.isArray(out.capabilities)) { out = { ...out, capabilities: out.capabilities.filter((c) => !withdrawn.has(c)) }; observed.push('capabilities'); }
282
- if (userOverride) out = applyOverride(out, userOverride);
283
- return { ...out, observed };
284
- }
285
-
286
- export const REACH_RANK = { device: 0, trusted: 1, any: 2 };
287
- const REACH_STEPS = ['device', 'trusted', 'any'];
288
-
289
- /**
290
- * The reach values a user may declare for a model we detected as `detected`.
291
- *
292
- * The rule and the CONTROL that offers it have to come from one place. They did not: the
293
- * settings page built its options by slicing from the model's CURRENT reach, which is the
294
- * value after the override has been applied — so saving 'any' left 'any' as the only option
295
- * and the correction could never be taken back. Enforcing outward-only in applyOverride while
296
- * a second copy of the rule decided what to offer is what turned a safety rule into a
297
- * one-way door.
298
- *
299
- * Always includes `detected` itself: coming back to what we detected is not moving inward, it
300
- * is dropping the override. Anything closer in than the detection is never offered, because
301
- * applyOverride would refuse it and a control that silently discards half its own values is
302
- * worse than no control.
303
- */
304
- export function reachChoicesFor(detected) {
305
- const i = REACH_STEPS.indexOf(detected);
306
- return i < 0 ? [...REACH_STEPS] : REACH_STEPS.slice(i);
307
- }
308
-
309
-
310
- /**
311
- * One router model from one configured target. The core of every client's candidate list.
312
- *
313
- * `kind` is 'bridge' for a CLI agent on this machine and 'api' for an HTTP endpoint;
314
- * `override` is what the user said about this model (see applyOverride); `health` is
315
- * `{ available, rateLimited }` as the host measured it, or null for "nothing observed".
316
- * Returns null for a target that names no model and is not an agent — nothing to route to.
317
- */
318
- export function inferCandidate(t, kind, { override = null, health = null } = {}) {
319
- if (!t || (!t.model && kind !== 'bridge' && t.kind !== 'bridge')) return null;
320
- const id = t.id || t.name || t.model;
321
- if (!id) return null;
322
- // NEVER A GENERATED ID. 'mqr0ifmw7sqxr7' appeared as the answer to "which model did this"
323
- // in a real log — falling back to the id was the same as having no label at all. A bridge
324
- // agent the user never renamed still knows which CLI it runs, and that is readable.
325
- const label = [t.name, t.model && t.model !== t.name ? t.model : null]
326
- .filter(Boolean).join(' · ')
327
- || [t.bridgeAgent, t.model].filter(Boolean).join(' · ')
328
- || kind || t.kind
329
- || String(id);
330
- const k = kind || t.kind;
331
- const reach = reachOf({ ...t, kind: k });
332
- const inferred = {
333
- id,
334
- label,
335
- // Kept so failover can recognise the SAME model at another provider — the closest
336
- // possible replacement, and invisible if only the display label survived.
337
- model: t.model || '',
338
- reach,
339
- classUsed: reach === 'device' ? 'L' : (k === 'bridge' ? 'A' : 'C'),
340
- capabilities: capabilitiesOf({ ...t, kind: k }),
341
- costPer1k: costOf(t, reach),
342
- latencyMs: latencyOf(reach, qualityOf({ ...t, kind: k })),
343
- quality: qualityOf({ ...t, kind: k }),
344
- providerRank: providerRankOf(t, k),
345
- available: t.enabled !== false,
346
- };
347
- const configured = applyOverride(inferred, override || {});
348
- // AN EXPLICIT DISABLE OUTRANKS A TUNING OVERRIDE. `enabled: false` is the user saying,
349
- // right now, "don't use this"; a routing override is a hint saved earlier.
350
- if (t.enabled === false) configured.available = false;
351
- // A CORRECTED QUALITY CORRECTS THE SPEED DERIVED FROM IT — unless the user set the speed.
352
- if (numericOverride(override?.latencyMs) === null) configured.latencyMs = latencyOf(reach, configured.quality);
353
- return defineModel({
354
- ...configured,
355
- available: configured.available && (health ? health.available !== false : true),
356
- rateLimited: !!health?.rateLimited,
357
- });
358
- }
1
+ import{classifySource as y}from"./sources.js";import{defineModel as x}from"./router.js";function w(e){if(e.kind==="bridge")return"trusted";const n=String(e.baseUrl||e.url||"");if(/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)(:|\/|$)/i.test(n))return"device";const i=y(n);return i.internal&&i.matched!=="unparseable"?"trusted":"any"}const L=Object.freeze([{id:"tools",label:"Tools",hint:"Can call functions needed for page actions, search and MCP."},{id:"vision",label:"Vision",hint:"Can read images and screenshots."},{id:"reasoning",label:"Reasoning",hint:"Thinks before answering worth the wait on hard tasks."},{id:"long-context",label:"Long context",hint:"Handles large documents and long meetings."},{id:"coding",label:"Coding",hint:"Strong at writing and refactoring code."},{id:"json",label:"Structured output",hint:"Reliably returns valid JSON."},{id:"speech-in",label:"Speech in",hint:"Takes audio as input — a meeting, a voice note."},{id:"speech-out",label:"Speech out",hint:"Speaks its answer."},{id:"audio",label:"Audio",hint:"Understands audio content beyond speech — music, sounds."},{id:"image-out",label:"Image out",hint:"Generates images."}]);function O(e){const n=String(e.model||"").toLowerCase(),i=new Set(["json"]);return(e.kind==="bridge"||e.model)&&i.add("tools"),/gpt-4|gpt-5|claude|gemini|vision|vl\b|llava|pixtral/.test(n)&&i.add("vision"),/o1|o3|r1|reason|think|opus|sonnet|deepseek-r/.test(n)&&i.add("reasoning"),/200k|1m\b|long|gemini|claude|gpt-4\.1|gpt-5/.test(n)&&i.add("long-context"),/code|coder|codex|deepseek|qwen|opus|sonnet/.test(n)&&i.add("coding"),e.kind==="bridge"&&(i.add("coding"),i.add("reasoning")),[...i]}const p=[/localhost|127\.0\.0\.1|ollama|lm.?studio/i,/anthropic|openai\.com|api\.deepseek|googleapis|x\.ai/i,/(^|\W)bridge(\W|$)/i,/openrouter|huggingface|together|groq|fireworks|nvidia|replicate/i],b=1e3;function R(e,n){const i=`${e.baseUrl||e.url||""} ${e.name||""} ${n||e.kind||""}`;for(let t=0;t<p.length;t++)if(p[t].test(i))return b+t*10;return b+50}function m(e){const n=`${e.model||""} ${e.name||""}`.toLowerCase(),i=Number(/(\d+(?:\.\d+)?)\s*b\b/.exec(n)?.[1]);return Number.isFinite(i)?i>=60?.85:i>=20?.6:.3:/instant|mini|nano|tiny|lite|-small\b|haiku/.test(n)?.3:/opus|gpt-5|o1|o3|\bpro\b|ultra|deepseek-r|thinking/.test(n)?.9:/sonnet|gpt-4|flash|gemini|deepseek|qwen|mistral|codestral/.test(n)?.6:e.kind==="bridge"?.8:.5}function g(e,n){const i=e==="device"?1500:700,t=Number.isFinite(n)?n:.5;return Math.round(i*(.6+t))}function S(e,n){if(n==="device")return 0;const i=String(e.model||"").toLowerCase();return/opus|gpt-4|pro\b/.test(i)?5:/sonnet|mini|flash|haiku/.test(i)?1:2}function c(e){if(e==null||e===""||typeof e=="boolean")return null;const n=Number(e);return Number.isFinite(n)?n:null}function u(e,n={}){if(!n||typeof n!="object")return e;const i={...e},t=c(n.providerRank);t!==null&&(i.providerRank=t,i.orderPinned=!0),Array.isArray(n.capabilities)&&(i.capabilities=[...n.capabilities]);for(const a of["costPer1k","latencyMs","quality"]){const l=c(n[a]);l!==null&&(i[a]=l)}return typeof n.available=="boolean"&&(i.available=n.available),n.reach&&k[n.reach]>k[e.reach]&&(i.reach=n.reach),i}import{DEFAULT_MIN_CALLS as h,cardOverride as v}from"./model-ledger.js";import{cardOverride as I}from"./model-ledger.js";function E(e,n,{minCalls:i=h,jobKind:t=null,userOverride:a=null}={}){const{override:l,observed:r}=v(n,{minCalls:i,jobKind:t});let o=u(e,l);const d=new Set(n?.capabilities?.withdrawn||[]);return d.size&&Array.isArray(o.capabilities)&&(o={...o,capabilities:o.capabilities.filter(s=>!d.has(s))},r.push("capabilities")),a&&(o=u(o,a)),{...o,observed:r}}const k={device:0,trusted:1,any:2},f=["device","trusted","any"];function M(e){const n=f.indexOf(e);return n<0?[...f]:f.slice(n)}function _(e,n,{override:i=null,health:t=null}={}){if(!e||!e.model&&n!=="bridge"&&e.kind!=="bridge")return null;const a=e.id||e.name||e.model;if(!a)return null;const l=[e.name,e.model&&e.model!==e.name?e.model:null].filter(Boolean).join(" · ")||[e.bridgeAgent,e.model].filter(Boolean).join(" · ")||n||e.kind||String(a),r=n||e.kind,o=w({...e,kind:r}),d={id:a,label:l,model:e.model||"",reach:o,classUsed:o==="device"?"L":r==="bridge"?"A":"C",capabilities:O({...e,kind:r}),costPer1k:S(e,o),latencyMs:g(o,m({...e,kind:r})),quality:m({...e,kind:r}),providerRank:R(e,r),available:e.enabled!==!1},s=u(d,i||{});return e.enabled===!1&&(s.available=!1),c(i?.latencyMs)===null&&(s.latencyMs=g(o,s.quality)),x({...s,available:s.available&&(t?t.available!==!1:!0),rateLimited:!!t?.rateLimited})}export{h as DEFAULT_MIN_CALLS,L as KNOWN_CAPABILITIES,k as REACH_RANK,E as applyCard,u as applyOverride,O as capabilitiesOf,I as cardOverride,S as costOf,_ as inferCandidate,g as latencyOf,c as numericOverride,R as providerRankOf,m as qualityOf,M as reachChoicesFor,w as reachOf};
package/model-health.js CHANGED
@@ -1,182 +1 @@
1
- // What a provider failure MEANS, and how long to stand the model down for it.
2
- //
3
- // Lived in the extension as `js/model-health.js`, wrapped around chrome.storage.session.
4
- // The desktop could not read it and so could not fail over at all: a model that returned
5
- // "you have depleted your monthly credits" ended the turn there, while the extension would
6
- // have moved to the next one. The classifier and the ledger are pure; only the persistence
7
- // was the extension's, and that is injected now.
8
- //
9
- // Only the categories that change what to DO are distinguished. A 402 and a 429 are both
10
- // "not now", but one is "not for a while" and the other is "in a moment", and routing that
11
- // treats them the same either hammers a dead endpoint or abandons a live one.
12
- //
13
- // Class R: strings in, a category and a deadline out. The clock is injected.
14
-
15
- const MODEL_STANDDOWN_MS = 30 * 60_000;
16
-
17
- /** How long to stand a model down, by what went wrong. */
18
- export const COOLDOWN_MS = Object.freeze({
19
- quota: 30 * 60_000,
20
- rate: 60_000,
21
- server: 2 * 60_000,
22
- // The model is gone — retired, removed, renamed. It is not coming back, so standing it
23
- // down for the rest of the session is the honest answer; anything shorter just repeats the
24
- // same failure on a timer.
25
- gone: 24 * 60 * 60_000,
26
- // The account needs reconnecting — a human action, on no timetable. Retrying on a short
27
- // timer just walks the chain back into the same wall every turn.
28
- auth: 6 * 60 * 60_000,
29
- // A request this provider would not take. Another may; this one probably still will not,
30
- // but it is worth re-checking well before an auth problem.
31
- request: 10 * 60_000,
32
- // Nobody is listening. A local model that is not running, a hostname that does not
33
- // resolve, a server that refuses the connection. It is not coming back on a 30-second
34
- // timer — someone has to start the thing — and re-dialling it every turn was the exact
35
- // failure a user watched as ERR_CONNECTION_REFUSED, twice, on two different pages.
36
- unreachable: 5 * 60_000,
37
- // Anything else — treat as transient and barely stand it down at all.
38
- unknown: 30_000,
39
- });
40
-
41
- /** The reasons that mean "not available" rather than "not right now". */
42
- export const UNAVAILABLE_REASONS = Object.freeze(['quota', 'server', 'gone', 'auth', 'request', 'unreachable']);
43
-
44
- export const normModelName = (m) => String(m || '').toLowerCase().replace(/^[^/]+\//, '').replace(/[:@].*$/, '').replace(/[^a-z0-9.]+/g, '');
45
-
46
- /**
47
- * Classify a provider failure — an Error, or a `{ status, message }` the host built from a
48
- * response it did not throw on.
49
- */
50
- export function classifyFailure(err) {
51
- const text = String(err?.message || err?.error || err || '');
52
- const status = Number(err?.status) || Number(/\b(4\d\d|5\d\d)\b/.exec(text)?.[1]) || 0;
53
- if (status === 402 || /credit|quota|billing|payment required|depleted/i.test(text)) return 'quota';
54
- if (status === 429 || /rate.?limit|too many requests/i.test(text)) return 'rate';
55
- // THE MODEL IS GONE, not our request. A 410 saying "reached its end of life", a 404 on the
56
- // model name, a deprecation notice — every other model would handle this request fine, so
57
- // failing the turn is the one response that helps nobody. Checked BEFORE the generic 4xx
58
- // rule, which would otherwise read this as our mistake and refuse to fail over.
59
- if (status === 410
60
- || /end of life|no longer available|has been (retired|deprecated|removed)|decommissioned/i.test(text)
61
- // "The model X does not exist or you do not have access to it" — a 404 naming a model is
62
- // the provider saying THIS model is unusable, not that our request was malformed.
63
- || /model.*(does not exist|not found|no access|do not have access)|unknown model|no such model/i.test(text)
64
- // An agent configured for a model it does not have. Nothing about that changes in thirty
65
- // seconds, and retrying it costs a process spawn to be told the same thing.
66
- || /invalid model selection|not recognized as a (known|custom) model|unsupported model/i.test(text)) return 'gone';
67
- if (status >= 500 || /overloaded|unavailable|timeout|ECONNRESET/i.test(text)) return 'server';
68
- // NOBODY IS LISTENING. A browser fetch to a dead endpoint throws TypeError: Failed to fetch
69
- // (Safari: "Load failed"); Node says ECONNREFUSED. None carry a status.
70
- if (/failed to fetch|load failed|networkerror|network error|connection refused|ECONNREFUSED|ERR_CONNECTION|ENOTFOUND|EHOSTUNREACH|ECONNABORTED|couldn't reach the gateway|gateway is not answering/i.test(text)) return 'unreachable';
71
- // A BROKEN CONNECTION IS THIS PROVIDER'S, NOT THE REQUEST'S. An expired refresh token —
72
- // "OAuth token exchange failed: HTTP 400 — invalid_grant" — says this provider's
73
- // credentials went stale, and every other model would have answered the question fine.
74
- if (status === 401 || status === 403
75
- || /oauth|invalid[_ ]?grant|refresh[_ ]?token|token exchange|api[_ ]?key|unauthorized|not authenticated|authentication|credential|expired token|sign in|log ?in again/i.test(text)) {
76
- return 'auth';
77
- }
78
- // A plain 400 usually IS a malformed request — but providers reject each other's
79
- // parameters, tool schemas and sampling settings all the time. Failing over costs one
80
- // extra attempt; dead-ending costs the user their turn.
81
- if (status === 400) return 'request';
82
- // Everything else gets tried elsewhere. A router that gives up on an unrecognised failure
83
- // is a router that gives up.
84
- return 'unknown';
85
- }
86
-
87
- /**
88
- * The health ledger: which models are standing down, and why.
89
- *
90
- * @param now clock
91
- * @param onChange `(snapshot) => void` — the host persists it (the extension: the session
92
- * area; the desktop: memory). Called after every change.
93
- */
94
- export function createModelHealth({ now = () => Date.now(), onChange = null } = {}) {
95
- const health = new Map(); // id -> { until, reason, failures }
96
- const byModel = new Map(); // normalised model name -> { providers:Set, until, reason }
97
-
98
- const snapshot = () => ({
99
- health: [...health].map(([id, h]) => [id, h]),
100
- byModel: [...byModel].map(([k, m]) => [k, { providers: [...m.providers], until: m.until, reason: m.reason }]),
101
- });
102
- const changed = () => { try { onChange?.(snapshot()); } catch { /* persistence is best effort */ } };
103
-
104
- return {
105
- snapshot,
106
-
107
- /** Load what another context already learned. Never overwrites what this one knows. */
108
- hydrate(snap) {
109
- if (!snap) return false;
110
- const t = now();
111
- for (const [id, h] of snap.health || []) if (h?.until > t && !health.has(id)) health.set(id, h);
112
- for (const [k, m] of snap.byModel || []) {
113
- if (!m || byModel.has(k)) continue;
114
- byModel.set(k, { providers: new Set(m.providers || []), until: m.until || 0, reason: m.reason || null });
115
- }
116
- return true;
117
- },
118
-
119
- /** Record that a model failed, and stand it down for as long as that failure warrants. */
120
- markUnhealthy(id, err, modelName = '') {
121
- const reason = classifyFailure(err);
122
- if (!id || !reason) return null;
123
- // Learn about the MODEL, not only the endpoint. A model that is gone is gone everywhere,
124
- // so one report is enough; anything else needs two providers to agree before we believe
125
- // it is the model rather than the provider.
126
- const key = normModelName(modelName);
127
- if (key) {
128
- const seen = byModel.get(key) || { providers: new Set(), until: 0, reason: null };
129
- seen.providers.add(id);
130
- if (reason === 'gone' || seen.providers.size >= 2) {
131
- seen.until = now() + MODEL_STANDDOWN_MS;
132
- seen.reason = reason;
133
- }
134
- byModel.set(key, seen);
135
- }
136
- const prev = health.get(id);
137
- const failures = (prev?.failures || 0) + 1;
138
- // Repeated failures extend the wait, capped — a model failing every time should be
139
- // tried rarely, not never. The cap never shortens the base: capping a 24-hour
140
- // stand-down at an hour would retry a model that no longer exists, 23 times a day.
141
- const base = COOLDOWN_MS[reason] || COOLDOWN_MS.unknown;
142
- const ceiling = Math.max(base, 60 * 60_000);
143
- const until = now() + Math.min(base * failures, ceiling);
144
- health.set(id, { until, reason, failures });
145
- changed();
146
- return { reason, until };
147
- },
148
-
149
- /** A model answered, so whatever was wrong is over. */
150
- markHealthy(id) {
151
- if (id && health.has(id)) { health.delete(id); changed(); }
152
- },
153
-
154
- /** `{ available, rateLimited, reason, until? }` for the router. Unknown models are healthy. */
155
- healthOf(id, modelName = '') {
156
- const key = normModelName(modelName);
157
- if (key) {
158
- const m = byModel.get(key);
159
- if (m && m.until && now() < m.until) return { available: false, rateLimited: false, reason: m.reason, until: m.until, model: true };
160
- }
161
- const h = health.get(id);
162
- if (!h || now() >= h.until) {
163
- if (h) health.delete(id); // expired; forget it rather than carrying dead state
164
- return { available: true, rateLimited: false, reason: null };
165
- }
166
- // A rate limit is "not right now"; everything else on the list is "not available". The
167
- // router rejects both; the reason it shows the user differs.
168
- return { available: !UNAVAILABLE_REASONS.includes(h.reason), rateLimited: h.reason === 'rate', reason: h.reason, until: h.until };
169
- },
170
-
171
- /** What is currently stood down, for a settings page. */
172
- unhealthyModels() {
173
- const out = [];
174
- const t = now();
175
- for (const [id, h] of health) if (t < h.until) out.push({ id, ...h });
176
- return out;
177
- },
178
-
179
- /** Forget everything. */
180
- reset() { health.clear(); byModel.clear(); changed(); },
181
- };
182
- }
1
+ const _=18e5,p=Object.freeze({quota:18e5,rate:6e4,server:12e4,gone:864e5,auth:216e5,request:6e5,unreachable:3e5,unknown:3e4}),N=Object.freeze(["quota","server","gone","auth","request","unreachable"]),g=r=>String(r||"").toLowerCase().replace(/^[^/]+\//,"").replace(/[:@].*$/,"").replace(/[^a-z0-9.]+/g,"");function b(r){const s=String(r?.message||r?.error||r||""),n=Number(r?.status)||Number(/\b(4\d\d|5\d\d)\b/.exec(s)?.[1])||0;return n===402||/credit|quota|billing|payment required|depleted/i.test(s)?"quota":n===429||/rate.?limit|too many requests/i.test(s)?"rate":n===410||/end of life|no longer available|has been (retired|deprecated|removed)|decommissioned/i.test(s)||/model.*(does not exist|not found|no access|do not have access)|unknown model|no such model/i.test(s)||/invalid model selection|not recognized as a (known|custom) model|unsupported model/i.test(s)?"gone":n>=500||/overloaded|unavailable|timeout|ECONNRESET/i.test(s)?"server":/failed to fetch|load failed|networkerror|network error|connection refused|ECONNREFUSED|ERR_CONNECTION|ENOTFOUND|EHOSTUNREACH|ECONNABORTED|couldn't reach the gateway|gateway is not answering/i.test(s)?"unreachable":n===401||n===403||/oauth|invalid[_ ]?grant|refresh[_ ]?token|token exchange|api[_ ]?key|unauthorized|not authenticated|authentication|credential|expired token|sign in|log ?in again/i.test(s)?"auth":n===400?"request":"unknown"}function E({now:r=()=>Date.now(),onChange:s=null}={}){const n=new Map,l=new Map,d=()=>({health:[...n].map(([t,o])=>[t,o]),byModel:[...l].map(([t,o])=>[t,{providers:[...o.providers],until:o.until,reason:o.reason}])}),c=()=>{try{s?.(d())}catch{}};return{snapshot:d,hydrate(t){if(!t)return!1;const o=r();for(const[a,e]of t.health||[])e?.until>o&&!n.has(a)&&n.set(a,e);for(const[a,e]of t.byModel||[])!e||l.has(a)||l.set(a,{providers:new Set(e.providers||[]),until:e.until||0,reason:e.reason||null});return!0},markUnhealthy(t,o,a=""){const e=b(o);if(!t||!e)return null;const i=g(a);if(i){const u=l.get(i)||{providers:new Set,until:0,reason:null};u.providers.add(t),(e==="gone"||u.providers.size>=2)&&(u.until=r()+18e5,u.reason=e),l.set(i,u)}const f=(n.get(t)?.failures||0)+1,h=p[e]||p.unknown,v=Math.max(h,60*6e4),m=r()+Math.min(h*f,v);return n.set(t,{until:m,reason:e,failures:f}),c(),{reason:e,until:m}},markHealthy(t){t&&n.has(t)&&(n.delete(t),c())},healthOf(t,o=""){const a=g(o);if(a){const i=l.get(a);if(i&&i.until&&r()<i.until)return{available:!1,rateLimited:!1,reason:i.reason,until:i.until,model:!0}}const e=n.get(t);return!e||r()>=e.until?(e&&n.delete(t),{available:!0,rateLimited:!1,reason:null}):{available:!N.includes(e.reason),rateLimited:e.reason==="rate",reason:e.reason,until:e.until}},unhealthyModels(){const t=[],o=r();for(const[a,e]of n)o<e.until&&t.push({id:a,...e});return t},reset(){n.clear(),l.clear(),c()}}}export{p as COOLDOWN_MS,N as UNAVAILABLE_REASONS,b as classifyFailure,E as createModelHealth,g as normModelName};