@cortexkit/opencode-magic-context 0.8.3 → 0.8.4

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 (33) hide show
  1. package/README.md +17 -7
  2. package/dist/cli.js +2 -2
  3. package/dist/features/builtin-commands/commands.d.ts.map +1 -1
  4. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  5. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +62246 -61171
  8. package/dist/plugin/rpc-handlers.d.ts +11 -0
  9. package/dist/plugin/rpc-handlers.d.ts.map +1 -0
  10. package/dist/shared/conflict-detector.d.ts +0 -4
  11. package/dist/shared/conflict-detector.d.ts.map +1 -1
  12. package/dist/shared/rpc-client.d.ts +16 -0
  13. package/dist/shared/rpc-client.d.ts.map +1 -0
  14. package/dist/shared/rpc-notifications.d.ts +21 -0
  15. package/dist/shared/rpc-notifications.d.ts.map +1 -0
  16. package/dist/shared/rpc-server.d.ts +17 -0
  17. package/dist/shared/rpc-server.d.ts.map +1 -0
  18. package/dist/shared/rpc-types.d.ts +59 -0
  19. package/dist/shared/rpc-types.d.ts.map +1 -0
  20. package/dist/shared/rpc-utils.d.ts +8 -0
  21. package/dist/shared/rpc-utils.d.ts.map +1 -0
  22. package/dist/tui/data/context-db.d.ts +17 -69
  23. package/dist/tui/data/context-db.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/shared/conflict-detector.ts +1 -17
  26. package/src/shared/rpc-client.ts +123 -0
  27. package/src/shared/rpc-notifications.ts +44 -0
  28. package/src/shared/rpc-server.ts +136 -0
  29. package/src/shared/rpc-types.ts +58 -0
  30. package/src/shared/rpc-utils.ts +16 -0
  31. package/src/tui/data/context-db.ts +99 -625
  32. package/src/tui/index.tsx +53 -55
  33. package/src/tui/slots/sidebar-content.tsx +8 -7
@@ -1,356 +1,84 @@
1
- import { Database } from "bun:sqlite";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import { log } from "../../shared/logger";
1
+ /**
2
+ * TUI data layer — pure RPC client, no direct SQLite access.
3
+ * All data is fetched from the server plugin via HTTP RPC.
4
+ */
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { MagicContextRpcClient } from "../../shared/rpc-client";
8
+ import type { RpcNotificationMessage, SidebarSnapshot, StatusDetail } from "../../shared/rpc-types";
5
9
 
6
- export interface SidebarSnapshot {
7
- sessionId: string;
8
- usagePercentage: number;
9
- inputTokens: number;
10
- systemPromptTokens: number;
11
- compartmentCount: number;
12
- factCount: number;
13
- memoryCount: number;
14
- memoryBlockCount: number;
15
- pendingOpsCount: number;
16
- historianRunning: boolean;
17
- compartmentInProgress: boolean;
18
- sessionNoteCount: number;
19
- readySmartNoteCount: number;
20
- cacheTtl: string;
21
- lastDreamerRunAt: number | null;
22
- projectIdentity: string | null;
23
- // Token estimates for breakdown bar (~4 chars/token)
24
- compartmentTokens: number;
25
- factTokens: number;
26
- memoryTokens: number;
27
- }
10
+ export type { SidebarSnapshot, StatusDetail };
28
11
 
