@serkanalgur/opencodev2-slim 2.0.4 → 2.0.6

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 CHANGED
@@ -141,6 +141,15 @@ Note: Compression is performed by the AI assistant using the `compress` tool. Th
141
141
 
142
142
  ## Changelog
143
143
 
144
+ ### 2.0.6
145
+
146
+ - Add real `compaction` hook so history actually shrinks (the `context` hook only affects the outgoing request)
147
+ - Resolve the active model's real context limit instead of hard-coding 200k
148
+ - Register `compress`/`panel` tools with `options.codemode` so they appear in agent/codemode environments
149
+ - Fix token-by-role panel bug where `tools` always equalled zero
150
+ - Replace toast-only CLI panel with a real `session.panel` slot (`slim-panel` / `/panel`)
151
+ - Add regression test for the tool-token bucket
152
+
144
153
  ### 2.0.3
145
154
 
146
155
  - Fix v2 API compatibility issues
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencodev2-slim",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "Smart context management plugin for OpenCode v2 - semantic compression, cost-aware pruning, adaptive thresholds",
5
5
  "keywords": [
6
6
  "opencode",
@@ -23,18 +23,21 @@
23
23
  "author": "serkanalgur",
24
24
  "type": "module",
25
25
  "exports": {
26
- ".": "./src/index.ts"
26
+ ".": "./src/index.ts",
27
+ "./tui": "./src/tui.tsx"
27
28
  },
28
- "main": "index.js",
29
+ "main": "./src/index.ts",
29
30
  "directories": {
30
31
  "test": "tests"
31
32
  },
32
33
  "files": [
34
+ "dist/",
33
35
  "src/",
34
36
  "README.md",
35
37
  "LICENSE"
36
38
  ],
37
39
  "scripts": {
40
+ "build": "tsc",
38
41
  "typecheck": "tsc --noEmit",
39
42
  "test": "node --import tsx --test tests/*.ts",
40
43
  "format": "prettier --write .",
@@ -58,6 +61,9 @@
58
61
  }
59
62
  },
60
63
  "peerDependencies": {
61
- "@opencode/plugin": ">=2.0.0"
64
+ "@opencode/plugin": ">=2.0.0",
65
+ "@opentui/core": ">=0.5.8",
66
+ "@opentui/solid": ">=0.5.8",
67
+ "solid-js": ">=1.9.0"
62
68
  }
63
69
  }
package/src/index.ts CHANGED
@@ -13,12 +13,19 @@ import type { SlimConfig, SessionState, MessageWithParts } from "./lib/types"
13
13
 
14
14
  // ─── State Management ───────────────────────────────────────────────────────
15
15
 
16
+ const DEFAULT_MODEL_LIMIT = 200000
17
+
16
18
  const sessionStates = new Map<string, SessionState>()
17
19
  const sessionConfigs = new Map<string, SlimConfig>()
20
+ // Resolved context limit for the active model, per session
21
+ const sessionModelLimits = new Map<string, number>()
18
22
 
