@chatpanel/events 0.54.0 → 0.62.0

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.
@@ -0,0 +1,77 @@
1
+ // Who does which job: appointing a model to each role of the co-writer team.
2
+ //
3
+ // A co-writer is not one model. Proofreading runs constantly and must be cheap; drafting
4
+ // ahead runs rarely and should be the best thing available; research sits between them. Using
5
+ // one model for all three means either paying strong-model prices to catch "the the", or
6
+ // asking a small model to write prose. So each ROLE states a preference and this appoints the
7
+ // nearest candidate to it.
8
+ //
9
+ // Pure and dependency-free on purpose — no settings, no license, no DOM. The caller
10
+ // normalizes whatever roster it holds (the extension's configured endpoints and agents, the
11
+ // desktop's gateway model list) into candidates; this only decides. That is what makes the
12
+ // same appointment logic available to a gateway or a bridge that wanted to offer it.
13
+ //
14
+ // candidate: { id, name, kind, model, tier?, subagents?, usable? }
15
+ // role: { id, prefer: 'cheap' | 'balanced' | 'strong' }
16
+
17
+ const TIER_RANK = { cheap: 0, balanced: 1, strong: 2 };
18
+
19
+ // Infer a capability tier from a model id (best-effort, provider-agnostic).
20
+ export function classifyModel(model = '') {
21
+ const m = String(model).toLowerCase();
22
+ if (/haiku|mini|flash|nano|lite|small|instant|\b[1-9]b\b|8b|7b|3b/.test(m)) return 'cheap';
23
+ if (/opus|ultra|o1|o3|405b|70b|72b|large|gpt-4(?!o)|gpt-5/.test(m)) return 'strong';
24
+ if (/sonnet|gpt-4o|mixtral|medium|32b|command-r/.test(m)) return 'balanced';
25
+ return 'balanced'; // unknown → treat as mid so it's never wrongly picked as "cheapest"
26
+ }
27
+
28
+ // Native-subagent capable = bridge CLIs that orchestrate their own subagents.
29
+ export function supportsSubagents(candidate) {
30
+ return candidate?.kind === 'bridge' && /^(claude|codex)$/i.test(candidate.bridgeAgent || candidate.model || '');
31
+ }
32
+
33
+ function withTierAndMode(c) {
34
+ const tier = c.tier || classifyModel(c.model);
35
+ return { ...c, tier, mode: (c.subagents ?? supportsSubagents(c)) ? 'subagent' : 'api' };
36
+ }
37
+
38
+ // Appoint one role → the best available candidate (or null if none usable).
39
+ export function appoint(role, candidates, { overrides = {} } = {}) {
40
+ const usable = (candidates || []).filter((c) => c && c.usable !== false && c.model);
41
+ if (!usable.length) return null;
42
+ const ovId = overrides[role.id];
43
+ if (ovId) {
44
+ const m = usable.find((c) => c.id === ovId);
45
+ if (m) return withTierAndMode(m);
46
+ }
47
+ const want = TIER_RANK[role.prefer] ?? 1;
48
+ const best = usable
49
+ .map((c) => ({ c: withTierAndMode(c), d: Math.abs((TIER_RANK[classifyModel(c.model)] ?? 1) - want) }))
50
+ .sort((a, b) => a.d - b.d || (a.c.name || a.c.id).localeCompare(b.c.name || b.c.id))[0];
51
+ return best.c;
52
+ }
53
+
54
+ // Appoint a whole team → { [roleId]: appointment | null }.
55
+ export function routeTeam(roles, candidates, opts = {}) {
56
+ const out = {};
57
+ for (const role of roles || []) out[role.id] = appoint(role, candidates, opts);
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * The team, and what each member is for.
63
+ *
64
+ * Here rather than in a client because a role is a CONTRACT — "the editor proofreads as you
65
+ * type, cheaply, constantly" — and a second client inventing its own list is a client whose
66
+ * Writer is not the same job as everyone else's. The icons and descriptions travel with it so
67
+ * two products describe the team in one voice.
68
+ */
69
+ export const SWARM_ROLES = Object.freeze([
70
+ Object.freeze({ id: 'editor', prefer: 'cheap', icon: '\u270d\ufe0f', name: 'Editor', desc: 'Proofreads as you type' }),
71
+ Object.freeze({ id: 'researcher', prefer: 'balanced', icon: '\ud83d\udd0e', name: 'Researcher', desc: 'Finds related material' }),
72
+ Object.freeze({ id: 'writer', prefer: 'strong', icon: '\u2728', name: 'Writer', desc: 'Drafts ahead and rewrites' }),
73
+ Object.freeze({ id: 'factcheck', prefer: 'strong', icon: '\u26a0\ufe0f', name: 'Fact-checker', desc: 'Flags shaky claims' }),
74
+ ]);
75
+
76
+ /** A role by id, or null — so a stored override naming a role this build dropped is ignored. */
77
+ export const roleById = (id) => SWARM_ROLES.find((r) => r.id === id) || null;
package/cowriter.js ADDED
@@ -0,0 +1,190 @@
1
+ // The co-writer's two deterministic halves: what is mechanically wrong with a paragraph, and
2
+ // what the SMALLEST change is that fixes a sentence.
3
+ //
4
+ // A co-writer that replaces a paragraph with its own version is a rewriter, and people do not
5
+ // want their prose rewritten — they want the doubled word caught. Everything here exists to
6
+ // keep a suggestion small enough to accept with one click and small enough to be obviously
7
+ // right when you look at it.
8
+ //
9
+ // THE DETERMINISTIC PASS RUNS FIRST, AND OFTEN ENDS IT. `lintText` catches the mechanical
10
+ // mistakes — doubled words, double spaces, a space before a comma, the lone lowercase "i" —
11
+ // for free, so a token is spent only on text that is already mechanically clean. That order
12
+ // is the difference between a co-writer that idles at zero cost and one that bills for
13
+ // noticing "the the".
14
+ //
15
+ // Both halves were `cowriter-lint.js` and `cowriter-diff.js` in the extension, written pure
16
+ // and dependency-free from the start and explicitly noted there as portable. They are here so
17
+ // the second and third clients inherit them rather than copy them.
18
+
19
+ // ── the deterministic pass ──────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Mechanical mistakes in `text`, as minimal edits.
23
+ *
24
+ * Returns `[{ start, end, before, after }]` — the same shape `wordDiff` produces, so a client
25
+ * renders a lint fix and a model fix with one component — non-overlapping and left-to-right.
26
+ */
27
+ export function lintText(text = '') {
28
+ const src = String(text);
29
+ const raw = [];
30
+ const add = (start, end, after) => {
31
+ if (after !== src.slice(start, end)) raw.push({ start, end, before: src.slice(start, end), after });
32
+ };
33
+ let m;
34
+
35
+ // 1) doubled word: "the the" → "the" (case-insensitive, same word).
36
+ const dup = /\b(\w+)(\s+)\1\b/gi;
37
+ while ((m = dup.exec(src))) add(m.index, m.index + m[0].length, m[1]);
38
+
39
+ // 2) a run of 2+ spaces between visible chars → a single space.
40
+ const runs = /(\S)( {2,})(\S)/g;
41
+ while ((m = runs.exec(src))) { const s = m.index + 1; add(s, s + m[2].length, ' '); runs.lastIndex = s + 1; }
42
+
43
+ // 3) whitespace before sentence punctuation: "word ," → "word,".
44
+ const sp = /(\S)(\s+)([,.;:!?])/g;
45
+ while ((m = sp.exec(src))) add(m.index + 1, m.index + m[0].length, m[3]);
46
+
47
+ // 4) standalone lowercase "i" → "I" (skipping the "i.e." abbreviation).
48
+ const iRe = /(^|[ \t(])i(?=[ \t.,;:!?)]|$)/g;
49
+ while ((m = iRe.exec(src))) {
50
+ const at = m.index + m[1].length;
51
+ if (src[at + 1] === '.' && /[a-z]/i.test(src[at + 2] || '')) continue; // i.e., i.g.
52
+ add(at, at + 1, 'I');
53
+ }
54
+
55
+ // Sort left-to-right and drop any edit overlapping one already kept: two fixes over the
56
+ // same characters cannot both be applied, and applying one invalidates the other's offsets.
57
+ raw.sort((a, b) => a.start - b.start || a.end - b.end);
58
+ const out = [];
59
+ let lastEnd = -1;
60
+ for (const e of raw) {
61
+ if (e.start < lastEnd) continue;
62
+ out.push(e);
63
+ lastEnd = e.end;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ // ── the minimal diff ────────────────────────────────────────────────────────────────────
69
+
70
+ /** Non-whitespace tokens with their char offsets in the source string. */
71
+ function words(text) {
72
+ const out = [];
73
+ const re = /\S+/g;
74
+ let m;
75
+ while ((m = re.exec(text))) out.push({ w: m[0], start: m.index, end: m.index + m[0].length });
76
+ return out;
77
+ }
78
+
79
+ /** Matched index pairs between two word arrays (longest common subsequence). */
80
+ function lcsPairs(a, b) {
81
+ const n = a.length;
82
+ const m = b.length;
83
+ const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
84
+ for (let i = n - 1; i >= 0; i--) {
85
+ for (let j = m - 1; j >= 0; j--) {
86
+ dp[i][j] = a[i].w === b[j].w ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
87
+ }
88
+ }
89
+ const pairs = [];
90
+ let i = 0;
91
+ let j = 0;
92
+ while (i < n && j < m) {
93
+ if (a[i].w === b[j].w) { pairs.push([i, j]); i++; j++; }
94
+ else if (dp[i + 1][j] >= dp[i][j + 1]) i++;
95
+ else j++;
96
+ }
97
+ return pairs;
98
+ }
99
+
100
+ /**
101
+ * Minimal edits turning `original` into `corrected`, with offsets into `original`.
102
+ *
103
+ * Word-run replacements, insertions (start === end, before '') and deletions (after ''). The
104
+ * whitespace handling is the fiddly half and the reason this is one tested function rather
105
+ * than an idea each client implements: an insertion carries a space so the new word is not
106
+ * glued to its neighbour, and a deletion absorbs one adjacent space so it leaves neither a
107
+ * double space nor a leading one.
108
+ */
109
+ export function wordDiff(original, corrected) {
110
+ const A = words(original);
111
+ const B = words(corrected);
112
+ const matches = lcsPairs(A, B);
113
+ const edits = [];
114
+ const push = (aFrom, aTo, bFrom, bTo) => {
115
+ if (aFrom === aTo && bFrom === bTo) return;
116
+ const insWords = B.slice(bFrom, bTo).map((x) => x.w);
117
+ let start;
118
+ let end;
119
+ let after;
120
+ if (aFrom === aTo) {
121
+ if (aFrom < A.length) { start = A[aFrom].start; end = start; after = `${insWords.join(' ')} `; }
122
+ else { start = A.length ? A[A.length - 1].end : 0; end = start; after = `${A.length ? ' ' : ''}${insWords.join(' ')}`; }
123
+ } else if (bFrom === bTo) {
124
+ start = A[aFrom].start;
125
+ if (aTo < A.length) end = A[aTo].start;
126
+ else { end = A[aTo - 1].end; if (aFrom > 0) start = A[aFrom - 1].end; }
127
+ after = '';
128
+ } else {
129
+ start = A[aFrom].start;
130
+ end = A[aTo - 1].end;
131
+ after = insWords.join(' ');
132
+ }
133
+ edits.push({ start, end, before: original.slice(start, end), after });
134
+ };
135
+ let ai = 0;
136
+ let bi = 0;
137
+ for (const [am, bm] of matches) {
138
+ if (am > ai || bm > bi) push(ai, am, bi, bm);
139
+ ai = am + 1;
140
+ bi = bm + 1;
141
+ }
142
+ if (ai < A.length || bi < B.length) push(ai, A.length, bi, B.length);
143
+ return edits;
144
+ }
145
+
146
+ /**
147
+ * Keep only SMALL corrections — a typo, a comma, a missing "the".
148
+ *
149
+ * Without this the co-writer restructures prose: ask a model to "fix the mistakes" in a
150
+ * paragraph and it will happily return a better paragraph, which arrives here as one enormous
151
+ * edit and is offered as a one-click "fix". A suggestion nobody can check at a glance is not
152
+ * a suggestion, so anything large is dropped rather than shown.
153
+ */
154
+ export function filterTypoEdits(edits, { maxWords = 5, maxLen = 48 } = {}) {
155
+ return (edits || []).filter((e) => {
156
+ if (e.before === e.after) return false;
157
+ if (e.before.length > maxLen || e.after.length > maxLen) return false;
158
+ const bw = e.before ? e.before.split(/\s+/).length : 0;
159
+ const aw = e.after ? e.after.split(/\s+/).length : 0;
160
+ if (Math.max(bw, aw) > maxWords) return false;
161
+ // A pure insertion is only "small" if it is a word or two (a missing "the", a comma).
162
+ if (!e.before && aw > 2) return false;
163
+ return true;
164
+ });
165
+ }
166
+
167
+ /** Stable identity for a suggestion, so a fix the user dismissed is not offered again. */
168
+ export function editKey(edit) {
169
+ return `${edit.before}␟${edit.after}`;
170
+ }
171
+
172
+ /** Apply non-overlapping edits right-to-left, so earlier offsets stay valid as it goes. */
173
+ export function applyEdits(text, edits) {
174
+ let out = String(text);
175
+ for (const e of [...(edits || [])].sort((a, b) => b.start - a.start)) {
176
+ out = out.slice(0, e.start) + e.after + out.slice(e.end);
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /**
182
+ * The instruction a co-writing model is given, and the guardrail inside it.
183
+ *
184
+ * It is told to return the corrected text and NOTHING else, because the answer is diffed
185
+ * against the original rather than read: a preamble becomes a spurious insertion at offset 0,
186
+ * offered to the user as a "fix" that pastes "Sure, here is the corrected text:" into their
187
+ * note.
188
+ */
189
+ export const COWRITER_SYSTEM = 'You are a meticulous copy editor. Fix ONLY spelling, grammar, punctuation and obvious word mistakes in the text. Do NOT rewrite, rephrase, restructure, shorten or improve the style, and do not add or remove content. Preserve the markdown exactly. Output ONLY the corrected text.';
190
+ export const COWRITER_TEMPERATURE = 0;
package/index.js CHANGED
@@ -18,6 +18,43 @@ export {
18
18
  export { REF_KINDS, RESOLUTION, makeRef, isRef, resolveRef } from './ref.js';
19
19
  export { linearize, compareEvents, causesAreWellFormed } from './order.js';
20
20
  export { pendingQueue, isQueued, dequeue, moveQueued, promoteQueued } from './queue.js';
21
+ export {
22
+ groupModels, filterSections, defaultModelId, modelSummary,
23
+ } from './model-picker.js';
24
+ export {
25
+ NOTE_ACTIONS, NOTE_ACTION_ORDER, NOTE_ACTION_TEMPERATURE, NOTE_ACTION_ERRORS,
26
+ NOTE_COMMANDS, NOTE_COMMAND_TEMPERATURE, NOTE_COMMAND_MAX_TOKENS,
27
+ frameNoteAction, noteActionLabel, noteActionItems, filterNoteActions,
28
+ commandLineAt, triggerQueryAt,
29
+ } from './note-actions.js';
30
+ export {
31
+ parseAgentMention, agentMentionAt, parseSkillMention, mergeSkillPrompt,
32
+ findSkillByName, findTargetByName, mentionAnswerPrefix,
33
+ } from './note-mentions.js';
34
+ export { wikiQueryAt, rankLinkTargets } from './note-links.js';
35
+ export {
36
+ SWARM_ROLES, roleById, classifyModel, supportsSubagents, appoint, routeTeam,
37
+ } from './cowriter-router.js';
38
+ export {
39
+ SEARCH_ENGINES, RESULTS_PER_ENGINE, buildSearchUrl, unwrapRedirect, isResultHost,
40
+ pickResults, mergeEngineResults, engineOrder,
41
+ } from './web-search.js';
42
+ export {
43
+ MAX_GRAPH_NODES, buildNoteGraph, egoGraph, trimGraph, graphStats,
44
+ } from './note-graph.js';
45
+ export {
46
+ lintText, wordDiff, filterTypoEdits, editKey, applyEdits,
47
+ COWRITER_SYSTEM, COWRITER_TEMPERATURE,
48
+ } from './cowriter.js';
49
+ export {
50
+ salientTerms, topicTerms, researchRelevance, webQuery, researchSnippet,
51
+ rankResearchCards, mergeResearchLanes,
52
+ } from './note-research.js';
53
+ export {
54
+ PLAN_ROLES, PLAN_AUTHORS, PLAN_DECOMPOSE_SYSTEM, PLAN_DECOMPOSE_MAX_TOKENS,
55
+ PLAN_DECOMPOSE_TEMPERATURE, PLAN_SECTION_MAX_TOKENS, PLAN_SECTION_TEMPERATURE,
56
+ planSectionSystem, parsePlanTasks, planTitleFor, planParts, planBody, planAttribution,
57
+ } from './note-plan.js';
21
58
  export { REACH, reachRank, reachSatisfies } from './reach.js';
22
59
  export { UPCASTERS, upcast, upcastAll } from './upcast.js';
23
60
  export {
@@ -0,0 +1,186 @@
1
+ // THE MODEL PICKER'S SHAPE — one flat list of models in, the menu a person can read out.
2
+ //
3
+ // Every client is handed the same flat array by the gateway's /v1/models: fifty-odd entries
4
+ // mixing local CLI agents with a dozen providers' cloud models, in whatever order the routing
5
+ // table happened to enumerate them. Rendered literally that is a single scrolling column of
6
+ // opaque ids — `claude-code`, `gpt-4o-mini`, `qwen2.5-coder:7b` — where the two things a user
7
+ // actually distinguishes are invisible:
8
+ //
9
+ // • AN AGENT IS NOT A MODEL. A bridge-backed CLI runs on this machine, holds a session, can
10
+ // touch files and costs a process; a cloud model is a stateless HTTP call. Picking between
11
+ // them is a different decision from picking between two models, and a flat list forces
12
+ // both decisions through one control.
13
+ // • A PROVIDER IS THE UNIT PEOPLE THINK IN. "the Anthropic one", "my Ollama". Sorting by id
14
+ // interleaves providers so the same provider's models are scattered down the list.
15
+ //
16
+ // So this turns the flat list into SECTIONS, and it lives here rather than in a client because
17
+ // it is pure input → output with no window in it: the desktop renders it as a menu, the
18
+ // extension as its agent menu, a mobile client as a grouped list, and all three group and
19
+ // order identically. Writing it in one client is how `displayName` got duplicated.
20
+ //
21
+ // UNAVAILABLE MODELS ARE KEPT, NOT DROPPED. A CLI the user has not installed is the single
22
+ // most common reason a first message fails, and a picker that silently omits it answers
23
+ // "where did Claude Code go?" with nothing. It is listed, ordered last within its section,
24
+ // flagged, and carries the reason — see `reason` on the entry.
25
+
26
+ /** A bridge-backed CLI agent, as opposed to an HTTP model endpoint. */
27
+ const isAgent = (m) => m?.providerType === 'agent' || m?.viaBridge === true;
28
+
29
+ /**
30
+ * The provider a model belongs to, in the words the gateway used.
31
+ *
32
+ * `provider` is what gateways from 0.6.64 report; `owned_by`/`owner` is the older field. A
33
+ * name is NEVER parsed for a provider — reading `gpt-` as "OpenAI" breaks the moment someone
34
+ * serves a GPT-named model from their own Ollama, and that user's whole point was that it is
35
+ * local.
36
+ */
37
+ const providerOf = (m) => String(m?.provider || m?.owner || '').trim();
38
+
39
+ /**
40
+ * How a provider is spelled when a person wrote it down.
41
+ *
42
+ * Capitalising the first letter is right for `anthropic` and wrong for every brand with
43
+ * internal capitals — it renders "Openai", "Deepseek", "Xai". A heading is the most-read text
44
+ * in the picker, and misspelling the company in it reads as carelessness about everything
45
+ * else, so the handful that do not follow the rule are simply listed.
46
+ */
47
+ const PROVIDER_NAMES = {
48
+ openai: 'OpenAI', openrouter: 'OpenRouter', deepseek: 'DeepSeek', xai: 'xAI',
49
+ vllm: 'vLLM', lmstudio: 'LM Studio', llamacpp: 'llama.cpp', 'llama.cpp': 'llama.cpp',
50
+ huggingface: 'Hugging Face', togetherai: 'Together AI', together: 'Together AI',
51
+ githubcopilot: 'GitHub Copilot', awsbedrock: 'AWS Bedrock', bedrock: 'AWS Bedrock',
52
+ azureopenai: 'Azure OpenAI', googleai: 'Google AI', vertexai: 'Vertex AI',
53
+ };
54
+
55
+ /** Sentence case for a bare provider slug, leaving names that already have shape alone. */
56
+ function providerLabel(raw) {
57
+ const p = String(raw || '').trim();
58
+ if (!p) return 'Other';
59
+ const known = PROVIDER_NAMES[p.toLowerCase().replace(/[\s_-]/g, '')];
60
+ if (known) return known;
61
+ if (/[A-Z ]/.test(p)) return p; // already presentable: "Together AI", "MyCorp LLM"
62
+ return p.charAt(0).toUpperCase() + p.slice(1);
63
+ }
64
+
65
+ const availableFirst = (a, b) => (
66
+ (a.available === false) - (b.available === false)
67
+ || String(a.id).localeCompare(String(b.id))
68
+ );
69
+
70
+ /**
71
+ * Group a flat model list into the sections a picker draws.
72
+ *
73
+ * Sections come back in the order they should be shown: **Agents first**, then each provider
74
+ * alphabetically. Agents lead because a local CLI is the one target a fresh install can be
75
+ * sure of — no key, no account — which is the same reason `target-choice.js` defaults to one.
76
+ *
77
+ * @param models the gateway's models, each `{ id, provider, providerType, viaBridge,
78
+ * available, reason, owner, label? }`
79
+ * @param selectedId currently chosen id, so a caller can mark it without a second pass
80
+ * @returns `[{ key, label, kind: 'agent'|'provider', items: [...] }]`, where each item is the
81
+ * model object plus `{ label, selected }`. Empty sections are never emitted.
82
+ */
83
+ export function groupModels(models, { selectedId = '' } = {}) {
84
+ const list = Array.isArray(models) ? models.filter(Boolean) : [];
85
+
86
+ const agents = [];
87
+ const byProvider = new Map();
88
+ for (const m of list) {
89
+ if (isAgent(m)) { agents.push(m); continue; }
90
+ const key = providerOf(m) || 'other';
91
+ if (!byProvider.has(key)) byProvider.set(key, []);
92
+ byProvider.get(key).push(m);
93
+ }
94
+
95
+ const decorate = (m) => ({
96
+ ...m,
97
+ label: m.label || m.id,
98
+ selected: !!selectedId && m.id === selectedId,
99
+ });
100
+
101
+ const sections = [];
102
+ if (agents.length) {
103
+ sections.push({
104
+ key: 'agents',
105
+ label: 'Agents',
106
+ kind: 'agent',
107
+ items: agents.slice().sort(availableFirst).map(decorate),
108
+ });
109
+ }
110
+ for (const key of [...byProvider.keys()].sort((a, b) => a.localeCompare(b))) {
111
+ sections.push({
112
+ key: `provider:${key}`,
113
+ label: providerLabel(key),
114
+ kind: 'provider',
115
+ items: byProvider.get(key).slice().sort(availableFirst).map(decorate),
116
+ });
117
+ }
118
+ return sections;
119
+ }
120
+
121
+ /**
122
+ * Filter the grouped sections by a typed query, keeping the grouping.
123
+ *
124
+ * A provider name matches ALL of its models: someone typing "ollama" is asking to see that
125
+ * provider, not to see the models whose ids happen to contain the string — and with local
126
+ * models the id usually does not contain it at all.
127
+ *
128
+ * Sections that end up empty are dropped, so an unmatched provider does not leave a heading
129
+ * floating over nothing.
130
+ */
131
+ export function filterSections(sections, query) {
132
+ const q = String(query || '').trim().toLowerCase();
133
+ if (!q) return sections;
134
+ const out = [];
135
+ for (const s of sections) {
136
+ if (s.label.toLowerCase().includes(q)) { out.push(s); continue; }
137
+ const items = s.items.filter((m) => (
138
+ String(m.label).toLowerCase().includes(q) || String(m.id).toLowerCase().includes(q)
139
+ ));
140
+ if (items.length) out.push({ ...s, items });
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * The id a picker should start on when the record does not name one.
147
+ *
148
+ * Prefers a REACHABLE agent, then any reachable model, and only then something known to be
149
+ * unavailable. The rule is the product's, and it is the same one `target-choice.js` states:
150
+ * never default to a target that cannot answer, because the user is the one who finds out.
151
+ */
152
+ export function defaultModelId(models) {
153
+ const list = Array.isArray(models) ? models.filter(Boolean) : [];
154
+ const usable = list.filter((m) => m.available !== false);
155
+ return (usable.find(isAgent) || usable[0] || list[0] || {}).id || '';
156
+ }
157
+
158
+ /**
159
+ * How to describe the chosen model in one line — what the picker's own button says.
160
+ *
161
+ * The three-valued `available` is the honesty rule applied to a button: an id that names
162
+ * nothing is only BROKEN if there was a list for it to be absent from. Before /v1/models has
163
+ * answered there is no such list, so the truthful answer is `null` — "nobody has looked yet"
164
+ * — and a caller must not paint that as a red dot. Marking an unloaded picker unavailable is
165
+ * the same mistake as drawing "0 redactions" when nothing was inspected.
166
+ */
167
+ export function modelSummary(models, id) {
168
+ const list = Array.isArray(models) ? models.filter(Boolean) : [];
169
+ const m = list.find((x) => x.id === id);
170
+ if (!m) {
171
+ return {
172
+ label: id || 'No model',
173
+ available: list.length ? false : null,
174
+ reason: list.length && id ? `${id} is not offered by the gateway any more` : '',
175
+ agent: false,
176
+ provider: '',
177
+ };
178
+ }
179
+ return {
180
+ label: m.label || m.id,
181
+ available: m.available !== false,
182
+ reason: m.reason || '',
183
+ agent: isAgent(m),
184
+ provider: providerLabel(providerOf(m)),
185
+ };
186
+ }