@cortexkit/opencode-magic-context 0.22.1 → 0.22.3

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 (70) hide show
  1. package/dist/config/agent-disable.d.ts +0 -9
  2. package/dist/config/agent-disable.d.ts.map +1 -1
  3. package/dist/config/schema/agent-overrides.d.ts +0 -3
  4. package/dist/config/schema/agent-overrides.d.ts.map +1 -1
  5. package/dist/features/builtin-commands/types.d.ts +0 -2
  6. package/dist/features/builtin-commands/types.d.ts.map +1 -1
  7. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  8. package/dist/features/magic-context/dreamer/scheduler.d.ts +0 -4
  9. package/dist/features/magic-context/dreamer/scheduler.d.ts.map +1 -1
  10. package/dist/features/magic-context/git-commits/git-log-reader.d.ts +8 -0
  11. package/dist/features/magic-context/git-commits/git-log-reader.d.ts.map +1 -1
  12. package/dist/features/magic-context/git-commits/index.d.ts +1 -0
  13. package/dist/features/magic-context/git-commits/index.d.ts.map +1 -1
  14. package/dist/features/magic-context/git-commits/indexer.d.ts.map +1 -1
  15. package/dist/features/magic-context/git-commits/storage-git-commits.d.ts.map +1 -1
  16. package/dist/features/magic-context/git-commits/sweep-coordinator.d.ts +48 -0
  17. package/dist/features/magic-context/git-commits/sweep-coordinator.d.ts.map +1 -0
  18. package/dist/features/magic-context/key-files/storage-key-files.d.ts +0 -5
  19. package/dist/features/magic-context/key-files/storage-key-files.d.ts.map +1 -1
  20. package/dist/features/magic-context/literal-probes.d.ts +24 -0
  21. package/dist/features/magic-context/literal-probes.d.ts.map +1 -0
  22. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  23. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  24. package/dist/features/magic-context/search.d.ts +7 -0
  25. package/dist/features/magic-context/search.d.ts.map +1 -1
  26. package/dist/features/magic-context/storage-db.d.ts +1 -1
  27. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  28. package/dist/features/magic-context/storage-notes.d.ts +8 -0
  29. package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
  30. package/dist/hooks/magic-context/compartment-runner-types.d.ts +14 -1
  31. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  32. package/dist/hooks/magic-context/derive-budgets.d.ts +3 -3
  33. package/dist/hooks/magic-context/event-handler.d.ts +7 -0
  34. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  35. package/dist/hooks/magic-context/event-payloads.d.ts +7 -0
  36. package/dist/hooks/magic-context/event-payloads.d.ts.map +1 -1
  37. package/dist/hooks/magic-context/event-resolvers.d.ts +1 -0
  38. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  40. package/dist/hooks/magic-context/live-session-state.d.ts +12 -0
  41. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  42. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +7 -2
  43. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  44. package/dist/hooks/magic-context/system-prompt-hash.d.ts +9 -0
  45. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/tag-content-primitives.d.ts +23 -0
  47. package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
  48. package/dist/hooks/magic-context/temporal-awareness.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/text-complete.d.ts +23 -0
  50. package/dist/hooks/magic-context/text-complete.d.ts.map +1 -1
  51. package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/transform.d.ts +9 -0
  53. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +561 -190
  56. package/dist/plugin/dream-timer.d.ts.map +1 -1
  57. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  58. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  59. package/dist/shared/models-dev-cache.d.ts +54 -27
  60. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  61. package/dist/shared/rpc-types.d.ts +3 -1
  62. package/dist/shared/rpc-types.d.ts.map +1 -1
  63. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  64. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  65. package/package.json +1 -1
  66. package/src/shared/models-dev-cache.test.ts +192 -360
  67. package/src/shared/models-dev-cache.ts +162 -193
  68. package/src/shared/rpc-types.ts +3 -1
  69. package/src/tui/index.tsx +17 -8
  70. package/src/tui/slots/sidebar-content.tsx +20 -10
