opencode-context-indicator 1.5.0 → 1.5.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 CHANGED
@@ -4,6 +4,20 @@ Notable user-facing changes per release. Dates are commit days (UTC).
4
4
  The package also ships a dual V1/V2 plugin — see the README for the surface
5
5
  matrix; everything below concerns the V2 path unless stated otherwise.
6
6
 
7
+ ## 1.5.1 — 2026-10-19
8
+
9
+ - **Compaction now handled by the real `compaction` hook.** opencode dispatches
10
+ a `compaction` hook at the start of a compaction request with the same
11
+ pre-compaction payload as the `context` hook. The plugin registers it and
12
+ persists the *true* pre-compaction breakdown at that moment — previously the
13
+ trailing `session.compacted` event re-persisted whatever the last model
14
+ request had left behind. The event is kept as a fallback (hosts/paths where
15
+ the hook does not fire), and a no-op compaction ("Nothing to compact yet")
16
+ writes no stale entry. Same `(stale — compacted)` UX, more accurate numbers.
17
+ - Dev: pinned the `@opencode/plugin` devDependency to `2.0.32`.
18
+ - Tests: T25 / T25a / T25b / T25c (compaction-hook snapshot, no-op guard,
19
+ event fallback, cold-cache clobber guard) — 62 unit checks total.
20
+
7
21
  ## 1.5.0 — 2026-10-16
8
22
 
9
23
  Highlights: two new context-health signals — the **provider-reported prompt
package/README.md CHANGED
@@ -529,9 +529,10 @@ V2 path). It does not register configurable keys of its own.
529
529
 
530
530
  The package default export is a dual plugin:
531
531
 
