opencode-context-indicator 1.5.4 → 1.7.0
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/CHANGELOG.md +107 -2
- package/README.md +147 -42
- package/dist/gui-experimental.cjs +1 -0
- package/index.js +16 -4
- package/lib/commands.js +81 -18
- package/lib/desktop.js +102 -0
- package/lib/estimate.js +31 -2
- package/lib/format.js +19 -0
- package/lib/limits.js +17 -10
- package/lib/rpc.d.ts +47 -0
- package/lib/rpc.js +88 -0
- package/lib/state.js +200 -56
- package/lib/tui-data.js +5 -9
- package/lib/v1.js +9 -2
- package/lib/v2.js +140 -15
- package/package.json +18 -9
- package/tui.tsx +5 -3
package/lib/v2.js
CHANGED
|
@@ -14,9 +14,13 @@ import {
|
|
|
14
14
|
estimateTokens,
|
|
15
15
|
extractToolTarget,
|
|
16
16
|
fmt,
|
|
17
|
+
isHookMeasuredEntry,
|
|
17
18
|
isSafeSessionKey,
|
|
18
19
|
oneLine,
|
|
20
|
+
safeReclaim,
|
|
19
21
|
safeSaneOrNull,
|
|
22
|
+
safeToolOutByTool,
|
|
23
|
+
safeTop,
|
|
20
24
|
tokenCountOf,
|
|
21
25
|
TOOL_OUT_MAX_KEYS,
|
|
22
26
|
withTimeout,
|
|
@@ -60,6 +64,7 @@ import {
|
|
|
60
64
|
debugEvent,
|
|
61
65
|
logErr,
|
|
62
66
|
pushFinalSummary,
|
|
67
|
+
readStateSnapshot,
|
|
63
68
|
writeLog,
|
|
64
69
|
writeStateFile,
|
|
65
70
|
} from "./state.js";
|
|
@@ -422,10 +427,6 @@ function onV2Compaction(context) {
|
|
|
422
427
|
pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
|
|
423
428
|
}
|
|
424
429
|
|
|
425
|
-
// Arm the fallback guard for the trailing `session.compacted` event.
|
|
426
|
-
lastCompactionHookAt.set(sid, Date.now());
|
|
427
|
-
pruneMap(lastCompactionHookAt, MAX_TRACKED_SESSIONS);
|
|
428
|
-
|
|
429
430
|
const ctxTokens = lastKnownCtx(sid);
|
|
430
431
|
if (ctxTokens > 0) {
|
|
431
432
|
const lines = buildLiveLinesV2(
|
|
@@ -436,7 +437,7 @@ function onV2Compaction(context) {
|
|
|
436
437
|
ctxTokens,
|
|
437
438
|
lastKnownReasoning(sid),
|
|
438
439
|
);
|
|
439
|
-
writeStateFile(
|
|
440
|
+
const wrote = writeStateFile(
|
|
440
441
|
sid,
|
|
441
442
|
lastKnownModel(sid),
|
|
442
443
|
lastKnownProvider(sid),
|
|
@@ -449,6 +450,16 @@ function onV2Compaction(context) {
|
|
|
449
450
|
cacheWrite: lastKnownCacheWrite(sid),
|
|
450
451
|
},
|
|
451
452
|
);
|
|
453
|
+
// Arm the fallback guard for the trailing `session.compacted` event ONLY
|
|
454
|
+
// when this hook actually PERSISTED the measurement (writeStateFile
|
|
455
|
+
// returns false on a busy lock / unsafe key / failed rename). A hook that
|
|
456
|
+
// wrote nothing leaves the entry untouched; arming there would tell the
|
|
457
|
+
// later event "already handled" and the compaction would leave no trace at
|
|
458
|
+
// all - no stale mark, no note ("after compaction nothing happens").
|
|
459
|
+
if (wrote) {
|
|
460
|
+
lastCompactionHookAt.set(sid, Date.now());
|
|
461
|
+
pruneMap(lastCompactionHookAt, MAX_TRACKED_SESSIONS);
|
|
462
|
+
}
|
|
452
463
|
// Claim key derived from the PAYLOAD shape (message count + total
|
|
453
464
|
// estimated chars): identical across plugin instances for the SAME
|
|
454
465
|
// compaction (cross-instance dedup preserved) yet distinct per compaction
|
|
@@ -464,7 +475,7 @@ function onV2Compaction(context) {
|
|
|
464
475
|
(parts.systemChars || 0) +
|
|
465
476
|
(parts.toolSchemasChars || 0));
|
|
466
477
|
pushFinalSummary(`compaction-hook:${sid}:${sig}`, [
|
|
467
|
-
`[${new Date().toISOString()}] COMPACTION (hook) session=${sid} — pre-compaction snapshot captured`,
|
|
478
|
+
`[${new Date().toISOString()}] COMPACTION (hook) session=${sid} — pre-compaction snapshot ${wrote ? "captured" : "estimated only (state write refused)"}`,
|
|
468
479
|
...lines.slice(1),
|
|
469
480
|
]);
|
|
470
481
|
}
|
|
@@ -671,6 +682,40 @@ function aggregateSubagentsV2(sessionID) {
|
|
|
671
682
|
return result;
|
|
672
683
|
}
|
|
673
684
|
|
|
685
|
+
// Convert a persisted state.json entry into the in-memory breakdown shape so the
|
|
686
|
+
// diagnostic log can render categories another instance measured with the
|
|
687
|
+
// request hook. Everything here is UNTRUSTED shared-file data: reuse the same
|
|
688
|
+
// sanitisers the writer and the command report use, so a poisoned entry cannot
|
|
689
|
+
// inject unbounded arrays, negative counts or foreign shapes into the log.
|
|
690
|
+
function stateEntryToBreakdown(entry) {
|
|
691
|
+
const c = entry?.categories || {};
|
|
692
|
+
const user = safeSaneOrNull(c.user) ?? 0;
|
|
693
|
+
const assistant = safeSaneOrNull(c.assistant) ?? 0;
|
|
694
|
+
const reasoning = safeSaneOrNull(c.reasoning) ?? 0;
|
|
695
|
+
const toolArgs = safeSaneOrNull(c.toolArgs) ?? 0;
|
|
696
|
+
const toolOut = safeSaneOrNull(c.toolOut) ?? 0;
|
|
697
|
+
return {
|
|
698
|
+
userChars: user,
|
|
699
|
+
assistantChars: assistant,
|
|
700
|
+
reasoningChars: reasoning,
|
|
701
|
+
toolArgsChars: toolArgs,
|
|
702
|
+
toolOutChars: toolOut,
|
|
703
|
+
toolOutByTool: safeToolOutByTool(entry?.toolOutByTool),
|
|
704
|
+
systemChars: safeSaneOrNull(c.system),
|
|
705
|
+
toolSchemasChars: safeSaneOrNull(c.toolSchemas),
|
|
706
|
+
reclaim: safeReclaim(entry?.reclaim, false),
|
|
707
|
+
top: safeTop(entry?.top, false),
|
|
708
|
+
// The entry's residual was computed against the INPUT the hook saw. This
|
|
709
|
+
// instance may have a different `lastKnownInput` (a mirror that only saw the
|
|
710
|
+
// trailing event has 0), so the residual must be carried over instead of
|
|
711
|
+
// recomputed - otherwise the log prints a non-zero breakdown next to
|
|
712
|
+
// "other 0 (= input - sum of estimates)".
|
|
713
|
+
otherChars: safeSaneOrNull(c.other),
|
|
714
|
+
capturedAt: Date.parse(entry?.updatedAt) || 0,
|
|
715
|
+
source: entry?.source || "snapshot",
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
674
719
|
// V2: assemble the live breakdown lines (no client, no fallback fetch —
|
|
675
720
|
// the context hook fills breakdownCache on every request).
|
|
676
721
|
function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens, reasoningTokens) {
|
|
@@ -678,7 +723,40 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
678
723
|
const ts = new Date().toISOString();
|
|
679
724
|
const limit = getModelLimit(providerID, modelID);
|
|
680
725
|
const usableInfo = getUsableContext(providerID, modelID);
|
|
681
|
-
|
|
726
|
+
|
|
727
|
+
// A client-context fallback cannot see the system prompt or the tool schemas:
|
|
728
|
+
// its `other` swallows both and, right after a compaction, it can even read a
|
|
729
|
+
// nearly empty conversation. The log is shared by every instance, so when
|
|
730
|
+
// ANOTHER instance has measured this session with the request hook, that
|
|
731
|
+
// measurement (state.json) is the truthful one to print here - same priority
|
|
732
|
+
// as writeStateFile / collectSessionReport. `!bd` (this instance has no cache
|
|
733
|
+
// entry at all, e.g. it only saw the trailing event) takes the same path.
|
|
734
|
+
// Only the DISPLAY is substituted; the cache itself is left alone.
|
|
735
|
+
//
|
|
736
|
+
// The counters come from the same snapshot on purpose: this instance's
|
|
737
|
+
// input/ctx belong to ITS view (a mirror that only saw the trailing event has
|
|
738
|
+
// 0), and mixing them with the hook's categories made the header contradict
|
|
739
|
+
// the "other = input - sum of estimates" line right below it.
|
|
740
|
+
let bd = breakdownCache.get(sessionID);
|
|
741
|
+
let snapInputTokens = null;
|
|
742
|
+
let snapCtxTokens = null;
|
|
743
|
+
let snapAt = null;
|
|
744
|
+
if (!bd || bd.source === "client-context") {
|
|
745
|
+
const entry = isSafeSessionKey(sessionID)
|
|
746
|
+
? readStateSnapshot()[sessionID]
|
|
747
|
+
: null;
|
|
748
|
+
if (isHookMeasuredEntry(entry)) {
|
|
749
|
+
bd = stateEntryToBreakdown(entry);
|
|
750
|
+
snapInputTokens = safeSaneOrNull(entry.input);
|
|
751
|
+
snapCtxTokens = safeSaneOrNull(entry.ctx);
|
|
752
|
+
snapAt = typeof entry.updatedAt === "string" ? entry.updatedAt : null;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const source = bd?.source || "none";
|
|
756
|
+
const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
|
|
757
|
+
const effInput = snapInputTokens != null ? snapInputTokens : inputTokens;
|
|
758
|
+
const effCtx = snapCtxTokens != null ? snapCtxTokens : ctxTokens;
|
|
759
|
+
const total = typeof effCtx === "number" && effCtx > 0 ? effCtx : effInput;
|
|
682
760
|
const denom =
|
|
683
761
|
usableInfo != null
|
|
684
762
|
? usableInfo.usable
|
|
@@ -687,18 +765,23 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
687
765
|
: null;
|
|
688
766
|
const pct =
|
|
689
767
|
denom != null ? ` (${((total / denom) * 100).toFixed(1)}%)` : "";
|
|
768
|
+
// The reader must be able to tell a fresh local measurement from a borrowed
|
|
769
|
+
// snapshot (and from how old it is).
|
|
770
|
+
const snapNote = snapAt ? ` snapshot=${snapAt}` : "";
|
|
690
771
|
|
|
691
772
|
lines.push(
|
|
692
|
-
`[${ts}] session=${sessionID} model=${modelID} ctx=${fmt(total)}/${denom != null ? fmt(denom) : "?"}${pct} count=${fmt(total)} usable=${usableInfo != null ? fmt(usableInfo.usable) : "?"} limit=${limit != null ? fmt(limit) : "?"} maxOutput=${usableInfo != null ? fmt(usableInfo.maxOutput) : "?"} reserved=${usableInfo != null ? fmt(usableInfo.reserved) : "?"} input=${fmt(
|
|
773
|
+
`[${ts}] session=${sessionID} model=${modelID} ctx=${fmt(total)}/${denom != null ? fmt(denom) : "?"}${pct} count=${fmt(total)} usable=${usableInfo != null ? fmt(usableInfo.usable) : "?"} limit=${limit != null ? fmt(limit) : "?"} maxOutput=${usableInfo != null ? fmt(usableInfo.maxOutput) : "?"} reserved=${usableInfo != null ? fmt(usableInfo.reserved) : "?"} input=${fmt(effInput)}${snapNote}`,
|
|
693
774
|
);
|
|
694
775
|
|
|
695
|
-
const bd = breakdownCache.get(sessionID);
|
|
696
|
-
const source = bd?.source || "none";
|
|
697
|
-
const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
|
|
698
|
-
|
|
699
776
|
// Residual = input minus every measured category (shared normaliser; `bd`
|
|
700
|
-
// may be absent -> everything 0 and the residual is the whole input).
|
|
701
|
-
|
|
777
|
+
// may be absent -> everything 0 and the residual is the whole input). A
|
|
778
|
+
// breakdown taken from state.json carries the residual the HOOK computed
|
|
779
|
+
// against the input IT saw; this instance's `inputTokens` can differ (a mirror
|
|
780
|
+
// that only saw the trailing event has 0), so that value is used instead.
|
|
781
|
+
const other =
|
|
782
|
+
typeof bd?.otherChars === "number"
|
|
783
|
+
? bd.otherChars
|
|
784
|
+
: breakdownCategories(bd, effInput).other;
|
|
702
785
|
|
|
703
786
|
if (bd) {
|
|
704
787
|
lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
|
|
@@ -877,8 +960,45 @@ async function handleV2Event(ctx, event) {
|
|
|
877
960
|
},
|
|
878
961
|
);
|
|
879
962
|
}
|
|
963
|
+
// Claim key: the event id when the host sends a usable one; otherwise the
|
|
964
|
+
// event's own timestamp (a per-event revision, when present); otherwise a
|
|
965
|
+
// per-session minute bucket (same shape as the V1 idle note). A
|
|
966
|
+
// session-only key would log just the FIRST compaction of a session and
|
|
967
|
+
// silently drop every later one whenever the host omits the event id.
|
|
968
|
+
// An empty/zero id is treated as absent: `??` would collapse it to the
|
|
969
|
+
// same key for every compaction and drop all but the first note.
|
|
970
|
+
const evId =
|
|
971
|
+
typeof event.id === "string" && event.id.length > 0 ? event.id : null;
|
|
972
|
+
let evStamp = null;
|
|
973
|
+
if (!evId) {
|
|
974
|
+
for (const v of [
|
|
975
|
+
event.time,
|
|
976
|
+
event.timestamp,
|
|
977
|
+
event.data?.time,
|
|
978
|
+
event.data?.createdAt,
|
|
979
|
+
event.data?.timestamp,
|
|
980
|
+
]) {
|
|
981
|
+
if (typeof v === "string" && v.length > 0) {
|
|
982
|
+
evStamp = v;
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
// `0` is a common "time unknown" sentinel: accepting it would make
|
|
986
|
+
// the key constant for the whole 24h dedup TTL and re-introduce the
|
|
987
|
+
// "only the first compaction is logged" bug.
|
|
988
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0) {
|
|
989
|
+
evStamp = String(v);
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
// The session id stays in the key even with an event id: some hosts
|
|
995
|
+
// number ids per session ("1", "2"), and cross-instance dedup only needs
|
|
996
|
+
// the id to be equal for the SAME event of the SAME session.
|
|
997
|
+
const claimKey = evId
|
|
998
|
+
? `compacted:${sid}:${evId}`
|
|
999
|
+
: `compacted:${sid}:${evStamp ?? Math.floor(Date.now() / 60000)}`;
|
|
880
1000
|
pushFinalSummary(
|
|
881
|
-
|
|
1001
|
+
claimKey,
|
|
882
1002
|
[
|
|
883
1003
|
`[${new Date().toISOString()}] COMPACTED session=${sid} (auto — the context may have overflowed)`,
|
|
884
1004
|
...lines.slice(1),
|
|
@@ -1071,5 +1191,10 @@ export {
|
|
|
1071
1191
|
handleV2Event,
|
|
1072
1192
|
estimateBreakdownFromContext,
|
|
1073
1193
|
estimateBreakdownFromClientMessages,
|
|
1194
|
+
// Harness-only (test/prepublish.mjs): the live-log assembly and its
|
|
1195
|
+
// state-entry adapter, so the hook-vs-fallback display rule can be tested
|
|
1196
|
+
// without a real opencode runtime.
|
|
1197
|
+
buildLiveLinesV2,
|
|
1198
|
+
stateEntryToBreakdown,
|
|
1074
1199
|
V2_FALLBACK_TIMEOUT_MS,
|
|
1075
1200
|
};
|
package/package.json
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-context-indicator",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Real-time context-window usage for OpenCode: per-category token breakdown, /context-diff, a zero-LLM /cx panel with per-session cost and a searchable family-session picker,
|
|
3
|
+
"version": "1.7.0",
|
|
4
|
+
"description": "Real-time context-window usage for OpenCode: per-category token breakdown, /context-diff, a zero-LLM /cx panel with per-session cost and a searchable family-session picker, a TUI sidebar, and an experimental Desktop panel. Dual V1 + V2 plugin entry.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.js",
|
|
9
|
-
"./tui": "./tui.tsx"
|
|
9
|
+
"./tui": "./tui.tsx",
|
|
10
|
+
"./rpc": "./lib/rpc.js",
|
|
11
|
+
"./gui-experimental": "./dist/gui-experimental.cjs"
|
|
10
12
|
},
|
|
11
13
|
"files": [
|
|
12
14
|
"index.js",
|
|
13
15
|
"lib/",
|
|
14
16
|
"tui.tsx",
|
|
17
|
+
"dist/",
|
|
15
18
|
"docs/",
|
|
16
19
|
"README.md",
|
|
17
20
|
"CHANGELOG.md",
|
|
@@ -31,7 +34,9 @@
|
|
|
31
34
|
"breakdown",
|
|
32
35
|
"tui",
|
|
33
36
|
"sidebar",
|
|
34
|
-
"panel"
|
|
37
|
+
"panel",
|
|
38
|
+
"desktop",
|
|
39
|
+
"gui"
|
|
35
40
|
],
|
|
36
41
|
"publishConfig": {
|
|
37
42
|
"access": "public"
|
|
@@ -62,23 +67,27 @@
|
|
|
62
67
|
}
|
|
63
68
|
},
|
|
64
69
|
"dependencies": {
|
|
65
|
-
"@opentui/solid": "0.
|
|
70
|
+
"@opentui/solid": "0.7.0"
|
|
66
71
|
},
|
|
67
72
|
"devDependencies": {
|
|
68
|
-
"@opencode/plugin": "2.
|
|
69
|
-
"@opentui/core": "0.
|
|
73
|
+
"@opencode/plugin": "2.2.0",
|
|
74
|
+
"@opentui/core": "0.7.0",
|
|
70
75
|
"@types/node": "26.6.3",
|
|
71
76
|
"esbuild": "^0.28.2",
|
|
72
77
|
"node-pty": "^1.1.0",
|
|
73
78
|
"solid-js": "^1.9.12",
|
|
74
|
-
"typescript": "5.9.3"
|
|
79
|
+
"typescript": "5.9.3",
|
|
80
|
+
"vite": "^8.3.5",
|
|
81
|
+
"vite-plugin-solid": "^2.11.14"
|
|
75
82
|
},
|
|
76
83
|
"scripts": {
|
|
84
|
+
"build:gui": "vite build --config vite.gui.config.mjs",
|
|
85
|
+
"test:types": "tsc --noEmit && tsc -p tsconfig.gui.json --noEmit",
|
|
77
86
|
"test:prepublish": "node test/prepublish.mjs",
|
|
78
87
|
"test:e2e": "node test/e2e-tui.mjs --full",
|
|
79
88
|
"test:e2e:quick": "node test/e2e-tui.mjs --quick",
|
|
80
89
|
"test:drift": "node test/drift-watch.mjs",
|
|
81
90
|
"test:drift:strict": "node test/drift-watch.mjs --strict"
|
|
82
91
|
},
|
|
83
|
-
"prepublishOnly": "node test/prepublish.mjs && node test/e2e-tui.mjs --full"
|
|
92
|
+
"prepublishOnly": "npm run build:gui && node test/prepublish.mjs && node test/e2e-tui.mjs --full"
|
|
84
93
|
}
|
package/tui.tsx
CHANGED
|
@@ -38,9 +38,11 @@
|
|
|
38
38
|
*
|
|
39
39
|
* Runtime requirements: @opentui/core and solid-js are optional peers resolved
|
|
40
40
|
* by OpenCode at runtime. @opentui/solid is a pinned direct dependency (exact
|
|
41
|
-
* 0.
|
|
42
|
-
* npm-installed plugins the JSX pragma
|
|
43
|
-
* `@opentui/solid/jsx-runtime` (upstream
|
|
41
|
+
* 0.7.0, the version opencode 2.2.0 ships): OpenCode's TUI loader does not
|
|
42
|
+
* expose a host instance of it, so for npm-installed plugins the JSX pragma
|
|
43
|
+
* would otherwise fail to resolve `@opentui/solid/jsx-runtime` (upstream
|
|
44
|
+
* opencode issue #33884). @opencode/plugin 2.2.0 declares OpenTUI 0.7.0 as its
|
|
45
|
+
* peer, so a plugin left on 0.6.x no longer installs against that host.
|
|
44
46
|
*
|
|
45
47
|
* Graceful mode: when this file is loaded from an npm install (its own path is
|
|
46
48
|
* inside a `node_modules` directory), OpenCode skips the host Solid transform
|