@@ -1,31 +1,34 @@
1
1
  /**
2
- * Resolve per-model context limits to match whatever OpenCode itself sees.
2
+ * Resolve per-model context limits from OpenCode's SDK the single source of
3
+ * truth — for OpenCode sessions.
3
4
  *
4
- * Two layers:
5
+ * `client.config.providers()` returns OpenCode's fully-resolved config: the
6
+ * live models.dev cache + compiled-in snapshot + opencode.json custom-provider
7
+ * overrides + auth-plugin caps (e.g. the Codex-OAuth gpt-5.5 400k cap). We
8
+ * consume ONLY that. We no longer read OpenCode's `models.json` file ourselves:
9
+ * a torn read mid-write produced impossible limits (a 6748 "limit" for a session
10
+ * that had run for hours), and a stale on-disk copy out-voted the live
11
+ * auth-resolved cap (922k vs the real 400k). OpenCode reads that file safely in
12
+ * its own process and hands us the merged answer.
5
13
  *
6
- * 1. API cache (primary): populated asynchronously via
7
- * `client.config.providers()`. OpenCode's own provider service merges
8
- * the live models.dev cache file, its compiled-in snapshot fallback,
9
- * opencode.json custom provider overrides, and derived experimental
10
- * modes. Whatever OpenCode reports is the source of truth.
14
+ * Layers:
15
+ * 1. `apiCache` (authoritative): warmed once at startup from the SDK; seeded
16
+ * from a persisted last-known-good file on cold start so a restart uses the
17
+ * real limit immediately (no 128k-default budget-collapse window).
11
18
  *
12
- * 2. File cache (fallback): read-from-disk parse of OpenCode's
13
- * `models.json` plus `opencode.json(c)` custom provider entries.
14
- * Used during cold starts before the API cache warms up and in any
15
- * code path that cannot reach the SDK client.
19
+ * All cached values are bounded to a sane [20k, 3M] range on insert, so torn /
20
+ * unconfigured-default garbage can never be returned or persisted. The startup
21
+ * warm retries a couple times when OpenCode's provider service isn't ready yet.
16
22
  *
17
- * The public getter (`getModelsDevContextLimit()`) is synchronous: it checks
18
- * the API cache first, then the file cache. The plugin warms the API cache
19
- * once from `src/index.ts` at startup. Runtime retries are reserved for the
20
- * issue #77 cache-regression recovery path.
23
+ * Pi does NOT use this it resolves from its own `ctx.getModel().contextWindow`
24
+ * (instant at extension load), so `getSdkContextLimit()` returns `undefined`
25
+ * for Pi and Pi's own path is used.
21
26
  */
22
27
 
23
- import { createHash } from "node:crypto";
24
- import { existsSync, readFileSync } from "node:fs";
25
- import { homedir, platform } from "node:os";
28
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
26
29
  import { join } from "node:path";
27
- import { getCacheDir } from "./data-path";
28
- import { parseJsonc } from "./jsonc-parser";
30
+ import { getMagicContextStorageDir } from "./data-path";
31
+ import { getHarness } from "./harness";
29
32
  import { sessionLog } from "./logger";
30
33
 
31
34
  interface OpencodeClientLike {
@@ -34,63 +37,95 @@ interface OpencodeClientLike {
34
37
  };
35
38
  }
36
39
 
37
- // File-cache fallback only. The primary API refresh is one-shot at startup;
38
- // this 5-minute interval governs the on-disk-cache fallback path when the API
39
- // loader hasn't run yet (e.g. during plugin warmup).
40
- const RELOAD_INTERVAL_MS = 5 * 60 * 1000;
40
+ // Plausible bounds for a real model's prompt limit. A value outside this range
41
+ // is physically impossible for an agentic session and signals a transient/garbage
42
+ // read e.g. a torn read of OpenCode's `models.json` mid-write once produced
43
+ // `contextLimit=6748` (smaller than a single system prompt) for a session that
44
+ // had been running for hours past 200k+ (issue #117). Such values must be
45
+ // REJECTED, not trusted as a "smaller real cap". A genuinely smaller real limit
46
+ // still comes through the overflow-detection path (detectedContextLimit).
47
+ export const MIN_SANE_LIMIT = 20_000;
48
+ export const MAX_SANE_LIMIT = 3_000_000;
49
+
50
+ /** True when `limit` is a plausible real prompt window — used to reject torn /
51
+ * unconfigured-default garbage in BOTH harnesses (OpenCode's SDK values and
52
+ * Pi's reported `contextWindow`). Exported so Pi applies the identical bound. */
53
+ export function isSaneLimit(limit: number | undefined): limit is number {
54
+ return typeof limit === "number" && limit >= MIN_SANE_LIMIT && limit <= MAX_SANE_LIMIT;
55
+ }
41
56
 
42
57
  interface CachedModelMetadata {
43
58
  limit?: number;
44
59
  }
45
60
 
