opencode-context-indicator 1.1.2 → 1.1.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/README.md +7 -0
- package/index.js +121 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -196,6 +196,13 @@ back to a plain notice if needed.
|
|
|
196
196
|
with no category data (no cache/snapshot and the fallback failed or ran out of
|
|
197
197
|
budget) is tagged `no-data`; `ctx` / `model` / `updatedAt` still come from
|
|
198
198
|
`state.json` / the live lookup, while its category cells stay `n/a`.
|
|
199
|
+
* **The denominator survives a transient model-registry miss.** If the serving
|
|
200
|
+
instance's `ctx.model.list()` returns no usable limits (freshly loaded instance,
|
|
201
|
+
provider not yet registered, malformed payload), the window is recovered from
|
|
202
|
+
the `state.json` snapshot instead of degrading the `ctx` column to `?`; limits
|
|
203
|
+
are also seeded from the snapshot at startup, and the retry backs off
|
|
204
|
+
(30 s → 5 min, capped). A known `usable`/`limit`/`agent` is never overwritten
|
|
205
|
+
with `null` when another instance rewrites the snapshot.
|
|
199
206
|
* Both commands append their payload to `context-breakdown.log` (final-summaries
|
|
200
207
|
section), so it stays recoverable even if delivery fails.
|
|
201
208
|
* If `ctx.session.synthetic` throws, the command logs an actionable error and the
|
package/index.js
CHANGED
|
@@ -177,7 +177,8 @@ const CRITICAL_THROTTLE_MS = 10000; // min gap between critical (>90%) toasts
|
|
|
177
177
|
const ERROR_TOAST_DEDUP_MS = 60000; // same error -> at most one toast / window
|
|
178
178
|
const COMPACTED_TOAST_DEDUP_MS = 60000; // compacted-toast dedup window
|
|
179
179
|
const PROVIDERS_TTL_MS = 5 * 60 * 1000; // providers()/model.list() cache TTL after SUCCESS
|
|
180
|
-
const PROVIDERS_FAIL_TTL_MS = 30 * 1000; // retry-no-sooner
|
|
180
|
+
const PROVIDERS_FAIL_TTL_MS = 30 * 1000; // retry-no-sooner base after FAILURE (doubles per consecutive miss)
|
|
181
|
+
const V2_LIMITS_MAX_BACKOFF_MS = 5 * 60 * 1000; // cap for the model.list retry backoff
|
|
181
182
|
const PROVIDERS_FETCH_TIMEOUT_MS = 2000; // hard cap for one providers() call (V1)
|
|
182
183
|
const V2_MODEL_FETCH_TIMEOUT_MS = 8000; // hard cap for one ctx.model.list() call (V2)
|
|
183
184
|
const TOAST_MS_ERROR = 8000; // duration for error / overflow toasts
|
|
@@ -278,6 +279,7 @@ let providersInFlight = null;
|
|
|
278
279
|
// absolute tokens (as in V1).
|
|
279
280
|
let v2LimitsLoadedAt = 0;
|
|
280
281
|
let v2LimitsFailAt = 0;
|
|
282
|
+
let v2LimitsFailCount = 0; // consecutive soft-failures -> exponential retry backoff
|
|
281
283
|
let v2LimitsInFlight = null;
|
|
282
284
|
|
|
283
285
|
// Bounded list of final summaries written on session.idle
|
|
@@ -472,6 +474,14 @@ function writeStateFile(
|
|
|
472
474
|
const prevEntry = all[sessionID] || null;
|
|
473
475
|
const pc = all[sessionID]?.categories || null;
|
|
474
476
|
|
|
477
|
+
// Same model AND provider? Only then is a previously persisted denominator
|
|
478
|
+
// meaningful — a session that switched model (or provider, even with the
|
|
479
|
+
// same modelID) must not inherit the old window.
|
|
480
|
+
const sameModelAsPrev =
|
|
481
|
+
prevEntry != null &&
|
|
482
|
+
prevEntry.model === (modelID || "unknown") &&
|
|
483
|
+
(prevEntry.providerID || "") === (providerID || "");
|
|
484
|
+
|
|
475
485
|
const catUser = bd ? bd.userChars || 0 : pc?.user ?? 0;
|
|
476
486
|
const catAssistant = bd ? bd.assistantChars || 0 : pc?.assistant ?? 0;
|
|
477
487
|
const catReasoning = bd ? bd.reasoningChars || 0 : pc?.reasoning ?? 0;
|
|
@@ -504,13 +514,23 @@ function writeStateFile(
|
|
|
504
514
|
sessionID,
|
|
505
515
|
parentID,
|
|
506
516
|
role: parentID ? "sub" : "main",
|
|
507
|
-
agent: sessionAgentCache.get(sessionID) || null,
|
|
517
|
+
agent: sessionAgentCache.get(sessionID) || prevEntry?.agent || null,
|
|
508
518
|
model: modelID || "unknown",
|
|
509
519
|
providerID: providerID || "",
|
|
510
520
|
ctx: total,
|
|
511
521
|
input: inputTokens || 0,
|
|
512
|
-
|
|
513
|
-
|
|
522
|
+
// Do NOT clobber a known denominator with null: multiple plugin instances
|
|
523
|
+
// share this file and one of them may lack modelLimits (its ctx.model.list
|
|
524
|
+
// returned nothing). Keep the previous model's known value when the live
|
|
525
|
+
// lookup came back empty, so a transient miss never becomes permanent.
|
|
526
|
+
usable:
|
|
527
|
+
denom != null ? denom : sameModelAsPrev ? prevEntry.usable ?? null : null,
|
|
528
|
+
limit:
|
|
529
|
+
limit != null
|
|
530
|
+
? limit
|
|
531
|
+
: sameModelAsPrev && typeof prevEntry.limit === "number"
|
|
532
|
+
? prevEntry.limit
|
|
533
|
+
: null,
|
|
514
534
|
reasoning: typeof reasoningTokens === "number" ? reasoningTokens : 0,
|
|
515
535
|
categories: {
|
|
516
536
|
user: catUser,
|
|
@@ -1916,7 +1936,14 @@ async function ensureV2Breakdown(ctx, sessionID) {
|
|
|
1916
1936
|
async function ensureModelLimits(ctx) {
|
|
1917
1937
|
const now = Date.now();
|
|
1918
1938
|
if (now - v2LimitsLoadedAt < PROVIDERS_TTL_MS) return; // fresh success
|
|
1919
|
-
|
|
1939
|
+
// Recent failure: back off exponentially (30s, 60s, 120s … capped at 5 min)
|
|
1940
|
+
// so a registry that is slow to expose the provider is retried without
|
|
1941
|
+
// hammering it, while a one-off blip still recovers quickly.
|
|
1942
|
+
const failBackoff = Math.min(
|
|
1943
|
+
PROVIDERS_FAIL_TTL_MS * Math.pow(2, Math.max(0, v2LimitsFailCount - 1)),
|
|
1944
|
+
V2_LIMITS_MAX_BACKOFF_MS,
|
|
1945
|
+
);
|
|
1946
|
+
if (now - v2LimitsFailAt < failBackoff) return; // recent failure
|
|
1920
1947
|
if (v2LimitsInFlight) return v2LimitsInFlight; // dedup parallel callers
|
|
1921
1948
|
v2LimitsInFlight = (async () => {
|
|
1922
1949
|
try {
|
|
@@ -1927,7 +1954,13 @@ async function ensureModelLimits(ctx) {
|
|
|
1927
1954
|
);
|
|
1928
1955
|
const models = Array.isArray(res) ? res : res?.data;
|
|
1929
1956
|
if (!Array.isArray(models)) {
|
|
1957
|
+
v2LimitsFailCount++;
|
|
1930
1958
|
v2LimitsFailAt = Date.now(); // malformed payload -> retry soon
|
|
1959
|
+
const seeded = hydrateModelLimitsFromState();
|
|
1960
|
+
logErr(
|
|
1961
|
+
"model.list returned no model array — limits unavailable, will retry",
|
|
1962
|
+
{ payloadType: typeof res, hydratedFromState: seeded },
|
|
1963
|
+
);
|
|
1931
1964
|
return;
|
|
1932
1965
|
}
|
|
1933
1966
|
let found = 0;
|
|
@@ -1964,11 +1997,36 @@ async function ensureModelLimits(ctx) {
|
|
|
1964
1997
|
// An empty / limit-less payload right after boot (registry not ready yet)
|
|
1965
1998
|
// must NOT be cached as success, or limits would stay unknown for the
|
|
1966
1999
|
// full TTL — treat it as a soft failure and retry after the fail gate.
|
|
1967
|
-
if (found > 0)
|
|
1968
|
-
|
|
2000
|
+
if (found > 0) {
|
|
2001
|
+
v2LimitsLoadedAt = Date.now();
|
|
2002
|
+
v2LimitsFailCount = 0; // recovered
|
|
2003
|
+
} else {
|
|
2004
|
+
// Soft failure: the registry answered but carried no usable limit for
|
|
2005
|
+
// any model (provider not registered yet, or `limit` absent). Log WHY
|
|
2006
|
+
// once per backoff window, and fall back to the shared snapshot so
|
|
2007
|
+
// denominators do not vanish meanwhile.
|
|
2008
|
+
const withLimit = models.filter(
|
|
2009
|
+
(m) => m?.limit && typeof m.limit === "object",
|
|
2010
|
+
).length;
|
|
2011
|
+
const lanit = models.filter(
|
|
2012
|
+
(m) => m?.providerID === "lanit" || m?.provider?.id === "lanit",
|
|
2013
|
+
).length;
|
|
2014
|
+
const seeded = hydrateModelLimitsFromState();
|
|
2015
|
+
v2LimitsFailCount++;
|
|
2016
|
+
v2LimitsFailAt = Date.now();
|
|
2017
|
+
logErr(
|
|
2018
|
+
"model.list yielded 0 usable limits — retrying",
|
|
2019
|
+
{ models: models.length, withLimit, lanit, hydratedFromState: seeded },
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
1969
2022
|
} catch (err) {
|
|
1970
|
-
|
|
1971
|
-
|
|
2023
|
+
v2LimitsFailCount++;
|
|
2024
|
+
v2LimitsFailAt = Date.now(); // failed attempt -> backoff gate
|
|
2025
|
+
const seeded = hydrateModelLimitsFromState();
|
|
2026
|
+
logErr(
|
|
2027
|
+
`model.list failed (hydrated ${seeded} limit(s) from state.json)`,
|
|
2028
|
+
err,
|
|
2029
|
+
);
|
|
1972
2030
|
} finally {
|
|
1973
2031
|
v2LimitsInFlight = null;
|
|
1974
2032
|
}
|
|
@@ -2377,6 +2435,56 @@ function readStateSnapshot() {
|
|
|
2377
2435
|
}
|
|
2378
2436
|
}
|
|
2379
2437
|
|
|
2438
|
+
// Seed `modelLimits` from previously persisted state.json entries. state.json is
|
|
2439
|
+
// a SHARED cross-instance snapshot rewritten at every live step, so it carries a
|
|
2440
|
+
// model's window even when THIS instance's ctx.model.list() came back empty (a
|
|
2441
|
+
// cold instance, or one whose model registry did not expose the provider).
|
|
2442
|
+
// Without this, a transient list miss degraded every denominator to "?" — and
|
|
2443
|
+
// because writeStateFile used to rewrite usable/limit from the (empty) in-memory
|
|
2444
|
+
// map, the degradation became permanent. Strictly additive: real in-memory
|
|
2445
|
+
// records always win (never overwritten). Returns the number of records seeded.
|
|
2446
|
+
function hydrateModelLimitsFromState() {
|
|
2447
|
+
try {
|
|
2448
|
+
const all = readStateSnapshot();
|
|
2449
|
+
let added = 0;
|
|
2450
|
+
for (const entry of Object.values(all)) {
|
|
2451
|
+
const providerID =
|
|
2452
|
+
typeof entry?.providerID === "string" ? entry.providerID : "";
|
|
2453
|
+
const modelID = entry?.model;
|
|
2454
|
+
const key = modelKey(providerID, modelID);
|
|
2455
|
+
if (!key || !modelID || modelID === "unknown") continue;
|
|
2456
|
+
if (modelLimits.has(key)) continue; // a real record always wins
|
|
2457
|
+
// A state entry stores the derived usable window (`usable`) and the raw
|
|
2458
|
+
// context window (`limit`) — NOT the input/output/reserved triple. Seed
|
|
2459
|
+
// `usable` as `input` (reserved 0) so getUsableContext() re-derives EXACTLY
|
|
2460
|
+
// the same denominator the warm instance persisted; seeding `limit` as
|
|
2461
|
+
// context instead would let the cold instance show the raw context window
|
|
2462
|
+
// (e.g. 200k) while the warm one shows the true usable window (e.g. 168k).
|
|
2463
|
+
// Fall back to `limit` as context only when no `usable` was recorded.
|
|
2464
|
+
const usable =
|
|
2465
|
+
typeof entry?.usable === "number" && entry.usable > 0
|
|
2466
|
+
? entry.usable
|
|
2467
|
+
: null;
|
|
2468
|
+
const ctxLimit =
|
|
2469
|
+
typeof entry?.limit === "number" && entry.limit > 0
|
|
2470
|
+
? entry.limit
|
|
2471
|
+
: null;
|
|
2472
|
+
let rec = null;
|
|
2473
|
+
if (usable != null) {
|
|
2474
|
+
rec = { context: ctxLimit, input: usable, output: null, reserved: 0 };
|
|
2475
|
+
} else if (ctxLimit != null) {
|
|
2476
|
+
rec = { context: ctxLimit, input: null, output: null, reserved: null };
|
|
2477
|
+
}
|
|
2478
|
+
if (rec == null) continue;
|
|
2479
|
+
modelLimits.set(key, rec);
|
|
2480
|
+
added++;
|
|
2481
|
+
}
|
|
2482
|
+
return added;
|
|
2483
|
+
} catch {
|
|
2484
|
+
return 0;
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2380
2488
|
// True when a state.json entry carries no measured tokens at all: a session
|
|
2381
2489
|
// registered (session.created) but with no served step. `writeStateFile` always
|
|
2382
2490
|
// persists `categories` as an object, so the emptiness test must SUM those
|
|
@@ -2796,6 +2904,10 @@ export default {
|
|
|
2796
2904
|
// to the generic 200k default), so the transform is not used for limit
|
|
2797
2905
|
// population.
|
|
2798
2906
|
|
|
2907
|
+
// Seed limits from the shared snapshot FIRST: another instance may already
|
|
2908
|
+
// have resolved them, so a cold start never shows "?" denominators.
|
|
2909
|
+
hydrateModelLimitsFromState();
|
|
2910
|
+
|
|
2799
2911
|
// Fire-and-forget prefetch of effective model limits.
|
|
2800
2912
|
ensureModelLimits(ctx).catch((err) =>
|
|
2801
2913
|
logErr("model limits prefetch failed", err),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-context-indicator",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "Real-time OpenCode context-window usage indicator: per-category token breakdown written to a log file, plus an optional TUI sidebar. Dual V1 + V2 plugin entry.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|