@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.
- package/dist/config/agent-disable.d.ts +0 -9
- package/dist/config/agent-disable.d.ts.map +1 -1
- package/dist/config/schema/agent-overrides.d.ts +0 -3
- package/dist/config/schema/agent-overrides.d.ts.map +1 -1
- package/dist/features/builtin-commands/types.d.ts +0 -2
- package/dist/features/builtin-commands/types.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/scheduler.d.ts +0 -4
- package/dist/features/magic-context/dreamer/scheduler.d.ts.map +1 -1
- package/dist/features/magic-context/git-commits/git-log-reader.d.ts +8 -0
- package/dist/features/magic-context/git-commits/git-log-reader.d.ts.map +1 -1
- package/dist/features/magic-context/git-commits/index.d.ts +1 -0
- package/dist/features/magic-context/git-commits/index.d.ts.map +1 -1
- package/dist/features/magic-context/git-commits/indexer.d.ts.map +1 -1
- package/dist/features/magic-context/git-commits/storage-git-commits.d.ts.map +1 -1
- package/dist/features/magic-context/git-commits/sweep-coordinator.d.ts +48 -0
- package/dist/features/magic-context/git-commits/sweep-coordinator.d.ts.map +1 -0
- package/dist/features/magic-context/key-files/storage-key-files.d.ts +0 -5
- package/dist/features/magic-context/key-files/storage-key-files.d.ts.map +1 -1
- package/dist/features/magic-context/literal-probes.d.ts +24 -0
- package/dist/features/magic-context/literal-probes.d.ts.map +1 -0
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/search.d.ts +7 -0
- package/dist/features/magic-context/search.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-notes.d.ts +8 -0
- package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +14 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/derive-budgets.d.ts +3 -3
- package/dist/hooks/magic-context/event-handler.d.ts +7 -0
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-payloads.d.ts +7 -0
- package/dist/hooks/magic-context/event-payloads.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-resolvers.d.ts +1 -0
- package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/live-session-state.d.ts +12 -0
- package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
- package/dist/hooks/magic-context/recomp-orchestrator.d.ts +7 -2
- package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts +9 -0
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-content-primitives.d.ts +23 -0
- package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
- package/dist/hooks/magic-context/temporal-awareness.d.ts.map +1 -1
- package/dist/hooks/magic-context/text-complete.d.ts +23 -0
- package/dist/hooks/magic-context/text-complete.d.ts.map +1 -1
- package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +9 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +561 -190
- package/dist/plugin/dream-timer.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts +54 -27
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +3 -1
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/tools/ctx-note/tools.d.ts.map +1 -1
- package/dist/tools/ctx-search/tools.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/shared/models-dev-cache.test.ts +192 -360
- package/src/shared/models-dev-cache.ts +162 -193
- package/src/shared/rpc-types.ts +3 -1
- package/src/tui/index.tsx +17 -8
- package/src/tui/slots/sidebar-content.tsx +20 -10
|
@@ -1,31 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve per-model context limits
|
|
2
|
+
* Resolve per-model context limits from OpenCode's SDK — the single source of
|
|
3
|
+
* truth — for OpenCode sessions.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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 {
|
|
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 {
|
|
28
|
-
import {
|
|
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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
244
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
293
|
-
* 1.
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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
|
-
*
|
|
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
|
|
301
|
-
|
|
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
|
-
|
|
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
|
|
325
|
-
* fallback.
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/src/shared/rpc-types.ts
CHANGED
|
@@ -77,7 +77,9 @@ export interface SidebarSnapshot {
|
|
|
77
77
|
* the runtime `RecompProgress` shape from compartment-runner-types.ts.
|
|
78
78
|
*/
|
|
79
79
|
recompProgress?: {
|
|
80
|
-
|
|
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>
|
|
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=
|
|
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=
|
|
349
|
-
return <R t={t()} l="Status" v={
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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:
|
|
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>
|
|
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
|
|
361
|
-
|
|
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"
|
|
490
|
-
// a grace window and "failed" until the next run,
|
|
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 {
|