46
- /** Populated async from OpenCode SDK. Primary source of truth when available. */
61
+ /**
62
+ * Authoritative source (OpenCode only): populated async from the SDK
63
+ * `config.providers()`, which is OpenCode's fully-resolved config — models.dev +
64
+ * compiled-in snapshot + opencode.json overrides + auth-plugin caps (e.g. the
65
+ * Codex-OAuth gpt-5.5 400k cap). When present, this WINS unconditionally; the
66
+ * disk file is never consulted (no torn-read exposure, no stale value
67
+ * out-voting the live limit). Pi never warms this — it has its own
68
+ * `contextWindow` source — so for Pi this stays null and resolution falls
69
+ * through to the file fallback exactly as before.
70
+ */
47
71
  let apiCache: Map<string, CachedModelMetadata> | null = null;
48
72
  let apiLoadedAt = 0;
49
73
 
50
- /** Populated sync from disk as fallback. */
51
- let fileCache: Map<string, CachedModelMetadata> | null = null;
52
- let fileLastAttempt = 0;
74
+ // Persisted last-known-good apiCache (OpenCode). Survives restart so a cold
75
+ // start uses the real limit instantly instead of falling to the disk file or the
76
+ // 128k default for the warm-up window (which over-shrinks the history budget).
77
+ // Harness-scoped: only OpenCode warms/persists apiCache, so Pi's file (which is
78
+ // never written) stays absent and Pi seeds nothing — keeping Pi byte-identical.
79
+ let persistSeedLoaded = false;
53
80
 