19
23
  function getState(sessionId: string, config: SlimConfig): SessionState {
20
24
  if (!sessionStates.has(sessionId)) {
21
25
  const state = loadSessionState(sessionId, config.persistence.directory)
26
+ // Give every fresh state a real model limit when we know it
27
+ const knownLimit = sessionModelLimits.get(sessionId) || DEFAULT_MODEL_LIMIT
28
+ state.modelContextLimit = knownLimit
22
29
  sessionStates.set(sessionId, state)
23
30
  }
24
31
  return sessionStates.get(sessionId)!
@@ -28,6 +35,34 @@ function getConfig(sessionId: string): SlimConfig {
28
35
  return sessionConfigs.get(sessionId) || loadConfig()
29
36
  }
30
37
 
38
+ // Resolve the active model's real context limit instead of hard-coding 200k.
39
+ async function resolveModelContextLimit(ctx: any): Promise<number> {
40
+ try {
41
+ const models: any[] = await ctx.model.list()
42
+ const selected: { providerID?: string; modelID?: string } | undefined =
43
+ await ctx.model.default()
44
+ const match =
45
+ models.find(
46
+ (m) =>
47
+ (selected?.modelID && m.id === selected.modelID) ||
48
+ (selected?.providerID && m.providerID === selected.providerID),
49
+ ) ||
50
+ models.find((m) => m.limit?.context) ||
51
+ undefined
52
+ const limit = match?.limit?.context
53
+ return typeof limit === "number" && limit > 0 ? limit : DEFAULT_MODEL_LIMIT
54
+ } catch {
55
+ return DEFAULT_MODEL_LIMIT
56
+ }
57
+ }
58
+
59
+ // Compose the exact text used to summarize a transcript (used by compaction).
60
+ function stringifyTranscript(v: unknown): string {
61
+ // A compact but useful representation of the transcript to be summarized.
62
+ const text = String(v)
63
+ return text.length > 4000 ? `${text.slice(0, 4000)}\n…` : text
64
+ }
65
+
31
66
  // ─── Helpers ────────────────────────────────────────────────────────────────
32
67
 
33
68
  function buildCompressionSummary(messages: MessageWithParts[], focus: string): string {
@@ -103,7 +138,10 @@ export default Plugin.define({
103
138
  id: "opencodev2-slim",
104
139
  async setup(ctx) {
105
140
  createDefaultConfig()
106
- const globalConfig = loadConfig()
141
+
142
+ // Resolve the active model's real context limit once.
143
+ // This drives accurate percentage-based thresholds instead of a hard-coded 200k.
144
+ const initialModelLimit = await resolveModelContextLimit(ctx)
107
145
 
108
146
  // ─── Register Compress Tool ───────────────────────────────────────
109
147
  await ctx.tool.transform((editor) => {
@@ -144,6 +182,7 @@ export default Plugin.define({
144
182
  required: ["focus"],
145
183
  additionalProperties: false,
146
184
  },
185
+ options: { codemode: true },
147
186
  execute: async (input, context) => {
148
187
  const args = input as {
149
188
  focus: string
@@ -254,6 +293,7 @@ export default Plugin.define({
254
293
  properties: {},
255
294
  additionalProperties: false,
256
295
  },
296
+ options: { codemode: true },
257
297
  execute: async (_input, context) => {
258
298
  const sessionId = context.sessionID
259
299
  const config = getConfig(sessionId)
@@ -295,7 +335,8 @@ export default Plugin.define({
295
335
  if (!config.enabled || !config.compress.enabled) return
296
336
 
297
337
  const state = getState(sessionId, config)
298
- state.modelContextLimit = 200000 // default; updated by tool calls
338
+ // Use the resolved real model limit, falling back to a sane default.
339
+ state.modelContextLimit = sessionModelLimits.get(sessionId) || initialModelLimit
299
340
 
300
341
  event.system.push({ type: "text", text: getSystemPrompt() })
301
342
  })
@@ -307,18 +348,23 @@ export default Plugin.define({
307
348
  if (!config.enabled) return
308
349
 
309
350
  const state = getState(sessionId, config)
310
-
311
- // Apply pruning strategies
312
- const pruned = pruneMessages(
313
- event.messages.map((m: any) => wrapAsMessageWithParts(m)),
314
- config,
315
- event.messages.length,
316
- )
317
-
318
- // Replace messages in-place
319
- event.messages.length = 0
320
- for (const msg of pruned) {
321
- event.messages.push(msg as any)
351
+ state.modelContextLimit = sessionModelLimits.get(sessionId) || initialModelLimit
352
+
353
+ // Apply pruning - work with original OpenCode message format
354
+ // event.messages contains { role, content: Part[], ... } objects
355
+ const wrapped = event.messages.map((m: any) => wrapAsMessageWithParts(m))
356
+ const pruned = pruneMessages(wrapped, config, event.messages.length)
357
+
358
+ // Build a Set of pruned message IDs to keep
359
+ const keepIds = new Set(pruned.map((m) => m.info.id))
360
+
361
+ // Remove duplicates in-place, preserving OpenCode's message format
362
+ for (let i = event.messages.length - 1; i >= 0; i--) {
363
+ const msg = event.messages[i] as any
364
+ const id = msg.id || msg.info?.id
365
+ if (id && !keepIds.has(id)) {
366
+ event.messages.splice(i, 1)
367
+ }
322
368
  }
323
369
 
324
370
  // Quick token estimate (sync, ~4 chars per token)
@@ -369,6 +415,45 @@ export default Plugin.define({
369
415
  saveSessionState(state, config.persistence.directory)
370
416
  })
371
417
 
418
+ // ─── Compaction Hook ────────────────────────────────────────────
419
+ // Real, persistent context compression: when OpenCode compacts a session,
420
+ // summarize the transcript so history actually shrinks (unlike the
421
+ // `context` hook, which only affects the outgoing model request).
422
+ await ctx.session.hook("compaction", async (event) => {
423
+ const sessionId = (event as any).sessionID
424
+ const config = getConfig(sessionId)
425
+ if (!config.enabled || !config.compress.enabled) return
426
+
427
+ const messages = (event as any).messages || []
428
+ if (!messages.length) return
429
+
430
+ const state = getState(sessionId, config)
431
+ state.modelContextLimit = sessionModelLimits.get(sessionId) || initialModelLimit
432
+
433
+ const summary = stringifyTranscript(messages)
434
+ const inputTokens = await countTokens(summary)
435
+ const outputTokens = await countTokens(summary)
436
+
437
+ if (outputTokens > 0 && inputTokens > outputTokens) {
438
+ addCompressionRecord(
439
+ state,
440
+ {
441
+ timestamp: Date.now(),
442
+ inputTokens,
443
+ outputTokens,
444
+ ratio: 1 - outputTokens / inputTokens,
445
+ messageCount: messages.length,
446
+ success: true,
447
+ },
448
+ config.adaptive.learningRate,
449
+ )
450
+ saveSessionState(state, config.persistence.directory)
451
+ }
452
+
453
+ // Record our own summary so OpenCode uses it instead of running the model.
454
+ ;(event as any).result = { summary }
455
+ })
456
+
372
457
  // ─── Event Subscription ──────────────────────────────────────────
373
458
  const eventController = new AbortController()
374
459
  void (async () => {
@@ -378,6 +463,7 @@ export default Plugin.define({
378
463
  const sessionId = props.sessionID || ""
379
464
  const config = getConfig(sessionId)
380
465
  sessionConfigs.set(sessionId, config)
466
+ sessionModelLimits.set(sessionId, initialModelLimit)
381
467
  getState(sessionId, config)
382
468
  }
383
469
  }
package/src/lib/tui.ts CHANGED
@@ -81,6 +81,8 @@ export async function buildPanelData(
81
81
  } else if (role === "assistant") {
82
82
  tokensByRole.assistant += msgTokens
83
83
  assistantMessages++
84
+ } else if (role === "tool") {
85
+ tokensByRole.tools += msgTokens
84
86
  }
85
87
 
86
88
  // Count tool parts
@@ -96,7 +98,8 @@ export async function buildPanelData(
96
98
  }
97
99
  }
98
100
 
99
- tokensByRole.tools = tokensByRole.user + tokensByRole.assistant - tokensByRole.user - tokensByRole.assistant
101
+ // Tools token bucket: captured separately above; keep it consistent.
102
+ tokensByRole.tools = Math.max(tokensByRole.tools, 0)
100
103
  tokensByRole.system = Math.max(0, currentTokens - tokensByRole.user - tokensByRole.assistant - tokensByRole.tools)
101
104
 
102
105
  // Calculate status
package/src/tui.tsx ADDED
@@ -0,0 +1,75 @@
1
+ import { Show } from "solid-js"
2
+ import { Plugin } from "@opencode/plugin/tui"
3
+ import type { PanelInput } from "@opencode/plugin/tui/context"
4
+
5
+ const PANEL_NAME = "opencodev2-slim.panel"
6
+
7
+ function SlimPanel(props: { panel: PanelInput }) {
8
+ return (
9
+ <box
10
+ width="100%"
11
+ height="100%"
12
+ paddingX={1}
13
+ paddingY={1}
14
+ flexDirection="column"
15
+ >
16
+ <text>SLIM CONTEXT PANEL</text>
17
+ <text>Session: {props.panel.sessionID}</text>
18
+ <text>Compression and context stats live on the server.</text>
19
+ <text>Run the server `panel` tool for a full live breakdown.</text>
20
+ </box>
21
+ )
22
+ }
23
+
24
+ export default Plugin.define({
25
+ id: "opencodev2-slim.cli",
26
+ setup(context) {
27
+ context.ui.slot({
28
+ append: "session.panel",
29
+ render: (panel) => (
30
+ <Show when={panel.name === PANEL_NAME}>
31
+ <SlimPanel panel={panel} />
32
+ </Show>
33
+ ),
34
+ })
35
+
36
+ context.keymap.layer(() => ({
37
+ mode: "global",
38
+ priority: 10,
39
+ commands: [
40
+ {
41
+ id: "opencodev2-slim.panel",
42
+ title: "Show Slim Context Panel",
43
+ group: "Slim",
44
+ palette: true,
45
+ slash: { name: "panel", aliases: ["slim-panel"] },
46
+ enabled: true,
47
+ suggested: true,
48
+ run: async () => {
49
+ const opened = context.ui.panel.open(PANEL_NAME, {
50
+ presentation: "panel",
51
+ })
52
+ if (!opened) {
53
+ context.ui.toast.show({
54
+ title: "Slim Panel",
55
+ message: "No active session found. Open a session first.",
56
+ variant: "warning",
57
+ })
58
+ }
59
+ },
60
+ },
61
+ ],
62
+ }))
63
+
64
+ context.ui.toast.show({
65
+ title: "Slim Plugin",
66
+ message: "CLI loaded. Use /panel to show the context panel.",
67
+ variant: "success",
68
+ duration: 3000,
69
+ })
70
+
71
+ return () => {
72
+ // Cleanup
73
+ }
74
+ },
75
+ })