@cortexkit/opencode-magic-context 0.4.2 → 0.5.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.
Files changed (35) hide show
  1. package/README.md +41 -0
  2. package/dist/cli/config-paths.d.ts +2 -0
  3. package/dist/cli/config-paths.d.ts.map +1 -1
  4. package/dist/cli/doctor.d.ts +2 -0
  5. package/dist/cli/doctor.d.ts.map +1 -0
  6. package/dist/cli/setup.d.ts.map +1 -1
  7. package/dist/cli.js +8549 -125
  8. package/dist/features/builtin-commands/commands.d.ts.map +1 -1
  9. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  10. package/dist/features/magic-context/scheduler.d.ts.map +1 -1
  11. package/dist/features/magic-context/search.d.ts +2 -0
  12. package/dist/features/magic-context/search.d.ts.map +1 -1
  13. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  14. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  15. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  16. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  17. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8653 -238
  20. package/dist/plugin/conflict-warning-hook.d.ts +24 -0
  21. package/dist/plugin/conflict-warning-hook.d.ts.map +1 -0
  22. package/dist/shared/conflict-detector.d.ts +29 -0
  23. package/dist/shared/conflict-detector.d.ts.map +1 -0
  24. package/dist/shared/conflict-fixer.d.ts +3 -0
  25. package/dist/shared/conflict-fixer.d.ts.map +1 -0
  26. package/dist/shared/tui-config.d.ts +10 -0
  27. package/dist/shared/tui-config.d.ts.map +1 -0
  28. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  29. package/dist/tui/data/context-db.d.ts +54 -0
  30. package/dist/tui/data/context-db.d.ts.map +1 -0
  31. package/package.json +20 -1
  32. package/src/tui/data/context-db.ts +584 -0
  33. package/src/tui/index.tsx +461 -0
  34. package/src/tui/slots/sidebar-content.tsx +422 -0
  35. package/src/tui/types/opencode-plugin-tui.d.ts +232 -0
