pi-orchestrator-config 4.2.0 → 4.3.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 (100) hide show
  1. package/README.md +76 -67
  2. package/agents/code-reviewer-docs.md +11 -1
  3. package/agents/code-reviewer-guidelines.md +11 -1
  4. package/agents/code-reviewer-quality.md +11 -1
  5. package/agents/code-reviewer-security.md +11 -1
  6. package/agents/code-reviewer-spec.md +11 -1
  7. package/agents/git-expert.md +42 -3
  8. package/agents/github-expert.md +11 -0
  9. package/extensions/acpx-provider/runtime-models.ts +18 -6
  10. package/extensions/cli-provider/agents/claude.ts +3 -2
  11. package/extensions/cli-provider/agents/cursor.ts +3 -5
  12. package/extensions/cli-provider/agents/gemini.ts +3 -2
  13. package/extensions/cli-provider/discover.ts +7 -7
  14. package/extensions/cli-provider/index.ts +5 -0
  15. package/extensions/cli-provider/runtime-models.ts +18 -6
  16. package/extensions/cli-provider/session-reaper.ts +5 -7
  17. package/extensions/cli-provider/sessions.ts +4 -0
  18. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  19. package/extensions/coms/coms-p2p.ts +1719 -423
  20. package/extensions/coms/coms-shared.ts +261 -39
  21. package/extensions/coms/coms-wrapper.ts +100 -62
  22. package/extensions/coms/index.ts +8 -4
  23. package/extensions/coms/mixed-turn.ts +26 -0
  24. package/extensions/coms/probe-socket.ts +42 -0
  25. package/extensions/coms/themeMap.ts +0 -1
  26. package/extensions/image-gen/image-gen.ts +19 -1
  27. package/extensions/orchestrator/async-agents.ts +563 -102
  28. package/extensions/orchestrator/async-capability.ts +8 -8
  29. package/extensions/orchestrator/cron.ts +15 -9
  30. package/extensions/orchestrator/dreaming.ts +32 -25
  31. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  32. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  33. package/extensions/orchestrator/enforcement.ts +80 -27
  34. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  35. package/extensions/orchestrator/git-helpers.ts +2 -2
  36. package/extensions/orchestrator/index.ts +78 -10
  37. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  38. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  39. package/extensions/orchestrator/memory-provenance.ts +29 -25
  40. package/extensions/orchestrator/memory-scoring.ts +23 -18
  41. package/extensions/orchestrator/memory-tools.ts +10 -3
  42. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  43. package/extensions/orchestrator/parse-model-override.ts +29 -0
  44. package/extensions/orchestrator/pi-config-review-state.ts +57 -33
  45. package/extensions/orchestrator/project-settings.ts +333 -127
  46. package/extensions/orchestrator/resolve-agent-model.ts +24 -13
  47. package/extensions/orchestrator/review-porcelain.ts +4 -0
  48. package/extensions/orchestrator/review-ui.ts +63 -15
  49. package/extensions/orchestrator/rule-placeholders.ts +411 -2
  50. package/extensions/orchestrator/rules.ts +80 -132
  51. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  52. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  53. package/extensions/orchestrator/settings-tui.ts +636 -0
  54. package/extensions/orchestrator/state-jsonl.ts +431 -0
  55. package/extensions/orchestrator/subagent-tool.ts +157 -59
  56. package/extensions/orchestrator/sync-limit.ts +23 -0
  57. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  58. package/extensions/orchestrator/utils.ts +8 -5
  59. package/extensions/pidash/pidash.ts +193 -156
  60. package/extensions/pidiff/pidiff.ts +96 -38
  61. package/extensions/pidiff/spawn-lock.ts +62 -0
  62. package/extensions/pitasks/index.ts +430 -0
  63. package/extensions/pitasks/reminders.ts +37 -0
  64. package/extensions/pitasks/task-store.ts +467 -0
  65. package/extensions/pitasks/task-tools.ts +375 -0
  66. package/extensions/pitasks/task-widget.ts +181 -0
  67. package/extensions/pitasks/types.ts +38 -0
  68. package/extensions/providers/acpx-driver.ts +12 -2
  69. package/extensions/providers/built-in-drivers.ts +6 -6
  70. package/extensions/providers/claude-driver.ts +1 -1
  71. package/extensions/providers/cursor-cli-driver.ts +1 -1
  72. package/extensions/providers/gemini-driver.ts +1 -1
  73. package/extensions/providers/index.ts +144 -26
  74. package/extensions/providers/initialized-guard.ts +27 -0
  75. package/extensions/providers/restore-default-model.ts +596 -0
  76. package/extensions/providers/session-shutdown.ts +39 -0
  77. package/extensions/shared/coms-active.ts +23 -0
  78. package/extensions/shared/create-runtime-provider.ts +2 -0
  79. package/extensions/shared/daemon-manager.ts +0 -8
  80. package/extensions/shared/file-logger.ts +132 -25
  81. package/extensions/shared/logger.ts +50 -0
  82. package/extensions/shared/models-dev.ts +421 -0
  83. package/extensions/shared/oneshot.ts +116 -0
  84. package/extensions/shared/resolve-binary.ts +191 -0
  85. package/extensions/shared/types.ts +3 -0
  86. package/package.json +9 -3
  87. package/prompts/coderabbit-rate-limit.md +2 -0
  88. package/rules/00-orchestrator-core.md +3 -0
  89. package/rules/10-agent-routing.md +12 -1
  90. package/rules/20-code-review-loop.md +52 -25
  91. package/rules/30-prompt-templates.md +4 -0
  92. package/rules/35-memory.md +3 -3
  93. package/rules/40-critical-rules.md +3 -0
  94. package/rules/55-coms-protocol.md +84 -18
  95. package/rules/60-task-tracking.md +2 -0
  96. package/settings-keys.json +563 -0
  97. package/templates/coms-feature-manager-prompt.md +4 -6
  98. package/extensions/coms/coms-net-server.ts +0 -1642
  99. package/extensions/coms/coms-net-wrapper.ts +0 -521
  100. package/extensions/coms/coms-net.ts +0 -1589
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * NEVER use console.* for operational/debug messages in extensions — pi
5
5
  * surfaces them into the chat text box. Write here instead:
6
- * ~/.pi/logs/<name>.log
6
+ * ~/.pi/logs/<name>/<PI_SESSION_ID>.log
7
7
  *
8
8
  * I/O is intentionally synchronous (mkdirSync/appendFileSync). Call sites are
9
9
  * low-volume ops events (reaper, discovery, dream complete) — not hot loops.
@@ -16,6 +16,30 @@ import * as path from "node:path";
16
16
 
17
17
  export type FileLogLevel = "debug" | "info" | "warn" | "error";
18
18
 
19
+ const SESSION_ID_ENV_KEY = "__PI_CONFIG_SESSION_ID";
20
+ let pendingLogLines: Array<{ name: string; line: string }> = [];
21
+
22
+ // Auto-init session ID from environment (subagent processes inherit parent's session ID)
23
+ if (!((globalThis as any).__piConfigSessionId) && process.env.__PI_CONFIG_SESSION_ID) {
24
+ (globalThis as any).__piConfigSessionId = process.env.__PI_CONFIG_SESSION_ID;
25
+ }
26
+
27
+ export function setGlobalSessionId(id: string): void {
28
+ process.env[SESSION_ID_ENV_KEY] = id;
29
+ (globalThis as any).__piConfigSessionId = id;
30
+
31
+ // Flush buffered log lines now that we have a session ID
32
+ for (const { name, line } of pendingLogLines) {
33
+ try {
34
+ const logPath = getPiLogPath(name);
35
+ if (logPath) appendLine(logPath, line);
36
+ } catch (e) {
37
+ recordWriteError(e);
38
+ }
39
+ }
40
+ pendingLogLines = [];
41
+ }
42
+
19
43
  /** Last filesystem failure from fileLog (for diagnostics; never console.*). */
20
44
  let lastFileLogError: string | null = null;
21
45
  let fileLogErrorCount = 0;
@@ -33,10 +57,46 @@ export function getPiLogsDir(): string {
33
57
  return path.join(os.homedir(), ".pi", "logs");
34
58
  }
35
59
 