29
- /** Extended status info for the status dialog — reads more from DB */
30
- export interface StatusDetail extends SidebarSnapshot {
31
- tagCounter: number;
32
- activeTags: number;
33
- droppedTags: number;
34
- totalTags: number;
35
- activeBytes: number;
36
- lastResponseTime: number;
37
- lastNudgeTokens: number;
38
- lastNudgeBand: string;
39
- lastTransformError: string | null;
40
- isSubagent: boolean;
41
- pendingOps: Array<{ tagId: number; operation: string }>;
42
- // Derived
43
- contextLimit: number;
44
- cacheTtlMs: number;
45
- cacheRemainingMs: number;
46
- cacheExpired: boolean;
47
- // Config-dependent (read from magic-context.jsonc or defaults)
48
- executeThreshold: number;
49
- protectedTagCount: number;
50
- nudgeInterval: number;
51
- historyBudgetPercentage: number;
52
- nextNudgeAfter: number;
53
- // History compression
54
- historyBlockTokens: number;
55
- compressionBudget: number | null;
56
- compressionUsage: string | null;
57
- }
12
+ let rpcClient: MagicContextRpcClient | null = null;
58
13
 
59
- function getContextDbPath(): string {
14
+ function getStorageDir(): string {
60
15
  const dataDir = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
61
- return path.join(dataDir, "opencode", "storage", "plugin", "magic-context", "context.db");
16
+ return path.join(dataDir, "opencode", "storage", "plugin", "magic-context");
62
17
  }
63
18
 
64
- let cachedDb: Database | null = null;
65
- let dbPath: string | null = null;
66
-
67
- function getDb(): Database | null {
68
- const targetPath = getContextDbPath();
69
- if (cachedDb && dbPath === targetPath) {
70
- return cachedDb;
71
- }
72
- try {
73
- // Open read-write: WAL-mode DBs need write access to the -shm file,
74
- // and the TUI writes to plugin_messages for the message bus.
75
- cachedDb = new Database(targetPath);
76
- cachedDb.exec("PRAGMA journal_mode = WAL");
77
- cachedDb.exec("PRAGMA busy_timeout = 3000");
78
- dbPath = targetPath;
79
- return cachedDb;
80
- } catch (err) {
81
- log("[tui] failed to open context.db", err);
82
- cachedDb = null;
83
- dbPath = null;
84
- return null;
85
- }
19
+ /** Initialize the RPC client. Call once on TUI startup. */
20
+ export function initRpcClient(directory: string): void {
21
+ const storageDir = getStorageDir();
22
+ rpcClient = new MagicContextRpcClient(storageDir, directory);
86
23
  }
87
24
 
88
- export function closeDb(): void {
89
- if (cachedDb) {
90
- try {
91
- cachedDb.close();
92
- } catch {
93
- // Ignore close errors
94
- }
95
- cachedDb = null;
96
- dbPath = null;
97
- }
25
+ /** Clean up the RPC client. */
26
+ export function closeRpc(): void {
27
+ rpcClient?.reset();
28
+ rpcClient = null;
98
29
  }
99
30
 
100
- function resolveProjectIdentity(directory: string): string | null {
101
- if (!directory) return null;
102
- try {
103
- // Match the plugin's own project identity resolution: git root commit hash
104
- const { execSync } = require("node:child_process") as typeof import("node:child_process");
105
- const rootCommit = execSync("git rev-list --max-parents=0 HEAD", {
106
- cwd: directory,
107
- encoding: "utf-8",
108
- timeout: 5000,
109
- stdio: ["pipe", "pipe", "pipe"],
110
- })
111
- .trim()
112
- .split("\n")[0];
113
- if (rootCommit && rootCommit.length >= 40) {
114
- return `git:${rootCommit}`;
115
- }
116
- } catch {
117
- // Not a git repo or git not available
118
- }
119
- // Fallback: canonical directory hash (matches plugin's directoryFallback)
120
- try {
121
- const realPath = require("node:fs").realpathSync(directory);
122
- const hash = require("node:crypto").createHash("sha256").update(realPath).digest("hex");
123
- return `dir:${hash}`;
124
- } catch {
125
- return null;
126
- }
127
- }
128
-
129
- export function loadSidebarSnapshot(sessionId: string, directory: string): SidebarSnapshot {
130
- const empty: SidebarSnapshot = {
131
- sessionId,
132
- usagePercentage: 0,
133
- inputTokens: 0,
134
- systemPromptTokens: 0,
135
- compartmentCount: 0,
136
- factCount: 0,
137
- memoryCount: 0,
138
- memoryBlockCount: 0,
139
- pendingOpsCount: 0,
140
- historianRunning: false,
141
- compartmentInProgress: false,
142
- sessionNoteCount: 0,
143
- readySmartNoteCount: 0,
144
- cacheTtl: "5m",
145
- lastDreamerRunAt: null,
146
- projectIdentity: null,
147
- compartmentTokens: 0,
148
- factTokens: 0,
149
- memoryTokens: 0,
150
- };
151
-
152
- const db = getDb();
153
- if (!db) return empty;
154
-
31
+ const EMPTY_SNAPSHOT: SidebarSnapshot = {
32
+ sessionId: "",
33
+ usagePercentage: 0,
34
+ inputTokens: 0,
35
+ systemPromptTokens: 0,
36
+ compartmentCount: 0,
37
+ factCount: 0,
38
+ memoryCount: 0,
39
+ memoryBlockCount: 0,
40
+ pendingOpsCount: 0,
41
+ historianRunning: false,
42
+ compartmentInProgress: false,
43
+ sessionNoteCount: 0,
44
+ readySmartNoteCount: 0,
45
+ cacheTtl: "5m",
46
+ lastDreamerRunAt: null,
47
+ projectIdentity: null,
48
+ compartmentTokens: 0,
49
+ factTokens: 0,
50
+ memoryTokens: 0,
51
+ };
52
+
53
+ /** Fetch sidebar snapshot from the server via RPC. */
54
+ export async function loadSidebarSnapshot(
55
+ sessionId: string,
56
+ directory: string,
57
+ ): Promise<SidebarSnapshot> {
58
+ if (!rpcClient) return { ...EMPTY_SNAPSHOT, sessionId };
155
59
  try {
156
- const projectIdentity = resolveProjectIdentity(directory);
157
-
158
- // Session meta
159
- const meta = db
160
- .query<Record<string, unknown>, [string]>(
161
- `SELECT * FROM session_meta WHERE session_id = ?`,
162
- )
163
- .get(sessionId);
164
-
165
- const usagePercentage = meta
166
- ? Number(
167
- (meta as Record<string, unknown>).last_context_percentage ??
168
- (meta as Record<string, unknown>).last_usage_percentage ??
169
- 0,
170
- )
171
- : 0;
172
- const inputTokens = meta
173
- ? Number((meta as Record<string, unknown>).last_input_tokens ?? 0)
174
- : 0;
175
- const systemPromptTokens = meta
176
- ? Number((meta as Record<string, unknown>).system_prompt_tokens ?? 0)
177
- : 0;
178
- const compartmentInProgress = meta
179
- ? Boolean((meta as Record<string, unknown>).compartment_in_progress)
180
- : false;
181
- const cacheTtl = meta ? String((meta as Record<string, unknown>).cache_ttl ?? "5m") : "5m";
182
-
183
- // Compartments
184
- const compartmentRow = db
185
- .query<{ count: number }, [string]>(
186
- `SELECT COUNT(*) as count FROM compartments WHERE session_id = ?`,
187
- )
188
- .get(sessionId);
189
- const compartmentCount = compartmentRow?.count ?? 0;
190
-
191
- // Session facts
192
- const factRow = db
193
- .query<{ count: number }, [string]>(
194
- `SELECT COUNT(*) as count FROM session_facts WHERE session_id = ?`,
195
- )
196
- .get(sessionId);
197
- const factCount = factRow?.count ?? 0;
198
-
199
- // Project memories
200
- let memoryCount = 0;
201
- if (projectIdentity) {
202
- const memRow = db
203
- .query<{ count: number }, [string]>(
204
- `SELECT COUNT(*) as count FROM memories WHERE project_path = ? AND status = 'active'`,
205
- )
206
- .get(projectIdentity);
207
- memoryCount = memRow?.count ?? 0;
208
- }
209
-
210
- // Memory block count from session meta
211
- const memoryBlockCount = meta
212
- ? Number((meta as Record<string, unknown>).memory_block_count ?? 0)
213
- : 0;
214
-
215
- // Pending operations
216
- let pendingOpsCount = 0;
217
- try {
218
- const pendingRow = db
219
- .query<{ count: number }, [string]>(
220
- `SELECT COUNT(*) as count FROM pending_ops WHERE session_id = ?`,
221
- )
222
- .get(sessionId);
223
- pendingOpsCount = pendingRow?.count ?? 0;
224
- } catch (pendingErr) {
225
- log("[magic-context-tui] pending_ops query failed", pendingErr);
226
- }
227
-
228
- // Historian running — check if compartmentInProgress is truthy
229
- const historianRunning = compartmentInProgress;
230
-
231
- // Session notes (from notes table if it exists)
232
- let sessionNoteCount = 0;
233
- try {
234
- const noteRow = db
235
- .query<{ count: number }, [string]>(
236
- `SELECT COUNT(*) as count FROM notes WHERE session_id = ? AND type = 'session' AND status = 'active'`,
237
- )
238
- .get(sessionId);
239
- sessionNoteCount = noteRow?.count ?? 0;
240
- } catch {
241
- // notes table may not exist
242
- }
243
-
244
- // Ready smart notes
245
- let readySmartNoteCount = 0;
246
- if (projectIdentity) {
247
- try {
248
- const smartRow = db
249
- .query<{ count: number }, [string]>(
250
- `SELECT COUNT(*) as count FROM notes WHERE project_path = ? AND type = 'smart' AND status = 'ready'`,
251
- )
252
- .get(projectIdentity);
253
- readySmartNoteCount = smartRow?.count ?? 0;
254
- } catch {
255
- // notes table may not exist
256
- }
257
- }
258
-
259
- // Token estimates for breakdown bar (~4 chars/token)
260
- let compartmentTokens = 0;
261
- let factTokens = 0;
262
- let memoryTokens = 0;
263
- try {
264
- const compRows = db
265
- .query<
266
- { content: string; title: string; start_message: number; end_message: number },
267
- [string]
268
- >(
269
- `SELECT content, title, start_message, end_message FROM compartments WHERE session_id = ?`,
270
- )
271
- .all(sessionId);
272
- for (const c of compRows) {
273
- compartmentTokens += Math.ceil(
274
- `<compartment start="${c.start_message}" end="${c.end_message}" title="${c.title}">\n${c.content}\n</compartment>\n`
275
- .length / 3.5,
276
- );
277
- }
278
- } catch {
279
- /* compartments table may not exist */
280
- }
281
- try {
282
- const factRows = db
283
- .query<{ content: string }, [string]>(
284
- `SELECT content FROM session_facts WHERE session_id = ?`,
285
- )
286
- .all(sessionId);
287
- for (const f of factRows) {
288
- factTokens += Math.ceil(`* ${f.content}\n`.length / 3.5);
289
- }
290
- } catch {
291
- /* session_facts table may not exist */
292
- }
293
- // Memory tokens from cached block in session_meta
294
- if (meta) {
295
- const cached = (meta as Record<string, unknown>).memory_block_cache;
296
- if (typeof cached === "string" && cached.length > 0) {
297
- memoryTokens = Math.ceil(cached.length / 3.5);
298
- }
299
- }
300
-
301
- // Last dreamer run
302
- let lastDreamerRunAt: number | null = null;
303
- if (projectIdentity) {
304
- try {
305
- const dreamRow = db
306
- .query<{ value: string }, [string]>(
307
- `SELECT value FROM dream_state WHERE key = ?`,
308
- )
309
- .get(`last_dream_at:${projectIdentity}`);
310
- if (dreamRow?.value) {
311
- lastDreamerRunAt = Number(dreamRow.value) || null;
312
- }
313
- } catch {
314
- // dream_state may not exist
315
- }
316
- }
317
-
318
- const result = {
60
+ const result = await rpcClient.call<SidebarSnapshot>("sidebar-snapshot", {
319
61
  sessionId,
320
- usagePercentage,
321
- inputTokens,
322
- systemPromptTokens,
323
- compartmentCount,
324
- factCount,
325
- memoryCount,
326
- memoryBlockCount,
327
- pendingOpsCount,
328
- historianRunning,
329
- compartmentInProgress,
330
- sessionNoteCount,
331
- readySmartNoteCount,
332
- cacheTtl,
333
- lastDreamerRunAt,
334
- projectIdentity,
335
- compartmentTokens,
336
- factTokens,
337
- memoryTokens,
338
- };
62
+ directory,
63
+ });
64
+ if ((result as unknown as Record<string, unknown>).error) {
65
+ return { ...EMPTY_SNAPSHOT, sessionId };
66
+ }
339
67
  return result;
340
- } catch (err) {
341
- log("[magic-context-tui] snapshot error:", err);
342
- return empty;
68
+ } catch {
69
+ return { ...EMPTY_SNAPSHOT, sessionId };
343
70
  }
344
71
  }
345
72
 
346
- export function loadStatusDetail(
73
+ /** Fetch full status detail from the server via RPC. */
74
+ export async function loadStatusDetail(
347
75
  sessionId: string,
348
76
  directory: string,
349
77
  modelKey?: string,
350
- ): StatusDetail {
351
- const base = loadSidebarSnapshot(sessionId, directory);
352
- const detail: StatusDetail = {
353
- ...base,
78
+ ): Promise<StatusDetail> {
79
+ const emptyDetail: StatusDetail = {
80
+ ...EMPTY_SNAPSHOT,
81
+ sessionId,
354
82
  tagCounter: 0,
355
83
  activeTags: 0,
356
84
  droppedTags: 0,
@@ -376,317 +104,63 @@ export function loadStatusDetail(
376
104
  compressionUsage: null,
377
105
  };
378
106
 
379
- const db = getDb();
380
- if (!db) return detail;
381
-
107
+ if (!rpcClient) return emptyDetail;
382
108
  try {
383
- // Session meta extras
384
- const meta = db
385
- .query<Record<string, unknown>, [string]>(
386
- `SELECT * FROM session_meta WHERE session_id = ?`,
387
- )
388
- .get(sessionId);
389
- if (meta) {
390
- detail.tagCounter = Number(meta.counter ?? 0);
391
- detail.lastResponseTime = Number(meta.last_response_time ?? 0);
392
- detail.lastNudgeTokens = Number(meta.last_nudge_tokens ?? 0);
393
- detail.lastNudgeBand = String(meta.last_nudge_band ?? "");
394
- detail.lastTransformError = meta.last_transform_error
395
- ? String(meta.last_transform_error)
396
- : null;
397
- detail.isSubagent = Boolean(meta.is_subagent);
398
- }
399
-
400
- // Tag counts
401
- try {
402
- const activeRow = db
403
- .query<{ count: number; bytes: number }, [string]>(
404
- `SELECT COUNT(*) as count, COALESCE(SUM(byte_size), 0) as bytes FROM tags WHERE session_id = ? AND status = 'active'`,
405
- )
406
- .get(sessionId);
407
- detail.activeTags = activeRow?.count ?? 0;
408
- detail.activeBytes = activeRow?.bytes ?? 0;
409
-
410
- const droppedRow = db
411
- .query<{ count: number }, [string]>(
412
- `SELECT COUNT(*) as count FROM tags WHERE session_id = ? AND status = 'dropped'`,
413
- )
414
- .get(sessionId);
415
- detail.droppedTags = droppedRow?.count ?? 0;
416
- detail.totalTags = detail.activeTags + detail.droppedTags;
417
- } catch {
418
- // tags table might have different schema
419
- }
420
-
421
- // Pending ops detail
422
- try {
423
- const ops = db
424
- .query<{ tag_id: number; operation: string }, [string]>(
425
- `SELECT tag_id, operation FROM pending_ops WHERE session_id = ?`,
426
- )
427
- .all(sessionId);
428
- detail.pendingOps = ops.map((o) => ({ tagId: o.tag_id, operation: o.operation }));
429
- } catch {
430
- // pending_ops may not exist
431
- }
432
-
433
- // Read config for threshold/budget values, resolving per-model overrides
434
- try {
435
- const cfg = readMagicContextConfig(directory);
436
- if (cfg) {
437
- // execute_threshold_percentage: number | { default, "provider/model" }
438
- const etp = cfg.execute_threshold_percentage;
439
- if (typeof etp === "number") {
440
- detail.executeThreshold = Math.min(etp, 80);
441
- } else if (etp && typeof etp === "object") {
442
- const etpObj = etp as Record<string, number>;
443
- let resolved = etpObj.default ?? 65;
444
- if (modelKey && typeof etpObj[modelKey] === "number") {
445
- resolved = etpObj[modelKey];
446
- } else if (modelKey) {
447
- const bare = modelKey.split("/").slice(1).join("/");
448
- if (bare && typeof etpObj[bare] === "number") resolved = etpObj[bare];
449
- }
450
- detail.executeThreshold = Math.min(resolved, 80);
451
- }
452
-
453
- // cache_ttl: string | { default, "provider/model" }
454
- const ct = cfg.cache_ttl;
455
- if (typeof ct === "string") {
456
- detail.cacheTtl = ct;
457
- } else if (ct && typeof ct === "object") {
458
- const ctObj = ct as Record<string, string>;
459
- let resolved = ctObj.default ?? "5m";
460
- if (modelKey && typeof ctObj[modelKey] === "string") {
461
- resolved = ctObj[modelKey];
462
- } else if (modelKey) {
463
- const bare = modelKey.split("/").slice(1).join("/");
464
- if (bare && typeof ctObj[bare] === "string") resolved = ctObj[bare];
465
- }
466
- detail.cacheTtl = resolved;
467
- }
468
-
469
- if (typeof cfg.protected_tag_count === "number") {
470
- detail.protectedTagCount = cfg.protected_tag_count;
471
- }
472
- if (typeof cfg.nudge_interval_tokens === "number") {
473
- detail.nudgeInterval = cfg.nudge_interval_tokens;
474
- }
475
- if (typeof cfg.history_budget_percentage === "number") {
476
- detail.historyBudgetPercentage = cfg.history_budget_percentage;
477
- }
478
- }
479
- } catch {
480
- // config read failure — keep defaults
481
- }
482
-
483
- // Derived: context limit
484
- if (base.usagePercentage > 0) {
485
- detail.contextLimit = Math.round(base.inputTokens / (base.usagePercentage / 100));
486
- }
487
-
488
- // Derived: cache TTL (re-resolve with potentially model-specific value)
489
- detail.cacheTtlMs = parseTtlString(detail.cacheTtl);
490
- if (detail.lastResponseTime > 0) {
491
- const elapsed = Date.now() - detail.lastResponseTime;
492
- detail.cacheRemainingMs = Math.max(0, detail.cacheTtlMs - elapsed);
493
- detail.cacheExpired = detail.cacheRemainingMs === 0;
494
- }
495
-
496
- // Derived: next nudge
497
- detail.nextNudgeAfter = detail.lastNudgeTokens + detail.nudgeInterval;
498
-
499
- // History compression: estimate tokens from compartment/fact content
500
- try {
501
- const compartments = db
502
- .query<
503
- { content: string; title: string; start_message: number; end_message: number },
504
- [string]
505
- >(
506
- `SELECT content, title, start_message, end_message FROM compartments WHERE session_id = ?`,
507
- )
508
- .all(sessionId);
509
- const facts = db
510
- .query<{ content: string }, [string]>(
511
- `SELECT content FROM session_facts WHERE session_id = ?`,
512
- )
513
- .all(sessionId);
514
-
515
- let histTokens = 0;
516
- for (const c of compartments) {
517
- // ~3.5 chars per token estimate (matches plugin's estimateTokens)
518
- histTokens += Math.ceil(
519
- `<compartment start="${c.start_message}" end="${c.end_message}" title="${c.title}">\n${c.content}\n</compartment>\n`
520
- .length / 3.5,
521
- );
522
- }
523
- for (const f of facts) {
524
- histTokens += Math.ceil(`* ${f.content}\n`.length / 3.5);
525
- }
526
- detail.historyBlockTokens = histTokens;
527
-
528
- if (detail.contextLimit > 0) {
529
- const budget = Math.floor(
530
- detail.contextLimit *
531
- (Math.min(detail.executeThreshold, 80) / 100) *
532
- detail.historyBudgetPercentage,
533
- );
534
- detail.compressionBudget = budget;
535
- detail.compressionUsage = `${((histTokens / budget) * 100).toFixed(0)}%`;
536
- }
537
- } catch {
538
- // compartments/facts read failure
109
+ const result = await rpcClient.call<StatusDetail>("status-detail", {
110
+ sessionId,
111
+ directory,
112
+ modelKey,
113
+ });
114
+ if ((result as unknown as Record<string, unknown>).error) {
115
+ return emptyDetail;
539
116
  }
540
- } catch (err) {
541
- log("[magic-context-tui] loadStatusDetail error:", err);
542
- }
543
-
544
- return detail;
545
- }
546
-
547
- function parseTtlString(ttl: string): number {
548
- const match = ttl.match(/^(\d+)(s|m|h)$/);
549
- if (!match) return 5 * 60 * 1000; // default 5m
550
- const value = Number(match[1]);
551
- switch (match[2]) {
552
- case "s":
553
- return value * 1000;
554
- case "m":
555
- return value * 60 * 1000;
556
- case "h":
557
- return value * 3600 * 1000;
558
- default:
559
- return 5 * 60 * 1000;
117
+ return result;
118
+ } catch {
119
+ return emptyDetail;
560
120
  }
561
121
  }
562
122
 
563
- function readMagicContextConfig(directory: string): Record<string, unknown> | null {
564
- const fs = require("node:fs") as typeof import("node:fs");
565
- // Try project config first, then user config
566
- const candidates = [
567
- path.join(directory, "magic-context.jsonc"),
568
- path.join(directory, ".opencode", "magic-context.jsonc"),
569
- ];
570
- const homeConfig = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
571
- candidates.push(path.join(homeConfig, "opencode", "magic-context.jsonc"));
572
-
573
- for (const p of candidates) {
574
- try {
575
- const raw = fs.readFileSync(p, "utf-8");
576
- // Strip JSONC comments
577
- const stripped = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
578
- return JSON.parse(stripped);
579
- } catch {
580
- // try next candidate
581
- }
123
+ /** Get compartment count via RPC. */
124
+ export async function getCompartmentCount(sessionId: string): Promise<number> {
125
+ if (!rpcClient) return 0;
126
+ try {
127
+ const result = await rpcClient.call<{ count: number }>("compartment-count", { sessionId });
128
+ return result.count ?? 0;
129
+ } catch {
130
+ return 0;
582
131
  }
583
- return null;
584
132
  }
585
133
 
586
- /**
587
- * Get compartment count for a session (used by recomp confirmation dialog).
588
- */
589
- export function getCompartmentCount(sessionId: string): number {
590
- const db = getDb();
591
- if (!db) return 0;
134
+ /** Send recomp request to server via RPC. */
135
+ export async function requestRecomp(sessionId: string): Promise<boolean> {
136
+ if (!rpcClient) return false;
592
137
  try {
593
- const row = db
594
- .prepare("SELECT COUNT(*) as count FROM compartments WHERE session_id = ?")
595
- .get(sessionId) as { count: number } | null;
596
- return row?.count ?? 0;
138
+ const result = await rpcClient.call<{ ok: boolean }>("recomp", { sessionId });
139
+ return result.ok ?? false;
597
140
  } catch {
598
- return 0;
141
+ return false;
599
142
  }
600
143
  }
601
144
 
602
- /**
603
- * Consume pending server→TUI messages from the plugin_messages table.
604
- * Returns consumed messages and marks them as consumed.
605
- */
606
145
  export interface TuiMessage {
607
- id: number;
608
146
  type: string;
609
147
  payload: Record<string, unknown>;
610
- sessionId: string | null;
611
- createdAt: number;
148
+ sessionId?: string;
612
149
  }
613
150
 
614
- export function consumeTuiMessages(): TuiMessage[] {
615
- const db = getDb();
616
- if (!db) return [];
617
-
151
+ /** Poll for pending server→TUI notifications via RPC. */
152
+ export async function consumeTuiMessages(): Promise<TuiMessage[]> {
153
+ if (!rpcClient) return [];
618
154
  try {
619
- // Check if plugin_messages table exists (migration may not have run yet)
620
- const tableCheck = db
621
- .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='plugin_messages'")
622
- .get();
623
- if (!tableCheck) return [];
624
-
625
- const now = Date.now();
626
- const rows = db
627
- .prepare(
628
- "SELECT id, type, payload, session_id, created_at FROM plugin_messages WHERE direction = 'server_to_tui' AND consumed_at IS NULL ORDER BY created_at ASC",
629
- )
630
- .all() as Array<{
631
- id: number;
632
- type: string;
633
- payload: string;
634
- session_id: string | null;
635
- created_at: number;
636
- }>;
637
-
638
- if (rows.length === 0) return [];
639
-
640
- const ids = rows.map((r) => r.id);
641
- db.prepare(
642
- `UPDATE plugin_messages SET consumed_at = ? WHERE id IN (${ids.map(() => "?").join(",")})`,
643
- ).run(now, ...ids);
644
-
645
- // Cleanup old messages
646
- db.prepare("DELETE FROM plugin_messages WHERE created_at < ?").run(now - 5 * 60 * 1000);
647
-
648
- return rows.map((r) => {
649
- let payload: Record<string, unknown> = {};
650
- try {
651
- payload = JSON.parse(r.payload);
652
- } catch {
653
- // Intentional: malformed payload treated as empty
654
- }
655
- return {
656
- id: r.id,
657
- type: r.type,
658
- payload,
659
- sessionId: r.session_id,
660
- createdAt: r.created_at,
661
- };
662
- });
155
+ const result = await rpcClient.call<{ messages: RpcNotificationMessage[] }>(
156
+ "pending-notifications",
157
+ );
158
+ return (result.messages ?? []).map((m) => ({
159
+ type: m.type,
160
+ payload: m.payload,
161
+ sessionId: m.sessionId,
162
+ }));
663
163
  } catch {
664
164
  return [];
665
165
  }
666
166
  }
667
-
668
- /**
669
- * Send a message from TUI to server via plugin_messages.
670
- */
671
- export function sendMessageToServer(
672
- type: string,
673
- payload: Record<string, unknown>,
674
- sessionId?: string,
675
- ): boolean {
676
- const db = getDb();
677
- if (!db) return false;
678
-
679
- try {
680
- const tableCheck = db
681
- .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='plugin_messages'")
682
- .get();
683
- if (!tableCheck) return false;
684
-
685
- db.prepare(
686
- "INSERT INTO plugin_messages (direction, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?)",
687
- ).run("tui_to_server", type, JSON.stringify(payload), sessionId ?? null, Date.now());
688
- return true;
689
- } catch {
690
- return false;
691
- }
692
- }