@@ -0,0 +1,461 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ // @ts-nocheck
3
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
4
+ import { dirname, join } from "node:path"
5
+ import { createMemo } from "solid-js"
6
+ import type { TuiPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
7
+ import { createSidebarContentSlot } from "./slots/sidebar-content"
8
+ import { closeDb, loadStatusDetail, type StatusDetail } from "./data/context-db"
9
+ import { detectConflicts } from "../shared/conflict-detector"
10
+ import { fixConflicts } from "../shared/conflict-fixer"
11
+ import { readJsoncFile } from "../shared/jsonc-parser"
12
+ import { getOpenCodeConfigPaths } from "../shared/opencode-config-dir"
13
+
14
+ const PLUGIN_NAME = "@cortexkit/opencode-magic-context"
15
+
16
+ function ensureParentDir(filePath: string) {
17
+ mkdirSync(dirname(filePath), { recursive: true })
18
+ }
19
+
20
+ function resolveTuiConfigPath() {
21
+ const configDir = getOpenCodeConfigPaths({ binary: "opencode" }).configDir
22
+ const jsoncPath = join(configDir, "tui.jsonc")
23
+ const jsonPath = join(configDir, "tui.json")
24
+
25
+ if (existsSync(jsoncPath)) {
26
+ return jsoncPath
27
+ }
28
+
29
+ if (existsSync(jsonPath)) {
30
+ return jsonPath
31
+ }
32
+
33
+ return jsonPath
34
+ }
35
+
36
+ function readTuiConfig(filePath: string): Record<string, unknown> | null {
37
+ if (!existsSync(filePath)) {
38
+ return {}
39
+ }
40
+
41
+ return readJsoncFile<Record<string, unknown>>(filePath)
42
+ }
43
+
44
+ function hasMagicContextTuiPlugin(): boolean {
45
+ const configPath = resolveTuiConfigPath()
46
+ const config = readTuiConfig(configPath)
47
+ if (!config) {
48
+ return false
49
+ }
50
+
51
+ const plugins = Array.isArray(config.plugin)
52
+ ? config.plugin.filter((plugin): plugin is string => typeof plugin === "string")
53
+ : []
54
+
55
+ return plugins.some((plugin) => plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`))
56
+ }
57
+
58
+ function addMagicContextTuiPlugin(): { ok: boolean; updated: boolean } {
59
+ const configPath = resolveTuiConfigPath()
60
+ const config = readTuiConfig(configPath)
61
+ if (!config) {
62
+ return { ok: false, updated: false }
63
+ }
64
+
65
+ const plugins = Array.isArray(config.plugin)
66
+ ? config.plugin.filter((plugin): plugin is string => typeof plugin === "string")
67
+ : []
68
+
69
+ if (plugins.some((plugin) => plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`))) {
70
+ return { ok: true, updated: false }
71
+ }
72
+
73
+ plugins.push(PLUGIN_NAME)
74
+ config.plugin = plugins
75
+
76
+ ensureParentDir(configPath)
77
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
78
+ return { ok: true, updated: true }
79
+ }
80
+
81
+ function showConflictDialog(api: TuiPluginApi, directory: string, reasons: string[], conflicts: ReturnType<typeof detectConflicts>["conflicts"]) {
82
+ api.ui.dialog.replace(() => (
83
+ <api.ui.DialogConfirm
84
+ title="⚠️ Magic Context Disabled"
85
+ message={`${reasons.join("\n")}\n\nFix these conflicts automatically?`}
86
+ onConfirm={() => {
87
+ const actions = fixConflicts(directory, conflicts)
88
+ const actionSummary = actions.length > 0
89
+ ? actions.map(a => `• ${a}`).join("\n")
90
+ : "No changes needed"
91
+ // DialogConfirm calls dialog.clear() after onConfirm, so defer the next dialog
92
+ setTimeout(() => {
93
+ api.ui.dialog.replace(() => (
94
+ <api.ui.DialogAlert
95
+ title="✅ Configuration Fixed"
96
+ message={`${actionSummary}\n\nPlease restart OpenCode for changes to take effect.`}
97
+ onConfirm={() => {
98
+ api.ui.toast({ message: "Restart OpenCode to enable Magic Context", variant: "warning", duration: 10000 })
99
+ }}
100
+ />
101
+ ))
102
+ }, 50)
103
+ }}
104
+ onCancel={() => {
105
+ api.ui.toast({ message: "Magic Context remains disabled. Run: bunx @cortexkit/opencode-magic-context doctor", variant: "warning", duration: 5000 })
106
+ }}
107
+ />
108
+ ))
109
+ }
110
+
111
+ function showTuiSetupDialog(api: TuiPluginApi) {
112
+ api.ui.dialog.replace(() => (
113
+ <api.ui.DialogConfirm
114
+ title="✨ Enable Magic Context Sidebar"
115
+ message={[
116
+ "Magic Context can show a sidebar with live context breakdown,",
117
+ "token usage, historian status, memory counts, and dreamer info.",
118
+ "",
119
+ "This requires adding the plugin to your tui.json config",
120
+ "(OpenCode's TUI plugin configuration file).",
121
+ "",
122
+ "Add it now?",
123
+ ].join("\n")}
124
+ onConfirm={() => {
125
+ const result = addMagicContextTuiPlugin()
126
+ if (!result.ok) {
127
+ setTimeout(() => {
128
+ api.ui.dialog.replace(() => (
129
+ <api.ui.DialogAlert
130
+ title="❌ Setup Failed"
131
+ message={'Could not update tui.json automatically. Add the plugin manually:\n\n { "plugin": ["@cortexkit/opencode-magic-context"] }'}
132
+ onConfirm={() => {
133
+ api.ui.toast({ message: "Add plugin to tui.json manually", variant: "warning", duration: 5000 })
134
+ }}
135
+ />
136
+ ))
137
+ }, 50)
138
+ return
139
+ }
140
+
141
+ setTimeout(() => {
142
+ api.ui.dialog.replace(() => (
143
+ <api.ui.DialogAlert
144
+ title="✅ Sidebar Enabled"
145
+ message="tui.json updated with Magic Context plugin.\n\nPlease restart OpenCode to see the sidebar."
146
+ onConfirm={() => {
147
+ api.ui.toast({ message: "Restart OpenCode to see the sidebar", variant: "warning", duration: 10000 })
148
+ }}
149
+ />
150
+ ))
151
+ }, 50)
152
+ }}
153
+ onCancel={() => {
154
+ api.ui.toast({ message: "You can add the sidebar later via: bunx @cortexkit/opencode-magic-context doctor", variant: "info", duration: 5000 })
155
+ }}
156
+ />
157
+ ))
158
+ }
159
+
160
+ function fmt(n: number): string {
161
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
162
+ if (n >= 1_000) return `${Math.round(n / 1_000)}K`
163
+ return String(n)
164
+ }
165
+
166
+ function fmtBytes(n: number): string {
167
+ if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`
168
+ if (n >= 1_024) return `${Math.round(n / 1_024)} KB`
169
+ return `${n} B`
170
+ }
171
+
172
+ function relTime(ms: number): string {
173
+ const d = Date.now() - ms
174
+ if (d < 60_000) return "just now"
175
+ if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`
176
+ if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`
177
+ return `${Math.floor(d / 86_400_000)}d ago`
178
+ }
179
+
180
+ function getSessionId(api: TuiPluginApi): string | null {
181
+ try {
182
+ const route = api.route.current
183
+ if (route?.name === "session" && route.params?.sessionID) {
184
+ return route.params.sessionID
185
+ }
186
+ } catch {
187
+ // ignore
188
+ }
189
+ return null
190
+ }
191
+
192
+ const R = (props: { t: TuiThemeCurrent; l: string; v: string; fg?: string }) => (
193
+ <box width="100%" flexDirection="row" justifyContent="space-between">
194
+ <text fg={props.t.textMuted}>{props.l}</text>
195
+ <text fg={props.fg ?? props.t.text}>{props.v}</text>
196
+ </box>
197
+ )
198
+
199
+ const StatusDialog = (props: { api: TuiPluginApi; s: StatusDetail }) => {
200
+ const theme = createMemo(() => (props.api as any).theme.current)
201
+ const t = () => theme()
202
+ const s = () => props.s
203
+
204
+ const contextLimit = () =>
205
+ s().usagePercentage > 0 ? Math.round(s().inputTokens / (s().usagePercentage / 100)) : 0
206
+
207
+ const elapsed = () => (s().lastResponseTime > 0 ? Date.now() - s().lastResponseTime : 0)
208
+
209
+ // Token breakdown segments — same colors as sidebar
210
+ const COLORS = {
211
+ system: "#c084fc",
212
+ compartments: "#60a5fa",
213
+ facts: "#fbbf24",
214
+ memories: "#34d399",
215
+ }
216
+
217
+ const breakdownSegments = () => {
218
+ const d = s()
219
+ const total = d.inputTokens || 1
220
+ const segs: Array<{ label: string; tokens: number; color: string; detail?: string }> = []
221
+
222
+ if (d.systemPromptTokens > 0)
223
+ segs.push({ label: "System", tokens: d.systemPromptTokens, color: COLORS.system })
224
+ if (d.compartmentTokens > 0)
225
+ segs.push({
226
+ label: "Compartments",
227
+ tokens: d.compartmentTokens,
228
+ color: COLORS.compartments,
229
+ detail: `(${d.compartmentCount})`,
230
+ })
231
+ if (d.factTokens > 0)
232
+ segs.push({
233
+ label: "Facts",
234
+ tokens: d.factTokens,
235
+ color: COLORS.facts,
236
+ detail: `(${d.factCount})`,
237
+ })
238
+ if (d.memoryTokens > 0)
239
+ segs.push({
240
+ label: "Memories",
241
+ tokens: d.memoryTokens,
242
+ color: COLORS.memories,
243
+ detail: `(${d.memoryBlockCount})`,
244
+ })
245
+
246
+ const used = d.systemPromptTokens + d.compartmentTokens + d.factTokens + d.memoryTokens
247
+ const convTokens = Math.max(0, d.inputTokens - used)
248
+ if (convTokens > 0)
249
+ segs.push({ label: "Conversation", tokens: convTokens, color: t().textMuted })
250
+
251
+ return { segs, total }
252
+ }
253
+
254
+ const barWidth = 56
255
+ const barSegments = () => {
256
+ const { segs, total } = breakdownSegments()
257
+ if (segs.length === 0) return []
258
+
259
+ let widths = segs.map((seg) => Math.max(1, Math.round((seg.tokens / total) * barWidth)))
260
+ let sum = widths.reduce((a, b) => a + b, 0)
261
+ while (sum > barWidth) {
262
+ const maxIdx = widths.indexOf(Math.max(...widths))
263
+ if (widths[maxIdx] > 1) { widths[maxIdx]--; sum-- } else break
264
+ }
265
+ while (sum < barWidth) {
266
+ const maxIdx = widths.indexOf(Math.max(...widths))
267
+ widths[maxIdx]++; sum++
268
+ }
269
+
270
+ return segs.map((seg, i) => ({
271
+ chars: "█".repeat(widths[i] || 0),
272
+ color: seg.color,
273
+ }))
274
+ }
275
+
276
+ return (
277
+ <box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
278
+ {/* Title */}
279
+ <box justifyContent="center" width="100%" marginBottom={1}>
280
+ <text fg={t().accent}>
281
+ <b>⚡ Magic Context Status</b>
282
+ </text>
283
+ </box>
284
+
285
+ {/* Context summary line */}
286
+ <box flexDirection="row" justifyContent="space-between" width="100%">
287
+ <text fg={t().text}>Context</text>
288
+ <text fg={s().usagePercentage >= 80 ? t().error : s().usagePercentage >= 65 ? t().warning : t().accent}>
289
+ <b>{s().usagePercentage.toFixed(1)}%</b> · {fmt(s().inputTokens)} / {contextLimit() > 0 ? fmt(contextLimit()) : "?"} tokens
290
+ </text>
291
+ </box>
292
+
293
+ {/* Segmented breakdown bar */}
294
+ <box flexDirection="row">
295
+ {barSegments().map((seg, i) => (
296
+ <text key={i} fg={seg.color}>{seg.chars}</text>
297
+ ))}
298
+ </box>
299
+
300
+ {/* Breakdown legend */}
301
+ <box flexDirection="column">
302
+ {breakdownSegments().segs.map((seg) => {
303
+ const pct = ((seg.tokens / breakdownSegments().total) * 100).toFixed(1)
304
+ return (
305
+ <box key={seg.label} width="100%" flexDirection="row" justifyContent="space-between">
306
+ <text fg={seg.color}>{seg.label} {seg.detail ?? ""}</text>
307
+ <text fg={t().textMuted}>{fmt(seg.tokens)} ({pct}%)</text>
308
+ </box>
309
+ )
310
+ })}
311
+ </box>
312
+
313
+ {/* 2-column layout */}
314
+ <box flexDirection="row" width="100%" marginTop={1} gap={4}>
315
+ {/* Left column */}
316
+ <box flexDirection="column" flexGrow={1} flexBasis={0}>
317
+ <text fg={t().text}><b>Tags</b></text>
318
+ <R t={t()} l="Active" v={`${s().activeTags} (~${fmtBytes(s().activeBytes)})`} />
319
+ <R t={t()} l="Dropped" v={String(s().droppedTags)} />
320
+ <R t={t()} l="Total" v={String(s().totalTags)} fg={t().textMuted} />
321
+
322
+ <box marginTop={1}>
323
+ <text fg={t().text}><b>Pending Queue</b></text>
324
+ </box>
325
+ <R t={t()} l="Drops" v={String(s().pendingOpsCount)} fg={s().pendingOpsCount > 0 ? t().warning : t().textMuted} />
326
+
327
+ <box marginTop={1}>
328
+ <text fg={t().text}><b>Cache TTL</b></text>
329
+ </box>
330
+ <R t={t()} l="Configured" v={s().cacheTtl} />
331
+ <R t={t()} l="Last response" v={s().lastResponseTime > 0 ? `${Math.round(elapsed() / 1000)}s ago` : "never"} />
332
+ <R t={t()} l="Remaining" v={s().cacheExpired ? "expired" : `${Math.round(s().cacheRemainingMs / 1000)}s`} fg={s().cacheExpired ? t().warning : t().textMuted} />
333
+ <R t={t()} l="Auto-execute" v={s().cacheExpired ? "yes (expired)" : `at TTL or ≥${s().executeThreshold}%`} fg={t().textMuted} />
334
+
335
+ <box marginTop={1}>
336
+ <text fg={t().text}><b>Memory</b></text>
337
+ </box>
338
+ <R t={t()} l="Active" v={String(s().memoryCount)} fg={t().accent} />
339
+ <R t={t()} l="Injected" v={String(s().memoryBlockCount)} fg={t().textMuted} />
340
+ </box>
341
+
342
+ {/* Right column */}
343
+ <box flexDirection="column" flexGrow={1} flexBasis={0}>
344
+ <text fg={t().text}><b>Rolling Nudges</b></text>
345
+ <R t={t()} l="Execute threshold" v={`${s().executeThreshold}%`} />
346
+ <R t={t()} l="Nudge anchor" v={`${fmt(s().lastNudgeTokens)} tok`} />
347
+ <R t={t()} l="Interval" v={`${fmt(s().nudgeInterval)} tok`} fg={t().textMuted} />
348
+ <R t={t()} l="Next nudge after" v={`${fmt(s().nextNudgeAfter)} tok`} />
349
+ {s().lastNudgeBand ? <R t={t()} l="Current band" v={s().lastNudgeBand} /> : null}
350
+
351
+ <box marginTop={1}>
352
+ <text fg={t().text}><b>Context Details</b></text>
353
+ </box>
354
+ <R t={t()} l="Protected tags" v={String(s().protectedTagCount)} fg={t().textMuted} />
355
+ <R t={t()} l="Subagent" v={s().isSubagent ? "yes" : "no"} fg={t().textMuted} />
356
+
357
+ <box marginTop={1}>
358
+ <text fg={t().text}><b>History Compression</b></text>
359
+ </box>
360
+ <R t={t()} l="History block" v={`~${fmt(s().historyBlockTokens)} tok`} />
361
+ {s().compressionBudget != null && (
362
+ <R t={t()} l="Budget" v={`~${fmt(s().compressionBudget!)} tok (${s().compressionUsage} used)`} />
363
+ )}
364
+ {s().lastDreamerRunAt && (
365
+ <R t={t()} l="Dreamer" v={`last ${relTime(s().lastDreamerRunAt!)}`} fg={t().textMuted} />
366
+ )}
367
+ </box>
368
+ </box>
369
+
370
+ {/* Error (full width, conditional) */}
371
+ {s().lastTransformError && (
372
+ <box marginTop={1} width="100%">
373
+ <text fg={t().error}>⚠ {s().lastTransformError}</text>
374
+ </box>
375
+ )}
376
+
377
+ {/* Footer */}
378
+ <box marginTop={1} justifyContent="flex-end" width="100%">
379
+ <text fg={t().textMuted}>Esc to close</text>
380
+ </box>
381
+ </box>
382
+ )
383
+ }
384
+
385
+ function getModelKeyFromMessages(api: TuiPluginApi, sessionId: string): string | undefined {
386
+ try {
387
+ const msgs = api.state.session.messages(sessionId)
388
+ // Find the last assistant message with model info
389
+ // AssistantMessage has providerID/modelID as top-level fields
390
+ // UserMessage has model: { providerID, modelID }
391
+ for (let i = msgs.length - 1; i >= 0; i--) {
392
+ const msg = msgs[i] as Record<string, unknown>
393
+ if (msg.role === "assistant" && msg.providerID && msg.modelID) {
394
+ return `${msg.providerID}/${msg.modelID}`
395
+ }
396
+ if (msg.role === "user") {
397
+ const model = msg.model as Record<string, unknown> | undefined
398
+ if (model?.providerID && model?.modelID) {
399
+ return `${model.providerID}/${model.modelID}`
400
+ }
401
+ }
402
+ }
403
+ } catch {
404
+ // messages not available
405
+ }
406
+ return undefined
407
+ }
408
+
409
+ function showStatusDialog(api: TuiPluginApi) {
410
+ const sessionId = getSessionId(api)
411
+ if (!sessionId) {
412
+ api.ui.toast({ message: "No active session", variant: "warning" })
413
+ return
414
+ }
415
+
416
+ const directory = api.state.path.directory ?? ""
417
+ const modelKey = getModelKeyFromMessages(api, sessionId)
418
+ const detail = loadStatusDetail(sessionId, directory, modelKey)
419
+
420
+ api.ui.dialog.replace(() => <StatusDialog api={api} s={detail} />)
421
+ }
422
+
423
+ const tui: TuiPlugin = async (api, _options, meta) => {
424
+ // Register sidebar slot
425
+ api.slots.register(createSidebarContentSlot(api))
426
+
427
+ // Register TUI command palette entries for commands with richer TUI-native UI.
428
+ api.command.register(() => [
429
+ {
430
+ title: "Magic Context: Status",
431
+ value: "magic-context.status",
432
+ category: "Magic Context",
433
+ slash: { name: "ctx-status" },
434
+ onSelect() {
435
+ showStatusDialog(api)
436
+ },
437
+ },
438
+ ])
439
+
440
+ // Clean up on dispose
441
+ api.lifecycle.onDispose(() => {
442
+ closeDb()
443
+ })
444
+
445
+ const directory = api.state.path.directory ?? ""
446
+ const conflictResult = detectConflicts(directory)
447
+ if (conflictResult.hasConflict) {
448
+ showConflictDialog(api, directory, conflictResult.reasons, conflictResult.conflicts)
449
+ return
450
+ }
451
+
452
+ // Note: tui.json detection moved to server plugin (src/index.ts) since
453
+ // if tui.json doesn't have our plugin, this TUI code never loads at all.
454
+ }
455
+
456
+ const id = "opencode-magic-context"
457
+
458
+ export default {
459
+ id,
460
+ tui,
461
+ }