54
- function hashFast(input: string): string {
55
- // Matches OpenCode's Hash.fast() (packages/shared/src/util/hash.ts).
56
- return createHash("sha1").update(input).digest("hex");
81
+ function persistFilePath(): string {
82
+ return join(getMagicContextStorageDir(), `model-context-limits-${getHarness()}.json`);
57
83
  }
58
84
 
59
- function getModelsJsonPath(): string {
60
- // 1. Explicit path override (OpenCode's OPENCODE_MODELS_PATH takes highest priority).
61
- const explicit = process.env.OPENCODE_MODELS_PATH?.trim();
62
- if (explicit) return explicit;
63
-
64
- // OpenCode uses `xdg-basedir`, which falls back to `<homedir>/.cache` on
65
- // every platform (including Windows) when XDG_CACHE_HOME is unset. See
66
- // shared/data-path.ts#getCacheDir for the shared helper.
67
- const cacheBase = getCacheDir();
68
-
69
- // 2. Custom models source → hashed filename (matches OpenCode).
70
- // source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`
71
- const source = process.env.OPENCODE_MODELS_URL?.trim();
72
- const filename =
73
- source && source !== "https://models.dev"
74
- ? `models-${hashFast(source)}.json`
75
- : "models.json";
76
-
77
- return join(cacheBase, "opencode", filename);
85
+ /** Seed apiCache from the persisted last-known-good file once per process, only
86
+ * when apiCache hasn't been warmed yet. Values are sane-filtered on load so a
87
+ * stale garbage entry can never resurrect. */
88
+ function loadPersistedApiCacheOnce(): void {
89
+ if (persistSeedLoaded || apiCache !== null) return;
90
+ persistSeedLoaded = true;
91
+ try {
92
+ const raw = readFileSync(persistFilePath(), "utf-8");
93
+ const obj = JSON.parse(raw) as Record<string, number>;
94
+ const map = new Map<string, CachedModelMetadata>();
95
+ for (const [key, limit] of Object.entries(obj)) {
96
+ if (isSaneLimit(limit)) map.set(key, { limit });
97
+ }
98
+ if (map.size > 0) {
99
+ apiCache = map;
100
+ sessionLog(
101
+ "global",
102
+ `models-dev-cache: seeded ${map.size} entries from persisted cache (cold start)`,
103
+ );
104
+ }
105
+ } catch {
106
+ // No persisted cache yet, or unreadable — fall through to file/SDK.
107
+ }
78
108
  }
79
109
 
80
- function getOpencodeConfigPath(): string | null {
81
- const envDir = process.env.OPENCODE_CONFIG_DIR?.trim();
82
- const configDir = envDir
83
- ? envDir
84
- : platform() === "win32"
85
- ? join(homedir(), ".config", "opencode")
86
- : join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
87
-
88
- // Check jsonc first, then json (matches OpenCode's own lookup order).
89
- const jsonc = join(configDir, "opencode.jsonc");
90
- if (existsSync(jsonc)) return jsonc;
91
- const json = join(configDir, "opencode.json");
92
- if (existsSync(json)) return json;
93
- return null;
110
+ /** Atomically persist the current (sane-filtered) apiCache so the next process
111
+ * cold-starts with the real limits. Temp-write + rename so a concurrent reader
112
+ * never sees a torn file (the exact failure mode we're eliminating). */
113
+ function persistApiCache(): void {
114
+ if (!apiCache) return;
115
+ const obj: Record<string, number> = {};
116
+ for (const [key, value] of apiCache) {
117
+ if (isSaneLimit(value.limit)) obj[key] = value.limit;
118
+ }
119
+ try {
120
+ const dir = getMagicContextStorageDir();
121
+ mkdirSync(dir, { recursive: true });
122
+ const target = persistFilePath();
123
+ const tmp = `${target}.${process.pid}.tmp`;
124
+ writeFileSync(tmp, JSON.stringify(obj), { encoding: "utf-8", mode: 0o600 });
125
+ renameSync(tmp, target);
126
+ } catch {
127
+ // best-effort — a failed persist only loses cold-start warmth, not correctness
128
+ }
94
129
  }
95
130
 
96
131
  /**
@@ -123,7 +158,10 @@ function setCachedModelMetadata(
123
158
  ): void {
124
159
  const limit = resolveLimit(model?.limit);
125
160
 
126
- if (limit === undefined) {
161
+ // Only cache plausible limits. A value outside [20k, 3M] is garbage (torn
162
+ // read / unconfigured default) and must never enter the cache or get
163
+ // persisted — see isSaneLimit.
164
+ if (!isSaneLimit(limit)) {
127
165
  return;
128
166
  }
129
167
 
@@ -141,88 +179,6 @@ function setCachedModelMetadata(
141
179
  }
142
180
  }
143
181
 
144
- function loadModelsDevMetadataFromFile(): Map<string, CachedModelMetadata> {
145
- const metadata = new Map<string, CachedModelMetadata>();
146
-
147
- // 1. Read OpenCode's models.dev cache file (base layer).
148
- const modelsJsonPath = getModelsJsonPath();
149
- let fileFound = false;
150
- try {
151
- if (existsSync(modelsJsonPath)) {
152
- fileFound = true;
153
- const raw = readFileSync(modelsJsonPath, "utf-8");
154
- const data = JSON.parse(raw) as Record<
155
- string,
156
- {
157
- models?: Record<
158
- string,
159
- {
160
- limit?: { context?: number; input?: number };
161
- experimental?: { modes?: Record<string, unknown> };
162
- }
163
- >;
164
- }
165
- >;
166
-
167
- for (const [providerId, provider] of Object.entries(data)) {
168
- if (!provider?.models || typeof provider.models !== "object") continue;
169
- for (const [modelId, model] of Object.entries(provider.models)) {
170
- setCachedModelMetadata(metadata, `${providerId}/${modelId}`, model);
171
- }
172
- }
173
- }
174
- } catch (error) {
175
- sessionLog(
176
- "global",
177
- `models-dev-cache: failed to read models.json at ${modelsJsonPath}:`,
178
- error instanceof Error ? error.message : String(error),
179
- );
180
- }
181
-
182
- // 2. Overlay custom provider models from OpenCode config (higher priority).
183
- // Users define custom/proxy models via provider.<id>.models.<name>.limit.{input,context}
184
- // in opencode.json(c). These override models.dev entries for the same key.
185
- try {
186
- const configPath = getOpencodeConfigPath();
187
- if (configPath && existsSync(configPath)) {
188
- // Use the shared JSONC parser — handles `//` comments AND trailing commas.
189
- // The previous custom regex stripped comments only; OpenCode's `opencode.jsonc`
190
- // frequently contains trailing commas (valid JSONC, invalid JSON), which broke
191
- // custom provider model-limit resolution silently. See issue #14 follow-up.
192
- const config = parseJsonc<{
193
- provider?: Record<
194
- string,
195
- {
196
- models?: Record<string, { limit?: { context?: number; input?: number } }>;
197
- }
198
- >;
199
- }>(readFileSync(configPath, "utf-8"));
200
-
201
- if (config.provider && typeof config.provider === "object") {
202
- for (const [providerId, provider] of Object.entries(config.provider)) {
203
- if (!provider?.models || typeof provider.models !== "object") continue;
204
- for (const [modelId, model] of Object.entries(provider.models)) {
205
- setCachedModelMetadata(metadata, `${providerId}/${modelId}`, model);
206
- }
207
- }
208
- }
209
- }
210
- } catch (error) {
211
- sessionLog(
212
- "global",
213
- "models-dev-cache: failed to read opencode config for custom models:",
214
- error instanceof Error ? error.message : String(error),
215
- );
216
- }
217
-
218
- sessionLog(
219
- "global",
220
- `models-dev-cache: file-layer loaded ${metadata.size} model metadata entries (modelsJsonPath=${modelsJsonPath}, found=${fileFound})`,
221
- );
222
-
223
- return metadata;
224
- }
225
-
226
182
  /**
227
183
  * Asynchronously refresh the API-layer cache from OpenCode's SDK.
228
184
  *
@@ -230,18 +186,42 @@ function loadModelsDevMetadataFromFile(): Map<string, CachedModelMetadata> {
230
186
  * OpenCode's `/config/providers` endpoint returns every provider with full
231
187
  * model metadata — including `limit.context` — resolved through the same path
232
188
  * OpenCode itself uses (live cache + compiled-in snapshot + opencode.json
233
- * overrides + derived experimental modes).
189
+ * overrides + derived experimental modes + auth-plugin caps).
190
+ *
191
+ * `retries`/`retryDelayMs`: when OpenCode's provider service isn't ready at our
192
+ * startup, `config.providers()` can return an empty/no-providers payload. Retry
193
+ * a few times so the cache warms instead of leaving the session on the 128k
194
+ * default until the next restart. A successful load (any providers) stops early.
234
195
  *
235
196
  * Safe to call concurrently; only overwrites the cache on success.
236
197
  */
237
- export async function refreshModelLimitsFromApi(client: OpencodeClientLike): Promise<void> {
198
+ export async function refreshModelLimitsFromApi(
199
+ client: OpencodeClientLike,
200
+ options?: { retries?: number; retryDelayMs?: number },
201
+ ): Promise<void> {
202
+ const attempts = Math.max(1, (options?.retries ?? 0) + 1);
203
+ const delayMs = options?.retryDelayMs ?? 1000;
204
+ for (let attempt = 1; attempt <= attempts; attempt++) {
205
+ const ok = await refreshModelLimitsOnce(client);
206
+ if (ok) return;
207
+ if (attempt < attempts) {
208
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
209
+ }
210
+ }
211
+ }
212
+
213
+ /** Single SDK fetch + cache rebuild. Returns true when providers were loaded. */
214
+ async function refreshModelLimitsOnce(client: OpencodeClientLike): Promise<boolean> {
238
215
  try {
239
216
  const result = await client.config.providers();
240
217
  const data = (result as { data?: { providers?: Array<unknown> } }).data;
241
218
  const providers = data?.providers;
242
- if (!Array.isArray(providers)) {
243
- sessionLog("global", "models-dev-cache: API refresh returned no providers payload");
244
- return;
219
+ if (!Array.isArray(providers) || providers.length === 0) {
220
+ sessionLog(
221
+ "global",
222
+ "models-dev-cache: API refresh returned no providers payload (will retry if attempts remain)",
223
+ );
224
+ return false;
245
225
  }
246
226
 
247
227
  const map = new Map<string, CachedModelMetadata>();
@@ -265,6 +245,9 @@ export async function refreshModelLimitsFromApi(client: OpencodeClientLike): Pro
265
245
  const previousSize = apiCache?.size ?? null;
266
246
  apiCache = map;
267
247
  apiLoadedAt = Date.now();
248
+ // Persist the freshly-resolved (sane-filtered) limits so the next process
249
+ // cold-starts with the real values instead of the 128k default.
250
+ persistApiCache();
268
251
 
269
252
  if (previousSize === null) {
270
253
  sessionLog(
@@ -277,59 +260,48 @@ export async function refreshModelLimitsFromApi(client: OpencodeClientLike): Pro
277
260
  `models-dev-cache: API layer loaded ${map.size} model metadata entries (was ${previousSize})`,
278
261
  );
279
262
  }
263
+ return true;
280
264
  } catch (error) {
281
265
  sessionLog(
282
266
  "global",
283
267
  "models-dev-cache: API refresh failed:",
284
268
  error instanceof Error ? error.message : String(error),
285
269
  );
270
+ return false;
286
271
  }
287
272
  }
288
273
 
289
274
  /**
290
- * Returns the context limit for a provider/model.
275
+ * Resolve a model's prompt limit from OpenCode's SDK (`config.providers()`),
276
+ * the single source of truth: it already merges models.dev + compiled-in
277
+ * snapshot + opencode.json overrides + auth-plugin caps (e.g. the Codex-OAuth
278
+ * gpt-5.5 400k cap). We deliberately do NOT read OpenCode's `models.json` file
279
+ * ourselves — a torn read of that file mid-write produced garbage limits, and a
280
+ * stale on-disk copy out-voted the live auth-resolved cap (922k vs the real
281
+ * 400k). OpenCode reads that file safely within its own process and exposes the
282
+ * merged result here.
291
283
  *
292
- * Lookup order:
293
- * 1. API cache (populated by {@link refreshModelLimitsFromApi}). Matches
294
- * what OpenCode sees exactly, including snapshot-only models.
295
- * 2. File cache (parsed from models.json + opencode.json overrides).
296
- * Used before the API cache warms and as a last resort.
284
+ * Resolution:
285
+ * 1. Seed `apiCache` from the persisted last-known-good file once (cold start).
286
+ * 2. Return the SDK value (sane by construction — only [20k,3M] is cached).
287
+ * 3. `undefined` when the SDK hasn't reported this model yet → the caller
288
+ * defaults / retries (the startup warm retries when OpenCode isn't ready).
297
289
  *
298
- * Returns `undefined` if neither layer knows the model.
290
+ * OpenCode-only: Pi never warms `apiCache` (it resolves from its own
291
+ * `ctx.getModel().contextWindow`), so for Pi this returns `undefined` and Pi's
292
+ * own resolution path is used.
299
293
  */
300
- export function getModelsDevContextLimit(providerID: string, modelID: string): number | undefined {
301
- const now = Date.now();
302
- if (!fileCache || now - fileLastAttempt > RELOAD_INTERVAL_MS) {
303
- fileLastAttempt = now;
304
- fileCache = loadModelsDevMetadataFromFile();
305
- }
306
-
294
+ export function getSdkContextLimit(providerID: string, modelID: string): number | undefined {
295
+ loadPersistedApiCacheOnce();
307
296
  const fromApi = lookupLimitWithTagFallback(apiCache, providerID, modelID);
308
- const fromFile = lookupLimitWithTagFallback(fileCache, providerID, modelID);
309
-
310
- // When BOTH layers know the model, take the LARGER limit. Providers never
311
- // under-report their real window, so a suspiciously small value — e.g.
312
- // ollama reporting its default `num_ctx` (4k/8k) for a cloud model via the
313
- // live `/config/providers` API — must not override the correct, larger
314
- // models.dev value. A genuinely smaller real limit (provider actually
315
- // rejects at N) is captured separately via the overflow-detection path
316
- // (detectedContextLimit), not here. (issue #117)
317
- if (typeof fromApi === "number" && typeof fromFile === "number") {
318
- return Math.max(fromApi, fromFile);
319
- }
320
- return fromApi ?? fromFile;
297
+ return isSaneLimit(fromApi) ? fromApi : undefined;
321
298
  }
322
299
 
323
300
  /**
324
- * Look up a model's limit in one cache layer, with an ollama-style tag-suffix
325
- * fallback.
326
- *
327
- * models.dev stores some models WITH a colon tag (e.g. `gemma3:27b`,
328
- * `deepseek-v3.1:671b`) and ollama-cloud base models WITHOUT one
329
- * (`deepseek-v4-pro`). But ollama invokes cloud models with a tag at runtime
330
- * (`deepseek-v4-pro:cloud`), so OpenCode reports the tagged id. An exact-only
331
- * match therefore misses → falls back to the 128k default → wrong pressure
332
- * denominator (issue #117).
301
+ * Look up a model's limit in the cache, with an ollama-style tag-suffix
302
+ * fallback. ollama invokes cloud models with a tag at runtime
303
+ * (`deepseek-v4-pro:cloud`) while the underlying metadata key is tag-less
304
+ * (`deepseek-v4-pro`), so an exact-only match misses.
333
305
  *
334
306
  * Strategy: exact match first (never collapses a legitimately-tagged model),
335
307
  * then retry once with the last `:tag` segment stripped.
@@ -352,12 +324,11 @@ function lookupLimitWithTagFallback(
352
324
  return undefined;
353
325
  }
354
326
 
355
- /** Clear in-memory caches (for testing). */
327
+ /** Clear in-memory caches (for testing and the regression-recovery refetch). */
356
328
  export function clearModelsDevCache(): void {
357
329
  apiCache = null;
358
330
  apiLoadedAt = 0;
359
- fileCache = null;
360
- fileLastAttempt = 0;
331
+ persistSeedLoaded = false;
361
332
  }
362
333
 
363
334
  /** Inspection helpers (for logging / debugging). */
@@ -365,12 +336,10 @@ export function getModelsDevCacheState(): {
365
336
  apiLoaded: boolean;
366
337
  apiCount: number;
367
338
  apiAgeMs: number;
368
- fileCount: number;
369
339
  } {
370
340
  return {
371
341
  apiLoaded: apiCache !== null,
372
342
  apiCount: apiCache?.size ?? 0,
373
343
  apiAgeMs: apiLoadedAt > 0 ? Date.now() - apiLoadedAt : -1,
374
- fileCount: fileCache?.size ?? 0,
375
344
  };
376
345
  }
@@ -77,7 +77,9 @@ export interface SidebarSnapshot {
77
77
  * the runtime `RecompProgress` shape from compartment-runner-types.ts.
78
78
  */
79
79
  recompProgress?: {
80
- phase: "recomp" | "migration" | "done" | "failed";
80
+ /** "recomp" "Recomp" labels; "upgrade" "Upgrade" labels. */
81
+ kind?: "recomp" | "upgrade";
82
+ phase: "recomp" | "migration" | "done" | "failed" | "skipped";
81
83
  processedMessages: number;
82
84
  totalMessages: number;
83
85
  passCount: number;
package/src/tui/index.tsx CHANGED
@@ -324,11 +324,15 @@ const StatusDialog = (props: { api: TuiPluginApi; s: StatusDetail }) => {
324
324
 
325
325
  {/* Recomp / session-upgrade live progress (full width, only while
326
326
  running or just finished — dogfood 2026-05-30). */}
327
- {s().recompProgress && (
327
+ {s().recompProgress && (() => {
328
+ const p = s().recompProgress!
329
+ // Label follows the flow that started the run, so a plain
330
+ // /ctx-recomp never reads as an "Upgrade" (dogfood 2026-06-04).
331
+ const verb = p.kind === "upgrade" ? "Upgrade" : "Recomp"
332
+ return (
328
333
  <box marginTop={1} width="100%" flexDirection="column">
329
- <text fg={t().text}><b>Recomp / Upgrade</b></text>
334
+ <text fg={t().text}><b>{verb}</b></text>
330
335
  {(() => {
331
- const p = s().recompProgress!
332
336
  if (p.phase === "recomp") {
333
337
  const frac = p.totalMessages > 0 ? p.processedMessages / p.totalMessages : 0
334
338
  const width = 24
@@ -336,20 +340,23 @@ const StatusDialog = (props: { api: TuiPluginApi; s: StatusDetail }) => {
336
340
  const bar = p.totalMessages > 0
337
341
  ? `[${"█".repeat(filled)}${"░".repeat(width - filled)}]`
338
342
  : "(starting…)"
343
+ const activeLabel = p.kind === "upgrade" ? "upgrading" : "comparting"
339
344
  return (
340
345
  <>
341
- <R t={t()} l="upgrading" v={p.totalMessages > 0 ? `${bar} ${Math.round(frac * 100)}%` : bar} fg={t().warning} />
346
+ <R t={t()} l={activeLabel} v={p.totalMessages > 0 ? `${bar} ${Math.round(frac * 100)}%` : bar} fg={t().warning} />
342
347
  {p.note ? <R t={t()} l="Status" v={p.note} fg={t().textMuted} /> : null}
343
348
  <R t={t()} l="Compartments" v={`${p.compartmentsCreated} (${p.passCount} pass${p.passCount === 1 ? "" : "es"})`} fg={t().textMuted} />
344
349
  </>
345
350
  )
346
351
  }
347
352
  if (p.phase === "migration") return <R t={t()} l="Status" v={p.note ?? "Migrating memories ⟳"} fg={t().warning} />
348
- if (p.phase === "done") return <R t={t()} l="Status" v="✓ Upgrade complete" fg={t().accent} />
349
- return <R t={t()} l="Status" v={`✗ Failed${p.message ? `: ${p.message}` : ""}`} fg={t().error} />
353
+ if (p.phase === "done") return <R t={t()} l="Status" v={`✓ ${verb} complete`} fg={t().accent} />
354
+ if (p.phase === "skipped") return <R t={t()} l="Status" v={`${verb} skipped — retry shortly${p.message ? `: ${p.message}` : ""}`} fg={t().textMuted} />
355
+ return <R t={t()} l="Status" v={`✗ ${verb} failed${p.message ? `: ${p.message}` : ""}`} fg={t().error} />
350
356
  })()}
351
357
  </box>
352
- )}
358
+ )
359
+ })()}
353
360
 
354
361
  {/* 2-column layout */}
355
362
  <box flexDirection="row" width="100%" marginTop={1} gap={4}>
@@ -457,7 +464,9 @@ async function showRecompDialog(api: TuiPluginApi, targetSessionId = getSessionI
457
464
  <api.ui.DialogConfirm
458
465
  title="⚠️ Recomp Confirmation"
459
466
  message={[
460
- `You have ${count} compartments.`,
467
+ count === 0
468
+ ? "This session has no compartments yet — recomp will build them from raw history."
469
+ : `You have ${count} compartments.`,
461
470
  "",
462
471
  "Recomp will regenerate all compartments and facts from raw history.",
463
472
  "This may take a long time and consume significant tokens.",
@@ -315,16 +315,25 @@ const RecompProgressSection = (props: {
315
315
  : 0
316
316
  const pct = () => Math.round(fraction() * 100)
317
317
 
318
+ // "Recomp" vs "Upgrade" wording follows the flow that started this run, so a
319
+ // plain /ctx-recomp never renders as an "Upgrade" (dogfood 2026-06-04).
320
+ const verb = () => (props.progress.kind === "upgrade" ? "Upgrade" : "Recomp")
318
321
  const label = createMemo(() => {
319
322
  switch (props.progress.phase) {
320
323
  case "recomp":
321
- return { text: "upgrading ⟳", color: props.theme.warning }
324
+ return {
325
+ text: props.progress.kind === "upgrade" ? "upgrading ⟳" : "comparting ⟳",
326
+ color: props.theme.warning,
327
+ }
322
328
  case "migration":
323
329
  return { text: "Migrating memories ⟳", color: props.theme.warning }
324
330
  case "done":
325
- return { text: "✓ Upgrade complete", color: props.theme.success ?? props.theme.accent }
331
+ return { text: `✓ ${verb()} complete`, color: props.theme.success ?? props.theme.accent }
332
+ case "skipped":
333
+ // Transient (lease busy) — neutral, not an error.
334
+ return { text: `${verb()} skipped — retry shortly`, color: props.theme.textMuted }
326
335
  case "failed":
327
- return { text: "✗ Upgrade failed", color: props.theme.error }
336
+ return { text: `✗ ${verb()} failed`, color: props.theme.error }
328
337
  }
329
338
  })
330
339
 
@@ -332,7 +341,7 @@ const RecompProgressSection = (props: {
332
341
  <>
333
342
  <box width="100%" marginTop={1} flexDirection="row" justifyContent="space-between">
334
343
  <text fg={props.theme.text}>
335
- <b>Recomp</b>
344
+ <b>{verb()}</b>
336
345
  </text>
337
346
  <text fg={label().color}>{label().text}</text>
338
347
  </box>
@@ -357,8 +366,9 @@ const RecompProgressSection = (props: {
357
366
  dim
358
367
  />
359
368
  )}
360
- {/* Terminal reason (failed) — kept visible so the user sees WHY. */}
361
- {phase() === "failed" && props.progress.message && (
369
+ {/* Terminal reason (failed/skipped) — kept visible so the user sees
370
+ WHY (a failure, or the transient "retry shortly" skip cause). */}
371
+ {(phase() === "failed" || phase() === "skipped") && props.progress.message && (
362
372
  <text fg={props.theme.textMuted}>{props.progress.message}</text>
363
373
  )}
364
374
  </>
@@ -485,10 +495,10 @@ const SidebarContent = (props: {
485
495
  recompSawPhase = true
486
496
  recompConsecutiveAbsent = 0
487
497
  scheduleRecompTick()
488
- } else if (phase === "done" || phase === "failed") {
489
- // Terminal state rendered — stop. The server keeps "done" for
490
- // a grace window and "failed" until the next run, so the
491
- // outcome stays visible without further polling.
498
+ } else if (phase === "done" || phase === "failed" || phase === "skipped") {
499
+ // Terminal state rendered — stop. The server keeps "done"/
500
+ // "skipped" for a grace window and "failed" until the next run,
501
+ // so the outcome stays visible without further polling.
492
502
  rtrace(`STOP: terminal phase=${phase}`)
493
503
  recompActive = false
494
504
  } else {