opencode-context-indicator 1.6.0 → 1.7.1
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 +123 -4
- package/README.md +146 -42
- package/dist/gui-experimental.cjs +1 -0
- package/index.js +15 -5
- 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 +14 -9
- 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 +188 -17
- package/package.json +15 -6
package/lib/v2.js
CHANGED
|
@@ -14,9 +14,14 @@ import {
|
|
|
14
14
|
estimateTokens,
|
|
15
15
|
extractToolTarget,
|
|
16
16
|
fmt,
|
|
17
|
+
isHookMeasuredEntry,
|
|
18
|
+
isHookSource,
|
|
17
19
|
isSafeSessionKey,
|
|
18
20
|
oneLine,
|
|
21
|
+
safeReclaim,
|
|
19
22
|
safeSaneOrNull,
|
|
23
|
+
safeToolOutByTool,
|
|
24
|
+
safeTop,
|
|
20
25
|
tokenCountOf,
|
|
21
26
|
TOOL_OUT_MAX_KEYS,
|
|
22
27
|
withTimeout,
|
|
@@ -60,6 +65,7 @@ import {
|
|
|
60
65
|
debugEvent,
|
|
61
66
|
logErr,
|
|
62
67
|
pushFinalSummary,
|
|
68
|
+
readStateSnapshot,
|
|
63
69
|
writeLog,
|
|
64
70
|
writeStateFile,
|
|
65
71
|
} from "./state.js";
|
|
@@ -422,10 +428,6 @@ function onV2Compaction(context) {
|
|
|
422
428
|
pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
|
|
423
429
|
}
|
|
424
430
|
|
|
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
431
|
const ctxTokens = lastKnownCtx(sid);
|
|
430
432
|
if (ctxTokens > 0) {
|
|
431
433
|
const lines = buildLiveLinesV2(
|
|
@@ -436,7 +438,7 @@ function onV2Compaction(context) {
|
|
|
436
438
|
ctxTokens,
|
|
437
439
|
lastKnownReasoning(sid),
|
|
438
440
|
);
|
|
439
|
-
writeStateFile(
|
|
441
|
+
const wrote = writeStateFile(
|
|
440
442
|
sid,
|
|
441
443
|
lastKnownModel(sid),
|
|
442
444
|
lastKnownProvider(sid),
|
|
@@ -449,6 +451,16 @@ function onV2Compaction(context) {
|
|
|
449
451
|
cacheWrite: lastKnownCacheWrite(sid),
|
|
450
452
|
},
|
|
451
453
|
);
|
|
454
|
+
// Arm the fallback guard for the trailing `session.compacted` event ONLY
|
|
455
|
+
// when this hook actually PERSISTED the measurement (writeStateFile
|
|
456
|
+
// returns false on a busy lock / unsafe key / failed rename). A hook that
|
|
457
|
+
// wrote nothing leaves the entry untouched; arming there would tell the
|
|
458
|
+
// later event "already handled" and the compaction would leave no trace at
|
|
459
|
+
// all - no stale mark, no note ("after compaction nothing happens").
|
|
460
|
+
if (wrote) {
|
|
461
|
+
lastCompactionHookAt.set(sid, Date.now());
|
|
462
|
+
pruneMap(lastCompactionHookAt, MAX_TRACKED_SESSIONS);
|
|
463
|
+
}
|
|
452
464
|
// Claim key derived from the PAYLOAD shape (message count + total
|
|
453
465
|
// estimated chars): identical across plugin instances for the SAME
|
|
454
466
|
// compaction (cross-instance dedup preserved) yet distinct per compaction
|
|
@@ -464,7 +476,7 @@ function onV2Compaction(context) {
|
|
|
464
476
|
(parts.systemChars || 0) +
|
|
465
477
|
(parts.toolSchemasChars || 0));
|
|
466
478
|
pushFinalSummary(`compaction-hook:${sid}:${sig}`, [
|
|
467
|
-
`[${new Date().toISOString()}] COMPACTION (hook) session=${sid} — pre-compaction snapshot captured`,
|
|
479
|
+
`[${new Date().toISOString()}] COMPACTION (hook) session=${sid} — pre-compaction snapshot ${wrote ? "captured" : "estimated only (state write refused)"}`,
|
|
468
480
|
...lines.slice(1),
|
|
469
481
|
]);
|
|
470
482
|
}
|
|
@@ -671,6 +683,40 @@ function aggregateSubagentsV2(sessionID) {
|
|
|
671
683
|
return result;
|
|
672
684
|
}
|
|
673
685
|
|
|
686
|
+
// Convert a persisted state.json entry into the in-memory breakdown shape so the
|
|
687
|
+
// diagnostic log can render categories another instance measured with the
|
|
688
|
+
// request hook. Everything here is UNTRUSTED shared-file data: reuse the same
|
|
689
|
+
// sanitisers the writer and the command report use, so a poisoned entry cannot
|
|
690
|
+
// inject unbounded arrays, negative counts or foreign shapes into the log.
|
|
691
|
+
function stateEntryToBreakdown(entry) {
|
|
692
|
+
const c = entry?.categories || {};
|
|
693
|
+
const user = safeSaneOrNull(c.user) ?? 0;
|
|
694
|
+
const assistant = safeSaneOrNull(c.assistant) ?? 0;
|
|
695
|
+
const reasoning = safeSaneOrNull(c.reasoning) ?? 0;
|
|
696
|
+
const toolArgs = safeSaneOrNull(c.toolArgs) ?? 0;
|
|
697
|
+
const toolOut = safeSaneOrNull(c.toolOut) ?? 0;
|
|
698
|
+
return {
|
|
699
|
+
userChars: user,
|
|
700
|
+
assistantChars: assistant,
|
|
701
|
+
reasoningChars: reasoning,
|
|
702
|
+
toolArgsChars: toolArgs,
|
|
703
|
+
toolOutChars: toolOut,
|
|
704
|
+
toolOutByTool: safeToolOutByTool(entry?.toolOutByTool),
|
|
705
|
+
systemChars: safeSaneOrNull(c.system),
|
|
706
|
+
toolSchemasChars: safeSaneOrNull(c.toolSchemas),
|
|
707
|
+
reclaim: safeReclaim(entry?.reclaim, false),
|
|
708
|
+
top: safeTop(entry?.top, false),
|
|
709
|
+
// The entry's residual was computed against the INPUT the hook saw. This
|
|
710
|
+
// instance may have a different `lastKnownInput` (a mirror that only saw the
|
|
711
|
+
// trailing event has 0), so the residual must be carried over instead of
|
|
712
|
+
// recomputed - otherwise the log prints a non-zero breakdown next to
|
|
713
|
+
// "other 0 (= input - sum of estimates)".
|
|
714
|
+
otherChars: safeSaneOrNull(c.other),
|
|
715
|
+
capturedAt: Date.parse(entry?.updatedAt) || 0,
|
|
716
|
+
source: entry?.source || "snapshot",
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
674
720
|
// V2: assemble the live breakdown lines (no client, no fallback fetch —
|
|
675
721
|
// the context hook fills breakdownCache on every request).
|
|
676
722
|
function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens, reasoningTokens) {
|
|
@@ -678,7 +724,74 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
678
724
|
const ts = new Date().toISOString();
|
|
679
725
|
const limit = getModelLimit(providerID, modelID);
|
|
680
726
|
const usableInfo = getUsableContext(providerID, modelID);
|
|
681
|
-
|
|
727
|
+
|
|
728
|
+
// A client-context fallback cannot see the system prompt or the tool schemas:
|
|
729
|
+
// its `other` swallows both and, right after a compaction, it can even read a
|
|
730
|
+
// nearly empty conversation. The log is shared by every instance, so when
|
|
731
|
+
// ANOTHER instance has measured this session with the request hook, that
|
|
732
|
+
// measurement (state.json) is the truthful one to print here - same priority
|
|
733
|
+
// as writeStateFile / collectSessionReport. `!bd` (this instance has no cache
|
|
734
|
+
// entry at all, e.g. it only saw the trailing event) takes the same path.
|
|
735
|
+
// Only the DISPLAY is substituted; the cache itself is left alone.
|
|
736
|
+
//
|
|
737
|
+
// The counters come from the same snapshot on purpose: this instance's
|
|
738
|
+
// input/ctx belong to ITS view (a mirror that only saw the trailing event has
|
|
739
|
+
// 0), and mixing them with the hook's categories made the header contradict
|
|
740
|
+
// the "other = input - sum of estimates" line right below it.
|
|
741
|
+
let bd = breakdownCache.get(sessionID);
|
|
742
|
+
let snapInputTokens = null;
|
|
743
|
+
let snapCtxTokens = null;
|
|
744
|
+
let snapAt = null;
|
|
745
|
+
let borrowedSnapshot = false;
|
|
746
|
+
// A hook breakdown whose instance has NO request counters is just as
|
|
747
|
+
// unprintable as a fallback: the categories are measured, but the total this
|
|
748
|
+
// instance knows is 0 (it never completed a step - e.g. a mirror that only
|
|
749
|
+
// saw the compaction hook). Such an entry also takes the state.json path, so
|
|
750
|
+
// the header and the categories come from ONE measurement instead of a
|
|
751
|
+
// `ctx=0` header sitting above non-zero categories.
|
|
752
|
+
const haveLocalCounters =
|
|
753
|
+
(typeof inputTokens === "number" && inputTokens > 0) ||
|
|
754
|
+
(typeof ctxTokens === "number" && ctxTokens > 0);
|
|
755
|
+
if (
|
|
756
|
+
!bd ||
|
|
757
|
+
bd.source === "client-context" ||
|
|
758
|
+
(isHookSource(bd.source) && !haveLocalCounters)
|
|
759
|
+
) {
|
|
760
|
+
const entry = isSafeSessionKey(sessionID)
|
|
761
|
+
? readStateSnapshot()[sessionID]
|
|
762
|
+
: null;
|
|
763
|
+
if (isHookMeasuredEntry(entry)) {
|
|
764
|
+
bd = stateEntryToBreakdown(entry);
|
|
765
|
+
snapInputTokens = safeSaneOrNull(entry.input);
|
|
766
|
+
snapCtxTokens = safeSaneOrNull(entry.ctx);
|
|
767
|
+
snapAt = typeof entry.updatedAt === "string" ? entry.updatedAt : null;
|
|
768
|
+
borrowedSnapshot = true;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
const source = bd?.source || "none";
|
|
772
|
+
const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
|
|
773
|
+
const effInput = snapInputTokens != null ? snapInputTokens : inputTokens;
|
|
774
|
+
const effCtx = snapCtxTokens != null ? snapCtxTokens : ctxTokens;
|
|
775
|
+
// Only a POSITIVE counter is knowledge. A zero is exactly what the broken
|
|
776
|
+
// case looks like (a state entry written by the trailing-event fallback of a
|
|
777
|
+
// mirror instance carries `ctx: 0, input: 0`), and treating it as "known"
|
|
778
|
+
// would reprint the very `ctx=0` this path exists to avoid.
|
|
779
|
+
const haveSnapCounters =
|
|
780
|
+
(typeof snapInputTokens === "number" && snapInputTokens > 0) ||
|
|
781
|
+
(typeof snapCtxTokens === "number" && snapCtxTokens > 0);
|
|
782
|
+
// Still unknown (no counters here AND no usable hook measurement on disk):
|
|
783
|
+
// print "?" rather than a fabricated 0 that contradicts the categories below.
|
|
784
|
+
const countersKnown = haveLocalCounters || haveSnapCounters;
|
|
785
|
+
// `other = input - sum` needs the input itself; a ctx-only snapshot (the
|
|
786
|
+
// compaction hook knows ctx but a mirror never saw the request) cannot
|
|
787
|
+
// produce an honest residual.
|
|
788
|
+
const inputKnown =
|
|
789
|
+
countersKnown && typeof effInput === "number" && effInput > 0;
|
|
790
|
+
const total = !countersKnown
|
|
791
|
+
? null
|
|
792
|
+
: typeof effCtx === "number" && effCtx > 0
|
|
793
|
+
? effCtx
|
|
794
|
+
: effInput;
|
|
682
795
|
const denom =
|
|
683
796
|
usableInfo != null
|
|
684
797
|
? usableInfo.usable
|
|
@@ -686,19 +799,31 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
686
799
|
? limit
|
|
687
800
|
: null;
|
|
688
801
|
const pct =
|
|
689
|
-
denom != null
|
|
802
|
+
denom != null && total != null
|
|
803
|
+
? ` (${((total / denom) * 100).toFixed(1)}%)`
|
|
804
|
+
: "";
|
|
805
|
+
// The reader must be able to tell a fresh local measurement from a borrowed
|
|
806
|
+
// snapshot (and from how old it is). A snapshot without a timestamp still
|
|
807
|
+
// must not look local.
|
|
808
|
+
const snapNote = borrowedSnapshot
|
|
809
|
+
? ` snapshot=${snapAt || "unknown"}`
|
|
810
|
+
: "";
|
|
690
811
|
|
|
812
|
+
const shownTotal = total != null ? fmt(total) : "?";
|
|
691
813
|
lines.push(
|
|
692
|
-
`[${ts}] session=${sessionID} model=${modelID} ctx=${
|
|
814
|
+
`[${ts}] session=${sessionID} model=${modelID} ctx=${shownTotal}/${denom != null ? fmt(denom) : "?"}${pct} count=${shownTotal} usable=${usableInfo != null ? fmt(usableInfo.usable) : "?"} limit=${limit != null ? fmt(limit) : "?"} maxOutput=${usableInfo != null ? fmt(usableInfo.maxOutput) : "?"} reserved=${usableInfo != null ? fmt(usableInfo.reserved) : "?"} input=${inputKnown ? fmt(effInput) : "?"}${snapNote}`,
|
|
693
815
|
);
|
|
694
816
|
|
|
695
|
-
const bd = breakdownCache.get(sessionID);
|
|
696
|
-
const source = bd?.source || "none";
|
|
697
|
-
const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
|
|
698
|
-
|
|
699
817
|
// Residual = input minus every measured category (shared normaliser; `bd`
|
|
700
|
-
// may be absent -> everything 0 and the residual is the whole input).
|
|
701
|
-
|
|
818
|
+
// may be absent -> everything 0 and the residual is the whole input). A
|
|
819
|
+
// breakdown taken from state.json carries the residual the HOOK computed
|
|
820
|
+
// against the input IT saw; this instance's `inputTokens` can differ (a mirror
|
|
821
|
+
// that only saw the trailing event has 0), so that value is used instead.
|
|
822
|
+
const other = inputKnown
|
|
823
|
+
? typeof bd?.otherChars === "number"
|
|
824
|
+
? bd.otherChars
|
|
825
|
+
: breakdownCategories(bd, effInput).other
|
|
826
|
+
: null;
|
|
702
827
|
|
|
703
828
|
if (bd) {
|
|
704
829
|
lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
|
|
@@ -728,7 +853,11 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
728
853
|
lines.push(` system n/a (hook did not run)`);
|
|
729
854
|
lines.push(` tool schemas n/a`);
|
|
730
855
|
}
|
|
731
|
-
lines.push(
|
|
856
|
+
lines.push(
|
|
857
|
+
other != null
|
|
858
|
+
? ` other ${fmt(other)} (= input - sum of estimates)`
|
|
859
|
+
: ` other n/a (input unknown on this instance)`,
|
|
860
|
+
);
|
|
732
861
|
lines.push(` source ${source}`);
|
|
733
862
|
|
|
734
863
|
// Provider-reported prompt cache (EXACT, not an estimate; a subset of input —
|
|
@@ -877,8 +1006,45 @@ async function handleV2Event(ctx, event) {
|
|
|
877
1006
|
},
|
|
878
1007
|
);
|
|
879
1008
|
}
|
|
1009
|
+
// Claim key: the event id when the host sends a usable one; otherwise the
|
|
1010
|
+
// event's own timestamp (a per-event revision, when present); otherwise a
|
|
1011
|
+
// per-session minute bucket (same shape as the V1 idle note). A
|
|
1012
|
+
// session-only key would log just the FIRST compaction of a session and
|
|
1013
|
+
// silently drop every later one whenever the host omits the event id.
|
|
1014
|
+
// An empty/zero id is treated as absent: `??` would collapse it to the
|
|
1015
|
+
// same key for every compaction and drop all but the first note.
|
|
1016
|
+
const evId =
|
|
1017
|
+
typeof event.id === "string" && event.id.length > 0 ? event.id : null;
|
|
1018
|
+
let evStamp = null;
|
|
1019
|
+
if (!evId) {
|
|
1020
|
+
for (const v of [
|
|
1021
|
+
event.time,
|
|
1022
|
+
event.timestamp,
|
|
1023
|
+
event.data?.time,
|
|
1024
|
+
event.data?.createdAt,
|
|
1025
|
+
event.data?.timestamp,
|
|
1026
|
+
]) {
|
|
1027
|
+
if (typeof v === "string" && v.length > 0) {
|
|
1028
|
+
evStamp = v;
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
// `0` is a common "time unknown" sentinel: accepting it would make
|
|
1032
|
+
// the key constant for the whole 24h dedup TTL and re-introduce the
|
|
1033
|
+
// "only the first compaction is logged" bug.
|
|
1034
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0) {
|
|
1035
|
+
evStamp = String(v);
|
|
1036
|
+
break;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
// The session id stays in the key even with an event id: some hosts
|
|
1041
|
+
// number ids per session ("1", "2"), and cross-instance dedup only needs
|
|
1042
|
+
// the id to be equal for the SAME event of the SAME session.
|
|
1043
|
+
const claimKey = evId
|
|
1044
|
+
? `compacted:${sid}:${evId}`
|
|
1045
|
+
: `compacted:${sid}:${evStamp ?? Math.floor(Date.now() / 60000)}`;
|
|
880
1046
|
pushFinalSummary(
|
|
881
|
-
|
|
1047
|
+
claimKey,
|
|
882
1048
|
[
|
|
883
1049
|
`[${new Date().toISOString()}] COMPACTED session=${sid} (auto — the context may have overflowed)`,
|
|
884
1050
|
...lines.slice(1),
|
|
@@ -1071,5 +1237,10 @@ export {
|
|
|
1071
1237
|
handleV2Event,
|
|
1072
1238
|
estimateBreakdownFromContext,
|
|
1073
1239
|
estimateBreakdownFromClientMessages,
|
|
1240
|
+
// Harness-only (test/prepublish.mjs): the live-log assembly and its
|
|
1241
|
+
// state-entry adapter, so the hook-vs-fallback display rule can be tested
|
|
1242
|
+
// without a real opencode runtime.
|
|
1243
|
+
buildLiveLinesV2,
|
|
1244
|
+
stateEntryToBreakdown,
|
|
1074
1245
|
V2_FALLBACK_TIMEOUT_MS,
|
|
1075
1246
|
};
|
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.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, 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"
|
|
@@ -71,14 +76,18 @@
|
|
|
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
|
}
|