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/commands.js
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
breakdownCategories,
|
|
10
10
|
fmt,
|
|
11
11
|
fmtCell,
|
|
12
|
+
isHookMeasuredEntry,
|
|
13
|
+
isHookSource,
|
|
12
14
|
isSafeSessionKey,
|
|
13
15
|
oneLine,
|
|
14
16
|
safeCategories,
|
|
@@ -70,6 +72,23 @@ import {
|
|
|
70
72
|
// from state.json / live, but the category cells stay n/a.
|
|
71
73
|
const COMMAND_COLLECT_BUDGET_MS = 3500;
|
|
72
74
|
|
|
75
|
+
// Session ids for which this instance already tried a live enrichment and found
|
|
76
|
+
// nothing better than state.json. The Desktop panel polls collectFamilyReports
|
|
77
|
+
// (and therefore collectSessionReport) every 1.5 s; without this, a session
|
|
78
|
+
// whose state entry has no agent/model would issue session.get() on every poll,
|
|
79
|
+
// forever. Bounded: an instance only ever tracks the sessions it has seen.
|
|
80
|
+
const ENRICH_ATTEMPTED_MAX = 256;
|
|
81
|
+
const enrichAttempted = new Set();
|
|
82
|
+
|
|
83
|
+
function markEnrichAttempted(sessionID) {
|
|
84
|
+
if (enrichAttempted.has(sessionID)) return;
|
|
85
|
+
if (enrichAttempted.size >= ENRICH_ATTEMPTED_MAX) {
|
|
86
|
+
const oldest = enrichAttempted.values().next().value;
|
|
87
|
+
enrichAttempted.delete(oldest);
|
|
88
|
+
}
|
|
89
|
+
enrichAttempted.add(sessionID);
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
// `resume` for the full /context-breakdown command. resume:true admits the
|
|
74
93
|
// payload to the transcript AND wakes the session, so the agent echoes the table
|
|
75
94
|
// as a normal message that the Desktop renders in full (the notice chip only
|
|
@@ -140,10 +159,14 @@ function collectDescendantSessionIDs(rootID, state) {
|
|
|
140
159
|
// Moved to lib/estimate.js (shared with the V2 live log); re-used here so the
|
|
141
160
|
// residual computation has exactly one implementation.
|
|
142
161
|
|
|
143
|
-
// Short source tag for the table (kept tiny on purpose).
|
|
162
|
+
// Short source tag for the table (kept tiny on purpose). A hook-family source
|
|
163
|
+
// is a real measurement of the request payload, so it reads "live".
|
|
144
164
|
function sourceTag(source) {
|
|
145
165
|
switch (source) {
|
|
146
166
|
case "context-hook":
|
|
167
|
+
case "compaction-hook":
|
|
168
|
+
case "transform":
|
|
169
|
+
case "system-transform":
|
|
147
170
|
return "live";
|
|
148
171
|
case "client-context":
|
|
149
172
|
return "fallback";
|
|
@@ -192,9 +215,16 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
192
215
|
// state.json is stale. ParentID comes from state.json ONLY (entry?.parentID)
|
|
193
216
|
// so the table is root-consistent; in-memory sessionParentCache is cross-session
|
|
194
217
|
// and must NOT be used for attribution.
|
|
218
|
+
//
|
|
219
|
+
// A state.json entry that already carries the agent/model counts as warm, and
|
|
220
|
+
// one live attempt per session is enough: this path is polled (the Desktop
|
|
221
|
+
// panel calls it every 1.5 s) and a session this instance never observed live
|
|
222
|
+
// would otherwise issue session.get() on every poll, for the root and every
|
|
223
|
+
// descendant.
|
|
195
224
|
const needEnrich =
|
|
196
|
-
!
|
|
197
|
-
|
|
225
|
+
!enrichAttempted.has(sessionID) &&
|
|
226
|
+
((!sessionAgentCache.has(sessionID) && !entry?.agent) ||
|
|
227
|
+
(lastKnownModel(sessionID) === "unknown" && !entry?.model));
|
|
198
228
|
|
|
199
229
|
// Fallback categories only when neither the live cache nor a state snapshot
|
|
200
230
|
// has them: derive user/assistant/reasoning/tool-args from the persisted
|
|
@@ -204,6 +234,7 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
204
234
|
// Enrichment and the context fallback run CONCURRENTLY, both bounded by the
|
|
205
235
|
// shared budget. Non-fatal: any failure resolves to null and keeps whatever
|
|
206
236
|
// the cache / state.json already provides.
|
|
237
|
+
if (needEnrich && budget > 0) markEnrichAttempted(sessionID);
|
|
207
238
|
const [live, contextMsgs] = await Promise.all([
|
|
208
239
|
needEnrich && budget > 0
|
|
209
240
|
? withTimeout(ctx.session.get({ sessionID }), budget, "session.get").then(
|
|
@@ -228,7 +259,25 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
228
259
|
let categories = null;
|
|
229
260
|
let source = "none";
|
|
230
261
|
let fallbackParts = null;
|
|
231
|
-
|
|
262
|
+
// In-memory breakdown priority. Hook data (the full request payload: system
|
|
263
|
+
// prompt AND tool schemas) wins outright. The client-context fallback only
|
|
264
|
+
// fills a session that has no hook-measured categories: it is rebuilt from the
|
|
265
|
+
// persisted messages, so it cannot see the system prompt or the tool schemas -
|
|
266
|
+
// reporting them as 0 would push their whole mass into `other` and make a
|
|
267
|
+
// second instance (the Desktop window's, which answered the RPC without ever
|
|
268
|
+
// running the hook) show 0 / 0 / inflated-other for a session that state.json
|
|
269
|
+
// measured correctly.
|
|
270
|
+
//
|
|
271
|
+
// A state entry is hook-measured when it carries a hook-family provenance
|
|
272
|
+
// (`source`, see isHookSource: the V2 context / compaction hooks and the V1
|
|
273
|
+
// transform hooks), or - for legacy entries without `source` - when
|
|
274
|
+
// system/tool schemas are non-null (only a hook can estimate those). An entry
|
|
275
|
+
// written by the fallback stays a fair game for a newer fallback: it is the
|
|
276
|
+
// best data available for that session.
|
|
277
|
+
const entryHookMeasured = isHookMeasuredEntry(entry);
|
|
278
|
+
const bdFromHook = !!bd && isHookSource(bd.source);
|
|
279
|
+
const useBd = !!bd && (bdFromHook || !entryHookMeasured);
|
|
280
|
+
if (useBd) {
|
|
232
281
|
// Sanitise: every value reaches a markdown/log sink and must be a number.
|
|
233
282
|
categories = safeCategories(
|
|
234
283
|
breakdownCategories(bd, lastKnownInput(sessionID) || entry?.input || 0),
|
|
@@ -273,9 +322,10 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
273
322
|
const updatedAt =
|
|
274
323
|
entry?.updatedAt ||
|
|
275
324
|
(bd?.capturedAt ? new Date(bd.capturedAt).toISOString() : null);
|
|
276
|
-
const toolOutByTool =
|
|
325
|
+
const toolOutByTool = useBd
|
|
277
326
|
? safeToolOutByTool(bd.toolOutByTool)
|
|
278
327
|
: safeToolOutByTool(entry?.toolOutByTool) ||
|
|
328
|
+
safeToolOutByTool(bd?.toolOutByTool) ||
|
|
279
329
|
safeToolOutByTool(fallbackParts?.toolOutByTool);
|
|
280
330
|
const compactStale = entry?.compactStale === true;
|
|
281
331
|
// Provider-reported prompt cache (exact; subset of input). Live cache first,
|
|
@@ -287,13 +337,16 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
287
337
|
safeSaneOrNull(lastKnownCacheWrite(sessionID)) ??
|
|
288
338
|
safeSaneOrNull(entry?.cacheWrite);
|
|
289
339
|
// Redundant tool output. The live breakdown owns targets; the persisted entry
|
|
290
|
-
// has them stripped (path-free) — sanitise both.
|
|
291
|
-
|
|
340
|
+
// has them stripped (path-free) — sanitise both. The fallback-derived entry
|
|
341
|
+
// loses to the persisted one, for the same reason as the categories above.
|
|
342
|
+
const reclaim = useBd
|
|
292
343
|
? safeReclaim(bd.reclaim, true)
|
|
293
|
-
: safeReclaim(entry?.reclaim, false);
|
|
344
|
+
: safeReclaim(entry?.reclaim, false) || safeReclaim(bd?.reclaim, false);
|
|
294
345
|
// Top heaviest single items. The live breakdown has targets; the persisted
|
|
295
346
|
// copy is path-free (sanitised on write), so keepTarget is false there.
|
|
296
|
-
const top =
|
|
347
|
+
const top = useBd
|
|
348
|
+
? safeTop(bd.top, true)
|
|
349
|
+
: safeTop(entry?.top, false) || safeTop(bd?.top, false);
|
|
297
350
|
// Previous distinct measurement (state.json only) for /context-diff.
|
|
298
351
|
const previous = safeSnapshot(entry?.previous);
|
|
299
352
|
return {
|
|
@@ -319,6 +372,21 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
319
372
|
};
|
|
320
373
|
}
|
|
321
374
|
|
|
375
|
+
// Root report plus the reports of every descendant that has a measurement,
|
|
376
|
+
// collected in parallel under one shared wall-clock budget. Shared by
|
|
377
|
+
// /context-breakdown and the Desktop RPC (lib/desktop.js), so the family walk,
|
|
378
|
+
// the budget and the per-session collection exist once.
|
|
379
|
+
async function collectFamilyReports(ctx, sessionID, budgetMs) {
|
|
380
|
+
const state = readStateSnapshot();
|
|
381
|
+
const childIDs = collectDescendantSessionIDs(sessionID, state);
|
|
382
|
+
const deadline = Date.now() + (budgetMs || COMMAND_COLLECT_BUDGET_MS);
|
|
383
|
+
const [root, ...children] = await Promise.all([
|
|
384
|
+
collectSessionReport(ctx, sessionID, state, deadline),
|
|
385
|
+
...childIDs.map((cid) => collectSessionReport(ctx, cid, state, deadline)),
|
|
386
|
+
]);
|
|
387
|
+
return { root, children };
|
|
388
|
+
}
|
|
389
|
+
|
|
322
390
|
// One markdown table row. Categories sit in columns (short headers) to keep the
|
|
323
391
|
// table compact; the ctx column already carries the percentage.
|
|
324
392
|
function renderSessionRow(r, isMain) {
|
|
@@ -731,20 +799,14 @@ async function commandContextBreakdown(ctx, invocation, opts) {
|
|
|
731
799
|
// state.json is read immediately and synchronously; the root and every
|
|
732
800
|
// descendant are then collected IN PARALLEL under one cumulative budget, so
|
|
733
801
|
// the command costs ~budget regardless of the subagent count (not N × 3s).
|
|
734
|
-
const
|
|
735
|
-
const childIDs = collectDescendantSessionIDs(sid, state);
|
|
736
|
-
const deadline = Date.now() + COMMAND_COLLECT_BUDGET_MS;
|
|
737
|
-
const [root, ...children] = await Promise.all([
|
|
738
|
-
collectSessionReport(ctx, sid, state, deadline),
|
|
739
|
-
...childIDs.map((cid) => collectSessionReport(ctx, cid, state, deadline)),
|
|
740
|
-
]);
|
|
802
|
+
const { root, children } = await collectFamilyReports(sid);
|
|
741
803
|
const rows = [renderSessionRow(root, true)];
|
|
742
804
|
for (const rep of children) rows.push(renderSessionRow(rep, false));
|
|
743
|
-
const table = renderBreakdownTable(rows,
|
|
805
|
+
const table = renderBreakdownTable(rows, children.length, root);
|
|
744
806
|
const payload = resume
|
|
745
807
|
? `${BREAKDOWN_MODEL_INSTRUCTION}\n\n<context-breakdown>\n${table}\n</context-breakdown>`
|
|
746
808
|
: table;
|
|
747
|
-
const description = `Full context breakdown — ${1 +
|
|
809
|
+
const description = `Full context breakdown — ${1 + children.length} session(s)`;
|
|
748
810
|
|
|
749
811
|
const delivered = await deliverSynthetic(ctx, sid, payload, description, resume);
|
|
750
812
|
|
|
@@ -765,6 +827,7 @@ export {
|
|
|
765
827
|
commandContextBreakdown,
|
|
766
828
|
commandContextDiff,
|
|
767
829
|
CONTEXT_BREAKDOWN_RESUME,
|
|
830
|
+
collectFamilyReports,
|
|
768
831
|
collectSessionReport,
|
|
769
832
|
renderSessionRow,
|
|
770
833
|
renderBreakdownTable,
|
package/lib/desktop.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// lib/desktop.js
|
|
2
|
+
//
|
|
3
|
+
// Server side of the Desktop GUI panel: registers the RPC contract from
|
|
4
|
+
// lib/rpc.js and answers it from the SAME collection the /context-breakdown
|
|
5
|
+
// command uses (lib/commands.js collectFamilyReports). The GUI is a window in
|
|
6
|
+
// the desktop app and cannot read state.json or the plugin's memory, so this is
|
|
7
|
+
// its only data path.
|
|
8
|
+
//
|
|
9
|
+
// Nothing here runs on hosts without the GUI API: the registration is guarded
|
|
10
|
+
// and a failure is logged, never thrown, so the TUI and the slash commands are
|
|
11
|
+
// unaffected. For the same reason lib/rpc.js - which imports
|
|
12
|
+
// `@opencode/plugin/rpc`, an OPTIONAL peer - is loaded lazily inside the guard:
|
|
13
|
+
// a host that does not provide that module must still load index.js, not fail
|
|
14
|
+
// at import time.
|
|
15
|
+
|
|
16
|
+
import { collectFamilyReports } from "./commands.js";
|
|
17
|
+
import { num } from "./format.js";
|
|
18
|
+
import { logErr } from "./state.js";
|
|
19
|
+
|
|
20
|
+
// A GUI panel polls this while it is open, so the budget is much smaller than
|
|
21
|
+
// the command's: the collection is state.json + in-memory caches only (the live
|
|
22
|
+
// lookups are skipped once the state entry carries the agent/model), and a poll
|
|
23
|
+
// must never hold a hook longer than a fraction of a second.
|
|
24
|
+
const DESKTOP_COLLECT_BUDGET_MS = 2000;
|
|
25
|
+
|
|
26
|
+
// One wire row (see lib/rpc.d.ts). JSON has no undefined/null in this contract:
|
|
27
|
+
// every optional value is flattened to "" or 0, which the GUI treats as
|
|
28
|
+
// "unknown". `report` is the sanitized object from collectSessionReport.
|
|
29
|
+
function toDesktopRow(report) {
|
|
30
|
+
const c = report?.categories || {};
|
|
31
|
+
return {
|
|
32
|
+
sessionID: typeof report?.sessionID === "string" ? report.sessionID : "",
|
|
33
|
+
parentID: typeof report?.parentID === "string" ? report.parentID : "",
|
|
34
|
+
agent: typeof report?.agent === "string" ? report.agent : "",
|
|
35
|
+
modelID: typeof report?.modelID === "string" ? report.modelID : "",
|
|
36
|
+
providerID: typeof report?.providerID === "string" ? report.providerID : "",
|
|
37
|
+
ctx: num(report?.ctxTokens),
|
|
38
|
+
input: num(report?.inputTokens),
|
|
39
|
+
usable: num(report?.usable),
|
|
40
|
+
reserve: num(report?.reserve),
|
|
41
|
+
user: num(c.user),
|
|
42
|
+
assistant: num(c.assistant),
|
|
43
|
+
reasoning: num(c.reasoning),
|
|
44
|
+
toolArgs: num(c.toolArgs),
|
|
45
|
+
toolOut: num(c.toolOut),
|
|
46
|
+
system: num(c.system),
|
|
47
|
+
toolSchemas: num(c.toolSchemas),
|
|
48
|
+
other: num(c.other),
|
|
49
|
+
source: typeof report?.source === "string" ? report.source : "",
|
|
50
|
+
updatedAt: typeof report?.updatedAt === "string" ? report.updatedAt : "",
|
|
51
|
+
stale: report?.compactStale === true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A row is worth showing only when the session has been measured at least once:
|
|
56
|
+
// no tokens, no window and no timestamp means "never seen by this plugin" (a
|
|
57
|
+
// foreign or already-pruned session), and a full row of zeros would read as a
|
|
58
|
+
// real, empty context. The GUI turns an empty list into its empty state.
|
|
59
|
+
function hasMeasurement(row) {
|
|
60
|
+
return row.ctx > 0 || row.usable > 0 || row.updatedAt !== "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Registers the contract on a V2 plugin context. Returns the registration (so
|
|
64
|
+
// the caller can keep it alive / dispose it) or null when this host has no RPC
|
|
65
|
+
// support (opencode 2.0.x), does not provide `@opencode/plugin/rpc`, or the
|
|
66
|
+
// registration failed.
|
|
67
|
+
async function registerDesktopRpc(ctx) {
|
|
68
|
+
try {
|
|
69
|
+
if (!ctx?.rpc || typeof ctx.rpc.register !== "function") return null;
|
|
70
|
+
const { DesktopContextRpc } = await import("./rpc.js");
|
|
71
|
+
return await ctx.rpc.register(DesktopContextRpc, {
|
|
72
|
+
family: async (input) => {
|
|
73
|
+
const sessionID =
|
|
74
|
+
input && typeof input.sessionID === "string" ? input.sessionID : "";
|
|
75
|
+
if (!sessionID) return { rows: [] };
|
|
76
|
+
const { root, children } = await collectFamilyReports(
|
|
77
|
+
ctx,
|
|
78
|
+
sessionID,
|
|
79
|
+
DESKTOP_COLLECT_BUDGET_MS,
|
|
80
|
+
);
|
|
81
|
+
const rootRow = toDesktopRow(root);
|
|
82
|
+
if (!hasMeasurement(rootRow)) return { rows: [] };
|
|
83
|
+
return {
|
|
84
|
+
rows: [
|
|
85
|
+
rootRow,
|
|
86
|
+
...children.map(toDesktopRow).filter(hasMeasurement),
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
logErr("desktop RPC registration failed", err);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export {
|
|
98
|
+
registerDesktopRpc,
|
|
99
|
+
toDesktopRow,
|
|
100
|
+
hasMeasurement,
|
|
101
|
+
DESKTOP_COLLECT_BUDGET_MS,
|
|
102
|
+
};
|
package/lib/estimate.js
CHANGED
|
@@ -111,8 +111,6 @@ function safeCategories(raw) {
|
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
// A session key safe to use as an object property / map key. Rejects the
|
|
115
|
-
// prototype-pollution vectors ("__proto__", "constructor", "prototype").
|
|
116
114
|
// Sum of the 8 canonical category fields (null `system`/`toolSchemas` count as
|
|
117
115
|
// 0). The single place the "total measured tokens" is derived — shared by the
|
|
118
116
|
// residual-`other` computation, the continuity test and the log/summary writers.
|
|
@@ -158,6 +156,35 @@ function breakdownCategories(rec, inputTokens) {
|
|
|
158
156
|
return { user, assistant, reasoning, toolArgs, toolOut, system, toolSchemas, other };
|
|
159
157
|
}
|
|
160
158
|
|
|
159
|
+
// True when a persisted state.json entry carries a categories object measured by
|
|
160
|
+
// a request/transform payload that saw the assembled prompt: the V2 context and
|
|
161
|
+
// compaction hooks ("context-hook" / "compaction-hook") and the V1 transform
|
|
162
|
+
// hooks ("transform" / "system-transform"). Those see the system prompt and the
|
|
163
|
+
// tool schemas. `source` is persisted by lib/state.js since 1.7.1; legacy
|
|
164
|
+
// entries have none, and for them only a non-null system / tool-schema estimate
|
|
165
|
+
// proves a hook ran (the message-based fallback cannot produce one). Shared by
|
|
166
|
+
// the state writer, the command report and the live log so the priority rule
|
|
167
|
+
// exists once.
|
|
168
|
+
const HOOK_SOURCES = new Set([
|
|
169
|
+
"context-hook",
|
|
170
|
+
"compaction-hook",
|
|
171
|
+
"transform",
|
|
172
|
+
"system-transform",
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
function isHookSource(source) {
|
|
176
|
+
return typeof source === "string" && HOOK_SOURCES.has(source);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isHookMeasuredEntry(entry) {
|
|
180
|
+
if (!entry || typeof entry !== "object" || !entry.categories) return false;
|
|
181
|
+
if (isHookSource(entry.source)) return true;
|
|
182
|
+
return (
|
|
183
|
+
entry.source == null &&
|
|
184
|
+
(entry.categories.system != null || entry.categories.toolSchemas != null)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
161
188
|
// A session key safe to use as an object property / map key. Rejects the
|
|
162
189
|
// prototype-pollution vectors ("__proto__", "constructor", "prototype").
|
|
163
190
|
function isSafeSessionKey(k) {
|
|
@@ -607,6 +634,8 @@ export {
|
|
|
607
634
|
safeCategories,
|
|
608
635
|
sumCategories,
|
|
609
636
|
breakdownCategories,
|
|
637
|
+
isHookSource,
|
|
638
|
+
isHookMeasuredEntry,
|
|
610
639
|
isSafeSessionKey,
|
|
611
640
|
safeToolOutByTool,
|
|
612
641
|
toolOutEntries,
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// lib/format.js
|
|
2
|
+
//
|
|
3
|
+
// Number formatting shared by every surface: the TUI sidebar and breakdown
|
|
4
|
+
// panel (re-exported from lib/tui-data.js, so existing imports keep working)
|
|
5
|
+
// and the Desktop GUI panel.
|
|
6
|
+
//
|
|
7
|
+
// Pure JS with no imports at all: the Desktop GUI bundle inlines this file, and
|
|
8
|
+
// a `node:*` import here would break the browser-side bundle.
|
|
9
|
+
|
|
10
|
+
// Compact token count: 12846 -> "12.8k", 940 -> "940".
|
|
11
|
+
export function fmt(n) {
|
|
12
|
+
if (!Number.isFinite(n)) return "0"
|
|
13
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Coerce any untrusted value (state.json is user-writable) to a finite number.
|
|
17
|
+
export function num(v) {
|
|
18
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0
|
|
19
|
+
}
|
package/lib/limits.js
CHANGED
|
@@ -7,11 +7,16 @@
|
|
|
7
7
|
// hydrateModelLimitsFromState() seeds the maps from the shared state.json
|
|
8
8
|
// snapshot via ./state.js. Extracted 1:1 (code + WHY comments) from index.js.
|
|
9
9
|
//
|
|
10
|
-
// Compaction ceiling (opencode
|
|
11
|
-
// packages/core/src/session/compaction.ts derives the
|
|
12
|
-
// ceiling as window - reserve, where
|
|
13
|
-
// (null/zero falls through to the context
|
|
14
|
-
//
|
|
10
|
+
// Compaction ceiling (opencode calculateCeiling): opencode's core
|
|
11
|
+
// calculateCeiling() in packages/core/src/session/compaction.ts derives the
|
|
12
|
+
// auto-compaction trigger ceiling as window - reserve, where
|
|
13
|
+
// window = limit.input || limit.context (null/zero falls through to the context
|
|
14
|
+
// window) and
|
|
15
|
+
// reserve = max(floor(window * 0.10), window >= 2 * 16_000 ? 16_000 : 0)
|
|
16
|
+
// The share was 15% up to v2.1.x and is 10% since v2.2.0 (PR #51271 "fit output
|
|
17
|
+
// limits to the context window"); the `2 * RESERVE_MIN` gate is unchanged. A
|
|
18
|
+
// wrong reserve makes every displayed percentage wrong, so this mirrors the
|
|
19
|
+
// host exactly.
|
|
15
20
|
// A compaction.buffer configured in opencode itself is NOT visible to the
|
|
16
21
|
// plugin (the plugin cannot read opencode's internal compaction config); the
|
|
17
22
|
// env var OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER overrides the reserve for
|
|
@@ -79,10 +84,12 @@ function getModelLimit(providerID, modelID) {
|
|
|
79
84
|
return typeof rec.context === "number" && rec.context > 0 ? rec.context : null;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
// Compute the reserve for a given window, matching opencode
|
|
83
|
-
// packages/core/src/session/compaction.ts calculateCeiling()
|
|
87
|
+
// Compute the reserve for a given window, matching opencode's
|
|
88
|
+
// packages/core/src/session/compaction.ts calculateCeiling() in 2.2.x (the
|
|
89
|
+
// share became 10% in 2.2.0; the 2 * RESERVE_MIN gate is older and unchanged).
|
|
90
|
+
// Returns null when
|
|
84
91
|
// window is not a finite positive number.
|
|
85
|
-
const RESERVE_SHARE = 0.
|
|
92
|
+
const RESERVE_SHARE = 0.1; // core: 10% of the window (15% before v2.2.0)
|
|
86
93
|
const RESERVE_MIN = 16_000; // core: RESERVE_MIN
|
|
87
94
|
function reserveFor(window, bufferOverride) {
|
|
88
95
|
if (
|
|
@@ -102,7 +109,7 @@ function reserveFor(window, bufferOverride) {
|
|
|
102
109
|
return bufferOverride;
|
|
103
110
|
}
|
|
104
111
|
// Default reserve formula (mirror of opencode core):
|
|
105
|
-
// max(floor(window * 0.
|
|
112
|
+
// max(floor(window * 0.10), window >= 2 * RESERVE_MIN ? RESERVE_MIN : 0)
|
|
106
113
|
// The threshold is 2 * RESERVE_MIN (32_000), not RESERVE_MIN.
|
|
107
114
|
return Math.max(
|
|
108
115
|
Math.floor(window * RESERVE_SHARE),
|
|
@@ -110,7 +117,7 @@ function reserveFor(window, bufferOverride) {
|
|
|
110
117
|
);
|
|
111
118
|
}
|
|
112
119
|
|
|
113
|
-
// Native opencode compaction ceiling (2.
|
|
120
|
+
// Native opencode compaction ceiling (calculateCeiling, core v2.2.x):
|
|
114
121
|
// window = limit.input || limit.context (zero/null falls through to context)
|
|
115
122
|
// reserve = reserveFor(window) [or bufferOverride when provided]
|
|
116
123
|
// usable = window - reserve
|
package/lib/rpc.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// lib/rpc.d.ts
|
|
2
|
+
//
|
|
3
|
+
// Type surface of the Desktop RPC contract (lib/rpc.js). The GUI part is
|
|
4
|
+
// TypeScript, the server part is JS, so the row type lives here once and the
|
|
5
|
+
// GUI imports it: `DesktopRow` must stay in sync with ROW_PROPERTIES, which the
|
|
6
|
+
// T30 gate check enforces at runtime.
|
|
7
|
+
|
|
8
|
+
import type { Rpc } from "@opencode/plugin/rpc"
|
|
9
|
+
|
|
10
|
+
/** One row of the Desktop context panel: one session, its own window. */
|
|
11
|
+
export interface DesktopRow {
|
|
12
|
+
readonly sessionID: string
|
|
13
|
+
/** "" for the root session of the family. */
|
|
14
|
+
readonly parentID: string
|
|
15
|
+
readonly agent: string
|
|
16
|
+
readonly modelID: string
|
|
17
|
+
readonly providerID: string
|
|
18
|
+
/** Context tokens in use (prompt + tool schemas + system). */
|
|
19
|
+
readonly ctx: number
|
|
20
|
+
readonly input: number
|
|
21
|
+
/** The engine's usable window; 0 when the model limits are not known yet. */
|
|
22
|
+
readonly usable: number
|
|
23
|
+
/** The compaction reserve subtracted from the window; 0 when unknown. */
|
|
24
|
+
readonly reserve: number
|
|
25
|
+
readonly user: number
|
|
26
|
+
readonly assistant: number
|
|
27
|
+
readonly reasoning: number
|
|
28
|
+
readonly toolArgs: number
|
|
29
|
+
readonly toolOut: number
|
|
30
|
+
readonly system: number
|
|
31
|
+
readonly toolSchemas: number
|
|
32
|
+
readonly other: number
|
|
33
|
+
/** "live" | "snapshot" | "client-context" | "no-data" (or an engine tag). */
|
|
34
|
+
readonly source: string
|
|
35
|
+
/** ISO timestamp of the measurement; "" when unknown. */
|
|
36
|
+
readonly updatedAt: string
|
|
37
|
+
/** The session was compacted after this measurement. */
|
|
38
|
+
readonly stale: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Output of the `family` method. */
|
|
42
|
+
export interface DesktopFamilyResult {
|
|
43
|
+
readonly rows: readonly DesktopRow[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export declare const DesktopContextRpc: Rpc.PortableDefinition
|
|
47
|
+
export declare const DESKTOP_ROW_KEYS: readonly string[]
|
package/lib/rpc.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// lib/rpc.js
|
|
2
|
+
//
|
|
3
|
+
// The RPC contract between the server part and the Desktop GUI part. The server
|
|
4
|
+
// registers it in V2 setup (lib/desktop.js) and the GUI calls it through
|
|
5
|
+
// `ctx.client.rpc()`; it is also published as the `./rpc` export so other
|
|
6
|
+
// plugins and clients can call it without loading the implementation.
|
|
7
|
+
//
|
|
8
|
+
// JSON Schema only. A Standard Schema validator (zod/valibot) would give
|
|
9
|
+
// inferred TypeScript types but adds a runtime dependency to the published
|
|
10
|
+
// package; every consumer here casts the one response it reads anyway, and the
|
|
11
|
+
// app validates both formats identically.
|
|
12
|
+
//
|
|
13
|
+
// The wire shape is FLAT on purpose: `lib/desktop.js` builds every row from one
|
|
14
|
+
// report, and the T30 gate check compares the schema keys with that builder's
|
|
15
|
+
// output keys, so a field added to one side without the other fails the gate.
|
|
16
|
+
|
|
17
|
+
import { Rpc } from "@opencode/plugin/rpc"
|
|
18
|
+
|
|
19
|
+
const ROW_PROPERTIES = {
|
|
20
|
+
// Session ids and the parent link, as reported by the state snapshot.
|
|
21
|
+
sessionID: { type: "string" },
|
|
22
|
+
// "" for the root session (JSON has no undefined; nulls would need a union
|
|
23
|
+
// type every consumer has to narrow).
|
|
24
|
+
parentID: { type: "string" },
|
|
25
|
+
agent: { type: "string" },
|
|
26
|
+
modelID: { type: "string" },
|
|
27
|
+
providerID: { type: "string" },
|
|
28
|
+
// Token counts. `usable` and `reserve` are 0 when the window is unknown.
|
|
29
|
+
ctx: { type: "number" },
|
|
30
|
+
input: { type: "number" },
|
|
31
|
+
usable: { type: "number" },
|
|
32
|
+
reserve: { type: "number" },
|
|
33
|
+
// The eight breakdown categories, in tokens.
|
|
34
|
+
user: { type: "number" },
|
|
35
|
+
assistant: { type: "number" },
|
|
36
|
+
reasoning: { type: "number" },
|
|
37
|
+
toolArgs: { type: "number" },
|
|
38
|
+
toolOut: { type: "number" },
|
|
39
|
+
system: { type: "number" },
|
|
40
|
+
toolSchemas: { type: "number" },
|
|
41
|
+
other: { type: "number" },
|
|
42
|
+
// Provenance of the numbers and the measurement time ("" when unknown).
|
|
43
|
+
source: { type: "string" },
|
|
44
|
+
updatedAt: { type: "string" },
|
|
45
|
+
// True when the session was compacted after this measurement.
|
|
46
|
+
stale: { type: "boolean" },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ROW_REQUIRED = Object.keys(ROW_PROPERTIES)
|
|
50
|
+
|
|
51
|
+
export const DesktopContextRpc = Rpc.define({
|
|
52
|
+
// Same id as the plugin itself: one RPC per plugin keeps the event names and
|
|
53
|
+
// client subclients unambiguous.
|
|
54
|
+
id: "context-indicator",
|
|
55
|
+
methods: {
|
|
56
|
+
// Root session plus every descendant that has a measurement, in the same
|
|
57
|
+
// order /context-breakdown uses (root first, then breadth-first children).
|
|
58
|
+
family: {
|
|
59
|
+
input: {
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: { sessionID: { type: "string" } },
|
|
62
|
+
required: ["sessionID"],
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
},
|
|
65
|
+
output: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
rows: {
|
|
69
|
+
type: "array",
|
|
70
|
+
items: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: ROW_PROPERTIES,
|
|
73
|
+
required: ROW_REQUIRED,
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ["rows"],
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
events: {},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Exported for the T30 gate check (schema/row drift) without a second copy of
|
|
87
|
+
// the key list.
|
|
88
|
+
export const DESKTOP_ROW_KEYS = ROW_REQUIRED
|