36
- /** Pure path — no mkdir. Callers that only need the path string stay side-effect free. */
37
- export function getPiLogPath(name: string): string {
38
- const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
39
- return path.join(getPiLogsDir(), `${safe}.log`);
60
+ /** Pure path — no mkdir. Returns null if session ID not yet set. */
61
+ export function getPiLogPath(name: string): string | null {
62
+ const sid = (globalThis as any).__piConfigSessionId;
63
+ if (!sid) return null;
64
+ const safeSid = sid.replace(/[^a-zA-Z0-9._-]/g, "_");
65
+ const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^\.+$/, "_");
66
+ const prefix = logFilePrefixes.get(name);
67
+
68
+ const parentSid = process.env.__PI_PARENT_SESSION_ID;
69
+ if (parentSid && parentSid !== sid) {
70
+ // Subagent: nest under parent session
71
+ const safeParentSid = parentSid.replace(/[^a-zA-Z0-9._-]/g, "_");
72
+ const filename = prefix ? `${prefix}-${safeSid}.log` : `${safeSid}.log`;
73
+ return path.join(getPiLogsDir(), safe, safeParentSid, filename);
74
+ }
75
+
76
+ // Parent: use <name>/<sid>/main.log (or <prefix>-main.log)
77
+ const filename = prefix ? `${prefix}-main.log` : "main.log";
78
+ return path.join(getPiLogsDir(), safe, safeSid, filename);
79
+ }
80
+
81
+ const logFilePrefixes: Map<string, string> = new Map();
82
+
83
+ export function setLogFilePrefix(name: string, prefix: string): void {
84
+ const sid = (globalThis as any).__piConfigSessionId;
85
+ if (sid) {
86
+ const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^\.+$/, "_");
87
+ const safeSid = sid.replace(/[^a-zA-Z0-9._-]/g, "_");
88
+ const oldPath = path.join(getPiLogsDir(), safe, safeSid, "main.log");
89
+ const newPath = path.join(getPiLogsDir(), safe, safeSid, `${prefix}-main.log`);
90
+ try {
91
+ if (fs.existsSync(oldPath) && !fs.existsSync(newPath)) {
92
+ fs.renameSync(oldPath, newPath);
93
+ } else if (fs.existsSync(oldPath) && fs.existsSync(newPath)) {
94
+ fs.appendFileSync(newPath, fs.readFileSync(oldPath, "utf-8"));
95
+ fs.unlinkSync(oldPath);
96
+ }
97
+ } catch {}
98
+ }
99
+ logFilePrefixes.set(name, prefix);
40
100
  }
41
101
 
42
102
  /** Collapse CR/LF so one fileLog call = one physical log line. */
@@ -63,11 +123,67 @@ function appendLine(filePath: string, line: string): void {
63
123
  }
64
124
 
65
125
  /**
66
- * Append one physical line to ~/.pi/logs/<name>.log.
126
+ * Append one physical line to ~/.pi/logs/<name>/<session>.log.
67
127
  * Never writes to console.* (chat UI leak).
68
128
  * Returns true on success.
69
129
  * On primary-path failure, tries os.tmpdir()/pi-logs/<name>.log once.
70
130
  */
