one-code-extension 0.1.4 → 0.3.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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +222 -177
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +128 -0
  8. package/docs/guide/configuration.md +173 -0
  9. package/docs/guide/differences-from-claude-code.md +152 -0
  10. package/docs/guide/doctor.md +88 -0
  11. package/docs/guide/hooks.md +111 -0
  12. package/docs/guide/installation.md +126 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +452 -0
  14. package/docs/guide/providers-and-models.md +196 -0
  15. package/docs/guide/reference.md +226 -0
  16. package/docs/guide/sessions-and-context.md +192 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +178 -0
  18. package/docs/guide/subagents-and-workflows.md +195 -0
  19. package/docs/guide/tasks-and-background-work.md +113 -0
  20. package/docs/guide/terminal-interface.md +157 -0
  21. package/docs/guide/tools.md +177 -0
  22. package/docs/guide/troubleshooting.md +177 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -217
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Facts about models that pi's catalog strips: release date and tool-calling
3
+ * support, from models.dev, bundled as `model-facts.generated.json` and refreshed
4
+ * with each pi bump (`scripts/gen-model-facts.mjs`). Pure: reads one
5
+ * JSON file lazily, no pi imports.
6
+ *
7
+ * Why release dates: the tier price floors are absolute dollars calibrated on US
8
+ * vendor prices, and nothing else in the classifier knew a model's generation,
9
+ * so on a ~10x-cheaper vendor a boundary-priced 2025 model (deepseek-r1-0528)
10
+ * beat a 2026 model priced below the floor (V4 Flash). A model's *generation* is
11
+ * measured against its vendor family's newest release, never against today, so
12
+ * a family that ships rarely is not aged by the calendar.
13
+ * docs/features/tiering/plan.md (Phase 1), docs/decisions/model-tiers.md.
14
+ *
15
+ * Lookups resolve OpenRouter's `~vendor/id` redirect aliases and `:variant`
16
+ * endpoints through their base id, and — since 2026-09-11 — BORROW across
17
+ * providers: models.dev has no `openai-codex` (or `azure-*`) provider entries,
18
+ * so `openai-codex/gpt-5.6-terra` had no facts while `openai/gpt-5.6-terra` did,
19
+ * which left every Codex row unscored by the measured floor and judged by name
20
+ * anchors alone. A row without its own facts takes the release date of the same
21
+ * model (same canonical vendor, same normalized id) on any other provider,
22
+ * provided every such row agrees on the date — a disagreement (OpenRouter's
23
+ * April `deepseek-v4-flash` vs DeepSeek's July one) borrows nothing. Only the
24
+ * date is borrowed, never the tool-call flag. A row models.dev does not know
25
+ * anywhere has no facts and gets the price-and-name behaviour that preceded
26
+ * this module. docs/decisions/model-tiers.md (2026-09-11).
27
+ */
28
+
29
+ import { readFileSync } from "node:fs";
30
+ import type { Api, Model } from "@earendil-works/pi-ai";
31
+ import { baseModelId, DAY_MS, isAliasOrVariantId, modelIdentity } from "./model-policy.ts";
32
+
33
+ export interface ModelFactsRow {
34
+ /** ISO date (YYYY-MM-DD) the model was released, per models.dev. */
35
+ releaseDate: string;
36
+ /** Present only when models.dev marks the model as NOT supporting tool calls. */
37
+ toolCall?: false;
38
+ }
39
+
40
+ interface FactsFile {
41
+ facts: Record<string, ModelFactsRow>;
42
+ }
43
+
44
+ /** A model released this many days before its family's newest is one generation behind. */
45
+ export const PRIOR_GENERATION_DAYS = 365;
46
+ /** …and this many days behind is two generations: maximum scaffolding, never auto-selected. */
47
+ export const ANCIENT_GENERATION_DAYS = 730;
48
+
49
+ let loaded:
50
+ | {
51
+ facts: Record<string, ModelFactsRow>;
52
+ familyNewest: Map<string, number>;
53
+ /** `vendor|normalizedId` → the agreed release date, or null when providers disagree. */
54
+ borrowed: Map<string, ModelFactsRow | null>;
55
+ }
56
+ | undefined;
57
+
58
+ /** Test seam: replace the bundled table (pass `undefined` to restore it). */
59
+ export function setModelFactsForTest(facts: Record<string, ModelFactsRow> | undefined): void {
60
+ loaded = facts ? index(facts) : undefined;
61
+ }
62
+
63
+ function load(): NonNullable<typeof loaded> {
64
+ if (loaded) return loaded;
65
+ let facts: Record<string, ModelFactsRow> = {};
66
+ try {
67
+ const file = JSON.parse(readFileSync(new URL("./model-facts.generated.json", import.meta.url), "utf8")) as FactsFile;
68
+ if (file && typeof file.facts === "object") facts = file.facts;
69
+ } catch {
70
+ // A missing or unreadable table means "no facts": every row degrades to the
71
+ // pre-facts price-and-name behaviour rather than throwing inside selection.
72
+ }
73
+ loaded = index(facts);
74
+ return loaded;
75
+ }
76
+
77
+ /**
78
+ * The vendor family a row's generation is judged within: the canonical vendor
79
+ * when the identity policy knows it (so `openrouter/deepseek/…` and the direct
80
+ * `deepseek` provider share one family), else the containment key.
81
+ */
82
+ export function modelFamily(model: { provider: string; id: string }): string {
83
+ const identity = modelIdentity(model as Model<Api>);
84
+ return identity.profile ?? identity.containment;
85
+ }
86
+
87
+ function index(facts: Record<string, ModelFactsRow>) {
88
+ const familyNewest = new Map<string, number>();
89
+ const borrowed = new Map<string, ModelFactsRow | null>();
90
+ for (const [key, row] of Object.entries(facts)) {
91
+ const slash = key.indexOf("/");
92
+ if (slash <= 0) continue;
93
+ const model = { provider: key.slice(0, slash), id: key.slice(slash + 1) };
94
+ // Variants and redirect aliases carry no generation of their own.
95
+ if (isAliasOrVariantId(model.id)) continue;
96
+ const t = Date.parse(row.releaseDate);
97
+ if (Number.isNaN(t)) continue;
98
+ const family = modelFamily(model);
99
+ if (t > (familyNewest.get(family) ?? -Infinity)) familyNewest.set(family, t);
100
+ const lend = borrowKey(model);
101
+ if (!lend) continue;
102
+ const prior = borrowed.get(lend);
103
+ if (prior === undefined) borrowed.set(lend, { releaseDate: row.releaseDate });
104
+ else if (prior !== null && prior.releaseDate !== row.releaseDate) borrowed.set(lend, null);
105
+ }
106
+ return { facts, familyNewest, borrowed };
107
+ }
108
+
109
+ /** The cross-provider identity a row may lend its release date under, or undefined for an unverifiable one. */
110
+ function borrowKey(model: { provider: string; id: string }): string | undefined {
111
+ const identity = modelIdentity(model as Model<Api>);
112
+ if (identity.confidence === "opaque" || !identity.profile) return undefined;
113
+ return `${identity.profile}|${baseModelId(identity.normalizedId)}`;
114
+ }
115
+
116
+ /**
117
+ * The models.dev row for a model, resolved through alias and variant spellings,
118
+ * else borrowed from the same model on another provider (module doc).
119
+ */
120
+ export function modelFacts(model: { provider: string; id: string }): ModelFactsRow | undefined {
121
+ const { facts, borrowed } = load();
122
+ const own = facts[`${model.provider}/${model.id}`] ?? facts[`${model.provider}/${baseModelId(model.id)}`];
123
+ if (own) return own;
124
+ const key = borrowKey(model);
125
+ return (key && borrowed.get(key)) || undefined;
126
+ }
127
+
128
+ /** Whether models.dev says the model cannot call tools (undefined = unknown or yes). */
129
+ export function lacksToolCalls(model: { provider: string; id: string }): boolean {
130
+ return modelFacts(model)?.toolCall === false;
131
+ }
132
+
133
+ /**
134
+ * How many days behind its family's newest release the model is, or undefined
135
+ * when either date is unknown. 0 for the newest model itself.
136
+ */
137
+ export function generationLagDays(model: { provider: string; id: string }): number | undefined {
138
+ const row = modelFacts(model);
139
+ if (!row) return undefined;
140
+ const released = Date.parse(row.releaseDate);
141
+ if (Number.isNaN(released)) return undefined;
142
+ const newest = load().familyNewest.get(modelFamily(model));
143
+ if (newest === undefined) return undefined;
144
+ return Math.max(0, Math.round((newest - released) / DAY_MS));
145
+ }
146
+
147
+ export type Generation = "current" | "prior" | "ancient";
148
+
149
+ /** The model's generation relative to its family, or undefined without facts. */
150
+ export function modelGeneration(model: { provider: string; id: string }): Generation | undefined {
151
+ const lag = generationLagDays(model);
152
+ if (lag === undefined) return undefined;
153
+ if (lag > ANCIENT_GENERATION_DAYS) return "ancient";
154
+ if (lag > PRIOR_GENERATION_DAYS) return "prior";
155
+ return "current";
156
+ }
157
+
158
+ /** Prior or ancient: excluded from every automatic pick (subagent, classifier, reader, presets). */
159
+ export function isPriorGeneration(model: { provider: string; id: string }): boolean {
160
+ const generation = modelGeneration(model);
161
+ return generation === "prior" || generation === "ancient";
162
+ }
@@ -1,112 +1,15 @@
1
- import type { Api, Model } from "@earendil-works/pi-ai";
2
-
3
- /** The two automatic model-selection jobs have different capability floors. */
4
- export type ModelRole = "classifier" | "subagent";
5
-
6
- export interface RoleProfile {
7
- /** Vetted, small-but-capable models. Price alone never adds to this list. */
8
- classifier: readonly string[];
9
- /** Economical models suitable for delegated coding/reasoning work. */
10
- subagent: readonly string[];
11
- }
1
+ import { type Api, clampThinkingLevel, type Model, type ThinkingLevel } from "@earendil-works/pi-ai";
12
2
 
13
3
  /**
14
- * Short family lists, not catalogs. Broad gateways apply these to their live
15
- * authenticated catalog after containment; they never get an OpenRouter-sized
16
- * hardcoded list. Reviewed against official provider docs on 2026-08-06.
4
+ * Whether a session runs on a Claude-family model. Claude Code's own subagent
5
+ * conventions (`CLAUDE_CODE_SUBAGENT_MODEL`, `.claude/agents` model fields) are
6
+ * honored across providers only on such sessions; elsewhere subagents stay on
7
+ * the session's provider. Only provider/id are consulted, so a minimal model
8
+ * shape suffices.
17
9
  */
18
- export const ROLE_PROFILES: Readonly<Record<string, RoleProfile>> = {
19
- anthropic: {
20
- classifier: ["claude-haiku-4-5", "claude-sonnet-5", "claude-sonnet-4-6"],
21
- subagent: ["claude-sonnet-5", "claude-haiku-4-5", "claude-sonnet-4-6"],
22
- },
23
- openai: {
24
- classifier: ["gpt-5.6-luna", "gpt-5.4-mini", "gpt-5-mini", "gpt-4.1-mini", "gpt-4o-mini"],
25
- subagent: ["gpt-5.6-luna", "gpt-5.4-mini", "gpt-5.3-codex-spark", "gpt-5.6-terra"],
26
- },
27
- google: {
28
- classifier: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3-flash-preview", "gemini-2.5-flash"],
29
- subagent: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3-flash-preview", "gemini-2.5-flash"],
30
- },
31
- xai: {
32
- classifier: ["grok-4.3", "grok-4.5"],
33
- subagent: ["grok-build-0.1", "grok-4.3", "grok-4.5"],
34
- },
35
- mistral: {
36
- classifier: ["mistral-small-2603", "mistral-small-latest", "mistral-medium-3.5"],
37
- subagent: ["mistral-small-2603", "mistral-medium-3.5", "codestral-latest"],
38
- },
39
- deepseek: {
40
- classifier: ["deepseek-v4-flash", "deepseek-v4-pro"],
41
- subagent: ["deepseek-v4-flash", "deepseek-v4-pro"],
42
- },
43
- zai: {
44
- classifier: ["glm-5-turbo", "glm-4.7", "glm-5.2", "glm-4.5-air", "glm-4-flash"],
45
- subagent: ["glm-5-turbo", "glm-4.7", "glm-5.2", "glm-4.5-air"],
46
- },
47
- moonshot: {
48
- classifier: ["kimi-k2.6", "kimi-k2.5"],
49
- subagent: ["kimi-k2.7-code", "kimi-k2.6", "kimi-k3"],
50
- },
51
- minimax: {
52
- classifier: ["MiniMax-M2.7", "MiniMax-M3"],
53
- subagent: ["MiniMax-M2.7", "MiniMax-M3"],
54
- },
55
- xiaomi: {
56
- classifier: ["mimo-v2.5", "mimo-v2-pro"],
57
- subagent: ["mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-pro"],
58
- },
59
- "ant-ling": {
60
- // The current pi catalog predates Ling 3.0. Flash remains opt-in: it has
61
- // not been calibrated as the approval boundary.
62
- classifier: ["Ling-2.6-1T", "Ring-2.6-1T"],
63
- subagent: ["Ling-2.6-1T", "Ring-2.6-1T", "Ling-2.6-flash"],
64
- },
65
- meta: {
66
- classifier: ["llama-3.3-70b", "llama-4-scout"],
67
- subagent: ["llama-4-scout", "llama-3.3-70b"],
68
- },
69
- amazon: {
70
- classifier: ["nova-2-lite", "nova-lite"],
71
- subagent: ["nova-2-lite", "nova-lite", "nova-pro"],
72
- },
73
- "host:nvidia": {
74
- classifier: ["nvidia/nvidia-nemotron-nano-9b-v2", "openai/gpt-oss-20b", "meta/llama-3.3-70b-instruct"],
75
- subagent: ["poolside/laguna-xs-2.1", "nvidia/nemotron-3-super-120b-a12b", "openai/gpt-oss-20b"],
76
- },
77
- "host:groq": {
78
- classifier: ["openai/gpt-oss-20b", "llama-3.3-70b-versatile"],
79
- subagent: ["openai/gpt-oss-20b", "openai/gpt-oss-120b", "llama-3.3-70b-versatile"],
80
- },
81
- "host:cerebras": {
82
- classifier: ["gpt-oss-120b", "zai-glm-4.7"],
83
- subagent: ["gpt-oss-120b", "zai-glm-4.7"],
84
- },
85
- "host:fireworks": {
86
- classifier: ["accounts/fireworks/models/deepseek-v4-flash", "accounts/fireworks/models/gpt-oss-20b"],
87
- subagent: ["accounts/fireworks/models/kimi-k2p7-code", "accounts/fireworks/models/gpt-oss-20b"],
88
- },
89
- "host:together": {
90
- classifier: ["Qwen/Qwen3.5-9B", "openai/gpt-oss-20b"],
91
- subagent: ["openai/gpt-oss-20b", "moonshotai/Kimi-K2.7-Code", "Qwen/Qwen3.5-9B"],
92
- },
93
- "host:baseten": {
94
- classifier: ["openai/gpt-oss-120b", "deepseek-ai/DeepSeek-V4-Flash"],
95
- subagent: ["openai/gpt-oss-120b", "zai-org/GLM-5.2", "moonshotai/Kimi-K2.7-Code"],
96
- },
97
- "host:cloudflare-workers": {
98
- classifier: ["@cf/openai/gpt-oss-20b", "@cf/zai-org/glm-4.7-flash"],
99
- subagent: ["@cf/moonshotai/kimi-k2.7-code", "@cf/openai/gpt-oss-20b"],
100
- },
101
- "host:qwen-plan": {
102
- classifier: ["qwen3.6-flash", "deepseek-v4-flash", "qwen3.6-plus"],
103
- subagent: ["kimi-k2.7-code", "deepseek-v4-flash", "qwen3.7-plus", "qwen3.6-plus"],
104
- },
105
- "host:kimi-coding": {
106
- classifier: ["kimi-for-coding", "k3"],
107
- subagent: ["kimi-for-coding", "k3"],
108
- },
109
- };
10
+ export function isClaudeFamilyModel(model: { provider: string; id: string }): boolean {
11
+ return model.provider === "anthropic" || /claude/i.test(model.id);
12
+ }
110
13
 
111
14
  export type ProviderPolicyKind = "direct" | "hosted" | "gateway" | "opaque";
112
15
 
@@ -114,14 +17,12 @@ export interface ProviderPolicy {
114
17
  kind: ProviderPolicyKind;
115
18
  /** Fixed profile for direct vendors and stable hosted catalogs. */
116
19
  profile?: string;
117
- /** Whether a price-ranked subagent may follow the vetted profile. */
118
- dynamicSubagents: boolean;
119
20
  }
120
21
 
121
- const direct = (profile: string): ProviderPolicy => ({ kind: "direct", profile, dynamicSubagents: true });
122
- const hosted = (profile?: string): ProviderPolicy => ({ kind: "hosted", profile, dynamicSubagents: true });
123
- const gateway = (): ProviderPolicy => ({ kind: "gateway", dynamicSubagents: true });
124
- const opaque = (): ProviderPolicy => ({ kind: "opaque", dynamicSubagents: false });
22
+ const direct = (profile: string): ProviderPolicy => ({ kind: "direct", profile });
23
+ const hosted = (profile?: string): ProviderPolicy => ({ kind: "hosted", profile });
24
+ const gateway = (): ProviderPolicy => ({ kind: "gateway" });
25
+ const opaque = (): ProviderPolicy => ({ kind: "opaque" });
125
26
 
126
27
  /**
127
28
  * Every built-in language-model provider in pi 0.84.0. Unknown/custom providers
@@ -301,38 +202,96 @@ export function modelIdentity(model: Model<Api>): ModelIdentity {
301
202
  };
302
203
  }
303
204
 
205
+ const UNSUITABLE_VARIANT = /:(batch|free|online|thinking)$/i;
206
+
304
207
  /**
305
- * Names of the known small-but-capable model families, best tier first. Too weak
306
- * to *choose* by — Groq and xAI carry none of them — but strong enough to
307
- * corroborate a choice price alone suggested, and to order tiers when several
308
- * cheap models qualify (mini-class ahead of nano/lite-class).
208
+ * The id without OpenRouter's `~` redirect-alias marker and without a
209
+ * `:batch`-style endpoint variant — the spelling the name rules, the facts
210
+ * table and the capability index all key on (`~deepseek/deepseek-v4-flash-latest`
211
+ * → `deepseek/deepseek-v4-flash-latest`, `gpt-5-mini:batch` → `gpt-5-mini`).
309
212
  */
310
- const NAME_HINTS = [/haiku/i, /flash/i, /mini/i, /nano/i, /small/i, /lite/i, /instant/i, /sonnet/i];
311
-
312
- /** Index into NAME_HINTS; NAME_HINTS.length means "no known small-model name". */
313
- export function hintRank(model: Model<Api>): number {
314
- const index = NAME_HINTS.findIndex((pattern) => pattern.test(model.id));
315
- return index === -1 ? NAME_HINTS.length : index;
213
+ export function baseModelId(id: string): string {
214
+ return (id.startsWith("~") ? id.slice(1) : id).replace(/:[a-z]+$/i, "");
316
215
  }
317
216
 
318
- /** Whether the model's name carries one of the known small-model family words. */
319
- export function hasSmallModelName(model: Model<Api>): boolean {
320
- return hintRank(model) < NAME_HINTS.length;
217
+ /** Whether the id carries a redirect marker or endpoint variant (`baseModelId` would change it). */
218
+ export function isAliasOrVariantId(id: string): boolean {
219
+ return baseModelId(id) !== id;
321
220
  }
322
221
 
323
- const UNSUITABLE_VARIANT = /:(batch|free|online|thinking)$/i;
324
-
325
- /** Automatic selection excludes endpoint variants with the wrong execution shape. */
222
+ /**
223
+ * Automatic selection excludes endpoint variants with the wrong execution shape,
224
+ * and OpenRouter's `~vendor/family-latest` redirect aliases ("always redirects to
225
+ * the latest model in the family"): the model behind one moves without notice,
226
+ * so it has no stable identity to tier, date or price — and the classifier is a
227
+ * permission boundary. The alias stays available by hand (/model, an explicit
228
+ * `subagentModel` / `classifierModel` setting).
229
+ */
326
230
  export function isSelectableVariant(model: Model<Api>): boolean {
327
- return !UNSUITABLE_VARIANT.test(model.id);
231
+ return !UNSUITABLE_VARIANT.test(model.id) && !model.id.startsWith("~");
328
232
  }
329
233
 
234
+ /** One day in milliseconds, for the release-date arithmetic in model-facts.ts and capability-index.ts. */
235
+ export const DAY_MS = 86_400_000;
236
+
330
237
  /** Non-positive values are catalog sentinels/unpriced, not evidence of being free. */
331
238
  export function pricedInput(model: Model<Api>): number | undefined {
332
239
  const input = model.cost?.input;
333
240
  return typeof input === "number" && input > 0 ? input : undefined;
334
241
  }
335
242
 
243
+ /** A dated snapshot id (`claude-haiku-4-5-20251001`): the trailing `-YYYYMMDD` marks a pinned alias of an undated model. */
244
+ export function isDatedModelId(id: string): boolean {
245
+ return /-20\d{6}$/.test(id);
246
+ }
247
+
248
+ /**
249
+ * The id with a trailing snapshot date removed: `-YYYYMMDD` (Anthropic, OpenAI)
250
+ * or `-MMDD` (DeepSeek's `deepseek-v4-flash-0731`, `deepseek-v4-pro-0813`; the
251
+ * four digits must be a real month and day, so a `-2411`-style version tag is
252
+ * left alone). The id itself when it carries no such suffix.
253
+ */
254
+ export function stripSnapshotDate(id: string): string {
255
+ if (isDatedModelId(id)) return id.slice(0, -9);
256
+ const short = id.match(/-(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])$/);
257
+ return short ? id.slice(0, -5) : id;
258
+ }
259
+
260
+ /** Whether the id ends in a snapshot date of either shape (`stripSnapshotDate` would change it). */
261
+ export function isSnapshotDatedId(id: string): boolean {
262
+ return stripSnapshotDate(id) !== id;
263
+ }
264
+
265
+ /**
266
+ * Whether `model` is a dated snapshot whose undated alias is also present in
267
+ * `pool` — the row a listing collapses so the same model is not offered twice.
268
+ * Exact: the alias must be the id minus its date, so `deepseek-v4-flash-vision-exp`
269
+ * is never read as a snapshot of `deepseek-v4-flash`.
270
+ */
271
+ export function isDatedDuplicate(model: { id: string }, pool: ReadonlyArray<{ id: string }>): boolean {
272
+ const alias = stripSnapshotDate(model.id);
273
+ return alias !== model.id && pool.some((other) => other.id === alias);
274
+ }
275
+
276
+ /** The canonical `provider/id` spec — one source of truth for the string form. */
277
+ export function modelSpec(model: { provider: string; id: string }): string {
278
+ return `${model.provider}/${model.id}`;
279
+ }
280
+
281
+ /** Whether a stamped-default setting was set for a provider this session has since left. */
282
+ export function isStaleContainmentStamp(
283
+ setForContainment: string | undefined,
284
+ sessionModel: { provider: string; id: string } | undefined,
285
+ ): boolean {
286
+ return setForContainment !== (sessionModel ? modelIdentity(sessionModel as Model<Api>).containment : undefined);
287
+ }
288
+
289
+ /** Whether an explicit model leaves the session's provider/route/family boundary. */
290
+ export function crossesProvider(model: Model<Api>, sessionModel: Model<Api>): boolean {
291
+ if (model.provider !== sessionModel.provider) return true;
292
+ return modelIdentity(model).containment !== modelIdentity(sessionModel).containment;
293
+ }
294
+
336
295
  /** Models that automatic selection may consider without changing containment. */
337
296
  export function modelsContainedToSession(available: Model<Api>[], sessionModel: Model<Api>): Model<Api>[] {
338
297
  const sessionIdentity = modelIdentity(sessionModel);
@@ -344,74 +303,71 @@ export function modelsContainedToSession(available: Model<Api>[], sessionModel:
344
303
  );
345
304
  }
346
305
 
347
- function comparableId(value: string): string {
348
- return value.toLowerCase().replaceAll(".", "-").replaceAll("_", "-");
349
- }
350
-
351
- function matchesPrefix(model: Model<Api>, prefix: string): boolean {
352
- const id = comparableId(modelIdentity(model).normalizedId);
353
- const wanted = comparableId(prefix);
354
- return id === wanted || id.startsWith(wanted);
306
+ /**
307
+ * The thinking level to send on a one-shot `completeSimple` call that would
308
+ * rather run with thinking OFF (classifier, setup drafting, recap, readers).
309
+ *
310
+ * `undefined` = the model supports off — omit `reasoning`, pi disables thinking
311
+ * explicitly, the request stays exactly as before. A level = the model cannot
312
+ * disable thinking (catalog `thinkingLevelMap.off: null`, e.g. Gemini 3.6+
313
+ * Flash); omitting `reasoning` makes the provider 400 ("Reasoning is mandatory
314
+ * for this endpoint and cannot be disabled"), so send the lowest level the
315
+ * model supports instead.
316
+ */
317
+ export function forcedReasoningLevel(model: Model<Api>): ThinkingLevel | undefined {
318
+ const clamped = clampThinkingLevel(model, "off");
319
+ return clamped === "off" ? undefined : (clamped as ThinkingLevel);
355
320
  }
356
321
 
357
- /** First live model matching a role's reviewed family order. */
358
- export function findRoleProfileModel(
359
- available: Model<Api>[],
360
- sessionModel: Model<Api>,
361
- role: ModelRole,
362
- accept: (model: Model<Api>) => boolean = () => true,
363
- // A caller that already computed the containment set can pass it to avoid the
364
- // O(catalog) recompute (classifierCandidates does, on a per-tool-call hot path).
365
- precomputedContained?: Model<Api>[],
366
- ): Model<Api> | undefined {
367
- const identity = modelIdentity(sessionModel);
368
- if (!identity.profile) return undefined;
369
- const prefixes = ROLE_PROFILES[identity.profile]?.[role] ?? [];
370
- const contained = precomputedContained ?? modelsContainedToSession(available, sessionModel);
371
- for (const prefix of prefixes) {
372
- const wanted = comparableId(prefix);
373
- const exact = contained.find(
374
- (model) => comparableId(modelIdentity(model).normalizedId) === wanted && accept(model),
375
- );
376
- if (exact) return exact;
377
- const prefixed = contained.find((model) => matchesPrefix(model, prefix) && accept(model));
378
- if (prefixed) return prefixed;
379
- }
380
- return undefined;
322
+ /**
323
+ * Provider error saying thinking cannot be disabled — the reactive net for a
324
+ * model whose catalog entry LACKS the `thinkingLevelMap.off: null` marker
325
+ * (OpenRouter entries, hand-added models newer than the bundled catalog).
326
+ * A false positive only costs one retry with minimal thinking.
327
+ */
328
+ export function isReasoningMandatoryError(message: string): boolean {
329
+ return /(reasoning|thinking)[^.]*\b(mandatory|cannot be disabled|can't be disabled|must be enabled|is required)\b/i.test(message);
381
330
  }
382
331
 
383
- export interface EconomicalModelChoice {
384
- model: Model<Api>;
385
- via: "profile" | "session";
332
+ /** The level to retry with after isReasoningMandatoryError: the lowest real level the model supports. */
333
+ export function reasoningRetryLevel(model: Model<Api>): ThinkingLevel {
334
+ const clamped = clampThinkingLevel(model, "minimal");
335
+ return clamped === "off" ? "minimal" : (clamped as ThinkingLevel);
386
336
  }
387
337
 
388
338
  /**
389
- * A vetted small-but-capable same-containment model within the session's
390
- * price ceiling, else the session model itself. Used for low-stakes one-shot
391
- * jobs over untrusted content (the web_fetch reader) — the content goes to
392
- * the model, so containment applies. Not used for compaction, which runs on
393
- * the session model to reuse the provider prompt cache.
339
+ * Run a one-shot `completeSimple`-style call that wants thinking OFF, handling
340
+ * the models that can't. The proactive path ({@link forcedReasoningLevel}) sends
341
+ * a level up front for catalog-marked models; the reactive path catches a
342
+ * provider's mandatory-thinking 400 ({@link isReasoningMandatoryError}) for the
343
+ * metadata gaps and retries ONCE at the model's floor. `call` receives the level
344
+ * to send (undefined = omit `reasoning`) and is invoked at most twice.
345
+ *
346
+ * `learned` optionally memoizes the reactive result per `provider/id` across
347
+ * calls, so a repeatedly-called site (the web-fetch reader) pays the failed
348
+ * round-trip once, not every call. Omit it for one-off callers.
349
+ *
350
+ * `onUsage` receives the final reply's `usage` once per invocation — this is the
351
+ * single choke point for the reader-style one-shots (web-fetch, recap, setup),
352
+ * so instrumenting it here reports their cost to the footer without each call
353
+ * site having to remember to.
394
354
  */
395
- export function pickEconomicalContainedModel(
396
- available: Model<Api>[],
397
- sessionModel: Model<Api> | undefined,
398
- ): EconomicalModelChoice | undefined {
399
- if (!sessionModel) return undefined;
400
- const sessionPrice = pricedInput(sessionModel);
401
- const withinBudget = (model: Model<Api>) => {
402
- if (sessionPrice === undefined) return true;
403
- const price = pricedInput(model);
404
- return price === undefined || price <= sessionPrice;
405
- };
406
- const profiled = findRoleProfileModel(available, sessionModel, "classifier", withinBudget);
407
- return profiled ? { model: profiled, via: "profile" } : { model: sessionModel, via: "session" };
408
- }
409
-
410
- /** Whether price ranking is an acceptable subagent fallback for this session. */
411
- export function allowsDynamicSubagentSelection(sessionModel: Model<Api>): boolean {
412
- const policy = providerPolicy(sessionModel.provider);
413
- if (!policy.dynamicSubagents) return false;
414
- return modelIdentity(sessionModel).confidence !== "opaque";
355
+ export async function withReasoningFallback<R extends { stopReason: string; errorMessage?: string; usage?: unknown }>(
356
+ model: Model<Api>,
357
+ call: (reasoning: ThinkingLevel | undefined) => Promise<R>,
358
+ learned?: Map<string, ThinkingLevel>,
359
+ onUsage?: (usage: unknown) => void,
360
+ ): Promise<R> {
361
+ const key = `${model.provider}/${model.id}`;
362
+ const reasoning = learned?.get(key) ?? forcedReasoningLevel(model);
363
+ let result = await call(reasoning);
364
+ if (result.stopReason === "error" && !reasoning && isReasoningMandatoryError(result.errorMessage ?? "")) {
365
+ const level = reasoningRetryLevel(model);
366
+ learned?.set(key, level);
367
+ result = await call(level);
368
+ }
369
+ onUsage?.(result.usage);
370
+ return result;
415
371
  }
416
372
 
417
373
  /** Resolve an explicit provider/id, bare id, or prefix. Explicit choices are not contained. */