532
- * `setup(ctx)` — V2 entry (OpenCode ≥ 2.0.16). Registers a `session.hook("context", …)`
533
- to capture the final messages / system prompt / tool schemas right before each
534
- model request, subscribes to the event stream (`session.created`,
532
+ * `setup(ctx)` — V2 entry (OpenCode ≥ 2.0.16). Registers `session.hook("context", …)`
533
+ and `session.hook("compaction", …)` to capture the final messages / system
534
+ prompt / tool schemas right before each model request (and each compaction
535
+ request), subscribes to the event stream (`session.created`,
535
536
  `session.step.ended`, `session.idle`, `session.compacted`, failure events),
536
537
  records limits from the model registry and registers the
537
538
  `/context` + `/context-breakdown` slash commands (via
@@ -549,12 +550,15 @@ duplicate final summaries are suppressed with an atomic claim marker
549
550
  (`lib/dedup.js`). Every file / estimate / subagent path is fault-tolerant: a
550
551
  failure is logged and never breaks the main event or toast path.
551
552
 
552
- **Compaction staleness.** OpenCode's compaction runs a separate flow that fires
553
- no `context` hook, so the numbers a `session.compacted` write re-persists are
554
- the *pre-compaction* ones. The plugin flags such an entry
555
- (`compactStale` in `state.json`) and every surface shows `(stale — compacted)`
556
- until the next model request refreshes the breakdown — i.e. the data is exactly
557
- as fresh as the last model request in the session.
553
+ **Compaction staleness.** OpenCode dispatches a dedicated `compaction` hook at
554
+ the start of a compaction request, carrying the same pre-compaction payload as
555
+ the `context` hook. The plugin registers it and persists the *true*
556
+ pre-compaction breakdown at that moment, flagging the entry stale
557
+ (`compactStale` in `state.json`) so every surface shows `(stale — compacted)`
558
+ until the next model request refreshes it. The trailing `session.compacted`
559
+ event is kept as a fallback (for hosts or paths where the hook does not fire);
560
+ a no-op compaction ("Nothing to compact yet") writes no stale entry. The data
561
+ is always exactly as fresh as the last model request in the session.
558
562
 
559
563
  This plugin does not touch `opencode-token-monitor`
560
564
  (`token_stats` / `token_history` / `token_export` keep working unchanged).
package/index.js CHANGED
@@ -150,7 +150,7 @@
150
150
  */
151
151
 
152
152
  import { makeV1 } from "./lib/v1.js";
153
- import { handleV2Event, onV2Context, estimateBreakdownFromContext, estimateBreakdownFromClientMessages } from "./lib/v2.js";
153
+ import { handleV2Event, onV2Context, onV2Compaction, estimateBreakdownFromContext, estimateBreakdownFromClientMessages } from "./lib/v2.js";
154
154
  import { breakdownCategories, computeReclaim, contentFingerprint, estimateTokens, extractToolTarget, isSafeSessionKey, safeCell, safeReclaim, safeSaneOrNull, safeToolOutByTool, sumCategories, toolOutEntries } from "./lib/estimate.js";
155
155
  import {
156
156
  commandContextBreakdown,
@@ -170,7 +170,7 @@ import {
170
170
  modelLimits,
171
171
  reserveFor,
172
172
  } from "./lib/limits.js";
173
- import { lastKnownCacheRead, lastKnownCacheWrite, sessionAgentCache, sessionParentCache } from "./lib/cache.js";
173
+ import { lastKnownCacheRead, lastKnownCacheWrite, lastKnownCtxCache, lastKnownInputCache, lastKnownReasoningCache, sessionAgentCache, sessionParentCache } from "./lib/cache.js";
174
174
  import {
175
175
  logErr,
176
176
  readStateSnapshot,
@@ -204,6 +204,7 @@ export {
204
204
  estimateBreakdownFromContext,
205
205
  estimateBreakdownFromClientMessages,
206
206
  onV2Context,
207
+ onV2Compaction,
207
208
  safeToolOutByTool,
208
209
  toolOutEntries,
209
210
  sumCategories,
@@ -218,6 +219,10 @@ export {
218
219
  safeSaneOrNull,
219
220
  lastKnownCacheRead,
220
221
  lastKnownCacheWrite,
222
+ lastKnownCtxCache,
223
+ lastKnownInputCache,
224
+ lastKnownReasoningCache,
225
+ handleV2Event,
221
226
  };
222
227
 
223
228
  // ---------------------------------------------------------------------------
@@ -238,6 +243,19 @@ export default {
238
243
  logErr("v2 context hook registration failed", err);
239
244
  }
240
245
 
246
+ // The compaction request carries the same pre-compaction context payload as
247
+ // the context hook. Registering it lets us persist the TRUE pre-compaction
248
+ // breakdown (see onV2Compaction); the `session.compacted` event then only
249
+ // acts as a fallback. A registration failure is non-fatal — the event path
250
+ // still marks the entry stale.
251
+ try {
252
+ registrations.push(
253
+ await ctx.session.hook("compaction", (context) => onV2Compaction(context)),
254
+ );
255
+ } catch (err) {
256
+ logErr("v2 compaction hook registration failed", err);
257
+ }
258
+
241
259
  // The model registry is captured asynchronously through ensureModelLimits
242
260
  // (ctx.model.list(), rank 3) — the authoritative source that carries the
243
261
  // user's merged config overrides. ctx.model.transform fires synchronously
package/lib/cache.js CHANGED
@@ -69,6 +69,13 @@ const lastErrorToast = new Map();
69
69
  // sessionID -> timestamp — dedup for session.compacted toasts
70
70
  const lastCompactedToast = new Map();
71
71
 
72
+ // sessionID -> timestamp — when the `compaction` HOOK last fired for a session.
73
+ // The hook is dispatched at the START of a compaction request (before the model
74
+ // summarises), carrying the exact pre-compaction context, so it lets us persist
75
+ // the TRUE pre-compaction breakdown. It also tells the later `session.compacted`
76
+ // EVENT that the hook already marked the entry (the event stays a fallback).
77
+ const lastCompactionHookAt = new Map();
78
+
72
79
  // sessionID -> parentID (null = main session) — [main]/[sub] attribution cache
73
80
  const sessionParentCache = new Map();
74
81
 
@@ -144,6 +151,8 @@ function clearSessionTracking(sessionID, keepToastDedup = false) {
144
151
  v2ContextFetchTimes.delete(sessionID);
145
152
  // Per-session tool-schema TTL cache (see estimateBreakdownFromContext).
146
153
  toolSchemasBySession.delete(sessionID);
154
+ // Compaction-hook marker (re-armed by the next compaction on this session).
155
+ lastCompactionHookAt.delete(sessionID);
147
156
  }
148
157
 
149
158
  export {
@@ -168,6 +177,7 @@ export {
168
177
  lastBdWrite,
169
178
  lastErrorToast,
170
179
  lastCompactedToast,
180
+ lastCompactionHookAt,
171
181
  sessionParentCache,
172
182
  sessionTotals,
173
183
  sessionAgentCache,
package/lib/v2.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  lastBdWrite,
30
30
  lastKnownCtx,
31
31
  lastKnownCtxCache,
32
+ lastCompactionHookAt,
32
33
  lastKnownCacheRead,
33
34
  lastKnownCacheReadCache,
34
35
  lastKnownCacheWrite,
@@ -68,6 +69,11 @@ const V2_CONTEXT_FETCH_THROTTLE_MS = 5000; // min gap between fallback fetches
68
69
  // event handlers): cap each at 3s so a slow/hung client cannot pin resources.
69
70
  const V2_FALLBACK_TIMEOUT_MS = 3000;
70
71
 
72
+ // Window within which a `session.compacted` EVENT is considered already handled
73
+ // by the preceding `compaction` HOOK: if the hook marked the entry recently, the
74
+ // event only logs + clears tracking instead of re-persisting the snapshot.
75
+ const COMPACTION_HOOK_FALLBACK_MS = 60000;
76
+
71
77
  // Estimate the per-category breakdown directly from a V2 SessionContext.
72
78
  // `context.messages` are @opencode/ai Message objects:
73
79
  // { role, content: [ {type:"text",text} | {type:"media"} |
@@ -329,6 +335,93 @@ function onV2Context(context) {
329
335
  }
330
336
  }
331
337
 
338
+ // V2 compaction hook handler: opencode dispatches `compaction` at the START of a
339
+ // compaction request with the SAME pre-compaction payload as the context hook
340
+ // (system / messages / tools / model). We use it to persist the TRUE
341
+ // pre-compaction breakdown instead of relying on the `session.compacted` event
342
+ // to re-persist whatever a previous request left behind.
343
+ //
344
+ // The entry is marked stale BY CONSTRUCTION (the context is about to be
345
+ // compacted, so the numbers change once the summary lands and the next context
346
+ // hook fires). Guard: the hook also fires for a NO-OP compaction ("Nothing to
347
+ // compact yet") on a session with no completed request — there is no measured
348
+ // context to mark, so we only cache and skip the state write (no fake stale
349
+ // badge).
350
+ function onV2Compaction(context) {
351
+ try {
352
+ if (!context || typeof context !== "object") return;
353
+ const sid = context.sessionID;
354
+ if (!sid) return;
355
+
356
+ const parts = estimateBreakdownFromContext(context, sid);
357
+ breakdownCache.set(sid, {
358
+ ...parts,
359
+ capturedAt: Date.now(),
360
+ source: "compaction-hook",
361
+ });
362
+ pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
363
+
364
+ const model = context.model;
365
+ if (model && typeof model === "object") {
366
+ if (typeof model.id === "string") lastKnownModelCache.set(sid, model.id);
367
+ if (typeof model.providerID === "string") {
368
+ lastKnownProviderCache.set(sid, model.providerID);
369
+ }
370
+ pruneMap(lastKnownModelCache, MAX_TRACKED_SESSIONS);
371
+ pruneMap(lastKnownProviderCache, MAX_TRACKED_SESSIONS);
372
+ }
373
+
374
+ // Arm the fallback guard for the trailing `session.compacted` event.
375
+ lastCompactionHookAt.set(sid, Date.now());
376
+ pruneMap(lastCompactionHookAt, MAX_TRACKED_SESSIONS);
377
+
378
+ const ctxTokens = lastKnownCtx(sid);
379
+ if (ctxTokens > 0) {
380
+ const lines = buildLiveLinesV2(
381
+ sid,
382
+ lastKnownModel(sid),
383
+ lastKnownProvider(sid),
384
+ lastKnownInput(sid),
385
+ ctxTokens,
386
+ lastKnownReasoning(sid),
387
+ );
388
+ writeStateFile(
389
+ sid,
390
+ lastKnownModel(sid),
391
+ lastKnownProvider(sid),
392
+ lastKnownInput(sid),
393
+ ctxTokens,
394
+ lastKnownReasoning(sid),
395
+ {
396
+ compacted: true,
397
+ cacheRead: lastKnownCacheRead(sid),
398
+ cacheWrite: lastKnownCacheWrite(sid),
399
+ },
400
+ );
401
+ // Claim key derived from the PAYLOAD shape (message count + total
402
+ // estimated chars): identical across plugin instances for the SAME
403
+ // compaction (cross-instance dedup preserved) yet distinct per compaction
404
+ // — a session-only key would log just the first compaction on a session.
405
+ const messages = Array.isArray(context.messages) ? context.messages : [];
406
+ const sig =
407
+ `${messages.length}:` +
408
+ (parts.userChars +
409
+ parts.assistantChars +
410
+ parts.reasoningChars +
411
+ parts.toolArgsChars +
412
+ parts.toolOutChars +
413
+ (parts.systemChars || 0) +
414
+ (parts.toolSchemasChars || 0));
415
+ pushFinalSummary(`compaction-hook:${sid}:${sig}`, [
416
+ `[${new Date().toISOString()}] COMPACTION (hook) session=${sid} — pre-compaction snapshot captured`,
417
+ ...lines.slice(1),
418
+ ]);
419
+ }
420
+ } catch (err) {
421
+ logErr("v2 compaction hook failed", err);
422
+ }
423
+ }
424
+
332
425
  // ---------------------------------------------------------------------------
333
426
  // V2 fallback: derive the message-based categories from the session's persisted
334
427
  // message list when the context hook did NOT populate the cache for this
@@ -685,23 +778,30 @@ async function handleV2Event(ctx, event) {
685
778
  lastKnownCtx(sid),
686
779
  lastKnownReasoning(sid),
687
780
  );
688
- writeStateFile(
689
- sid,
690
- lastKnownModel(sid),
691
- lastKnownProvider(sid),
692
- lastKnownInput(sid),
693
- lastKnownCtx(sid),
694
- lastKnownReasoning(sid),
695
- // This write re-persists the PRE-compaction snapshot (the compaction
696
- // flow fires no context hook — see README): mark the entry stale so the
697
- // UI shows the numbers as pre-compaction until the next model request
698
- // refreshes them.
699
- {
700
- compacted: true,
701
- cacheRead: lastKnownCacheRead(sid),
702
- cacheWrite: lastKnownCacheWrite(sid),
703
- },
704
- );
781
+ // The `compaction` HOOK already captured + persisted the exact
782
+ // pre-compaction snapshot and marked the entry stale. This event is now a
783
+ // FALLBACK: it re-persists only when the hook did NOT fire recently (host
784
+ // gap, mirror instance, or a compaction path that bypassed the hook).
785
+ // Either way the write re-persists the PRE-compaction numbers and marks
786
+ // the entry stale, so the UI shows them as pre-compaction until the next
787
+ // model request refreshes them.
788
+ const hookAt = lastCompactionHookAt.get(sid) || 0;
789
+ const handledByHook = Date.now() - hookAt < COMPACTION_HOOK_FALLBACK_MS;
790
+ if (!handledByHook) {
791
+ writeStateFile(
792
+ sid,
793
+ lastKnownModel(sid),
794
+ lastKnownProvider(sid),
795
+ lastKnownInput(sid),
796
+ lastKnownCtx(sid),
797
+ lastKnownReasoning(sid),
798
+ {
799
+ compacted: true,
800
+ cacheRead: lastKnownCacheRead(sid),
801
+ cacheWrite: lastKnownCacheWrite(sid),
802
+ },
803
+ );
804
+ }
705
805
  pushFinalSummary(
706
806
  `compacted:${event.id ?? sid}`,
707
807
  [
@@ -886,6 +986,7 @@ async function handleV2Event(ctx, event) {
886
986
 
887
987
  export {
888
988
  onV2Context,
989
+ onV2Compaction,
889
990
  handleV2Event,
890
991
  estimateBreakdownFromContext,
891
992
  estimateBreakdownFromClientMessages,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Real-time context-window usage for OpenCode: per-category token breakdown, a zero-LLM /cx panel with per-session cost and a searchable family-session picker, and a TUI sidebar. Dual V1 + V2 plugin entry.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,7 +64,7 @@
64
64
  "@opentui/solid": "0.5.17"
65
65
  },
66
66
  "devDependencies": {
67
- "@opencode/plugin": "2.0.30",
67
+ "@opencode/plugin": "2.0.32",
68
68
  "@opentui/core": "0.5.17",
69
69
  "@types/node": "26.6.3",
70
70
  "esbuild": "^0.28.2",