@cortexkit/opencode-magic-context 0.42.5 → 0.42.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.
Files changed (128) hide show
  1. package/README.md +2 -2
  2. package/dist/config/index.d.ts +9 -0
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/schema/magic-context.d.ts +1 -0
  5. package/dist/config/schema/magic-context.d.ts.map +1 -1
  6. package/dist/{conflict-warning-hook-jyj54gbe.js → conflict-warning-hook-aktepwpz.js} +3 -3
  7. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  8. package/dist/features/magic-context/context-authority.d.ts +28 -1
  9. package/dist/features/magic-context/context-authority.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/open-opencode-db.d.ts.map +1 -1
  11. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  12. package/dist/features/magic-context/memory/embedding-local.d.ts +1 -0
  13. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  14. package/dist/features/magic-context/memory/embedding.d.ts +1 -1
  15. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  16. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  17. package/dist/features/magic-context/migrations.d.ts +12 -0
  18. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  19. package/dist/features/magic-context/project-embedding-registry.d.ts +1 -0
  20. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  21. package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
  22. package/dist/features/magic-context/storage-db.d.ts +1 -1
  23. package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
  24. package/dist/hooks/auto-update-checker/index.d.ts.map +1 -1
  25. package/dist/hooks/magic-context/cache-busting-signals.d.ts +0 -1
  26. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
  27. package/dist/hooks/magic-context/channel2-delivery.d.ts +8 -0
  28. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  29. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +12 -0
  30. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  31. package/dist/hooks/magic-context/hook.d.ts +4 -4
  32. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  33. package/dist/hooks/magic-context/module-state-sync.d.ts +3 -0
  34. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  35. package/dist/hooks/magic-context/module-transport.d.ts +24 -2
  36. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  37. package/dist/hooks/magic-context/module-wire.d.ts +1 -1
  38. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/producer-window-guard.d.ts +31 -1
  40. package/dist/hooks/magic-context/producer-window-guard.d.ts.map +1 -1
  41. package/dist/hooks/magic-context/read-session-chunk.d.ts +6 -0
  42. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/read-session-db.d.ts +8 -0
  44. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/read-session-formatting.d.ts +6 -0
  46. package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
  47. package/dist/hooks/magic-context/rust-mode-transform.d.ts +30 -0
  48. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/rust-refusal-recovery.d.ts +28 -0
  50. package/dist/hooks/magic-context/rust-refusal-recovery.d.ts.map +1 -0
  51. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/transform.d.ts +7 -0
  53. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  54. package/dist/{index-jkrqhc7y.js → index-a9ssj59b.js} +94 -15
  55. package/dist/{index-3wbd8sdq.js → index-djp6rwz6.js} +455 -46
  56. package/dist/{index-p474rw9p.js → index-ngjmj6xx.js} +1 -1
  57. package/dist/{index-d8mgqezq.js → index-s61hh7gv.js} +50 -17
  58. package/dist/{index-1ar7b3e9.js → index-znraa0kv.js} +1 -1
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +1485 -360
  61. package/dist/plugin/embedding-bootstrap.d.ts.map +1 -1
  62. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  63. package/dist/plugin/memory-id-translation.d.ts +23 -0
  64. package/dist/plugin/memory-id-translation.d.ts.map +1 -0
  65. package/dist/plugin/rust-tool-backends.d.ts +1 -1
  66. package/dist/plugin/rust-tool-backends.d.ts.map +1 -1
  67. package/dist/{recomp-orchestrator-k20sfpm2.js → recomp-orchestrator-0xx1364v.js} +4 -4
  68. package/dist/{send-session-notification-xnypkxpx.js → send-session-notification-jbxknvar.js} +2 -2
  69. package/dist/shared/auto-update-provenance.d.ts +9 -0
  70. package/dist/shared/auto-update-provenance.d.ts.map +1 -0
  71. package/dist/shared/conflict-detector.d.ts +5 -1
  72. package/dist/shared/conflict-detector.d.ts.map +1 -1
  73. package/dist/shared/opencode-db-path.d.ts +31 -2
  74. package/dist/shared/opencode-db-path.d.ts.map +1 -1
  75. package/dist/shared/prompt-surface-runtime.d.ts +3 -3
  76. package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
  77. package/dist/{storage-meta-session-x3j0q9qy.js → storage-meta-session-z818pn1e.js} +2 -2
  78. package/dist/tools/ctx-expand/mode.d.ts +31 -0
  79. package/dist/tools/ctx-expand/mode.d.ts.map +1 -0
  80. package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
  81. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  82. package/dist/tools/ctx-note/constants.d.ts +1 -1
  83. package/dist/tools/ctx-note/constants.d.ts.map +1 -1
  84. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  85. package/dist/tools/ctx-note/types.d.ts +1 -1
  86. package/dist/tools/ctx-note/types.d.ts.map +1 -1
  87. package/dist/tools/ctx-reduce/constants.d.ts +1 -1
  88. package/dist/tools/ctx-reduce/constants.d.ts.map +1 -1
  89. package/dist/tools/ctx-search/types.d.ts +1 -1
  90. package/dist/tools/ctx-search/types.d.ts.map +1 -1
  91. package/dist/tools/light-descriptions.d.ts +2 -2
  92. package/dist/tools/light-descriptions.d.ts.map +1 -1
  93. package/dist/v2/hidden-completion.d.ts +69 -13
  94. package/dist/v2/hidden-completion.d.ts.map +1 -1
  95. package/dist/v2/hooks/context.d.ts +13 -1
  96. package/dist/v2/hooks/context.d.ts.map +1 -1
  97. package/dist/v2/hooks/hidden-child.d.ts +31 -0
  98. package/dist/v2/hooks/hidden-child.d.ts.map +1 -0
  99. package/dist/v2/hooks/store.d.ts.map +1 -1
  100. package/dist/v2/hooks/types.d.ts +99 -19
  101. package/dist/v2/hooks/types.d.ts.map +1 -1
  102. package/dist/v2/server.d.ts +19 -0
  103. package/dist/v2/server.d.ts.map +1 -1
  104. package/dist/v2/server.js +1781 -253
  105. package/dist/v2/store-reader.d.ts +11 -5
  106. package/dist/v2/store-reader.d.ts.map +1 -1
  107. package/dist/v2/tui/index.d.ts +10 -0
  108. package/dist/v2/tui/index.d.ts.map +1 -0
  109. package/dist/v2/tui/types.d.ts +80 -0
  110. package/dist/v2/tui/types.d.ts.map +1 -0
  111. package/package.json +5 -4
  112. package/src/agents/magic-context-prompt.ts +1 -1
  113. package/src/config/index.ts +31 -1
  114. package/src/config/protected-tokens.test.ts +40 -0
  115. package/src/config/schema/magic-context.ts +8 -3
  116. package/src/shared/auto-update-provenance.ts +42 -0
  117. package/src/shared/conflict-detector.test.ts +53 -0
  118. package/src/shared/conflict-detector.ts +88 -19
  119. package/src/shared/models-dev-cache.test.ts +25 -0
  120. package/src/shared/opencode-db-path.test.ts +96 -0
  121. package/src/shared/opencode-db-path.ts +146 -15
  122. package/src/shared/prompt-surface-a1-golden.md +12 -16
  123. package/src/shared/prompt-surface-runtime.test.ts +20 -0
  124. package/src/shared/prompt-surface-runtime.ts +7 -5
  125. package/src/tui/entry.mjs +11 -1
  126. package/src/v2/tui/host-contract.test.ts +202 -0
  127. package/src/v2/tui/index.ts +270 -0
  128. package/src/v2/tui/types.ts +67 -0
