@aeondave/pi-persona 1.15.2

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 (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The single Markdown + YAML-frontmatter engine for pi-persona.
3
+ *
4
+ * Pure module — no Pi imports. Used by personas, agents, chains, and any other
5
+ * frontmatter-bearing file. The YAML is a deliberate SUBSET (not a full engine):
6
+ * top-level `key: value` scalars, inline `[a, b]` and block (`- a`) lists, and one
7
+ * level of nested maps (for `delegate`/`tools` allow/deny). Coercion helpers turn
8
+ * raw values into the typed shapes the rest of the system expects.
9
+ */
10
+
11
+ import type { Permission } from "./permissions.ts";
12
+
13
+ // --------------------------------------------------------------------------
14
+ // Frontmatter split
15
+ // --------------------------------------------------------------------------
16
+
17
+ /** Split `---\nfrontmatter\n---\nbody`. Missing frontmatter → all body. */
18
+ export function splitFrontmatter(content: string): { frontmatter: string; body: string } {
19
+ const normalized = content.replace(/^/, "");
20
+ const match = normalized.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?([\s\S]*)$/);
21
+ if (!match) return { frontmatter: "", body: normalized.trim() };
22
+ return { frontmatter: match[1] ?? "", body: (match[2] ?? "").trim() };
23
+ }
24
+
25
+ // --------------------------------------------------------------------------
26
+ // Minimal YAML-subset parser
27
+ // --------------------------------------------------------------------------
28
+
29
+ /** Strip a trailing `# comment` that is not inside quotes. */
30
+ function stripComment(line: string): string {
31
+ let inSingle = false;
32
+ let inDouble = false;
33
+ for (let i = 0; i < line.length; i++) {
34
+ const c = line[i];
35
+ if (c === "'" && !inDouble) inSingle = !inSingle;
36
+ else if (c === '"' && !inSingle) inDouble = !inDouble;
37
+ else if (c === "#" && !inSingle && !inDouble && (i === 0 || line[i - 1] === " " || line[i - 1] === "\t")) {
38
+ return line.slice(0, i);
39
+ }
40
+ }
41
+ return line;
42
+ }
43
+
44
+ function unquote(value: string): string {
45
+ const v = value.trim();
46
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
47
+ return v.slice(1, -1);
48
+ }
49
+ return v;
50
+ }
51
+
52
+ /** Split on top-level commas only — commas inside `[...]`/`{...}` OR inside a quoted
53
+ * string (`"a, b"` / `'a, b'`) are left intact. Quote-aware so an inline map value like
54
+ * `role: "injection, authz, secrets"` isn't shredded at every comma. */
55
+ function splitTopLevel(s: string): string[] {
56
+ const out: string[] = [];
57
+ let depth = 0;
58
+ let start = 0;
59
+ let inSingle = false;
60
+ let inDouble = false;
61
+ for (let i = 0; i < s.length; i++) {
62
+ const c = s[i];
63
+ if (c === "'" && !inDouble) inSingle = !inSingle;
64
+ else if (c === '"' && !inSingle) inDouble = !inDouble;
65
+ else if (inSingle || inDouble) continue; // inside a string: ignore brackets/commas
66
+ else if (c === "[" || c === "{") depth++;
67
+ else if (c === "]" || c === "}") depth--;
68
+ else if (c === "," && depth === 0) {
69
+ out.push(s.slice(start, i));
70
+ start = i + 1;
71
+ }
72
+ }
73
+ out.push(s.slice(start));
74
+ return out.map((x) => x.trim()).filter((x) => x.length > 0);
75
+ }
76
+
77
+ /** Parse a scalar, inline list (`[a, b]`), or inline flow map (`{ k: v }`). */
78
+ function parseValue(raw: string): string | string[] | boolean | number | Record<string, unknown> {
79
+ const v = raw.trim();
80
+ if (v.startsWith("[") && v.endsWith("]")) {
81
+ const inner = v.slice(1, -1).trim();
82
+ if (inner === "") return [];
83
+ return splitTopLevel(inner)
84
+ .map((s) => unquote(s))
85
+ .filter((s) => s.length > 0);
86
+ }
87
+ if (v.startsWith("{") && v.endsWith("}")) {
88
+ const inner = v.slice(1, -1).trim();
89
+ const obj: Record<string, unknown> = {};
90
+ if (inner === "") return obj;
91
+ for (const pair of splitTopLevel(inner)) {
92
+ const ci = pair.indexOf(":");
93
+ if (ci < 0) continue;
94
+ obj[unquote(pair.slice(0, ci).trim())] = parseValue(pair.slice(ci + 1).trim());
95
+ }
96
+ return obj;
97
+ }
98
+ if (v === "true") return true;
99
+ if (v === "false") return false;
100
+ if (/^-?\d+(?:\.\d+)?$/.test(v)) return Number(v);
101
+ return unquote(v);
102
+ }
103
+
104
+ interface Frame {
105
+ indent: number;
106
+ container: Record<string, unknown>;
107
+ /** When this frame was opened by `key:` with no value, the parent + key so a
108
+ * following `- item` line can convert it to an array. */
109
+ selfKey?: string;
110
+ parent?: Record<string, unknown>;
111
+ listArr?: unknown[];
112
+ }
113
+
114
+ /** Parse the YAML subset (see module doc) into a plain object. */
115
+ export function parseYamlSubset(src: string): Record<string, unknown> {
116
+ const root: Record<string, unknown> = {};
117
+ const stack: Frame[] = [{ indent: -1, container: root }];
118
+
119
+ for (const rawLine of src.split(/\r?\n/)) {
120
+ const line = stripComment(rawLine);
121
+ if (line.trim() === "") continue;
122
+ const indent = line.length - line.trimStart().length;
123
+ const content = line.trim();
124
+
125
+ // A `- item` may sit at the SAME indent as the `key:` it belongs to, so only a
126
+ // strictly smaller indent closes that frame; key lines still close on equal indent.
127
+ const isItem = content.startsWith("- ") || content === "-";
128
+ while (stack.length > 1) {
129
+ const frameIndent = stack[stack.length - 1]!.indent;
130
+ if (isItem ? indent < frameIndent : indent <= frameIndent) stack.pop();
131
+ else break;
132
+ }
133
+ const top = stack[stack.length - 1]!;
134
+
135
+ if (isItem) {
136
+ // Block list item under the current `key:` frame.
137
+ const itemRaw = content === "-" ? "" : content.slice(2);
138
+ if (!top.listArr) {
139
+ top.listArr = [];
140
+ if (top.parent && top.selfKey) top.parent[top.selfKey] = top.listArr;
141
+ }
142
+ top.listArr.push(parseValue(itemRaw));
143
+ continue;
144
+ }
145
+
146
+ const colon = content.indexOf(":");
147
+ if (colon < 0) continue; // not a key line — ignore
148
+ const key = content.slice(0, colon).trim();
149
+ const rest = content.slice(colon + 1).trim();
150
+
151
+ if (rest === "") {
152
+ // Open a nested container (map by default; becomes a list if `- ` follows).
153
+ const child: Record<string, unknown> = {};
154
+ top.container[key] = child;
155
+ stack.push({ indent, container: child, selfKey: key, parent: top.container });
156
+ } else {
157
+ top.container[key] = parseValue(rest);
158
+ }
159
+ }
160
+
161
+ return root;
162
+ }
163
+
164
+ // --------------------------------------------------------------------------
165
+ // Coercion helpers
166
+ // --------------------------------------------------------------------------
167
+
168
+ /** Coerce a value to a string[] (array, or comma-separated scalar). Empty → undefined. */
169
+ export function asStringArray(value: unknown): string[] | undefined {
170
+ if (Array.isArray(value)) return value.map((v) => String(v)).filter((s) => s.length > 0);
171
+ if (typeof value === "string" && value.trim().length > 0) {
172
+ return value
173
+ .split(",")
174
+ .map((s) => s.trim())
175
+ .filter((s) => s.length > 0);
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ /** Coerce a value to a `Permission`. A bare list/scalar is the `allow` shorthand. */
181
+ export function asPermission(value: unknown): Permission | undefined {
182
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
183
+ const allow = asStringArray(value);
184
+ return allow ? { allow } : undefined;
185
+ }
186
+ const obj = value as Record<string, unknown>;
187
+ const allow = asStringArray(obj.allow);
188
+ const deny = asStringArray(obj.deny);
189
+ if (allow === undefined && deny === undefined) return undefined;
190
+ const perm: Permission = {};
191
+ if (allow !== undefined) perm.allow = allow;
192
+ if (deny !== undefined) perm.deny = deny;
193
+ return perm;
194
+ }
195
+
196
+ /** Coerce a value to a boolean (real booleans or the strings `"true"`/`"false"`). */
197
+ export function asBoolean(value: unknown): boolean | undefined {
198
+ if (typeof value === "boolean") return value;
199
+ if (value === "true") return true;
200
+ if (value === "false") return false;
201
+ return undefined;
202
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Model reference resolution — turn a loose model name the supervisor typed into a
3
+ * canonical `provider/id` from the registry, or fail with the candidates. Stops a
4
+ * bare name (e.g. "sonnet") from silently resolving to an unauthenticated provider
5
+ * (which would spawn a child that just errors "no API key").
6
+ *
7
+ * The same model name often exists under several providers (anthropic, bedrock×N,
8
+ * openrouter, copilot…). When a name is ambiguous we prefer the **loader/session
9
+ * provider** — the supervisor's own provider, which is the one actually authenticated
10
+ * — so "sonnet" resolves to e.g. `claude-pro-max-native/claude-sonnet-4-6`, not a
11
+ * Bedrock look-alike. Pure — list + preferred provider injected.
12
+ */
13
+
14
+ export interface ModelLite {
15
+ provider: string;
16
+ id: string;
17
+ }
18
+
19
+ /** A model-family routing rule. Rules are data so deployments can extend the paid-provider
20
+ * boundary without coupling it to a persona or to one concrete model release. Prefix matching is
21
+ * case-insensitive and applies to the model id after the provider slash. */
22
+ export interface ModelRoutingRule {
23
+ idPrefixes: readonly string[];
24
+ allowedProviders: readonly string[];
25
+ }
26
+
27
+ export interface ModelRoutingPolicy {
28
+ rules: readonly ModelRoutingRule[];
29
+ }
30
+
31
+ /**
32
+ * Safe defaults for paid model families. OpenCode/OpenRouter remain usable for model families
33
+ * without a rule, but a Claude model may only route through claude-pro-max-native and an OpenAI
34
+ * family model may only route through the first-party OpenAI providers. Keep this table at the
35
+ * family/provider boundary: model ids are versioned and must not be allowlisted one by one.
36
+ */
37
+ export const DEFAULT_MODEL_ROUTING_POLICY: ModelRoutingPolicy = {
38
+ rules: [
39
+ { idPrefixes: ["claude-"], allowedProviders: ["claude-pro-max-native"] },
40
+ { idPrefixes: ["gpt-", "chatgpt-", "o1", "o3", "o4"], allowedProviders: ["openai", "openai-codex"] },
41
+ ],
42
+ };
43
+
44
+ export type ModelResolution =
45
+ | { ok: true; ref: string }
46
+ | { ok: false; reason: "unknown" | "ambiguous" | "blocked"; candidates: string[] };
47
+
48
+ const full = (m: ModelLite): string => `${m.provider}/${m.id}`;
49
+
50
+ function dedupe(models: ModelLite[]): ModelLite[] {
51
+ const seen = new Set<string>();
52
+ const out: ModelLite[] = [];
53
+ for (const m of models) {
54
+ const key = full(m);
55
+ if (!seen.has(key)) {
56
+ seen.add(key);
57
+ out.push(m);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function routingRule(id: string, policy: ModelRoutingPolicy): ModelRoutingRule | undefined {
64
+ const lower = id.toLowerCase();
65
+ return policy.rules.find((rule) => rule.idPrefixes.some((prefix) => lower.startsWith(prefix.toLowerCase())));
66
+ }
67
+
68
+ function providerAllowed(provider: string, allowedProviders: readonly string[] | undefined): boolean {
69
+ return allowedProviders === undefined || allowedProviders.some((allowed) => allowed.toLowerCase() === provider.toLowerCase());
70
+ }
71
+
72
+ /** Providers permitted for a model family, or undefined when the policy leaves that family open. */
73
+ export function allowedModelProviders(id: string, policy?: ModelRoutingPolicy): readonly string[] | undefined {
74
+ return policy ? routingRule(id, policy)?.allowedProviders : undefined;
75
+ }
76
+
77
+ export function isModelProviderAllowed(model: ModelLite, policy?: ModelRoutingPolicy): boolean {
78
+ return providerAllowed(model.provider, allowedModelProviders(model.id, policy));
79
+ }
80
+
81
+ /** Compare two numeric version tuples ([4,6] vs [4,5]); >0 when `a` is newer. */
82
+ function compareVersion(a: number[], b: number[]): number {
83
+ const n = Math.max(a.length, b.length);
84
+ for (let i = 0; i < n; i++) {
85
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
86
+ if (d !== 0) return d;
87
+ }
88
+ return 0;
89
+ }
90
+
91
+ /**
92
+ * When EVERY candidate is the same model line differing only by a trailing numeric version
93
+ * (`claude-sonnet-4-6` vs `-4-5` vs `-4`), the newest is the natural pick — a loose family
94
+ * name ("sonnet") shouldn't force a `models` lookup just to choose between versions. Returns
95
+ * the newest `provider/id`, or `undefined` when the candidates are NOT one clean version line
96
+ * (different families/prefixes, or a non-versioned id) — those stay genuinely ambiguous.
97
+ * Provider-agnostic: the caller passes an already single-provider set.
98
+ */
99
+ function newestOfVersionLine(candidates: ModelLite[]): string | undefined {
100
+ const parsed: Array<{ ref: string; prefix: string; version: number[] }> = [];
101
+ for (const m of candidates) {
102
+ const match = /^(.*?)-(\d+(?:-\d+)*)$/.exec(m.id);
103
+ if (!match || match[1] === undefined || match[2] === undefined) return undefined; // non-versioned → not a clean line
104
+ parsed.push({ ref: full(m), prefix: match[1], version: match[2].split("-").map(Number) });
105
+ }
106
+ if (parsed.length === 0) return undefined;
107
+ const prefixes = new Set(parsed.map((p) => p.prefix));
108
+ if (prefixes.size !== 1) return undefined; // distinct model lines → genuinely ambiguous
109
+ let best = parsed[0]!;
110
+ for (const p of parsed.slice(1)) if (compareVersion(p.version, best.version) > 0) best = p;
111
+ return best.ref;
112
+ }
113
+
114
+ export function resolveModelRef(
115
+ ref: string,
116
+ models: ModelLite[],
117
+ preferProvider?: string,
118
+ policy?: ModelRoutingPolicy,
119
+ ): ModelResolution {
120
+ const wanted = ref.trim();
121
+ const all = models.map(full);
122
+ const exact = models.find((m) => full(m) === wanted);
123
+ // An exact provider-qualified pin is an explicit user choice. Never rewrite or reject it
124
+ // because the current routing policy is intended only for loose automatic selection.
125
+ if (exact) return { ok: true, ref: wanted };
126
+
127
+ // Candidate set: a unique exact id (no provider), else a case-insensitive substring.
128
+ const byId = models.filter((m) => m.id === wanted);
129
+ const lower = wanted.toLowerCase();
130
+ const rawCandidates = dedupe(byId.length > 0 ? byId : models.filter((m) => full(m).toLowerCase().includes(lower)));
131
+ const candidates = policy ? rawCandidates.filter((m) => isModelProviderAllowed(m, policy)) : rawCandidates;
132
+ if (policy) {
133
+ const allCandidatesArePolicyBlocked = rawCandidates.length > 0 && rawCandidates.every((m) => allowedModelProviders(m.id, policy) !== undefined && !isModelProviderAllowed(m, policy));
134
+ if (allCandidatesArePolicyBlocked) return { ok: false, reason: "blocked", candidates: rawCandidates.map(full) };
135
+ }
136
+
137
+ if (candidates.length === 0) return { ok: false, reason: "unknown", candidates: all };
138
+ if (candidates.length === 1) return { ok: true, ref: full(candidates[0]!) };
139
+
140
+ // Ambiguous → prefer the loader/session provider (the authenticated one).
141
+ if (preferProvider) {
142
+ const preferred = candidates.filter((m) => m.provider === preferProvider);
143
+ if (preferred.length === 1) return { ok: true, ref: full(preferred[0]!) };
144
+ if (preferred.length > 1) {
145
+ // All on the authenticated provider: if they're one model line differing only by
146
+ // version, take the newest instead of stranding the caller with a `models` lookup.
147
+ const newest = newestOfVersionLine(preferred);
148
+ if (newest) return { ok: true, ref: newest };
149
+ return { ok: false, reason: "ambiguous", candidates: preferred.map(full) };
150
+ }
151
+ }
152
+
153
+ // Still ambiguous — list the preferred provider's matches first (helps the retry).
154
+ const ordered = preferProvider
155
+ ? [
156
+ ...candidates.filter((m) => m.provider === preferProvider),
157
+ ...candidates.filter((m) => m.provider !== preferProvider),
158
+ ]
159
+ : candidates;
160
+ return { ok: false, reason: "ambiguous", candidates: ordered.map(full) };
161
+ }
162
+
163
+ /**
164
+ * The provider-fallback chain for a `provider/id` that failed at runtime: the SAME model
165
+ * id offered by OTHER (authenticated) providers, so an unpinned run can be retried by switching
166
+ * the route while keeping the model. Preferred provider first (most likely authenticated), the
167
+ * failed `ref` excluded. An optional routing policy filters automatic alternatives; exact pins
168
+ * are enforced by the fallback decorator, not this pure helper. Empty when no alternate survives.
169
+ */
170
+ export function providerFallbacks(
171
+ ref: string,
172
+ models: ModelLite[],
173
+ preferProvider?: string,
174
+ policy?: ModelRoutingPolicy,
175
+ ): string[] {
176
+ const slash = ref.indexOf("/");
177
+ const id = slash > 0 ? ref.slice(slash + 1) : ref;
178
+ const alts = dedupe(models.filter((m) => m.id === id && full(m) !== ref && isModelProviderAllowed(m, policy)));
179
+ const ordered = preferProvider
180
+ ? [...alts.filter((m) => m.provider === preferProvider), ...alts.filter((m) => m.provider !== preferProvider)]
181
+ : alts;
182
+ const seen = new Set<string>();
183
+ const out: string[] = [];
184
+ for (const m of ordered) {
185
+ const r = full(m);
186
+ if (!seen.has(r)) {
187
+ seen.add(r);
188
+ out.push(r);
189
+ }
190
+ }
191
+ return out;
192
+ }