@cortexkit/opencode-magic-context 0.4.1 → 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.
- package/README.md +42 -1
- package/dist/cli/config-paths.d.ts +2 -0
- package/dist/cli/config-paths.d.ts.map +1 -1
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli.js +8549 -125
- package/dist/features/builtin-commands/commands.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
- package/dist/features/magic-context/scheduler.d.ts.map +1 -1
- package/dist/features/magic-context/search.d.ts +2 -0
- package/dist/features/magic-context/search.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8654 -239
- package/dist/plugin/conflict-warning-hook.d.ts +24 -0
- package/dist/plugin/conflict-warning-hook.d.ts.map +1 -0
- package/dist/shared/conflict-detector.d.ts +29 -0
- package/dist/shared/conflict-detector.d.ts.map +1 -0
- package/dist/shared/conflict-fixer.d.ts +3 -0
- package/dist/shared/conflict-fixer.d.ts.map +1 -0
- package/dist/shared/tui-config.d.ts +10 -0
- package/dist/shared/tui-config.d.ts.map +1 -0
- package/dist/tools/ctx-search/tools.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts +54 -0
- package/dist/tui/data/context-db.d.ts.map +1 -0
- package/package.json +20 -1
- package/src/tui/data/context-db.ts +584 -0
- package/src/tui/index.tsx +461 -0
- package/src/tui/slots/sidebar-content.tsx +422 -0
- package/src/tui/types/opencode-plugin-tui.d.ts +232 -0
|
@@ -0,0 +1,584 @@
|
|
|
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";
|
|
5
|
+
|
|
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
|
+
}
|
|
28
|
+
|
|
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
|
+
}
|
|
58
|
+
|
|
59
|
+
function getContextDbPath(): string {
|
|
60
|
+
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");
|
|
62
|
+
}
|
|
63
|
+
|
|
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 without readonly flag — WAL-mode DBs need read-write access to the
|
|
74
|
+
// -shm file even for read-only queries. We never write from the TUI process.
|
|
75
|
+
cachedDb = new Database(targetPath);
|
|
76
|
+
cachedDb.exec("PRAGMA journal_mode = WAL");
|
|
77
|
+
cachedDb.exec("PRAGMA query_only = ON");
|
|
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
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
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
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
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
|
+
|
|
155
|
+
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 = ?`,
|
|
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 smart_notes WHERE project_path = ? AND status = 'ready'`,
|
|
251
|
+
)
|
|
252
|
+
.get(projectIdentity);
|
|
253
|
+
readySmartNoteCount = smartRow?.count ?? 0;
|
|
254
|
+
} catch {
|
|
255
|
+
// smart_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 / 4,
|
|
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 / 4);
|
|
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 / 4);
|
|
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 = {
|
|
319
|
+
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
|
+
};
|
|
339
|
+
return result;
|
|
340
|
+
} catch (err) {
|
|
341
|
+
log("[magic-context-tui] snapshot error:", err);
|
|
342
|
+
return empty;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function loadStatusDetail(
|
|
347
|
+
sessionId: string,
|
|
348
|
+
directory: string,
|
|
349
|
+
modelKey?: string,
|
|
350
|
+
): StatusDetail {
|
|
351
|
+
const base = loadSidebarSnapshot(sessionId, directory);
|
|
352
|
+
const detail: StatusDetail = {
|
|
353
|
+
...base,
|
|
354
|
+
tagCounter: 0,
|
|
355
|
+
activeTags: 0,
|
|
356
|
+
droppedTags: 0,
|
|
357
|
+
totalTags: 0,
|
|
358
|
+
activeBytes: 0,
|
|
359
|
+
lastResponseTime: 0,
|
|
360
|
+
lastNudgeTokens: 0,
|
|
361
|
+
lastNudgeBand: "",
|
|
362
|
+
lastTransformError: null,
|
|
363
|
+
isSubagent: false,
|
|
364
|
+
pendingOps: [],
|
|
365
|
+
contextLimit: 0,
|
|
366
|
+
cacheTtlMs: 0,
|
|
367
|
+
cacheRemainingMs: 0,
|
|
368
|
+
cacheExpired: false,
|
|
369
|
+
executeThreshold: 65,
|
|
370
|
+
protectedTagCount: 20,
|
|
371
|
+
nudgeInterval: 20000,
|
|
372
|
+
historyBudgetPercentage: 0.15,
|
|
373
|
+
nextNudgeAfter: 0,
|
|
374
|
+
historyBlockTokens: 0,
|
|
375
|
+
compressionBudget: null,
|
|
376
|
+
compressionUsage: null,
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const db = getDb();
|
|
380
|
+
if (!db) return detail;
|
|
381
|
+
|
|
382
|
+
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
|
+
// ~4 chars per token estimate (same as 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 / 4,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
for (const f of facts) {
|
|
524
|
+
histTokens += Math.ceil(`* ${f.content}\n`.length / 4);
|
|
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
|
|
539
|
+
}
|
|
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;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
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
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
}
|