opencode-context-indicator 1.0.0 → 1.1.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/README.md +157 -15
- package/index.js +565 -49
- package/package.json +7 -1
- package/tui.tsx +14 -2
package/README.md
CHANGED
|
@@ -9,6 +9,9 @@ arguments and the residual "other" bucket.
|
|
|
9
9
|
|
|
10
10
|
The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
|
|
11
11
|
generations) plus an optional TUI sidebar module exposed as the `./tui` entry.
|
|
12
|
+
The V2 path is **tested against opencode v2.0.19**; the V1 path against 1.18.29+.
|
|
13
|
+
|
|
14
|
+

|
|
12
15
|
|
|
13
16
|
---
|
|
14
17
|
|
|
@@ -70,26 +73,163 @@ other 5.6k 12%
|
|
|
70
73
|
updated 12:30:01
|
|
71
74
|
```
|
|
72
75
|
|
|
76
|
+
### Slash commands (V2)
|
|
77
|
+
|
|
78
|
+
On OpenCode 2.x (Desktop **and** terminal TUI) the plugin registers two slash
|
|
79
|
+
commands:
|
|
80
|
+
|
|
81
|
+
| Command | Scope | Delivery | Agent turn? |
|
|
82
|
+
| --- | --- | --- | :---: |
|
|
83
|
+
| `/context` | **this session only** (no subagents); cache + `state.json` only | `resume:true` — the model echoes the 3-line summary into the transcript | yes |
|
|
84
|
+
| `/context-breakdown` | this session **+ all subagent descendants** | `resume:true` — the model echoes the table into the transcript | yes |
|
|
85
|
+
|
|
86
|
+
#### `/context` — quick summary (main session only)
|
|
87
|
+
|
|
88
|
+
A 3-line summary of the current session, built purely from the in-memory cache
|
|
89
|
+
and the `state.json` snapshot: **no subagent traversal and no live RPC**, so
|
|
90
|
+
collection is instant (delivery itself still runs an agent turn — see below).
|
|
91
|
+
The synthetic notice chip shows a self-contained one-liner (label =
|
|
92
|
+
`description`):
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
Context: 41.5k (25%) · your-model · 12:36
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The payload is delivered with `resume:true` and a verbatim instruction prefix,
|
|
99
|
+
so the model's answer (a normal assistant message) carries the detail:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
Reproduce the markdown table below exactly, verbatim, with no changes and no commentary:
|
|
103
|
+
|
|
104
|
+
**Context** — your-model · 41.5k / 168k (25%)
|
|
105
|
+
usr 2.3k · asst 285 · rsn 9.6k · tool 2.1k · sys 8.5k · schm 3.8k · oth 14.9k
|
|
106
|
+
updated 12:36:16 (snap)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The Desktop renders the model's answer in full; the notice chip only shows the
|
|
110
|
+
one-liner description.
|
|
111
|
+
|
|
112
|
+
#### `/context-breakdown` — full table (main + subagents)
|
|
113
|
+
|
|
114
|
+
The full markdown table for the current session **and every subagent (child)
|
|
115
|
+
session**:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
### Context breakdown
|
|
119
|
+
|
|
120
|
+
| role | model | ctx | usr | asst | rsn | tool | sys | schm | oth | updated (src) |
|
|
121
|
+
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
122
|
+
| main | gpt-4o | 45.2k (38%) | 4.2k | 3.1k | 0.8k | 0.3k | 2.1k | 1.4k | 5.6k | 12:30:01 live |
|
|
123
|
+
| sub:build | gpt-4o | 8.1k (7%) | 1.2k | 0.9k | 0.3k | 0.1k | 2.1k | 1.4k | 2.1k | 12:29:58 snap |
|
|
124
|
+
|
|
125
|
+
_1 subagent session(s). Tokens are estimates (unicode heuristic). usr=user,
|
|
126
|
+
asst=assistant, rsn=reasoning, tool=tool args, sys=system prompt, schm=tool
|
|
127
|
+
schemas, oth=residual input. src: live=context hook, snap=state.json,
|
|
128
|
+
fallback=session.context (no system/schemas → n/a), no-data=categories
|
|
129
|
+
unavailable._
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
It is delivered with `resume:true` **and** a verbatim instruction prefix, so the
|
|
133
|
+
agent's answer (a normal assistant message) carries the table and the Desktop
|
|
134
|
+
renders it in full:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
Reproduce the markdown table below exactly, verbatim, with no changes and no commentary:
|
|
138
|
+
|
|
139
|
+
### Context breakdown
|
|
140
|
+
| … |
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
#### Delivery semantics (`resume`)
|
|
144
|
+
|
|
145
|
+
`ctx.session.synthetic({ sessionID, text, description, resume })` durably *admits*
|
|
146
|
+
the message to the session **inbox**; the `resume` flag only gates whether the
|
|
147
|
+
session is woken (opencode `Session.synthetic`:
|
|
148
|
+
`if (resume !== false) wake(session)`):
|
|
149
|
+
|
|
150
|
+
| `resume` | Effect |
|
|
151
|
+
| --- | --- |
|
|
152
|
+
| `false` | durable inbox notice; **no** LLM request; session context unchanged. A synthetic message is rendered as a compact **Notice chip** whose label is `description ?? text` (the client row-builder maps `synthetic` → Notice) — so the chip shows the description, **not** the table. |
|
|
153
|
+
| `true` | the same inbox item **plus** a wake → a real agent turn runs. The model sees the payload as input and its answer is a **normal assistant message**, rendered in full. |
|
|
154
|
+
|
|
155
|
+
Both commands now use `resume:true` and a verbatim instruction prefix. A
|
|
156
|
+
real agent turn runs and the model renders the payload as a normal assistant
|
|
157
|
+
message — the only way the content is visible in the Desktop (the chip only
|
|
158
|
+
shows `description`). `CONTEXT_BREAKDOWN_RESUME` controls `/context-breakdown`
|
|
159
|
+
back to a plain notice if needed.
|
|
160
|
+
|
|
161
|
+
> **E2E note (Desktop 2.0.19, local model `llama3.2:latest`, `build` agent).**
|
|
162
|
+
> `resume:true` behaved as designed: the synthetic item was drained into
|
|
163
|
+
> `/message` and a turn ran; the assistant message contained the table verbatim
|
|
164
|
+
> (the trailing `_…_` note line was dropped by the model). Because `resume:true`
|
|
165
|
+
> runs a **real agent turn with tools**, the model may additionally emit tool
|
|
166
|
+
> calls — in the E2E it attempted one stray `edit` (harmless: the path did not
|
|
167
|
+
> exist). Treat `/context-breakdown` as *"ask the agent to show the table"*, not
|
|
168
|
+
> as a pure UI action.
|
|
169
|
+
|
|
170
|
+
* **Both commands run an agent turn.** `/context` collects from the in-memory
|
|
171
|
+
cache and `state.json` only (no RPC, no subagent traversal) and
|
|
172
|
+
`/context-breakdown` collects the full tree, but both deliver with
|
|
173
|
+
`resume:true` — so each issues a real LLM request and the session context
|
|
174
|
+
grows by that turn.
|
|
175
|
+
* Subagents are discovered by walking the **parentID chain persisted in
|
|
176
|
+
`state.json`** (breadth-first from the current session, every level — subagents
|
|
177
|
+
of subagents included). `state.json` is the only source: each entry is keyed by
|
|
178
|
+
its own `sessionID` and carries the `parentID` recorded when it was written, so
|
|
179
|
+
the chain is root-consistent and entries of *other* sessions are unreachable.
|
|
180
|
+
A `seen` set guards against cycles and double-listing. Subagents that already
|
|
181
|
+
finished stay listed while their `state.json` entry exists.
|
|
182
|
+
* **Phantom sessions are hidden.** A session registered via `session.created`
|
|
183
|
+
but with no served step (no tokens in any category, `ctx = 0`, `input = 0`) —
|
|
184
|
+
e.g. an E2E/test registration or an agent ping without a turn — is skipped.
|
|
185
|
+
Traversal still descends through such a node so real grandchildren behind it
|
|
186
|
+
are found. The footer's `N subagent session(s)` counts only non-phantom rows.
|
|
187
|
+
* Collection is **parallel** (root + all descendants) under a single wall-clock
|
|
188
|
+
deadline of 3.5 s, with each per-session live lookup capped at 3 s — so the
|
|
189
|
+
command takes roughly the per-session cap (not the sum), rather than N × 3 s
|
|
190
|
+
for N subagents. Per session the data is resolved as: live breakdown cache →
|
|
191
|
+
`state.json` snapshot → a bounded `session.context()` fallback. A session left
|
|
192
|
+
with no category data (no cache/snapshot and the fallback failed or ran out of
|
|
193
|
+
budget) is tagged `no-data`; `ctx` / `model` / `updatedAt` still come from
|
|
194
|
+
`state.json` / the live lookup, while its category cells stay `n/a`.
|
|
195
|
+
* Both commands append their payload to `context-breakdown.log` (final-summaries
|
|
196
|
+
section), so it stays recoverable even if delivery fails.
|
|
197
|
+
* If `ctx.session.synthetic` throws, the command logs an actionable error and the
|
|
198
|
+
payload is preserved in the log — there is no other delivery channel in this
|
|
199
|
+
runtime (no `noReply`/`prompt` option exists).
|
|
200
|
+
* **The model may echo the instruction line.** Because delivery is an agent turn,
|
|
201
|
+
the model can reproduce the verbatim instruction prefix
|
|
202
|
+
(`Reproduce the markdown table below exactly…`) in its answer instead of
|
|
203
|
+
echoing only the payload.
|
|
204
|
+
* **Deleted sessions can linger.** `session.deleted` clears the in-memory caches
|
|
205
|
+
but not the `state.json` entry, so a deleted session that had already served a
|
|
206
|
+
step (has tokens) is not filtered as a phantom and stays listed until its entry
|
|
207
|
+
is evicted by the `state.json` LRU cap (`MAX_TRACKED_SESSIONS`) — it may
|
|
208
|
+
momentarily appear in the table.
|
|
209
|
+
|
|
73
210
|
---
|
|
74
211
|
|
|
75
212
|
## Surface matrix
|
|
76
213
|
|
|
77
|
-
| Surface | Toast | TUI sidebar | Breakdown log file |
|
|
78
|
-
| --- | :---: | :---: | :---: |
|
|
79
|
-
| OpenCode **1.x** — Desktop | ✅ | — | ✅ |
|
|
80
|
-
| OpenCode **1.x** — TUI | ✅ | — | ✅ |
|
|
81
|
-
| OpenCode **2.x** — TUI | — | ✅ | ✅ |
|
|
82
|
-
| OpenCode **2.x** — Desktop | — | — | ✅ |
|
|
214
|
+
| Surface | Toast | TUI sidebar | Breakdown log file | Slash commands |
|
|
215
|
+
| --- | :---: | :---: | :---: | :---: |
|
|
216
|
+
| OpenCode **1.x** — Desktop | ✅ | — | ✅ | — |
|
|
217
|
+
| OpenCode **1.x** — TUI | ✅ | — | ✅ | — |
|
|
218
|
+
| OpenCode **2.x** — TUI | — | ✅ | ✅ | ✅ |
|
|
219
|
+
| OpenCode **2.x** — Desktop | — | — | ✅ | ✅ |
|
|
83
220
|
|
|
84
221
|
Notes — these reflect what the plugin API actually exposes today:
|
|
85
222
|
|
|
86
223
|
* **V2 has no toast channel on the server side.** The V2 plugin API
|
|
87
224
|
(`ctx.*`) exposes no TUI/toast method (opencode issue `#49380`); the V2 path
|
|
88
|
-
therefore delivers everything through the log file
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
225
|
+
therefore delivers everything through the log file, the TUI sidebar and the
|
|
226
|
+
`/context` / `/context-breakdown` slash commands. The sidebar is a TUI-process
|
|
227
|
+
slot and does not exist in the Desktop app; the slash commands work in **both**.
|
|
228
|
+
* **V1 has no sidebar and no slash command.** The command/slot API (and the
|
|
229
|
+
`./tui` entry) is a V2 feature; OpenCode 1.x has no equivalent, so V1 is
|
|
230
|
+
toast + log only.
|
|
231
|
+
* The Desktop app is TUI-less; its V2 surfaces are the log file and the
|
|
232
|
+
`/context` / `/context-breakdown` slash commands.
|
|
93
233
|
|
|
94
234
|
---
|
|
95
235
|
|
|
@@ -180,7 +320,7 @@ recommended path for the sidebar).
|
|
|
180
320
|
gracefully (the breakdown falls back to throttled `session.messages` fetches
|
|
181
321
|
and toasts keep working).
|
|
182
322
|
* **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
|
|
183
|
-
**2.0.
|
|
323
|
+
**2.0.19**).
|
|
184
324
|
* **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
|
|
185
325
|
ships with OpenCode (`@opentui/core`, `@opentui/solid`, `solid-js`). These are
|
|
186
326
|
declared as **optional** peer dependencies, so the main plugin installs and
|
|
@@ -204,9 +344,11 @@ The package default export is a dual plugin:
|
|
|
204
344
|
* `setup(ctx)` — V2 entry (OpenCode ≥ 2.0.16). Registers a `session.hook("context", …)`
|
|
205
345
|
to capture the final messages / system prompt / tool schemas right before each
|
|
206
346
|
model request, subscribes to the event stream (`session.created`,
|
|
207
|
-
`session.step.ended`, `session.idle`, `session.compacted`, failure events)
|
|
208
|
-
records limits from the model registry
|
|
209
|
-
|
|
347
|
+
`session.step.ended`, `session.idle`, `session.compacted`, failure events),
|
|
348
|
+
records limits from the model registry and registers the
|
|
349
|
+
`/context` + `/context-breakdown` slash commands (via
|
|
350
|
+
`ctx.command.transform`). Read-only: the context is never mutated (the
|
|
351
|
+
commands wake the session via `resume:true`, which is what renders the table).
|
|
210
352
|
* `server({ client })` — V1 entry (OpenCode ≥ 1.18.29). Classic event handler +
|
|
211
353
|
experimental transform hooks + toasts.
|
|
212
354
|
|
package/index.js
CHANGED
|
@@ -66,6 +66,44 @@
|
|
|
66
66
|
* session.step.ended (keyed by the child's own sessionID). This is
|
|
67
67
|
* event-driven and does not need the removed API.
|
|
68
68
|
*
|
|
69
|
+
* V2 slash commands (added 1.1.0, verified against the live Desktop 2.0.19
|
|
70
|
+
* plugin runtime). Two commands:
|
|
71
|
+
* - /context — a 3-line summary of THIS session only (model, tokens, %,
|
|
72
|
+
* updated + category line). Main only: NO subagent traversal and NO live
|
|
73
|
+
* RPC (cache + state.json only), so it is instant. Delivered as a
|
|
74
|
+
* resume:false notice; the chip label (`description`) is self-contained:
|
|
75
|
+
* "Context: 41.5k (25%) · Auto · 12:36".
|
|
76
|
+
* - /context-breakdown — the full markdown table (main + all subagent
|
|
77
|
+
* descendants), collected in parallel under one budget. Delivered with
|
|
78
|
+
* resume:true and a verbatim instruction prefix, so the agent echoes the
|
|
79
|
+
* table into the transcript as a normal message (rendered in full by the
|
|
80
|
+
* Desktop); resume:false remains a one-constant fallback.
|
|
81
|
+
* - ctx.command.transform(editor => editor.add({ name, description,
|
|
82
|
+
* execute })) — registers a slash command; `execute` receives the
|
|
83
|
+
* invocation { sessionID, prompt:{text,files,agents,skills}, delivery }.
|
|
84
|
+
* - ctx.session.synthetic({ sessionID, text, description?, resume? }) —
|
|
85
|
+
* durably ADMITS a synthetic message to the session INBOX. `resume` only
|
|
86
|
+
* gates whether the session is woken (Session.synthetic:
|
|
87
|
+
* `if (resume !== false) wake(session)`): resume:true starts an agent turn
|
|
88
|
+
* (grows session context), resume:false leaves a durable notice without
|
|
89
|
+
* running the agent loop. A synthetic message is always shown as a compact
|
|
90
|
+
* Notice chip whose label is `description ?? text` (client row-builder:
|
|
91
|
+
* `synthetic` -> Notice), which is exactly why /context-breakdown uses
|
|
92
|
+
* resume:true: the model answer, not the chip, carries the full table.
|
|
93
|
+
* - session list = the parentID chain persisted in state.json (BFS from the
|
|
94
|
+
* root; see collectDescendantSessionIDs). state.json is the ONLY source:
|
|
95
|
+
* every entry is keyed by its own sessionID and carries the parentID
|
|
96
|
+
* recorded at write time, so the chain is root-consistent — entries of
|
|
97
|
+
* OTHER roots are unreachable. Finished / deleted subagents stay listed
|
|
98
|
+
* while their state.json entry exists. Module-scope maps persist across
|
|
99
|
+
* requests for the lifetime of a plugin INSTANCE; PluginSupervisor
|
|
100
|
+
* (re)activates the plugin on start / config or file change / every 24h —
|
|
101
|
+
* NOT per request — so an in-memory index would be cold for sessions
|
|
102
|
+
* created before the current instance loaded, which is exactly why
|
|
103
|
+
* discovery uses the durable state.json instead.
|
|
104
|
+
* - All logic stays in module scope: the handlers need the in-memory maps.
|
|
105
|
+
* The V1 server() path is intentionally untouched (commands are V2-only).
|
|
106
|
+
*
|
|
69
107
|
* ---------------------------------------------------------------------------
|
|
70
108
|
* TUI STATE BRIDGE (optional sidebar)
|
|
71
109
|
* ---------------------------------------------------------------------------
|
|
@@ -221,6 +259,10 @@ const sessionParentCache = new Map();
|
|
|
221
259
|
// from session.step.ended, used to fold child-session totals into the parent.
|
|
222
260
|
const sessionTotals = new Map();
|
|
223
261
|
|
|
262
|
+
// V2: sessionID -> agent name (session.created data.agent). Used only as the
|
|
263
|
+
// role label ("sub:<agent>") in the /context-breakdown table.
|
|
264
|
+
const sessionAgentCache = new Map();
|
|
265
|
+
|
|
224
266
|
// Gating for client.config.providers() (V1) fetches:
|
|
225
267
|
// providersLoadedAt — last SUCCESSFUL fetch (full PROVIDERS_TTL_MS cache);
|
|
226
268
|
// providersFailAt — last FAILED/timed-out fetch (short fail TTL, so a
|
|
@@ -427,6 +469,7 @@ function writeStateFile(
|
|
|
427
469
|
all = {}; // missing / empty / corrupt -> start fresh
|
|
428
470
|
}
|
|
429
471
|
if (!all || typeof all !== "object" || Array.isArray(all)) all = {};
|
|
472
|
+
const prevEntry = all[sessionID] || null;
|
|
430
473
|
const pc = all[sessionID]?.categories || null;
|
|
431
474
|
|
|
432
475
|
const catUser = bd ? bd.userChars || 0 : pc?.user ?? 0;
|
|
@@ -450,8 +493,18 @@ function writeStateFile(
|
|
|
450
493
|
(catSystem || 0) +
|
|
451
494
|
(catToolSchemas || 0);
|
|
452
495
|
const other = Math.max(0, (inputTokens || 0) - sumEstimates);
|
|
496
|
+
// Parent link for the /context-breakdown command. Prefer the live cache;
|
|
497
|
+
// fall back to the previously persisted value (backward compatible with
|
|
498
|
+
// snapshots written before parentID existed -> null). role mirrors it.
|
|
499
|
+
const parentID =
|
|
500
|
+
sessionParentCache.has(sessionID)
|
|
501
|
+
? sessionParentCache.get(sessionID) ?? null
|
|
502
|
+
: prevEntry?.parentID ?? null;
|
|
453
503
|
const entry = {
|
|
454
504
|
sessionID,
|
|
505
|
+
parentID,
|
|
506
|
+
role: parentID ? "sub" : "main",
|
|
507
|
+
agent: sessionAgentCache.get(sessionID) || null,
|
|
455
508
|
model: modelID || "unknown",
|
|
456
509
|
providerID: providerID || "",
|
|
457
510
|
ctx: total,
|
|
@@ -606,6 +659,16 @@ function getUsableContext(providerID, modelID) {
|
|
|
606
659
|
return { usable, limit: context, maxOutput, reserved };
|
|
607
660
|
}
|
|
608
661
|
|
|
662
|
+
// Store a model limit record under `<providerID>:<modelID>`. Written by
|
|
663
|
+
// `ensureModelLimits` (V2: ctx.model.list()) and `ensureProviderLimits` (V1:
|
|
664
|
+
// client.config.providers()), both carrying the effective merged config.
|
|
665
|
+
function setModelLimit(providerID, modelID, rec) {
|
|
666
|
+
const key = modelKey(providerID, modelID);
|
|
667
|
+
if (!key) return false;
|
|
668
|
+
modelLimits.set(key, rec);
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
|
|
609
672
|
// ---------------------------------------------------------------------------
|
|
610
673
|
// V1 breakdown estimation from messages (Array<{info, parts}>).
|
|
611
674
|
// ---------------------------------------------------------------------------
|
|
@@ -736,7 +799,7 @@ async function ensureProviderLimits(client) {
|
|
|
736
799
|
typeof lim.output === "number" && lim.output > 0 ? lim.output : null;
|
|
737
800
|
if (context == null && input == null) continue;
|
|
738
801
|
const prevRec = modelLimits.get(key);
|
|
739
|
-
|
|
802
|
+
setModelLimit(providerID, modelID, {
|
|
740
803
|
context,
|
|
741
804
|
input,
|
|
742
805
|
output,
|
|
@@ -1765,6 +1828,22 @@ const V2_CONTEXT_FETCH_THROTTLE_MS = 5000; // min gap between fallback fetches
|
|
|
1765
1828
|
// event handlers): cap each at 3s so a slow/hung client cannot pin resources.
|
|
1766
1829
|
const V2_FALLBACK_TIMEOUT_MS = 3000;
|
|
1767
1830
|
|
|
1831
|
+
// Cumulative wall-clock budget for one /context-breakdown collection (root +
|
|
1832
|
+
// every descendant, fetched in PARALLEL). Each per-session live lookup is
|
|
1833
|
+
// additionally capped at V2_FALLBACK_TIMEOUT_MS, so the command costs about the
|
|
1834
|
+
// per-session cap (not the sum) instead of N × 3s for N subagents. A session
|
|
1835
|
+
// with no cached/snapshot categories whose bounded session.context lookup fails
|
|
1836
|
+
// or never gets any budget is tagged "no-data": ctx/model/updatedAt still come
|
|
1837
|
+
// from state.json / live, but the category cells stay n/a.
|
|
1838
|
+
const COMMAND_COLLECT_BUDGET_MS = 3500;
|
|
1839
|
+
|
|
1840
|
+
// `resume` for the full /context-breakdown command. resume:true admits the
|
|
1841
|
+
// payload to the transcript AND wakes the session, so the agent echoes the table
|
|
1842
|
+
// as a normal message that the Desktop renders in full (the notice chip only
|
|
1843
|
+
// shows `description`). Set to false to fall back to a plain inbox notice if a
|
|
1844
|
+
// given runtime renders the model answer worse than the notice.
|
|
1845
|
+
const CONTEXT_BREAKDOWN_RESUME = true;
|
|
1846
|
+
|
|
1768
1847
|
// Fill breakdownCache for a session when the context hook has not already done
|
|
1769
1848
|
// so. Throttled, fault-tolerant, READ-ONLY. Hook data always wins.
|
|
1770
1849
|
async function ensureV2Breakdown(ctx, sessionID) {
|
|
@@ -1871,7 +1950,7 @@ async function ensureModelLimits(ctx) {
|
|
|
1871
1950
|
typeof lim.output === "number" && lim.output > 0 ? lim.output : null;
|
|
1872
1951
|
if (context == null && input == null) continue;
|
|
1873
1952
|
const prevRec = modelLimits.get(key);
|
|
1874
|
-
|
|
1953
|
+
setModelLimit(providerID, modelID, {
|
|
1875
1954
|
context,
|
|
1876
1955
|
input,
|
|
1877
1956
|
output,
|
|
@@ -2036,8 +2115,16 @@ async function handleV2Event(ctx, event) {
|
|
|
2036
2115
|
const data = event.data || {};
|
|
2037
2116
|
const sid = data.sessionID;
|
|
2038
2117
|
if (!sid) return;
|
|
2039
|
-
|
|
2118
|
+
const parentID = data.parentID || null;
|
|
2119
|
+
// sessionParentCache is the durable parent link used when WRITING the
|
|
2120
|
+
// state.json entry (writeStateFile); discovery itself reads the persisted
|
|
2121
|
+
// parentID chain — see collectDescendantSessionIDs.
|
|
2122
|
+
sessionParentCache.set(sid, parentID);
|
|
2040
2123
|
pruneMap(sessionParentCache, MAX_TRACKED_SESSIONS);
|
|
2124
|
+
if (typeof data.agent === "string" && data.agent) {
|
|
2125
|
+
sessionAgentCache.set(sid, data.agent);
|
|
2126
|
+
pruneMap(sessionAgentCache, MAX_TRACKED_SESSIONS);
|
|
2127
|
+
}
|
|
2041
2128
|
if (data.model && typeof data.model === "object") {
|
|
2042
2129
|
if (typeof data.model.id === "string") lastKnownModelCache.set(sid, data.model.id);
|
|
2043
2130
|
if (typeof data.model.providerID === "string") {
|
|
@@ -2071,6 +2158,10 @@ async function handleV2Event(ctx, event) {
|
|
|
2071
2158
|
if (event.type === "session.deleted") {
|
|
2072
2159
|
const sid = event.data?.sessionID;
|
|
2073
2160
|
if (!sid) return;
|
|
2161
|
+
// Discovery reads the persisted parentID chain, so a deleted session drops
|
|
2162
|
+
// out of the table as soon as its state.json entry is gone (or, while the
|
|
2163
|
+
// stale entry lingers, via the phantom filter — a deleted session has no
|
|
2164
|
+
// step). No dedicated in-memory index to clean up.
|
|
2074
2165
|
clearSessionTracking(sid);
|
|
2075
2166
|
return;
|
|
2076
2167
|
}
|
|
@@ -2259,6 +2350,423 @@ async function handleV2Event(ctx, event) {
|
|
|
2259
2350
|
}
|
|
2260
2351
|
}
|
|
2261
2352
|
|
|
2353
|
+
// ---------------------------------------------------------------------------
|
|
2354
|
+
// V2 slash commands: /context and /context-breakdown
|
|
2355
|
+
// ---------------------------------------------------------------------------
|
|
2356
|
+
// /context prints a 3-line summary of the CURRENT session only (cache +
|
|
2357
|
+
// state.json, no subagents, no RPC); /context-breakdown prints a compact
|
|
2358
|
+
// markdown table for the CURRENT session plus EVERY descendant (subagent)
|
|
2359
|
+
// session. Both deliver via ctx.session.synthetic({sessionID, text, ...}):
|
|
2360
|
+
// resume:true -> wake / agent turn (the model renders the table);
|
|
2361
|
+
// resume:false -> a durable notice, no LLM call. Works in the Desktop v2 app
|
|
2362
|
+
// (which has no TUI slots, issue #49380) and in the TUI. Registered only on
|
|
2363
|
+
// the V2 path.
|
|
2364
|
+
//
|
|
2365
|
+
// Session resolution cascade (per task): in-memory breakdownCache -> the
|
|
2366
|
+
// state.json snapshot -> a throttled/3s ctx.session.context() fallback. The
|
|
2367
|
+
// fallback cannot see the assembled system prompt or the tool schemas, so those
|
|
2368
|
+
// two cells stay "n/a" and the row's source is labelled honestly.
|
|
2369
|
+
|
|
2370
|
+
// Read the whole state.json snapshot ({ sessionID: entry }) or {} on any error.
|
|
2371
|
+
function readStateSnapshot() {
|
|
2372
|
+
try {
|
|
2373
|
+
const all = JSON.parse(readFileSync(STATE_FILE, "utf8"));
|
|
2374
|
+
return all && typeof all === "object" && !Array.isArray(all) ? all : {};
|
|
2375
|
+
} catch {
|
|
2376
|
+
return {};
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// True when a state.json entry carries no measured tokens at all: a session
|
|
2381
|
+
// registered (session.created) but with no served step. `writeStateFile` always
|
|
2382
|
+
// persists `categories` as an object, so the emptiness test must SUM those
|
|
2383
|
+
// fields — a missing/`undefined` categories must not be the only trigger.
|
|
2384
|
+
// Note: `system` / `toolSchemas` may legitimately be null (hook-only), so they
|
|
2385
|
+
// are coerced with `|| 0`.
|
|
2386
|
+
function isEmptyStateEntry(entry) {
|
|
2387
|
+
const c = entry?.categories || {};
|
|
2388
|
+
const sum =
|
|
2389
|
+
(c.user || 0) +
|
|
2390
|
+
(c.assistant || 0) +
|
|
2391
|
+
(c.reasoning || 0) +
|
|
2392
|
+
(c.toolArgs || 0) +
|
|
2393
|
+
(c.system || 0) +
|
|
2394
|
+
(c.toolSchemas || 0) +
|
|
2395
|
+
(c.other || 0);
|
|
2396
|
+
return (
|
|
2397
|
+
sum === 0 &&
|
|
2398
|
+
(!entry?.ctx || entry.ctx === 0) &&
|
|
2399
|
+
(!entry?.input || entry.input === 0)
|
|
2400
|
+
);
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
// ALL descendant session IDs of `rootID` (breadth-first over the parentID chain
|
|
2404
|
+
// in the state.json snapshot), de-duplicated. The snapshot is the ONLY source:
|
|
2405
|
+
// each entry is keyed by its own sessionID and carries the parentID recorded
|
|
2406
|
+
// when it was written, so the chain is root-consistent — entries of OTHER roots
|
|
2407
|
+
// are unreachable from `rootID` (their parentID points at their own root). The
|
|
2408
|
+
// earlier cross-root leakage came from the in-memory index, which is no longer
|
|
2409
|
+
// consulted here.
|
|
2410
|
+
//
|
|
2411
|
+
// The BFS visits every level (subagents of subagents included). A phantom node
|
|
2412
|
+
// (no tokens, see isEmptyStateEntry) is never emitted, but traversal still
|
|
2413
|
+
// descends through it so real grandchildren behind a phantom parent are found.
|
|
2414
|
+
// `seen` guards against cycles (parentID loops) and double-listing.
|
|
2415
|
+
function collectDescendantSessionIDs(rootID, state) {
|
|
2416
|
+
const seen = new Set([rootID]);
|
|
2417
|
+
const out = [];
|
|
2418
|
+
const queue = [rootID];
|
|
2419
|
+
// Index the snapshot by parentID once (O(n)) instead of an O(n) scan per
|
|
2420
|
+
// visited node.
|
|
2421
|
+
const byParent = new Map();
|
|
2422
|
+
for (const [id, entry] of Object.entries(state || {})) {
|
|
2423
|
+
const pid = entry?.parentID;
|
|
2424
|
+
if (!pid) continue;
|
|
2425
|
+
let set = byParent.get(pid);
|
|
2426
|
+
if (!set) {
|
|
2427
|
+
set = new Set();
|
|
2428
|
+
byParent.set(pid, set);
|
|
2429
|
+
}
|
|
2430
|
+
set.add(id);
|
|
2431
|
+
}
|
|
2432
|
+
while (queue.length > 0) {
|
|
2433
|
+
const cur = queue.shift();
|
|
2434
|
+
const kids = byParent.get(cur);
|
|
2435
|
+
if (!kids) continue;
|
|
2436
|
+
for (const c of kids) {
|
|
2437
|
+
if (!c || seen.has(c)) continue; // cycle + dedup guard
|
|
2438
|
+
seen.add(c);
|
|
2439
|
+
if (!isEmptyStateEntry(state[c])) out.push(c);
|
|
2440
|
+
queue.push(c); // descend through phantom parents too
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
return out;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// Normalise a breakdown record (cache or state entry) into the 7 categories.
|
|
2447
|
+
// `inputTokens` is used only for the residual "other".
|
|
2448
|
+
function breakdownCategories(rec, inputTokens) {
|
|
2449
|
+
const user = rec?.userChars ?? rec?.user ?? 0;
|
|
2450
|
+
const assistant = rec?.assistantChars ?? rec?.assistant ?? 0;
|
|
2451
|
+
const reasoning = rec?.reasoningChars ?? rec?.reasoning ?? 0;
|
|
2452
|
+
const toolArgs = rec?.toolArgsChars ?? rec?.toolArgs ?? 0;
|
|
2453
|
+
const system = rec?.systemChars !== undefined ? rec.systemChars : rec?.system ?? null;
|
|
2454
|
+
const toolSchemas =
|
|
2455
|
+
rec?.toolSchemasChars !== undefined ? rec.toolSchemasChars : rec?.toolSchemas ?? null;
|
|
2456
|
+
const sum =
|
|
2457
|
+
user + assistant + reasoning + toolArgs + (system || 0) + (toolSchemas || 0);
|
|
2458
|
+
const other = Math.max(0, (inputTokens || 0) - sum);
|
|
2459
|
+
return { user, assistant, reasoning, toolArgs, system, toolSchemas, other };
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
// Short source tag for the table (kept tiny on purpose).
|
|
2463
|
+
function sourceTag(source) {
|
|
2464
|
+
switch (source) {
|
|
2465
|
+
case "context-hook":
|
|
2466
|
+
return "live";
|
|
2467
|
+
case "client-context":
|
|
2468
|
+
return "fallback";
|
|
2469
|
+
case "snapshot":
|
|
2470
|
+
return "snap";
|
|
2471
|
+
case "no-data":
|
|
2472
|
+
return "no-data";
|
|
2473
|
+
default:
|
|
2474
|
+
return source || "none";
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
function timeOnly(iso) {
|
|
2479
|
+
if (!iso) return "-";
|
|
2480
|
+
try {
|
|
2481
|
+
const d = new Date(iso);
|
|
2482
|
+
if (Number.isNaN(d.getTime())) return "-";
|
|
2483
|
+
return d.toTimeString().slice(0, 8); // HH:MM:SS
|
|
2484
|
+
} catch {
|
|
2485
|
+
return "-";
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// Resolve one session's report: totals, model, categories + honest source.
|
|
2490
|
+
// `deadline` is the shared wall-clock budget for the whole command collection
|
|
2491
|
+
// (see commandContextBreakdown): the per-session live lookups are capped by
|
|
2492
|
+
// whatever remains of it and skipped once it is exhausted. A session that ends
|
|
2493
|
+
// up with no category data is tagged "no-data" (see the branch below).
|
|
2494
|
+
async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
2495
|
+
const entry = state?.[sessionID] || null;
|
|
2496
|
+
const bd = breakdownCache.get(sessionID) || null;
|
|
2497
|
+
|
|
2498
|
+
const remaining = Math.max(0, (deadline || 0) - Date.now());
|
|
2499
|
+
const budget = Math.min(V2_FALLBACK_TIMEOUT_MS, remaining);
|
|
2500
|
+
|
|
2501
|
+
// The attribution/model caches survive across requests for the lifetime of a
|
|
2502
|
+
// plugin instance. When cold — and while budget remains — resolve the session
|
|
2503
|
+
// live so the [sub:<agent>] attribution and the model stay correct even if
|
|
2504
|
+
// state.json is stale. ParentID comes from state.json ONLY (entry?.parentID)
|
|
2505
|
+
// so the table is root-consistent; in-memory sessionParentCache is cross-session
|
|
2506
|
+
// and must NOT be used for attribution.
|
|
2507
|
+
const needEnrich =
|
|
2508
|
+
!sessionAgentCache.has(sessionID) ||
|
|
2509
|
+
lastKnownModel(sessionID) === "unknown";
|
|
2510
|
+
|
|
2511
|
+
// Fallback categories only when neither the live cache nor a state snapshot
|
|
2512
|
+
// has them: derive user/assistant/reasoning/tool-args from the persisted
|
|
2513
|
+
// message list (system/tool-schemas stay null there).
|
|
2514
|
+
const needFallback = !bd && !entry?.categories;
|
|
2515
|
+
|
|
2516
|
+
// Enrichment and the context fallback run CONCURRENTLY, both bounded by the
|
|
2517
|
+
// shared budget. Non-fatal: any failure resolves to null and keeps whatever
|
|
2518
|
+
// the cache / state.json already provides.
|
|
2519
|
+
const [live, contextMsgs] = await Promise.all([
|
|
2520
|
+
needEnrich && budget > 0
|
|
2521
|
+
? withTimeout(ctx.session.get({ sessionID }), budget, "session.get").then(
|
|
2522
|
+
(info) => info?.data ?? info ?? null,
|
|
2523
|
+
(err) => {
|
|
2524
|
+
logErr(`context-breakdown: session.get enrichment failed for ${sessionID}`, err);
|
|
2525
|
+
return null;
|
|
2526
|
+
},
|
|
2527
|
+
)
|
|
2528
|
+
: null,
|
|
2529
|
+
needFallback && budget > 0
|
|
2530
|
+
? withTimeout(ctx.session.context({ sessionID }), budget, "session.context").then(
|
|
2531
|
+
(res) => (Array.isArray(res) ? res : res?.data),
|
|
2532
|
+
(err) => {
|
|
2533
|
+
logErr(`context-breakdown: session.context fallback failed for ${sessionID}`, err);
|
|
2534
|
+
return null;
|
|
2535
|
+
},
|
|
2536
|
+
)
|
|
2537
|
+
: null,
|
|
2538
|
+
]);
|
|
2539
|
+
|
|
2540
|
+
let categories = null;
|
|
2541
|
+
let source = "none";
|
|
2542
|
+
if (bd) {
|
|
2543
|
+
categories = breakdownCategories(bd, lastKnownInput(sessionID) || entry?.input || 0);
|
|
2544
|
+
source = bd.source || "live";
|
|
2545
|
+
} else if (entry?.categories) {
|
|
2546
|
+
categories = entry.categories;
|
|
2547
|
+
source = "snapshot";
|
|
2548
|
+
} else if (Array.isArray(contextMsgs)) {
|
|
2549
|
+
categories = breakdownCategories(
|
|
2550
|
+
estimateBreakdownFromClientMessages(contextMsgs),
|
|
2551
|
+
0,
|
|
2552
|
+
);
|
|
2553
|
+
source = "client-context";
|
|
2554
|
+
} else if (needFallback) {
|
|
2555
|
+
// No live cache, no state.json categories, and the bounded session.context
|
|
2556
|
+
// fallback produced no usable messages (it failed or had no budget left).
|
|
2557
|
+
// The category cells stay n/a; ctx/model/updatedAt still come from
|
|
2558
|
+
// state.json / the live lookup.
|
|
2559
|
+
source = "no-data";
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
const liveModel =
|
|
2563
|
+
live?.model && typeof live.model === "object" ? live.model : null;
|
|
2564
|
+
const cachedModel = lastKnownModel(sessionID);
|
|
2565
|
+
const modelID =
|
|
2566
|
+
cachedModel !== "unknown"
|
|
2567
|
+
? cachedModel
|
|
2568
|
+
: liveModel?.id || entry?.model || "unknown";
|
|
2569
|
+
const providerID =
|
|
2570
|
+
lastKnownProvider(sessionID) || liveModel?.providerID || entry?.providerID || "";
|
|
2571
|
+
const ctxTokens = lastKnownCtx(sessionID) || entry?.ctx || 0;
|
|
2572
|
+
const usableInfo = getUsableContext(providerID, modelID);
|
|
2573
|
+
const usable =
|
|
2574
|
+
usableInfo?.usable ?? entry?.usable ?? getModelLimit(providerID, modelID) ?? null;
|
|
2575
|
+
const parentID = entry?.parentID ?? null;
|
|
2576
|
+
const updatedAt =
|
|
2577
|
+
entry?.updatedAt ||
|
|
2578
|
+
(bd?.capturedAt ? new Date(bd.capturedAt).toISOString() : null);
|
|
2579
|
+
return {
|
|
2580
|
+
sessionID,
|
|
2581
|
+
parentID,
|
|
2582
|
+
agent: sessionAgentCache.get(sessionID) || entry?.agent || null,
|
|
2583
|
+
modelID,
|
|
2584
|
+
providerID,
|
|
2585
|
+
ctxTokens,
|
|
2586
|
+
usable,
|
|
2587
|
+
categories,
|
|
2588
|
+
source,
|
|
2589
|
+
updatedAt,
|
|
2590
|
+
};
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
function fmtCell(n) {
|
|
2594
|
+
return n == null ? "n/a" : fmt(n);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// One markdown table row. Categories sit in columns (short headers) to keep the
|
|
2598
|
+
// table compact; the ctx column already carries the percentage.
|
|
2599
|
+
function renderSessionRow(r, isMain) {
|
|
2600
|
+
const c = r.categories || {};
|
|
2601
|
+
const role = isMain ? "main" : r.agent ? `sub:${r.agent}` : "sub";
|
|
2602
|
+
const pct =
|
|
2603
|
+
r.usable && r.usable > 0
|
|
2604
|
+
? ` (${((r.ctxTokens / r.usable) * 100).toFixed(0)}%)`
|
|
2605
|
+
: "";
|
|
2606
|
+
const ctx = `${fmt(r.ctxTokens)}${pct}`;
|
|
2607
|
+
const upd = `${timeOnly(r.updatedAt)} ${sourceTag(r.source)}`;
|
|
2608
|
+
return `| ${role} | ${r.modelID} | ${ctx} | ${fmtCell(c.user)} | ${fmtCell(c.assistant)} | ${fmtCell(c.reasoning)} | ${fmtCell(c.toolArgs)} | ${fmtCell(c.system)} | ${fmtCell(c.toolSchemas)} | ${fmtCell(c.other)} | ${upd} |`;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
function renderBreakdownTable(rows, childCount) {
|
|
2612
|
+
const lines = [];
|
|
2613
|
+
lines.push("### Context breakdown");
|
|
2614
|
+
lines.push("");
|
|
2615
|
+
lines.push(
|
|
2616
|
+
"| role | model | ctx | usr | asst | rsn | tool | sys | schm | oth | updated (src) |",
|
|
2617
|
+
);
|
|
2618
|
+
lines.push("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |");
|
|
2619
|
+
for (const row of rows) lines.push(row);
|
|
2620
|
+
lines.push("");
|
|
2621
|
+
lines.push(
|
|
2622
|
+
`_${childCount} subagent session(s). Tokens are estimates (unicode heuristic). ` +
|
|
2623
|
+
`usr=user, asst=assistant, rsn=reasoning, tool=tool args, sys=system prompt, ` +
|
|
2624
|
+
`schm=tool schemas, oth=residual input. src: live=context hook, snap=state.json, ` +
|
|
2625
|
+
`fallback=session.context (no system/schemas → n/a), no-data=categories unavailable._`,
|
|
2626
|
+
);
|
|
2627
|
+
return lines.join("\n");
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
// Shared delivery via ctx.session.synthetic — a synthetic message ALWAYS enters
|
|
2631
|
+
// the session inbox; `resume` only gates whether the session is woken:
|
|
2632
|
+
// resume:false -> durable notice, session NOT woken (LLM context unchanged);
|
|
2633
|
+
// resume:true -> same inbox item PLUS a wake, i.e. an agent turn runs and the
|
|
2634
|
+
// session context grows (a real LLM request is issued).
|
|
2635
|
+
// Returns true on success. On failure the user sees nothing, so we log a loud,
|
|
2636
|
+
// actionable error; the payload is still written to the human-readable log by the
|
|
2637
|
+
// caller (there is no other verified delivery channel: no noReply/prompt option
|
|
2638
|
+
// exists in this runtime).
|
|
2639
|
+
async function deliverSynthetic(ctx, sid, text, description, resume) {
|
|
2640
|
+
try {
|
|
2641
|
+
await ctx.session.synthetic({ sessionID: sid, text, description, resume });
|
|
2642
|
+
return true;
|
|
2643
|
+
} catch (err) {
|
|
2644
|
+
logErr(
|
|
2645
|
+
`context command: session.synthetic FAILED (session=${sid}, ` +
|
|
2646
|
+
`resume=${resume}) — no message delivered; payload preserved in the context log`,
|
|
2647
|
+
err,
|
|
2648
|
+
);
|
|
2649
|
+
return false;
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// ---- /context : quick summary of THIS session only (no subagents, no RPC) ----
|
|
2654
|
+
|
|
2655
|
+
// The one-line description is what the Desktop notice chip shows, so it must be
|
|
2656
|
+
// self-contained (model + tokens + % + time).
|
|
2657
|
+
function shortSummaryDescription(r) {
|
|
2658
|
+
const pct =
|
|
2659
|
+
r.usable && r.usable > 0
|
|
2660
|
+
? ` (${((r.ctxTokens / r.usable) * 100).toFixed(0)}%)`
|
|
2661
|
+
: "";
|
|
2662
|
+
const hhmm = timeOnly(r.updatedAt).slice(0, 5);
|
|
2663
|
+
const model = r.modelID && r.modelID !== "unknown" ? r.modelID : "model?";
|
|
2664
|
+
return `Context: ${fmt(r.ctxTokens)}${pct} · ${model} · ${hhmm}`;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// Three short lines, built from cache/state only (see commandContextSummary).
|
|
2668
|
+
function shortSummaryText(r) {
|
|
2669
|
+
const model =
|
|
2670
|
+
r.modelID && r.modelID !== "unknown" ? r.modelID : "unknown model";
|
|
2671
|
+
const total =
|
|
2672
|
+
r.usable && r.usable > 0
|
|
2673
|
+
? ` / ${fmt(r.usable)} (${((r.ctxTokens / r.usable) * 100).toFixed(0)}%)`
|
|
2674
|
+
: "";
|
|
2675
|
+
const lines = [`**Context** — ${model} · ${fmt(r.ctxTokens)}${total}`];
|
|
2676
|
+
const c = r.categories;
|
|
2677
|
+
if (c) {
|
|
2678
|
+
lines.push(
|
|
2679
|
+
`usr ${fmtCell(c.user)} · asst ${fmtCell(c.assistant)} · rsn ${fmtCell(c.reasoning)} · ` +
|
|
2680
|
+
`tool ${fmtCell(c.toolArgs)} · sys ${fmtCell(c.system)} · schm ${fmtCell(c.toolSchemas)} · ` +
|
|
2681
|
+
`oth ${fmtCell(c.other)}`,
|
|
2682
|
+
);
|
|
2683
|
+
} else {
|
|
2684
|
+
lines.push("_category breakdown unavailable (no cache/state entry yet)_");
|
|
2685
|
+
}
|
|
2686
|
+
lines.push(`updated ${timeOnly(r.updatedAt)} (${sourceTag(r.source)})`);
|
|
2687
|
+
return lines.join("\n");
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
// /context handler: main session only, cache + state.json only (NO enrichment,
|
|
2691
|
+
// NO child traversal) -> instant. Delivered as resume:true so the model
|
|
2692
|
+
// renders the 3-line summary as a normal assistant message (the Desktop
|
|
2693
|
+
// notice chip only shows `description`).
|
|
2694
|
+
async function commandContextSummary(ctx, invocation, opts) {
|
|
2695
|
+
const name = opts?.name || "context";
|
|
2696
|
+
const sid = invocation?.sessionID || invocation?.session?.id;
|
|
2697
|
+
if (!sid) {
|
|
2698
|
+
logErr("context: invocation carried no sessionID");
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
try {
|
|
2702
|
+
const state = readStateSnapshot();
|
|
2703
|
+
// Deadline in the past => collectSessionReport performs no live RPC at all
|
|
2704
|
+
// (cache + state.json only), which keeps this command instant.
|
|
2705
|
+
const report = await collectSessionReport(ctx, sid, state, 0);
|
|
2706
|
+
const body = shortSummaryText(report);
|
|
2707
|
+
// Reproduce verbatim so the model echoes the 3 lines as the assistant
|
|
2708
|
+
// answer; the Desktop renders that answer in full (the notice chip only
|
|
2709
|
+
// shows `description`).
|
|
2710
|
+
const text = `${BREAKDOWN_MODEL_INSTRUCTION}\n\n${body}`;
|
|
2711
|
+
const description = shortSummaryDescription(report);
|
|
2712
|
+
const delivered = await deliverSynthetic(ctx, sid, text, description, true);
|
|
2713
|
+
pushFinalSummary(`cmd:${sid}:${Date.now()}`, [
|
|
2714
|
+
`[${new Date().toISOString()}] COMMAND ${name} session=${sid} delivered=${delivered}`,
|
|
2715
|
+
...text.split("\n"),
|
|
2716
|
+
]);
|
|
2717
|
+
} catch (err) {
|
|
2718
|
+
logErr("context command failed", err);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// ---- /context-breakdown : full table (main + subagents), model-rendered ----
|
|
2723
|
+
|
|
2724
|
+
// Prefixed to the table for resume:true so the agent echoes it verbatim into the
|
|
2725
|
+
// transcript (the Desktop renders the model's answer as a normal message).
|
|
2726
|
+
const BREAKDOWN_MODEL_INSTRUCTION =
|
|
2727
|
+
"Reproduce the markdown table below exactly, verbatim, with no changes and no commentary:";
|
|
2728
|
+
|
|
2729
|
+
// Command handler shared by both slash commands. Registered via
|
|
2730
|
+
// ctx.command.transform in setup(); invoked by opencode with
|
|
2731
|
+
// { sessionID, prompt:{text,...}, delivery }. `opts` = { resume, name }.
|
|
2732
|
+
async function commandContextBreakdown(ctx, invocation, opts) {
|
|
2733
|
+
const resume = !!opts?.resume;
|
|
2734
|
+
const name = opts?.name || "context-breakdown";
|
|
2735
|
+
const sid = invocation?.sessionID || invocation?.session?.id;
|
|
2736
|
+
if (!sid) {
|
|
2737
|
+
logErr("context-breakdown: invocation carried no sessionID");
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
try {
|
|
2741
|
+
// state.json is read immediately and synchronously; the root and every
|
|
2742
|
+
// descendant are then collected IN PARALLEL under one cumulative budget, so
|
|
2743
|
+
// the command costs ~budget regardless of the subagent count (not N × 3s).
|
|
2744
|
+
const state = readStateSnapshot();
|
|
2745
|
+
const childIDs = collectDescendantSessionIDs(sid, state);
|
|
2746
|
+
const deadline = Date.now() + COMMAND_COLLECT_BUDGET_MS;
|
|
2747
|
+
const [root, ...children] = await Promise.all([
|
|
2748
|
+
collectSessionReport(ctx, sid, state, deadline),
|
|
2749
|
+
...childIDs.map((cid) => collectSessionReport(ctx, cid, state, deadline)),
|
|
2750
|
+
]);
|
|
2751
|
+
const rows = [renderSessionRow(root, true)];
|
|
2752
|
+
for (const rep of children) rows.push(renderSessionRow(rep, false));
|
|
2753
|
+
const table = renderBreakdownTable(rows, childIDs.length);
|
|
2754
|
+
const payload = resume ? `${BREAKDOWN_MODEL_INSTRUCTION}\n\n${table}` : table;
|
|
2755
|
+
const description = `Full context breakdown — ${1 + childIDs.length} session(s)`;
|
|
2756
|
+
|
|
2757
|
+
const delivered = await deliverSynthetic(ctx, sid, payload, description, resume);
|
|
2758
|
+
|
|
2759
|
+
// Persist the table itself (not the instruction) to the human-readable log
|
|
2760
|
+
// (survives rewrites, bounded) so it is always recoverable.
|
|
2761
|
+
pushFinalSummary(`cmd:${sid}:${Date.now()}`, [
|
|
2762
|
+
`[${new Date().toISOString()}] COMMAND ${name} session=${sid} delivered=${delivered}`,
|
|
2763
|
+
...table.split("\n"),
|
|
2764
|
+
]);
|
|
2765
|
+
} catch (err) {
|
|
2766
|
+
logErr("context-breakdown command failed", err);
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2262
2770
|
// ---------------------------------------------------------------------------
|
|
2263
2771
|
// Entrypoints
|
|
2264
2772
|
// ---------------------------------------------------------------------------
|
|
@@ -2277,58 +2785,66 @@ export default {
|
|
|
2277
2785
|
logErr("v2 context hook registration failed", err);
|
|
2278
2786
|
}
|
|
2279
2787
|
|
|
2280
|
-
//
|
|
2281
|
-
// model
|
|
2282
|
-
//
|
|
2283
|
-
//
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
const modelID = m?.modelID ?? m?.id;
|
|
2291
|
-
const providerID =
|
|
2292
|
-
typeof m?.providerID === "string" && m.providerID
|
|
2293
|
-
? m.providerID
|
|
2294
|
-
: typeof m?.provider?.id === "string"
|
|
2295
|
-
? m.provider.id
|
|
2296
|
-
: "";
|
|
2297
|
-
const key = modelKey(providerID, modelID);
|
|
2298
|
-
const lim = m?.limit;
|
|
2299
|
-
if (!key || !lim || typeof lim !== "object") continue;
|
|
2300
|
-
const context =
|
|
2301
|
-
typeof lim.context === "number" && lim.context > 0 ? lim.context : null;
|
|
2302
|
-
const input =
|
|
2303
|
-
typeof lim.input === "number" && lim.input > 0 ? lim.input : null;
|
|
2304
|
-
const output =
|
|
2305
|
-
typeof lim.output === "number" && lim.output > 0 ? lim.output : null;
|
|
2306
|
-
if (context == null && input == null) continue;
|
|
2307
|
-
const prevRec = modelLimits.get(key);
|
|
2308
|
-
modelLimits.set(key, {
|
|
2309
|
-
context,
|
|
2310
|
-
input,
|
|
2311
|
-
output,
|
|
2312
|
-
reserved:
|
|
2313
|
-
typeof m?.compaction?.reserved === "number"
|
|
2314
|
-
? m.compaction.reserved
|
|
2315
|
-
: (prevRec?.reserved ?? null),
|
|
2316
|
-
});
|
|
2317
|
-
}
|
|
2318
|
-
} catch (err) {
|
|
2319
|
-
logErr("v2 model transform limits failed", err);
|
|
2320
|
-
}
|
|
2321
|
-
}),
|
|
2322
|
-
);
|
|
2323
|
-
} catch (err) {
|
|
2324
|
-
logErr("v2 model transform registration failed", err);
|
|
2325
|
-
}
|
|
2788
|
+
// The model registry is captured asynchronously through ensureModelLimits
|
|
2789
|
+
// (ctx.model.list(), rank 3) — the authoritative source that carries the
|
|
2790
|
+
// user's merged config overrides. ctx.model.transform fires synchronously
|
|
2791
|
+
// on every catalog rebuild BEFORE ctx.model.list completes and carries the
|
|
2792
|
+
// generic Model.Info defaults (context:200000, output:32000) for provider-
|
|
2793
|
+
// declared models before overrides are applied; writing it to modelLimits
|
|
2794
|
+
// would flip the denominator from the configured window to the generic
|
|
2795
|
+
// default on every catalog rebuild (observed: a configured window flipping
|
|
2796
|
+
// to the generic 200k default), so the transform is not used for limit
|
|
2797
|
+
// population.
|
|
2326
2798
|
|
|
2327
2799
|
// Fire-and-forget prefetch of effective model limits.
|
|
2328
2800
|
ensureModelLimits(ctx).catch((err) =>
|
|
2329
2801
|
logErr("model limits prefetch failed", err),
|
|
2330
2802
|
);
|
|
2331
2803
|
|
|
2804
|
+
// Two V2 slash commands (V2 only): /context (instant per-session summary)
|
|
2805
|
+
// and /context-breakdown (full table, main + all subagent descendants).
|
|
2806
|
+
// Guarded: older 2.0.x builds may lack ctx.command / ctx.session.synthetic —
|
|
2807
|
+
// registering a command whose handler cannot deliver would fail silently.
|
|
2808
|
+
try {
|
|
2809
|
+
const canCommand =
|
|
2810
|
+
ctx.command && typeof ctx.command.transform === "function";
|
|
2811
|
+
const canSynthetic =
|
|
2812
|
+
!!ctx.session && typeof ctx.session.synthetic === "function";
|
|
2813
|
+
if (canCommand && canSynthetic) {
|
|
2814
|
+
registrations.push(
|
|
2815
|
+
await ctx.command.transform((editor) => {
|
|
2816
|
+
try {
|
|
2817
|
+
editor.add({
|
|
2818
|
+
name: "context",
|
|
2819
|
+
description:
|
|
2820
|
+
"Quick context summary for the current session (compact notice)",
|
|
2821
|
+
execute: (invocation) =>
|
|
2822
|
+
commandContextSummary(ctx, invocation, { name: "context" }),
|
|
2823
|
+
});
|
|
2824
|
+
editor.add({
|
|
2825
|
+
name: "context-breakdown",
|
|
2826
|
+
description:
|
|
2827
|
+
"Full per-session context breakdown table (main + subagents), rendered by the model",
|
|
2828
|
+
execute: (invocation) =>
|
|
2829
|
+
commandContextBreakdown(ctx, invocation, {
|
|
2830
|
+
resume: CONTEXT_BREAKDOWN_RESUME,
|
|
2831
|
+
name: "context-breakdown",
|
|
2832
|
+
}),
|
|
2833
|
+
});
|
|
2834
|
+
} catch (err) {
|
|
2835
|
+
logErr("v2 command transform failed", err);
|
|
2836
|
+
}
|
|
2837
|
+
}),
|
|
2838
|
+
);
|
|
2839
|
+
} else {
|
|
2840
|
+
logErr(
|
|
2841
|
+
`context commands not registered (command=${canCommand}, synthetic=${canSynthetic})`,
|
|
2842
|
+
);
|
|
2843
|
+
}
|
|
2844
|
+
} catch (err) {
|
|
2845
|
+
logErr("v2 command transform registration failed", err);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2332
2848
|
const controller = new AbortController();
|
|
2333
2849
|
void (async () => {
|
|
2334
2850
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-context-indicator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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",
|
|
@@ -53,5 +53,11 @@
|
|
|
53
53
|
"solid-js": {
|
|
54
54
|
"optional": true
|
|
55
55
|
}
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@opencode/plugin": "^2.0.19",
|
|
59
|
+
"@opentui/core": "^0.5.12",
|
|
60
|
+
"@opentui/solid": "^0.5.12",
|
|
61
|
+
"solid-js": "^1.9.12"
|
|
56
62
|
}
|
|
57
63
|
}
|
package/tui.tsx
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
1
2
|
/**
|
|
2
3
|
* tui.tsx — optional OpenCode TUI sidebar for `opencode-context-indicator`.
|
|
3
4
|
*
|
|
5
|
+
* The import-source pragma on the first line above is REQUIRED: without it the
|
|
6
|
+
* opencode TUI loader (Bun) transforms JSX against the React runtime and emits
|
|
7
|
+
* `import ... from "react/jsx-runtime"`, which the TUI runtime does not provide,
|
|
8
|
+
* so loading the plugin from the npm cache throws `Cannot find package 'react'`
|
|
9
|
+
* (react only ever resolved from a developer's local node_modules). The pragma
|
|
10
|
+
* must stay the very first line, before any import, and no other pragma-like
|
|
11
|
+
* token may appear in this file. See the OpenTUI Solid integration guide.
|
|
12
|
+
*
|
|
4
13
|
* Exposed as the `./tui` package entry ("beside the main plugin for automatic
|
|
5
14
|
* loading"). OpenCode loads it only in the terminal TUI; it is NOT loaded in
|
|
6
15
|
* the Desktop app (Desktop exposes no TUI slot API). It renders the
|
|
@@ -19,6 +28,7 @@
|
|
|
19
28
|
*/
|
|
20
29
|
import {Plugin, usePlugin} from "@opencode/plugin/tui"
|
|
21
30
|
import {createSignal, Show} from "solid-js"
|
|
31
|
+
import type {ColorInput} from "@opentui/core"
|
|
22
32
|
import {readFileSync} from "node:fs"
|
|
23
33
|
import {tmpdir} from "node:os"
|
|
24
34
|
import {join} from "node:path"
|
|
@@ -62,8 +72,10 @@ function num(v: unknown): number {
|
|
|
62
72
|
// Theme tokens are OpenTUI RGBA values (see ResolvedTheme.text.base/.muted);
|
|
63
73
|
// pass them straight to the renderer. Fall back to a named color when a token
|
|
64
74
|
// is absent so an unexpected theme shape can never blank the sidebar.
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
// `fg` on <text> is typed as OpenTUI's ColorInput (= string | RGBA); using the
|
|
76
|
+
// same alias keeps the JSX assignment type-correct instead of a loose `object`.
|
|
77
|
+
type Fg = ColorInput
|
|
78
|
+
function token(v: unknown, fallback: Fg): Fg {
|
|
67
79
|
return v && typeof v === "object" ? (v as Fg) : fallback
|
|
68
80
|
}
|
|
69
81
|
|