131
+ const LEVEL_ORDER: Record<string, number> = Object.assign(Object.create(null), { off: -1, debug: 0, info: 1, warn: 2, error: 3 });
132
+
133
+ let _cachedGetSetting: ((cwd: string, key: string) => any) | null | false = null;
134
+
135
+ /** Match settings mtime throttle — burst register calls must not re-stat. */
136
+ const MIN_LEVEL_CACHE_MS = 30_000;
137
+ const minLevelCache = new Map<string, { level: number; at: number }>();
138
+
139
+ function resolveMinLevel(name: string): number {
140
+ if (_cachedGetSetting !== false) {
141
+ try {
142
+ if (!_cachedGetSetting) {
143
+ _cachedGetSetting = require("../orchestrator/project-settings.js").getSetting;
144
+ }
145
+ if (_cachedGetSetting) {
146
+ const settingKey = `log_${name.replace(/-/g, "_")}`;
147
+ const settingVal = _cachedGetSetting(process.cwd(), settingKey);
148
+ if (settingVal && settingVal in LEVEL_ORDER) return LEVEL_ORDER[settingVal];
149
+ }
150
+ } catch {
151
+ _cachedGetSetting = false;
152
+ }
153
+ }
154
+
155
+ const envKey = `PI_LOG_${name.replace(/-/g, "_").toUpperCase()}`;
156
+ const val = process.env[envKey];
157
+ if (val && val in LEVEL_ORDER) return LEVEL_ORDER[val];
158
+
159
+ return LEVEL_ORDER.info;
160
+ }
161
+
162
+ function getMinLevel(name: string): number {
163
+ const now = Date.now();
164
+ const hit = minLevelCache.get(name);
165
+ if (hit && now - hit.at < MIN_LEVEL_CACHE_MS) return hit.level;
166
+ const level = resolveMinLevel(name);
167
+ minLevelCache.set(name, { level, at: now });
168
+ return level;
169
+ }
170
+
171
+ /** Drop cached min levels (tests). No fileLog here — this is the log filter. */
172
+ export function clearLogLevelCache(): void {
173
+ minLevelCache.clear();
174
+ }
175
+
176
+ /**
177
+ * True when `level` would be written for `name`.
178
+ * Same sources as fileLog (log_<name> setting, then PI_LOG_<NAME>, then info).
179
+ * Cached 30s so debug gates on hot paths do not re-resolve settings.
180
+ */
181
+ export function isLevelEnabled(name: string, level: FileLogLevel): boolean {
182
+ const minLevel = getMinLevel(name);
183
+ if (minLevel < 0) return false;
184
+ return (LEVEL_ORDER[level] ?? 0) >= minLevel;
185
+ }
186
+
71
187
  export function fileLog(
72
188
  name: string,
73
189
  level: FileLogLevel,
@@ -75,14 +191,22 @@ export function fileLog(
75
191
  message: string,
76
192
  err?: unknown,
77
193
  ): boolean {
194
+ const minLevel = getMinLevel(name);
195
+ if (minLevel < 0) return true; // logging disabled (off)
196
+ if ((LEVEL_ORDER[level] ?? 0) < minLevel) return true; // filtered
197
+
78
198
  const detail =
79
199
  err !== undefined ? ` ${formatErr(err)}` : "";
80
200
  const line = `${new Date().toISOString()} [${level}] [${prefix}] ${oneLine(message)}${detail}\n`;
81
201
  const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
82
202
 
203
+ const logPath = getPiLogPath(name);
204
+ if (!logPath) {
205
+ pendingLogLines.push({ name, line });
206
+ return true;
207
+ }
83
208
  try {
84
- // Path resolve stays inside try so homedir/path failures still hit fallback.
85
- appendLine(getPiLogPath(name), line);
209
+ appendLine(logPath, line);
86
210
  return true;
87
211
  } catch (primaryErr) {
88
212
  recordWriteError(primaryErr);
@@ -96,20 +220,3 @@ export function fileLog(
96
220
  }
97
221
  }
98
222
  }
99
-
100
- /** Convenience loggers for known components. */
101
- export function cliProviderLog(
102
- level: FileLogLevel,
103
- message: string,
104
- err?: unknown,
105
- ): boolean {
106
- return fileLog("cli-provider", level, "cli-provider", message, err);
107
- }
108
-
109
- export function dreamingLog(
110
- level: FileLogLevel,
111
- message: string,
112
- err?: unknown,
113
- ): boolean {
114
- return fileLog("dreaming", level, "dreaming", message, err);
115
- }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Convenience logger factory for extensions.
3
+ *
4
+ * Wraps the existing fileLog infrastructure so callers get a clean OO API:
5
+ *
6
+ * import { createLogger } from "../shared/logger.js";
7
+ * const log = createLogger("pitasks");
8
+ * log.debug("hello", { count: 42 });
9
+ *
10
+ * Writes to ~/.pi/logs/<name>/<PI_SESSION_ID>.log. Never console.* (chat UI leak).
11
+ */
12
+
13
+ import { fileLog, isLevelEnabled, type FileLogLevel } from "./file-logger.js";
14
+
15
+ export interface Logger {
16
+ debug(...args: any[]): void;
17
+ info(...args: any[]): void;
18
+ warn(...args: any[]): void;
19
+ error(...args: any[]): void;
20
+ isDebugEnabled(): boolean;
21
+ }
22
+
23
+ function fmt(args: any[]): string {
24
+ return args.map(a => {
25
+ if (typeof a === "string") return a;
26
+ try { return JSON.stringify(a); } catch { return String(a); }
27
+ }).join(" ");
28
+ }
29
+
30
+ export function createLogger(name: string, prefix?: string): Logger {
31
+ const pfx = prefix ?? name;
32
+ const emit = (level: FileLogLevel, args: any[]) => {
33
+ const last = args[args.length - 1];
34
+ if (last instanceof Error && args.length > 1) {
35
+ fileLog(name, level, pfx, fmt(args.slice(0, -1)), last);
36
+ } else {
37
+ fileLog(name, level, pfx, fmt(args));
38
+ }
39
+ };
40
+ return {
41
+ debug(...args: any[]) {
42
+ if (!isLevelEnabled(name, "debug")) return;
43
+ emit("debug", args);
44
+ },
45
+ info(...args: any[]) { emit("info", args); },
46
+ warn(...args: any[]) { emit("warn", args); },
47
+ error(...args: any[]) { emit("error", args); },
48
+ isDebugEnabled() { return isLevelEnabled(name, "debug"); },
49
+ };
50
+ }
@@ -0,0 +1,421 @@
1
+ /**
2
+ * models.dev catalog cache + CLI/ACPX model metadata fill.
3
+ *
4
+ * Native pi providers are untouched. Only cli-* / acpx-* registration
5
+ * looks up missing contextWindow / maxTokens / cost / input.
6
+ * Reasoning comes from the discovered id (`-high`, `[effort=xhigh]`), not
7
+ * from models.dev `reasoning`.
8
+ */
9
+
10
+ import { randomUUID } from "node:crypto";
11
+ import { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { createLogger } from "./logger.js";
15
+ import type { BuildRuntimeModelOptions } from "./create-runtime-provider.js";
16
+
17
+ const log = createLogger("models-dev");
18
+
19
+ export const MODELS_DEV_URL = "https://models.dev/api.json";
20
+ export const MODELS_DEV_TTL_MS = 24 * 60 * 60 * 1000;
21
+ const FETCH_TIMEOUT_MS = 20_000;
22
+
23
+ export interface ModelsDevModel {
24
+ id?: string;
25
+ name?: string;
26
+ reasoning?: boolean;
27
+ limit?: { context?: number; input?: number; output?: number };
28
+ cost?: {
29
+ input?: number;
30
+ output?: number;
31
+ cache_read?: number;
32
+ cache_write?: number;
33
+ };
34
+ modalities?: { input?: string[]; output?: string[] };
35
+ }
36
+
37
+ export interface ModelsDevProvider {
38
+ models?: Record<string, ModelsDevModel>;
39
+ }
40
+
41
+ export type ModelsDevCatalog = Record<string, ModelsDevProvider>;
42
+
43
+ export interface ModelsDevHit {
44
+ provider: string;
45
+ modelId: string;
46
+ entry: ModelsDevModel;
47
+ }
48
+
49
+ export function modelsDevCachePath(home: string = homedir()): string {
50
+ return join(home, ".pi", "pi-config", "models.dev.json");
51
+ }
52
+
53
+ function isCatalog(value: unknown): value is ModelsDevCatalog {
54
+ return !!value && typeof value === "object" && !Array.isArray(value);
55
+ }
56
+
57
+ function readCache(path: string): ModelsDevCatalog | null {
58
+ try {
59
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
60
+ if (!isCatalog(parsed)) {
61
+ log.warn("models.dev cache is not an object", path);
62
+ return null;
63
+ }
64
+ log.debug("models.dev cache read", path);
65
+ return parsed;
66
+ } catch (err) {
67
+ log.warn("models.dev cache read failed", path, err);
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function cacheAgeMs(path: string, nowMs: number): number | null {
73
+ try {
74
+ return nowMs - statSync(path).mtimeMs;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeCache(path: string, catalog: ModelsDevCatalog): void {
81
+ mkdirSync(dirname(path), { recursive: true });
82
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
83
+ log.debug("models.dev cache write tmp", tmp);
84
+ try {
85
+ writeFileSync(tmp, `${JSON.stringify(catalog)}\n`, "utf8");
86
+ renameSync(tmp, path);
87
+ log.info("models.dev cache written", path);
88
+ } finally {
89
+ try {
90
+ unlinkSync(tmp);
91
+ } catch {
92
+ // rename consumed tmp, or write never created it
93
+ }
94
+ }
95
+ }
96
+
97
+ async function fetchCatalog(
98
+ fetchImpl: typeof fetch,
99
+ ): Promise<ModelsDevCatalog | null> {
100
+ log.debug("models.dev fetch", MODELS_DEV_URL);
101
+ const ac = new AbortController();
102
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
103
+ if (timer.unref) timer.unref();
104
+ try {
105
+ const res = await fetchImpl(MODELS_DEV_URL, { signal: ac.signal });
106
+ if (!res.ok) {
107
+ log.warn("models.dev fetch HTTP", res.status);
108
+ return null;
109
+ }
110
+ const parsed: unknown = await res.json();
111
+ if (!isCatalog(parsed)) {
112
+ log.warn("models.dev response is not an object");
113
+ return null;
114
+ }
115
+ log.info("models.dev fetch ok", Object.keys(parsed).length, "providers");
116
+ return parsed;
117
+ } catch (err) {
118
+ log.warn("models.dev fetch error", err);
119
+ return null;
120
+ } finally {
121
+ clearTimeout(timer);
122
+ }
123
+ }
124
+
125
+ export interface LoadModelsDevCatalogOptions {
126
+ cachePath?: string;
127
+ fetchImpl?: typeof fetch;
128
+ nowMs?: number;
129
+ ttlMs?: number;
130
+ }
131
+
132
+ /** Load models.dev api.json from disk cache, refreshing when older than 1 day. */
133
+ export async function loadModelsDevCatalog(
134
+ opts: LoadModelsDevCatalogOptions = {},
135
+ ): Promise<ModelsDevCatalog | null> {
136
+ const cachePath = opts.cachePath ?? modelsDevCachePath();
137
+ const fetchImpl = opts.fetchImpl ?? fetch;
138
+ const nowMs = opts.nowMs ?? Date.now();
139
+ const ttlMs = opts.ttlMs ?? MODELS_DEV_TTL_MS;
140
+
141
+ const age = cacheAgeMs(cachePath, nowMs);
142
+ const cached = age !== null ? readCache(cachePath) : null;
143
+ if (cached && age !== null && age <= ttlMs) {
144
+ log.debug("models.dev cache fresh", Math.round(age / 1000), "s");
145
+ return cached;
146
+ }
147
+
148
+ const fetched = await fetchCatalog(fetchImpl);
149
+ if (fetched) {
150
+ try {
151
+ writeCache(cachePath, fetched);
152
+ } catch (err) {
153
+ log.warn("models.dev cache write failed", cachePath, err);
154
+ }
155
+ return fetched;
156
+ }
157
+
158
+ if (cached) {
159
+ log.warn("models.dev refresh failed; using stale cache", cachePath);
160
+ return cached;
161
+ }
162
+ log.warn("models.dev unavailable and no cache");
163
+ return null;
164
+ }
165
+
166
+ const AGENT_PROVIDER_PREF: Record<string, readonly string[]> = {
167
+ cursor: ["xai", "openai", "anthropic", "google"],
168
+ claude: ["anthropic"],
169
+ gemini: ["google"],
170
+ };
171
+
172
+ function stripBrackets(id: string): string {
173
+ const i = id.indexOf("[");
174
+ return i >= 0 ? id.slice(0, i) : id;
175
+ }
176
+
177
+ const SESSION_THINKING_LEVELS = [
178
+ "xhigh",
179
+ "high",
180
+ "medium",
181
+ "low",
182
+ "minimal",
183
+ "max",
184
+ "off",
185
+ ] as const;
186
+
187
+ export type SessionThinkingLevel = (typeof SESSION_THINKING_LEVELS)[number];
188
+
189
+ function isSessionThinkingLevel(value: string): value is SessionThinkingLevel {
190
+ return (SESSION_THINKING_LEVELS as readonly string[]).includes(value);
191
+ }
192
+
193
+ function stripEffortSuffix(id: string): string {
194
+ return id.replace(/-(?:xhigh|high|medium|low|minimal|max|off)$/i, "");
195
+ }
196
+
197
+ /** Thinking level encoded in a CLI/ACPX discovered id. */
198
+ export function thinkingLevelFromDiscoveredId(
199
+ discoveredId: string,
200
+ ): SessionThinkingLevel | undefined {
201
+ const bracket = /\[([^\]]*)\]/.exec(discoveredId);
202
+ if (bracket) {
203
+ const effort = /(?:^|,)\s*effort=([^,\]]+)/i.exec(bracket[1]);
204
+ const fromEffort = effort?.[1]?.trim().toLowerCase();
205
+ if (fromEffort && isSessionThinkingLevel(fromEffort)) return fromEffort;
206
+ const thinking = /(?:^|,)\s*thinking=([^,\]]+)/i.exec(bracket[1]);
207
+ const fromThinking = thinking?.[1]?.trim().toLowerCase();
208
+ if (fromThinking && isSessionThinkingLevel(fromThinking)) return fromThinking;
209
+ }
210
+ const noBr = stripBrackets(discoveredId);
211
+ const suffix = /-(xhigh|high|medium|low|minimal|max|off)$/i.exec(noBr);
212
+ const fromSuffix = suffix?.[1]?.toLowerCase();
213
+ if (fromSuffix && isSessionThinkingLevel(fromSuffix)) return fromSuffix;
214
+ return undefined;
215
+ }
216
+
217
+ export function discoveredIdFromRuntimeModelId(modelId: string): string {
218
+ const i = modelId.indexOf(":");
219
+ return i >= 0 ? modelId.slice(i + 1) : modelId;
220
+ }
221
+
222
+ export function isCliOrAcpxProvider(provider: string): boolean {
223
+ return provider.startsWith("cli-") || provider.startsWith("acpx-");
224
+ }
225
+
226
+ /**
227
+ * Set pi session thinking from a CLI/ACPX model id (`-high`, `[effort=xhigh]`).
228
+ * Native providers are skipped. Returns the parsed level, or undefined if none.
229
+ */
230
+ export function applyThinkingLevelFromModel(
231
+ model: { id?: string; provider?: string } | undefined,
232
+ setThinkingLevel: (level: SessionThinkingLevel) => void,
233
+ getThinkingLevel?: () => string,
234
+ ): SessionThinkingLevel | undefined {
235
+ if (!model?.id || typeof model.provider !== "string") return undefined;
236
+ if (!isCliOrAcpxProvider(model.provider)) {
237
+ log.debug("thinking skip native provider", model.provider, model.id);
238
+ return undefined;
239
+ }
240
+ const discovered = discoveredIdFromRuntimeModelId(model.id);
241
+ const level = thinkingLevelFromDiscoveredId(discovered);
242
+ if (!level) {
243
+ log.debug("thinking none in id", model.provider, discovered);
244
+ return undefined;
245
+ }
246
+ const current = getThinkingLevel?.();
247
+ if (current === level) {
248
+ log.debug("thinking already", level, model.provider, discovered);
249
+ return level;
250
+ }
251
+ log.info("thinking from id", discovered, "->", level);
252
+ setThinkingLevel(level);
253
+ return level;
254
+ }
255
+
256
+ function claudeReorder(id: string): string | undefined {
257
+ const m = /^(claude)-(\d+(?:\.\d+)*)-(opus|sonnet|haiku)(?:-.*)?$/i.exec(id);
258
+ if (!m) return undefined;
259
+ return `${m[1].toLowerCase()}-${m[3].toLowerCase()}-${m[2]}`;
260
+ }
261
+
262
+ /** Candidate catalog keys for a discovered CLI/ACPX model id. */
263
+ export function modelsDevLookupKeys(agent: string, discoveredId: string): string[] {
264
+ const out: string[] = [];
265
+ const add = (raw: string) => {
266
+ const t = raw.trim().toLowerCase();
267
+ if (t && !out.includes(t)) out.push(t);
268
+ };
269
+ add(discoveredId);
270
+ const noBr = stripBrackets(discoveredId);
271
+ add(noBr);
272
+ add(stripEffortSuffix(noBr));
273
+ if (agent === "cursor" && noBr.toLowerCase().startsWith("cursor-")) {
274
+ const rest = noBr.slice("cursor-".length);
275
+ add(rest);
276
+ add(stripEffortSuffix(rest));
277
+ }
278
+ const reordered = claudeReorder(stripEffortSuffix(noBr));
279
+ if (reordered) add(reordered);
280
+ const reorderedRaw = claudeReorder(noBr);
281
+ if (reorderedRaw) add(reorderedRaw);
282
+ for (const v of [...out]) {
283
+ if (v.includes(".")) add(v.replace(/\./g, "-"));
284
+ }
285
+ return out;
286
+ }
287
+
288
+ function providerOrder(agent: string, catalog: ModelsDevCatalog): string[] {
289
+ const preferred = AGENT_PROVIDER_PREF[agent] ?? [];
290
+ const first = preferred.filter((p) => Object.hasOwn(catalog, p));
291
+ const rest = Object.keys(catalog).filter((p) => !first.includes(p));
292
+ return [...first, ...rest];
293
+ }
294
+
295
+ type ProviderModelIndex = Map<string, { modelId: string; entry: ModelsDevModel }>;
296
+ type CatalogLookupIndex = Map<string, ProviderModelIndex>;
297
+
298
+ const catalogIndexCache = new WeakMap<ModelsDevCatalog, CatalogLookupIndex>();
299
+
300
+ function catalogLookupIndex(catalog: ModelsDevCatalog): CatalogLookupIndex {
301
+ const cached = catalogIndexCache.get(catalog);
302
+ if (cached) {
303
+ log.debug("models.dev catalog index reuse", cached.size, "providers");
304
+ return cached;
305
+ }
306
+ const index: CatalogLookupIndex = new Map();
307
+ for (const [provider, block] of Object.entries(catalog)) {
308
+ const models = block?.models;
309
+ if (!models) continue;
310
+ const byKey: ProviderModelIndex = new Map();
311
+ for (const [modelId, entry] of Object.entries(models)) {
312
+ const hit = { modelId, entry };
313
+ const idKey = modelId.toLowerCase();
314
+ if (!byKey.has(idKey)) byKey.set(idKey, hit);
315
+ const alt = entry.id?.toLowerCase();
316
+ if (alt && !byKey.has(alt)) byKey.set(alt, hit);
317
+ }
318
+ index.set(provider, byKey);
319
+ }
320
+ catalogIndexCache.set(catalog, index);
321
+ log.debug("models.dev catalog index built", index.size, "providers");
322
+ return index;
323
+ }
324
+
325
+ export function lookupModelsDevModel(
326
+ catalog: ModelsDevCatalog | null | undefined,
327
+ agent: string,
328
+ discoveredId: string,
329
+ ): ModelsDevHit | undefined {
330
+ if (!catalog) return undefined;
331
+ const keys = modelsDevLookupKeys(agent, discoveredId);
332
+ const index = catalogLookupIndex(catalog);
333
+ for (const provider of providerOrder(agent, catalog)) {
334
+ const byKey = index.get(provider);
335
+ if (!byKey) continue;
336
+ for (const key of keys) {
337
+ const hit = byKey.get(key);
338
+ if (hit) {
339
+ log.debug("models.dev hit", agent, discoveredId, "->", provider, hit.modelId);
340
+ return { provider, modelId: hit.modelId, entry: hit.entry };
341
+ }
342
+ }
343
+ }
344
+ log.debug("models.dev miss", agent, discoveredId);
345
+ return undefined;
346
+ }
347
+
348
+ export function parseBracketContextTokens(discoveredId: string): number | undefined {
349
+ const bracket = /\[([^\]]*)\]/.exec(discoveredId);
350
+ if (!bracket) return undefined;
351
+ const ctx = /(?:^|,)\s*context=([^,\]]+)/i.exec(bracket[1]);
352
+ if (!ctx) return undefined;
353
+ const m = /^(\d+(?:\.\d+)?)(k|m)?$/i.exec(ctx[1].trim());
354
+ if (!m) return undefined;
355
+ const n = Number(m[1]);
356
+ if (!Number.isFinite(n)) return undefined;
357
+ const unit = (m[2] ?? "").toLowerCase();
358
+ if (unit === "k") return Math.round(n * 1000);
359
+ if (unit === "m") return Math.round(n * 1_000_000);
360
+ return Math.round(n);
361
+ }
362
+
363
+ function catalogInputModalities(
364
+ entry: ModelsDevModel,
365
+ ): ("text" | "image")[] | undefined {
366
+ const raw = entry.modalities?.input;
367
+ if (!Array.isArray(raw) || raw.length === 0) return undefined;
368
+ const allowed = raw.filter((x): x is "text" | "image" => x === "text" || x === "image");
369
+ return allowed.length > 0 ? allowed : undefined;
370
+ }
371
+
372
+ /** Fill only unset CLI/ACPX model fields from models.dev (and ACPX context=). */
373
+ export function fillRuntimeModelFromCatalog(
374
+ opts: BuildRuntimeModelOptions,
375
+ catalog: ModelsDevCatalog | null | undefined,
376
+ agent: string,
377
+ discoveredId: string,
378
+ ): BuildRuntimeModelOptions {
379
+ const hit = lookupModelsDevModel(catalog, agent, discoveredId);
380
+ const entry = hit?.entry;
381
+ const next: BuildRuntimeModelOptions = { ...opts };
382
+
383
+ if (next.contextWindow == null) {
384
+ const fromBracket = parseBracketContextTokens(discoveredId);
385
+ if (fromBracket !== undefined) {
386
+ next.contextWindow = fromBracket;
387
+ log.debug("context from id bracket", discoveredId, fromBracket);
388
+ } else if (typeof entry?.limit?.context === "number") {
389
+ next.contextWindow = entry.limit.context;
390
+ }
391
+ }
392
+
393
+ if (next.maxTokens == null && typeof entry?.limit?.output === "number") {
394
+ next.maxTokens = entry.limit.output;
395
+ }
396
+
397
+ const thinking = thinkingLevelFromDiscoveredId(discoveredId);
398
+ if (next.reasoning == null && thinking !== undefined) {
399
+ next.reasoning = thinking !== "off";
400
+ log.debug("reasoning from id", discoveredId, thinking, next.reasoning);
401
+ }
402
+ if (next.thinkingLevelMap == null && (thinking === "xhigh" || thinking === "max")) {
403
+ next.thinkingLevelMap = { [thinking]: thinking };
404
+ }
405
+
406
+ if (next.cost == null && entry?.cost) {
407
+ next.cost = {
408
+ input: entry.cost.input ?? 0,
409
+ output: entry.cost.output ?? 0,
410
+ cacheRead: entry.cost.cache_read ?? 0,
411
+ cacheWrite: entry.cost.cache_write ?? 0,
412
+ };
413
+ }
414
+
415
+ if (next.input == null) {
416
+ const modalities = entry ? catalogInputModalities(entry) : undefined;
417
+ if (modalities) next.input = modalities;
418
+ }
419
+
420
+ return next;
421
+ }