@@ -1,11 +1,11 @@
1
1
  # Magic Context agent-facing surface (exported from source)
2
2
 
3
- Generated by `packages/plugin/scripts/export-agent-surface.ts` at 2026-09-11.
3
+ Generated by `packages/plugin/scripts/export-agent-surface.ts` at 2026-09-17.
4
4
  Token counts are Claude BPE estimates on the raw text.
5
5
 
6
6
  ## 1. System-prompt guidance section
7
7
 
8
- ### PRIMARY full (reduce=on, memory=on, dreamer=on, temporal=on) — 8752 chars, ~2008 tokens
8
+ ### PRIMARY full (reduce=on, memory=on, dreamer=on, temporal=on) — 8697 chars, ~1998 tokens
9
9
 
10
10
  ```markdown
11
11
  ## Magic Context
@@ -23,7 +23,7 @@ Because of this:
23
23
  Reduction prompts are routine housekeeping to keep the session fast and cheap — act on them as light maintenance, never as scarcity warnings. Keep individual operations efficient, but never let context size change *what* work you take on or *how thoroughly* you do it.
24
24
 
25
25
  Messages and tool outputs are tagged with §N§ identifiers (e.g., §1§, §42§).
26
- Use `ctx_reduce` to mark spent tagged content as discardable and reclaim space. Marking is NOT an immediate delete — it queues the content, which stays fully visible until space is actually needed (as soon as the next turn if you're already under pressure, much later if not), so mark a tool output as soon as you're done with it rather than hoarding the call for the end of the turn. The newest token-mass window stays protected until displaced. Syntax: "3-5", "1,2,9", or "1-5,8,12-15".
26
+ Use `ctx_reduce` to mark spent tagged content as discardable and reclaim space. Marking QUEUES content for release. It stays fully visible to you until it is actually released, which may be the next turn or many turns later. Mark a tool output as soon as you're done with it rather than hoarding the call for the end of the turn. The newest token-mass window stays protected until displaced. Syntax: "3-5", "1,2,9", or "1-5,8,12-15".
27
27
  Do not announce or narrate `ctx_reduce` drops — just call the tool silently. Saying "I'll drop these outputs" wastes tokens the user does not care about.
28
28
  Use `ctx_note` ONLY for genuinely future concerns — something to revisit much later, not work coming up in the next few turns (that's already in your active context) and not active multi-step work (use todos for that). Magic Context preserves your full context across both compaction and restarts, so an upcoming restart or "let's come back to this later" is never a reason to take a note — nothing is lost either way. Notes you do take survive compression and resurface at natural work boundaries (after commits, historian runs, todo completion).
29
29
  Use `ctx_memory` for durable project knowledge: write what future sessions must know, update/archive/merge the memories you see in `<project-memory>` when they drift. Memories persist across sessions and every new session starts with them.
@@ -71,7 +71,7 @@ Example: `ctx_note(action="write", content="Implement X because Y", surface_cond
71
71
  Prefer many small targeted operations over one large blanket operation, and keep the working set tidy as routine maintenance.
72
72
  ```
73
73
 
74
- ### PRIMARY memory-off (reduce=on, memory=off) — 7717 chars, ~1749 tokens
74
+ ### PRIMARY memory-off (reduce=on, memory=off) — 7662 chars, ~1739 tokens
75
75
 
76
76
  ```markdown
77
77
  ## Magic Context
@@ -89,7 +89,7 @@ Because of this:
89
89
  Reduction prompts are routine housekeeping to keep the session fast and cheap — act on them as light maintenance, never as scarcity warnings. Keep individual operations efficient, but never let context size change *what* work you take on or *how thoroughly* you do it.
90
90
 
91
91
  Messages and tool outputs are tagged with §N§ identifiers (e.g., §1§, §42§).
92
- Use `ctx_reduce` to mark spent tagged content as discardable and reclaim space. Marking is NOT an immediate delete — it queues the content, which stays fully visible until space is actually needed (as soon as the next turn if you're already under pressure, much later if not), so mark a tool output as soon as you're done with it rather than hoarding the call for the end of the turn. The newest token-mass window stays protected until displaced. Syntax: "3-5", "1,2,9", or "1-5,8,12-15".
92
+ Use `ctx_reduce` to mark spent tagged content as discardable and reclaim space. Marking QUEUES content for release. It stays fully visible to you until it is actually released, which may be the next turn or many turns later. Mark a tool output as soon as you're done with it rather than hoarding the call for the end of the turn. The newest token-mass window stays protected until displaced. Syntax: "3-5", "1,2,9", or "1-5,8,12-15".
93
93
  Do not announce or narrate `ctx_reduce` drops — just call the tool silently. Saying "I'll drop these outputs" wastes tokens the user does not care about.
94
94
  Use `ctx_note` ONLY for genuinely future concerns — something to revisit much later, not work coming up in the next few turns (that's already in your active context) and not active multi-step work (use todos for that). Magic Context preserves your full context across both compaction and restarts, so an upcoming restart or "let's come back to this later" is never a reason to take a note — nothing is lost either way. Notes you do take survive compression and resurface at natural work boundaries (after commits, historian runs, todo completion).
95
95
  Use `ctx_search` to search across project memories, indexed git commits, and this session's full conversation history (including compacted parts) from one query.
@@ -185,7 +185,7 @@ Older tool calls may show `[dropped §N§]` sentinels; that is normal context ma
185
185
 
186
186
  ## 2. Tool surface (description + parameters as serialized to the provider)
187
187
 
188
- ### ctx_reduce — description ~318 tokens, params ~31 tokens (total ~349)
188
+ ### ctx_reduce — description ~304 tokens, params ~31 tokens (total ~335)
189
189
 
190
190
  **Description:**
191
191
 
@@ -193,7 +193,7 @@ Older tool calls may show `[dropped §N§]` sentinels; that is normal context ma
193
193
  Mark spent tagged content as discardable to reclaim context space. This is NOT an immediate delete. Use §N§ identifiers visible in the conversation. The `drop` param accepts ranges: "3-5", "1,2,9", "1-5,8".
194
194
 
195
195
  How it works:
196
- - Marking QUEUES content for release. It stays fully visible to you until context space is actually needed — which may be as soon as the next turn if you are already under pressure, or many turns later if not. So mark spent outputs as soon as you finish with them; don't hoard the call for the end of the turn.
196
+ - Marking QUEUES content for release. It stays fully visible to you until it is actually released, which may be the next turn or many turns later. Mark spent outputs as soon as you finish with them; don't hoard the call for the end of the turn.
197
197
  - The newest tags are protected: marking one just queues it until it ages out of the recent window, so marking recent output is harmless.
198
198
  - When content is finally released it becomes a short placeholder, and re-running the tool is the only way to get it back. So mark only what you are genuinely DONE with — the test is "have I extracted what I need from this?", not "is it safe / do I have time before it drops?".
199
199
 
@@ -254,7 +254,7 @@ Two recovery modes for finer detail:
254
254
  }
255
255
  ```
256
256
 
257
- ### ctx_note — description ~392 tokens, params ~378 tokens (total ~770)
257
+ ### ctx_note — description ~387 tokens, params ~359 tokens (total ~746)
258
258
 
259
259
  **Description:**
260
260
 
@@ -266,7 +266,7 @@ Use a note when something matters LATER but not in the next few steps: "revisit
266
266
  Actions:
267
267
  - write: save a note (content). Add surface_condition to make it a smart note (below).
268
268
  - read: list notes, newest first. Default: latest active session notes + ready smart notes; page older ones with limit/offset, or inspect other states with filter.
269
- - update: change a note by note_id. dismiss: retire one by note_id or 1–50 by note_ids; provide exactly one.
269
+ - update: change one note (note_ids=[N]). dismiss: retire 1–50 notes (note_ids=[...]).
270
270
 
271
271
  Smart notes: pass surface_condition and the note stays hidden until a background checker confirms the condition — using ONLY externally verifiable signals (GitHub state via gh, files on disk, git history, web pages). It cannot see this conversation, so the condition must be checkable from outside:
272
272
  ✓ "When PR #42 in cortexkit/magic-context is merged"
@@ -318,12 +318,8 @@ Example: ctx_note(action="write", content="Re-run the perf benchmark once the bo
318
318
  "description": "Skip this many newest notes for read — page older ones (default: 0)",
319
319
  "type": "number"
320
320
  },
321
- "note_id": {
322
- "description": "Note ID (required for 'dismiss' and 'update' actions).",
323
- "type": "number"
324
- },
325
321
  "note_ids": {
326
- "description": "One to fifty note ids for 'dismiss' only; do not combine with note_id.",
322
+ "description": "Note ids: exactly one for 'update', one to fifty for 'dismiss'. Ignored by 'write' and 'read'.",
327
323
  "minItems": 1,
328
324
  "maxItems": 50,
329
325
  "type": "array",
@@ -465,8 +461,8 @@ The hash handler persists the MD5 of `output.system.join("\\n")`. The values bel
465
461
 
466
462
  | Variant | Guidance bytes | MD5 system-prompt hash |
467
463
  |---|---:|---|
468
- | PRIMARY full | 8816 | `e8a75373e85edefedf7a5c8c19af3926` |
469
- | PRIMARY memory-off | 7775 | `402c342a3ff283375c47a1735d03aa1a` |
464
+ | PRIMARY full | 8759 | `f513711009bd420fe07b2672ce30d279` |
465
+ | PRIMARY memory-off | 7718 | `6834a01be9891fa5461e996f281ba63d` |
470
466
  | PRIMARY reduce-off | 6526 | `7e5d386d94abe4f101bfd6d891ec183b` |
471
467
  | SUBAGENT minimal | 705 | `1c09c70b742f79274c501dd8f7eff1fc` |
472
468
 
@@ -148,6 +148,26 @@ describe("prompt-surface runtime", () => {
148
148
  );
149
149
  });
150
150
 
151
+ it("honors model routes for tool text when a model key is supplied", () => {
152
+ const runtime = createPromptSurfaceRuntime({
153
+ userConfigDirectory: tempDir(),
154
+ warn: () => undefined,
155
+ });
156
+ const config = {
157
+ default: "full" as const,
158
+ models: { "provider/light": "light" as const },
159
+ };
160
+ const processScoped = runtime.resolveRegistration(config);
161
+ const perModel = runtime.resolveRegistration(config, "provider/light");
162
+
163
+ expect(processScoped.preset).toBe("full");
164
+ expect(processScoped.descriptionFor("ctx_search", "Full search")).toBe("Full search");
165
+ expect(perModel.preset).toBe("light");
166
+ expect(perModel.descriptionFor("ctx_search", "Full search")).toBe(
167
+ LIGHT_TOOL_DESCRIPTIONS.ctx_search,
168
+ );
169
+ });
170
+
151
171
  it("serves built-in light descriptions without a fallback notice", () => {
152
172
  const warnings: string[] = [];
153
173
  const runtime = createPromptSurfaceRuntime({
@@ -80,6 +80,7 @@ export interface PromptSurfaceRegistrationSelection {
80
80
  export interface PromptSurfaceRuntime {
81
81
  resolveRegistration: (
82
82
  config: PromptSurfaceConfig | undefined,
83
+ modelKey?: string,
83
84
  ) => PromptSurfaceRegistrationSelection;
84
85
  resolveGuidance: (
85
86
  config: PromptSurfaceConfig | undefined,
@@ -184,11 +185,12 @@ export function createPromptSurfaceRuntime(
184
185
  };
185
186
 
186
187
  return {
187
- resolveRegistration(config) {
188
- // OpenCode and Pi expose one immutable provider tool map per host
189
- // registration. Model routes are intentionally ignored here: only the
190
- // registration owner's default and user-tier overrides can choose text.
191
- const { preset } = resolvePromptSurface(config, undefined);
188
+ resolveRegistration(config, modelKey) {
189
+ // OpenCode 1.x and Pi expose one immutable provider tool map per host
190
+ // registration, so callers omit modelKey and only the default preset
191
+ // can choose text. OpenCode 2 rewrites draft.tools per request and
192
+ // passes the draft model so the same resolver honors model routes.
193
+ const { preset } = resolvePromptSurface(config, modelKey);
192
194
 
193
195
  const overrides = config?.tool_descriptions ?? {};
194
196
  for (const [toolId, description] of Object.entries(overrides)) {
package/src/tui/entry.mjs CHANGED
@@ -33,4 +33,14 @@ if (!mod) {
33
33
  }
34
34
  }
35
35
 
36
- export default mod.default;
36
+ const v1 = mod.default;
37
+
38
+ async function setup(context) {
39
+ const v2 = await import("../v2/tui/index.ts");
40
+ return v2.setup(context);
41
+ }
42
+
43
+ // OpenCode 1.18.30's TUI loader reads only id/server/tui and ignores setup
44
+ // (packages/opencode/src/plugin/shared.ts:272-304). OpenCode 2.0.3 validates
45
+ // id/setup and ignores tui, so one object safely serves both package loaders.
46
+ export default { id: v1.id, tui: v1.tui, setup };
@@ -0,0 +1,202 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { resolve } from "node:path";
5
+ import { Host } from "@opencode/plugin/host";
6
+ import { setupWithJsx } from "./index";
7
+ import type { V2SidebarState, V2TuiContext } from "./types";
8
+
9
+ const temporary: string[] = [];
10
+ afterEach(() => {
11
+ for (const directory of temporary.splice(0))
12
+ rmSync(directory, { recursive: true, force: true });
13
+ });
14
+
15
+ function v2Context() {
16
+ const claims: Array<{ render: (input: { sessionID: string }) => unknown }> = [];
17
+ const layers: Array<ReturnType<Parameters<V2TuiContext["keymap"]["layer"]>[0]>> = [];
18
+ const cleanups: Array<() => void> = [];
19
+ const state: V2SidebarState = { snapshots: {} };
20
+ const context: V2TuiContext = {
21
+ location: { directory: process.cwd() },
22
+ renderer: { requestRender() {} },
23
+ data: {
24
+ listen(handler) {
25
+ void handler;
26
+ const cleanup = () => {};
27
+ cleanups.push(cleanup);
28
+ return cleanup;
29
+ },
30
+ location: { default: () => ({ directory: process.cwd() }) },
31
+ },
32
+ keymap: { layer: (input) => layers.push(input()) },
33
+ storage: {
34
+ memory: <Value extends object>(_key: string, options: { initial: Value }) => [
35
+ options.initial,
36
+ (mutation: (draft: Value) => void) => mutation(options.initial),
37
+ ],
38
+ },
39
+ ui: {
40
+ router: { current: () => ({ type: "session", sessionID: "ses-v2-tui" }) },
41
+ slot: (claim) => {
42
+ claims.push(claim);
43
+ return () => {};
44
+ },
45
+ toast: { show() {} },
46
+ dialog: {
47
+ async alert() {},
48
+ async confirm() {
49
+ return false;
50
+ },
51
+ },
52
+ },
53
+ };
54
+ return { context, claims, layers, state };
55
+ }
56
+
57
+ function v1Api(directory: string) {
58
+ let slot:
59
+ | { slots: { sidebar_content: (ctx: unknown, value: unknown) => unknown }; dispose(): void }
60
+ | undefined;
61
+ const disposals: Array<() => void | Promise<void>> = [];
62
+ const color = "#ffffff";
63
+ const api = {
64
+ state: {
65
+ path: { directory },
66
+ session: { messages: () => [] },
67
+ },
68
+ route: { current: { name: "session", params: { sessionID: "ses-v1-tui" } } },
69
+ slots: { register: (value: typeof slot) => (slot = value) },
70
+ keymap: { registerLayer() {} },
71
+ command: { register() {} },
72
+ event: { on: () => () => {} },
73
+ renderer: { requestRender() {} },
74
+ lifecycle: { onDispose: (cleanup: () => void | Promise<void>) => disposals.push(cleanup) },
75
+ theme: {
76
+ current: {
77
+ primary: color,
78
+ secondary: color,
79
+ accent: color,
80
+ error: color,
81
+ warning: color,
82
+ success: color,
83
+ info: color,
84
+ text: color,
85
+ textMuted: color,
86
+ background: "#000000",
87
+ backgroundPanel: "#000000",
88
+ backgroundElement: "#000000",
89
+ backgroundMenu: "#000000",
90
+ border: color,
91
+ borderActive: color,
92
+ borderSubtle: color,
93
+ },
94
+ },
95
+ ui: {
96
+ toast() {},
97
+ dialog: { replace() {}, clear() {} },
98
+ DialogAlert: () => null,
99
+ DialogConfirm: () => null,
100
+ DialogPrompt: () => null,
101
+ DialogSelect: () => null,
102
+ },
103
+ };
104
+ return {
105
+ api,
106
+ slot: () => slot,
107
+ dispose: async () => Promise.all(disposals.map((cleanup) => cleanup())),
108
+ };
109
+ }
110
+
111
+ test("GA 2.0.5 resolves ./tui and executes the union setup contract", async () => {
112
+ const packageRoot = resolve(import.meta.dir, "../../..");
113
+ const entrypoints = Host.resolve({
114
+ name: "@cortexkit/opencode-magic-context",
115
+ directory: packageRoot,
116
+ });
117
+ expect(entrypoints.tui).toContain("src/tui/entry.mjs");
118
+ const loaded = (await Host.load(entrypoints.tui!)) as {
119
+ default: {
120
+ id: string;
121
+ tui: unknown;
122
+ setup: (context: V2TuiContext) => Promise<() => void>;
123
+ };
124
+ };
125
+ expect(typeof loaded.default.setup).toBe("function");
126
+ const fixture = v2Context();
127
+ const cleanup = await setupWithJsx(fixture.context, (type, props) => ({ type, props }));
128
+ expect(fixture.claims).toHaveLength(1);
129
+ expect(fixture.claims[0]!.render({ sessionID: "ses-v2-tui" })).toEqual({
130
+ type: "text",
131
+ props: { children: expect.stringContaining("Magic Context") },
132
+ });
133
+ expect(fixture.layers[0]!.commands.map((command) => command.slash.name)).toEqual([
134
+ "ctx-status",
135
+ "ctx-recomp",
136
+ ]);
137
+ cleanup();
138
+ });
139
+
140
+ test("GA 2.0.5 records its unbound keymap.layer gap without losing the sidebar", async () => {
141
+ const fixture = v2Context();
142
+ Object.assign(fixture.context.keymap, {
143
+ layer: () => {
144
+ throw new Error("Keymap.Provider is missing");
145
+ },
146
+ });
147
+ const cleanup = await setupWithJsx(fixture.context, (type, props) => ({ type, props }));
148
+ expect(fixture.claims).toHaveLength(1);
149
+ expect(fixture.layers).toHaveLength(0);
150
+ cleanup();
151
+ });
152
+
153
+ test("OpenCode 1.18.30 TUI loader projection executes unchanged sidebar registration", async () => {
154
+ // v1.18.30 packages/opencode/src/plugin/shared.ts:272-304 reads only id,
155
+ // server and tui, rejects a simultaneous server+tui pair, and returns the
156
+ // object without validating unrelated keys. `setup` is therefore invisible
157
+ // to the pinned v1 TUI loader while this test executes the selected tui().
158
+ const packageRoot = resolve(import.meta.dir, "../../..");
159
+ const entrypoints = Host.resolve({
160
+ name: "@cortexkit/opencode-magic-context",
161
+ directory: packageRoot,
162
+ });
163
+ const loaded = (await Host.load(entrypoints.tui!)) as {
164
+ default: {
165
+ id: string;
166
+ tui: (api: unknown, options?: unknown, meta?: unknown) => Promise<void>;
167
+ setup: unknown;
168
+ server?: unknown;
169
+ };
170
+ };
171
+ const plugin = loaded.default;
172
+ expect({ id: plugin.id, tui: plugin.tui }).toEqual({
173
+ id: "opencode-magic-context",
174
+ tui: expect.any(Function),
175
+ });
176
+ expect(plugin.server).toBeUndefined();
177
+ expect(typeof plugin.setup).toBe("function");
178
+
179
+ const directory = mkdtempSync(resolve(tmpdir(), "mc-v1-tui-union-"));
180
+ temporary.push(directory);
181
+ const fixture = v1Api(directory);
182
+ const previousCompactionOverride = process.env.OPENCODE_DISABLE_AUTOCOMPACT;
183
+ process.env.OPENCODE_DISABLE_AUTOCOMPACT = "1";
184
+ try {
185
+ await plugin.tui(fixture.api, undefined, {
186
+ state: "first",
187
+ id: plugin.id,
188
+ source: "file",
189
+ spec: "fixture",
190
+ target: entrypoints.tui,
191
+ });
192
+ } finally {
193
+ if (previousCompactionOverride === undefined)
194
+ delete process.env.OPENCODE_DISABLE_AUTOCOMPACT;
195
+ else process.env.OPENCODE_DISABLE_AUTOCOMPACT = previousCompactionOverride;
196
+ }
197
+ const slot = fixture.slot();
198
+ expect(slot).toBeDefined();
199
+ expect(typeof slot!.slots.sidebar_content).toBe("function");
200
+ await fixture.dispose();
201
+ slot!.dispose();
202
+ }, 15_000);
@@ -0,0 +1,270 @@
1
+ import { jsx } from "@opentui/solid/jsx-runtime";
2
+ import type { SidebarSnapshot, StatusDetail } from "../../shared/rpc-types";
3
+ import {
4
+ closeRpc,
5
+ getCompartmentCount,
6
+ initRpcClient,
7
+ loadSidebarSnapshot,
8
+ loadStatusDetail,
9
+ requestRecomp,
10
+ } from "../../tui/data/context-db";
11
+ import {
12
+ type SocketNotification,
13
+ startNotificationSocket,
14
+ stopNotificationSocket,
15
+ } from "../../tui/data/notification-socket";
16
+ import type { V2SidebarState, V2TuiContext } from "./types";
17
+
18
+ const SIDEBAR_REFRESH_MS = 1_000;
19
+ const inflight = new Set<string>();
20
+ const refreshedAt = new Map<string, number>();
21
+
22
+ function compactTokens(value: number): string {
23
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
24
+ if (value >= 1_000) return `${Math.round(value / 1_000)}K`;
25
+ return String(value);
26
+ }
27
+
28
+ function sidebarText(snapshot: SidebarSnapshot | undefined): string {
29
+ if (!snapshot) return "Magic Context · loading…";
30
+ const pressure =
31
+ snapshot.contextLimit > 0
32
+ ? `${snapshot.usagePercentage.toFixed(1)}% · ${compactTokens(snapshot.inputTokens)}/${compactTokens(snapshot.contextLimit)}`
33
+ : `${compactTokens(snapshot.inputTokens)} tokens`;
34
+ const historian = snapshot.historianRunning ? "running" : "idle";
35
+ return [
36
+ "Magic Context",
37
+ `Context ${pressure}`,
38
+ `Historian ${historian} · C:${snapshot.compartmentCount}`,
39
+ `Memories ${snapshot.memoryBlockCount}/${snapshot.memoryCount} · Q:${snapshot.pendingOpsCount}`,
40
+ ...(snapshot.lastTransformError ? [`Warning: ${snapshot.lastTransformError}`] : []),
41
+ ].join("\n");
42
+ }
43
+
44
+ function statusText(detail: StatusDetail): string {
45
+ const context =
46
+ detail.contextLimit > 0
47
+ ? `${detail.usagePercentage.toFixed(1)}% (${compactTokens(detail.inputTokens)}/${compactTokens(detail.contextLimit)} tokens)`
48
+ : `${compactTokens(detail.inputTokens)} tokens`;
49
+ return [
50
+ `Context: ${context}`,
51
+ `Historian: ${detail.historianRunning ? "running" : "idle"}`,
52
+ `Compartments: ${detail.compartmentCount}`,
53
+ `Memories: ${detail.memoryBlockCount} injected / ${detail.memoryCount} stored`,
54
+ `Pending reductions: ${detail.pendingOpsCount}`,
55
+ `Harness: opencode2`,
56
+ ...(detail.lastTransformError ? [`Warning: ${detail.lastTransformError}`] : []),
57
+ ].join("\n");
58
+ }
59
+
60
+ function currentSessionID(context: V2TuiContext): string | null {
61
+ const route = context.ui.router.current();
62
+ return route.type === "session" && route.sessionID ? route.sessionID : null;
63
+ }
64
+
65
+ function eventSessionID(event: unknown): string | undefined {
66
+ if (typeof event !== "object" || event === null) return undefined;
67
+ const record = event as Record<string, unknown>;
68
+ const data =
69
+ typeof record.data === "object" && record.data !== null
70
+ ? (record.data as Record<string, unknown>)
71
+ : record;
72
+ for (const key of ["sessionID", "sessionId", "id"]) {
73
+ if (typeof data[key] === "string") return data[key];
74
+ }
75
+ const info = data.info;
76
+ if (typeof info === "object" && info !== null) {
77
+ const nested = info as Record<string, unknown>;
78
+ if (typeof nested.sessionID === "string") return nested.sessionID;
79
+ if (typeof nested.id === "string") return nested.id;
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ type JsxFactory = (type: string, props: Record<string, unknown>) => unknown;
85
+
86
+ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Promise<() => void> {
87
+ const directory = context.location?.directory ?? context.data.location.default().directory;
88
+ initRpcClient(directory);
89
+ const [sidebar, updateSidebar] = context.storage.memory<V2SidebarState>(
90
+ "magic-context.sidebar.v2",
91
+ { initial: { snapshots: {} } },
92
+ );
93
+
94
+ const refresh = async (sessionID: string, force = false): Promise<void> => {
95
+ if (!sessionID || inflight.has(sessionID)) return;
96
+ const now = Date.now();
97
+ if (!force && now - (refreshedAt.get(sessionID) ?? 0) < SIDEBAR_REFRESH_MS) return;
98
+ refreshedAt.set(sessionID, now);
99
+ inflight.add(sessionID);
100
+ try {
101
+ const snapshot = await loadSidebarSnapshot(sessionID, directory);
102
+ updateSidebar((draft) => {
103
+ draft.snapshots[sessionID] = snapshot;
104
+ });
105
+ context.renderer.requestRender();
106
+ } finally {
107
+ inflight.delete(sessionID);
108
+ }
109
+ };
110
+
111
+ const showStatus = async (diagnostics = false, target = currentSessionID(context)) => {
112
+ if (!target) {
113
+ context.ui.toast.show({ message: "No active session", variant: "warning" });
114
+ return false;
115
+ }
116
+ const result = await loadStatusDetail(target, directory);
117
+ if (currentSessionID(context) !== target) return false;
118
+ if (!result.ok) {
119
+ context.ui.toast.show({
120
+ message: "Magic Context status is unavailable",
121
+ variant: "warning",
122
+ });
123
+ return false;
124
+ }
125
+ await context.ui.dialog.alert({
126
+ title: diagnostics ? "Magic Context diagnostics" : "Magic Context status",
127
+ message: statusText(result.detail),
128
+ });
129
+ return true;
130
+ };
131
+
132
+ const showRecomp = async (target = currentSessionID(context)) => {
133
+ if (!target) {
134
+ context.ui.toast.show({ message: "No active session", variant: "warning" });
135
+ return false;
136
+ }
137
+ const count = await getCompartmentCount(target, directory);
138
+ if (currentSessionID(context) !== target) return false;
139
+ if (!count.ok) {
140
+ context.ui.toast.show({ message: "Unable to load recomp details", variant: "error" });
141
+ return false;
142
+ }
143
+ const confirmed = await context.ui.dialog.confirm({
144
+ title: "Recomp confirmation",
145
+ message: [
146
+ count.count === 0
147
+ ? "This session has no compartments yet; recomp will build them from raw history."
148
+ : `This session has ${count.count} compartments.`,
149
+ "Recomp rebuilds compressed history and can consume significant tokens.",
150
+ ].join("\n\n"),
151
+ label: { confirm: "Run recomp", cancel: "Cancel" },
152
+ });
153
+ if (!confirmed) return true;
154
+ const requested = await requestRecomp(target);
155
+ context.ui.toast.show({
156
+ message: requested
157
+ ? "Recomp requested; historian will start shortly"
158
+ : "Recomp request failed",
159
+ variant: requested ? "info" : "error",
160
+ });
161
+ if (requested) void refresh(target, true);
162
+ return requested;
163
+ };
164
+
165
+ const unregisterSlot = context.ui.slot({
166
+ append: "sidebar.content",
167
+ render: ({ sessionID }) => {
168
+ void refresh(sessionID);
169
+ return jsx("text", { children: sidebarText(sidebar.snapshots[sessionID]) });
170
+ },
171
+ });
172
+
173
+ try {
174
+ context.keymap.layer(() => ({
175
+ mode: "global",
176
+ commands: [
177
+ {
178
+ id: "magic-context.status",
179
+ title: "Magic Context: Status",
180
+ group: "Magic Context",
181
+ palette: true,
182
+ slash: { name: "ctx-status", arguments: true },
183
+ run: async (input) => {
184
+ await showStatus(input?.trim().toLowerCase() === "diagnostics");
185
+ },
186
+ },
187
+ {
188
+ id: "magic-context.recomp",
189
+ title: "Magic Context: Recomp",
190
+ group: "Magic Context",
191
+ palette: true,
192
+ slash: { name: "ctx-recomp" },
193
+ run: async () => {
194
+ await showRecomp();
195
+ },
196
+ },
197
+ ],
198
+ }));
199
+ } catch (error) {
200
+ if (!(error instanceof Error) || error.message !== "Keymap.Provider is missing")
201
+ throw error;
202
+ console.warn(
203
+ "[magic-context] OpenCode 2.0.5 keymap.layer is unavailable during plugin setup; /ctx-status and /ctx-recomp were not registered",
204
+ );
205
+ }
206
+
207
+ const stopListening = context.data.listen(({ details }) => {
208
+ const sessionID = eventSessionID(details);
209
+ if (sessionID) void refresh(sessionID, true);
210
+ });
211
+
212
+ const handleNotification = async (notification: SocketNotification): Promise<boolean> => {
213
+ const target = notification.sessionId ?? currentSessionID(context);
214
+ if (notification.sessionId && notification.sessionId !== currentSessionID(context))
215
+ return false;
216
+ if (notification.type === "toast") {
217
+ const payload = notification.payload;
218
+ context.ui.toast.show({
219
+ title: typeof payload.title === "string" ? payload.title : undefined,
220
+ message: String(payload.message ?? ""),
221
+ variant:
222
+ payload.variant === "success" ||
223
+ payload.variant === "warning" ||
224
+ payload.variant === "error"
225
+ ? payload.variant
226
+ : "info",
227
+ duration: typeof payload.duration === "number" ? payload.duration : undefined,
228
+ });
229
+ return true;
230
+ }
231
+ if (notification.type !== "action") return false;
232
+ if (notification.payload.action === "show-status-dialog") {
233
+ return showStatus(notification.payload.diagnostics === true, target);
234
+ }
235
+ if (notification.payload.action === "show-recomp-dialog") return showRecomp(target);
236
+ if (notification.payload.action === "refresh-sidebar" && target) {
237
+ await refresh(target, true);
238
+ return true;
239
+ }
240
+ if (notification.payload.action === "show-result-dialog") {
241
+ await context.ui.dialog.alert({
242
+ title: String(notification.payload.title ?? "Magic Context"),
243
+ message: String(notification.payload.message ?? ""),
244
+ });
245
+ return true;
246
+ }
247
+ return false;
248
+ };
249
+
250
+ startNotificationSocket({
251
+ getSessionId: () => currentSessionID(context),
252
+ onNotification: handleNotification,
253
+ });
254
+ console.info("[magic-context] @cortexkit/opencode-magic-context v2 TUI setup");
255
+
256
+ return () => {
257
+ unregisterSlot();
258
+ stopListening();
259
+ stopNotificationSocket();
260
+ closeRpc();
261
+ inflight.clear();
262
+ refreshedAt.clear();
263
+ };
264
+ }
265
+
266
+ export async function setup(context: V2TuiContext): Promise<() => void> {
267
+ return setupWithJsx(context, jsx);
268
+ }
269
+
270
+ export default { id: "opencode-magic-context", setup };