@cortexkit/opencode-magic-context 0.32.0 → 0.32.1
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 +3 -3
- package/dist/features/magic-context/dreamer/cron.d.ts +2 -2
- package/dist/features/magic-context/dreamer/cron.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/message-index-async.d.ts +4 -1
- package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
- package/dist/features/magic-context/message-index.d.ts +7 -4
- package/dist/features/magic-context/message-index.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts +8 -16
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/plugin-messages.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/session-project-backfill.d.ts +6 -3
- package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
- package/dist/features/magic-context/session-project-storage.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/runner.d.ts +1 -0
- package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +8 -1
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +10 -0
- package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/storage.d.ts +16 -1
- package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +8 -5
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
- package/dist/features/magic-context/storage-schema-helpers.d.ts +3 -4
- package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -0
- package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts +12 -0
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -0
- package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
- package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
- package/dist/hooks/magic-context/emergency-drop.d.ts +1 -0
- package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +29 -0
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -0
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -0
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
- package/dist/hooks/magic-context/historian-prompt.generated.d.ts +1 -1
- package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +1 -0
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts +15 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-raw.d.ts +24 -2
- package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
- package/dist/hooks/magic-context/shadow-sender.d.ts +65 -27
- package/dist/hooks/magic-context/shadow-sender.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +27 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.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 +4128 -1207
- package/dist/plugin/rpc-handlers.d.ts +3 -0
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts +5 -0
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +3 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +11 -6
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +2 -2
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/sqlite.d.ts.map +1 -1
- package/dist/shared/tui-config.d.ts +5 -3
- package/dist/shared/tui-config.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts +9 -2
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui/data/notification-socket.d.ts +4 -5
- package/dist/tui/data/notification-socket.d.ts.map +1 -1
- package/dist/tui-compiled/data/context-db.d.ts +9 -2
- package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
- package/dist/tui-compiled/data/notification-socket.d.ts +4 -5
- package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/shared/models-dev-cache.test.ts +5 -0
- package/src/shared/models-dev-cache.ts +44 -6
- package/src/shared/opencode-compaction-detector.test.ts +11 -2
- package/src/shared/rpc-client.test.ts +213 -9
- package/src/shared/rpc-client.ts +48 -39
- package/src/shared/rpc-notifications.test.ts +26 -5
- package/src/shared/rpc-notifications.ts +44 -35
- package/src/shared/rpc-server.ts +69 -33
- package/src/shared/sqlite.ts +22 -3
- package/src/shared/tui-config.test.ts +4 -12
- package/src/shared/tui-config.ts +6 -6
- package/src/tui/data/context-db.test.ts +113 -0
- package/src/tui/data/context-db.ts +26 -19
- package/src/tui/data/notification-socket.test.ts +277 -11
- package/src/tui/data/notification-socket.ts +257 -86
- package/src/tui/index.tsx +33 -25
- package/src/tui/slots/sidebar-content.tsx +62 -24
- package/src/tui-compiled/data/context-db.ts +26 -19
- package/src/tui-compiled/data/notification-socket.ts +257 -86
- package/src/tui-compiled/index.tsx +41 -24
- package/src/tui-compiled/slots/sidebar-content.tsx +45 -23
package/src/shared/rpc-server.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { dirname } from "node:path";
|
|
|
13
13
|
import type { Server, ServerWebSocket } from "bun";
|
|
14
14
|
import { log } from "./logger";
|
|
15
15
|
import {
|
|
16
|
+
acknowledgeNotifications,
|
|
16
17
|
drainNotifications,
|
|
17
18
|
type NotificationSink,
|
|
18
19
|
registerNotificationSink,
|
|
@@ -57,8 +58,12 @@ function bearerToken(req: Request): string {
|
|
|
57
58
|
return typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
function websocketToken(req: Request
|
|
61
|
-
|
|
61
|
+
function websocketToken(req: Request): string {
|
|
62
|
+
const headerToken = bearerToken(req);
|
|
63
|
+
if (headerToken) return headerToken;
|
|
64
|
+
// v0.32 TUI clients put the token in the upgrade URL. Keep this fallback only
|
|
65
|
+
// for the v0.32→v0.32.1 skew window; remove it when v0.32 clients are unsupported.
|
|
66
|
+
return new URL(req.url).searchParams.get("token") ?? "";
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
/**
|
|
@@ -242,7 +247,7 @@ export class MagicContextRpcServer {
|
|
|
242
247
|
// WebSocket upgrade — the persistent push channel. Authenticate before
|
|
243
248
|
// `srv.upgrade` so an unauthorized request never becomes a live socket.
|
|
244
249
|
if (url.pathname === "/ws") {
|
|
245
|
-
if (!tokensMatch(websocketToken(req
|
|
250
|
+
if (!tokensMatch(websocketToken(req), this.token)) {
|
|
246
251
|
return new Response("Unauthorized", { status: 401 });
|
|
247
252
|
}
|
|
248
253
|
const ok = srv.upgrade(req, { data: { authed: false } });
|
|
@@ -253,7 +258,7 @@ export class MagicContextRpcServer {
|
|
|
253
258
|
// No wildcard CORS: the only legitimate client is the in-process TUI
|
|
254
259
|
// client, not a browser origin.
|
|
255
260
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
256
|
-
return json({ ok: true, pid: process.pid });
|
|
261
|
+
return json({ ok: true, pid: process.pid, instance_id: this.instanceId });
|
|
257
262
|
}
|
|
258
263
|
|
|
259
264
|
if (req.method !== "POST" || !url.pathname.startsWith("/rpc/")) {
|
|
@@ -293,8 +298,8 @@ export class MagicContextRpcServer {
|
|
|
293
298
|
}
|
|
294
299
|
}
|
|
295
300
|
|
|
296
|
-
/** WS message handler: hello (auth + sink registration + backlog
|
|
297
|
-
* ack (
|
|
301
|
+
/** WS message handler: hello (auth + sink registration + backlog delivery) and
|
|
302
|
+
* ack (exact removal or legacy cursor pruning). All other messages are ignored. */
|
|
298
303
|
private handleWsMessage(ws: ServerWebSocket<WsData>, raw: string | Buffer): void {
|
|
299
304
|
let msg: {
|
|
300
305
|
type?: string;
|
|
@@ -303,6 +308,10 @@ export class MagicContextRpcServer {
|
|
|
303
308
|
lastReceivedId?: number;
|
|
304
309
|
globalLastReceivedId?: number;
|
|
305
310
|
ackScope?: string;
|
|
311
|
+
protocol?: number;
|
|
312
|
+
instanceId?: string;
|
|
313
|
+
ids?: unknown;
|
|
314
|
+
cursor?: number;
|
|
306
315
|
};
|
|
307
316
|
try {
|
|
308
317
|
msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
@@ -336,6 +345,7 @@ export class MagicContextRpcServer {
|
|
|
336
345
|
// Register a live sink so future pushes reach this socket immediately.
|
|
337
346
|
const sink: NotificationSink = {
|
|
338
347
|
sessionId: ws.data.sessionId,
|
|
348
|
+
protocol: msg.protocol,
|
|
339
349
|
send: (notification) => {
|
|
340
350
|
ws.send(JSON.stringify({ type: "notification", notification }));
|
|
341
351
|
},
|
|
@@ -343,40 +353,66 @@ export class MagicContextRpcServer {
|
|
|
343
353
|
ws.data.unregister = registerNotificationSink(sink);
|
|
344
354
|
this.sockets.add(ws);
|
|
345
355
|
|
|
346
|
-
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
:
|
|
367
|
-
|
|
356
|
+
const usesExactAcknowledgements = msg.protocol === 2;
|
|
357
|
+
// The epoch arrives before backlog frames so the client can discard
|
|
358
|
+
// cursors and deduplication entries from a replaced server first.
|
|
359
|
+
ws.send(
|
|
360
|
+
JSON.stringify({
|
|
361
|
+
type: "hello-ack",
|
|
362
|
+
protocol: 2,
|
|
363
|
+
instanceId: this.instanceId,
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
let backlog: ReturnType<typeof drainNotifications>;
|
|
368
|
+
if (usesExactAcknowledgements) {
|
|
369
|
+
// Protocol 2 never treats a high handled id as proof that lower ids
|
|
370
|
+
// were consumed. Exact acknowledgements are the only destructive
|
|
371
|
+
// operation, so declined or interrupted dialogs survive reconnects.
|
|
372
|
+
backlog =
|
|
373
|
+
ws.data.sessionId === undefined
|
|
374
|
+
? drainNotifications(0, undefined, { globalOnly: true })
|
|
375
|
+
: drainNotifications(0, ws.data.sessionId, {
|
|
376
|
+
globalLastReceivedId: 0,
|
|
377
|
+
});
|
|
378
|
+
} else {
|
|
379
|
+
// Legacy clients use independent session and global watermarks.
|
|
380
|
+
const lastReceivedId = Number(msg.lastReceivedId ?? 0);
|
|
381
|
+
const sessionCursor = Number.isFinite(lastReceivedId) ? lastReceivedId : 0;
|
|
382
|
+
const hasGlobalCursor = typeof msg.globalLastReceivedId === "number";
|
|
383
|
+
const globalLastReceivedId = hasGlobalCursor
|
|
384
|
+
? Number.isFinite(msg.globalLastReceivedId)
|
|
385
|
+
? msg.globalLastReceivedId
|
|
386
|
+
: 0
|
|
387
|
+
: 0;
|
|
388
|
+
backlog =
|
|
389
|
+
ws.data.sessionId === undefined && hasGlobalCursor
|
|
390
|
+
? drainNotifications(globalLastReceivedId, undefined, { globalOnly: true })
|
|
391
|
+
: drainNotifications(
|
|
392
|
+
sessionCursor,
|
|
393
|
+
ws.data.sessionId,
|
|
394
|
+
hasGlobalCursor
|
|
395
|
+
? { globalLastReceivedId: globalLastReceivedId }
|
|
396
|
+
: undefined,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
368
399
|
for (const notification of backlog) {
|
|
369
400
|
ws.send(JSON.stringify({ type: "notification", notification }));
|
|
370
401
|
}
|
|
371
|
-
ws.send(JSON.stringify({ type: "hello-ack" }));
|
|
372
402
|
return;
|
|
373
403
|
}
|
|
374
404
|
|
|
375
405
|
if (msg.type === "ack") {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
406
|
+
if (Array.isArray(msg.ids)) {
|
|
407
|
+
acknowledgeNotifications(
|
|
408
|
+
msg.ids.filter((id): id is number => typeof id === "number"),
|
|
409
|
+
);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Keep watermark acknowledgements during the one-release skew window.
|
|
414
|
+
// Scope isolation remains mandatory for these legacy messages.
|
|
415
|
+
const lastReceivedId = Number(msg.cursor ?? msg.lastReceivedId ?? 0);
|
|
380
416
|
if (Number.isFinite(lastReceivedId) && lastReceivedId > 0) {
|
|
381
417
|
if (msg.ackScope === "global") {
|
|
382
418
|
drainNotifications(lastReceivedId, undefined, { globalOnly: true });
|
package/src/shared/sqlite.ts
CHANGED
|
@@ -137,11 +137,15 @@ function buildNodeSqliteDatabaseClass(DatabaseSync: any): typeof BetterSqlite3 {
|
|
|
137
137
|
transaction<F extends (...args: any[]) => any>(fn: F): F {
|
|
138
138
|
// biome-ignore lint/suspicious/noExplicitAny: faithful pass-through of this/args to fn.
|
|
139
139
|
const self = this as any;
|
|
140
|
-
const
|
|
140
|
+
const execute = (
|
|
141
|
+
mode: "" | "DEFERRED" | "IMMEDIATE" | "EXCLUSIVE",
|
|
142
|
+
receiver: unknown,
|
|
143
|
+
args: unknown[],
|
|
144
|
+
) => {
|
|
141
145
|
const nested = self.isTransaction === true;
|
|
142
|
-
self.exec(nested ? `SAVEPOINT ${SAVEPOINT}` :
|
|
146
|
+
self.exec(nested ? `SAVEPOINT ${SAVEPOINT}` : `BEGIN${mode ? ` ${mode}` : ""}`);
|
|
143
147
|
try {
|
|
144
|
-
const result = fn.apply(
|
|
148
|
+
const result = fn.apply(receiver, args);
|
|
145
149
|
self.exec(nested ? `RELEASE ${SAVEPOINT}` : "COMMIT");
|
|
146
150
|
return result;
|
|
147
151
|
} catch (error) {
|
|
@@ -157,6 +161,21 @@ function buildNodeSqliteDatabaseClass(DatabaseSync: any): typeof BetterSqlite3 {
|
|
|
157
161
|
throw error;
|
|
158
162
|
}
|
|
159
163
|
};
|
|
164
|
+
const wrapped = function (this: unknown, ...args: unknown[]): unknown {
|
|
165
|
+
return execute("", this, args);
|
|
166
|
+
};
|
|
167
|
+
wrapped.default = function (this: unknown, ...args: unknown[]): unknown {
|
|
168
|
+
return execute("", this, args);
|
|
169
|
+
};
|
|
170
|
+
wrapped.deferred = function (this: unknown, ...args: unknown[]): unknown {
|
|
171
|
+
return execute("DEFERRED", this, args);
|
|
172
|
+
};
|
|
173
|
+
wrapped.immediate = function (this: unknown, ...args: unknown[]): unknown {
|
|
174
|
+
return execute("IMMEDIATE", this, args);
|
|
175
|
+
};
|
|
176
|
+
wrapped.exclusive = function (this: unknown, ...args: unknown[]): unknown {
|
|
177
|
+
return execute("EXCLUSIVE", this, args);
|
|
178
|
+
};
|
|
160
179
|
return wrapped as unknown as F;
|
|
161
180
|
}
|
|
162
181
|
}
|
|
@@ -4,11 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
const roots: string[] = [];
|
|
7
|
-
const prevConfigDir = process.env.OPENCODE_CONFIG_DIR;
|
|
8
|
-
|
|
9
7
|
afterEach(() => {
|
|
10
|
-
if (prevConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR;
|
|
11
|
-
else process.env.OPENCODE_CONFIG_DIR = prevConfigDir;
|
|
12
8
|
for (const root of roots.splice(0)) {
|
|
13
9
|
rmSync(root, { recursive: true, force: true });
|
|
14
10
|
}
|
|
@@ -18,7 +14,6 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
18
14
|
it("preserves tuple dev-path plugin entry and does not add @latest", async () => {
|
|
19
15
|
const root = mkdtempSync(join(tmpdir(), "mc-tui-"));
|
|
20
16
|
roots.push(root);
|
|
21
|
-
process.env.OPENCODE_CONFIG_DIR = root;
|
|
22
17
|
const devPath = "/Work/magic-context/packages/plugin";
|
|
23
18
|
const tuiPath = join(root, "tui.json");
|
|
24
19
|
writeFileSync(
|
|
@@ -27,7 +22,7 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
27
22
|
);
|
|
28
23
|
|
|
29
24
|
const { ensureTuiPluginEntry } = await import("./tui-config");
|
|
30
|
-
const changed = ensureTuiPluginEntry();
|
|
25
|
+
const changed = ensureTuiPluginEntry({ configDir: root });
|
|
31
26
|
expect(changed).toBe(false);
|
|
32
27
|
const parsed = JSON.parse(readFileSync(tuiPath, "utf-8")) as { plugin: unknown[] };
|
|
33
28
|
expect(parsed.plugin).toHaveLength(2);
|
|
@@ -40,7 +35,6 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
40
35
|
it("upgrades bare npm name to @latest while preserving tuple options", async () => {
|
|
41
36
|
const root = mkdtempSync(join(tmpdir(), "mc-tui-npm-"));
|
|
42
37
|
roots.push(root);
|
|
43
|
-
process.env.OPENCODE_CONFIG_DIR = root;
|
|
44
38
|
const tuiPath = join(root, "tui.json");
|
|
45
39
|
writeFileSync(
|
|
46
40
|
tuiPath,
|
|
@@ -54,7 +48,7 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
54
48
|
);
|
|
55
49
|
|
|
56
50
|
const { ensureTuiPluginEntry } = await import("./tui-config");
|
|
57
|
-
expect(ensureTuiPluginEntry()).toBe(true);
|
|
51
|
+
expect(ensureTuiPluginEntry({ configDir: root })).toBe(true);
|
|
58
52
|
const parsed = JSON.parse(readFileSync(tuiPath, "utf-8")) as { plugin: unknown[] };
|
|
59
53
|
const entry = parsed.plugin[0] as unknown[];
|
|
60
54
|
expect(entry[0]).toBe("@cortexkit/opencode-magic-context@latest");
|
|
@@ -64,10 +58,9 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
64
58
|
it("creates tui.jsonc (not tui.json) on a fresh install", async () => {
|
|
65
59
|
const root = mkdtempSync(join(tmpdir(), "mc-tui-fresh-"));
|
|
66
60
|
roots.push(root);
|
|
67
|
-
process.env.OPENCODE_CONFIG_DIR = root;
|
|
68
61
|
|
|
69
62
|
const { ensureTuiPluginEntry } = await import("./tui-config");
|
|
70
|
-
expect(ensureTuiPluginEntry()).toBe(true);
|
|
63
|
+
expect(ensureTuiPluginEntry({ configDir: root })).toBe(true);
|
|
71
64
|
|
|
72
65
|
// The new file must be tui.jsonc so a tui.json stub never ends up
|
|
73
66
|
// sitting next to a tui.jsonc the user writes later (#176).
|
|
@@ -82,7 +75,6 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
82
75
|
it("writes into the existing tui.jsonc when both files exist", async () => {
|
|
83
76
|
const root = mkdtempSync(join(tmpdir(), "mc-tui-both-"));
|
|
84
77
|
roots.push(root);
|
|
85
|
-
process.env.OPENCODE_CONFIG_DIR = root;
|
|
86
78
|
// A real user config in tui.jsonc plus a leftover empty tui.json.
|
|
87
79
|
writeFileSync(
|
|
88
80
|
join(root, "tui.jsonc"),
|
|
@@ -91,7 +83,7 @@ describe("ensureTuiPluginEntry", () => {
|
|
|
91
83
|
writeFileSync(join(root, "tui.json"), "{}\n");
|
|
92
84
|
|
|
93
85
|
const { ensureTuiPluginEntry } = await import("./tui-config");
|
|
94
|
-
expect(ensureTuiPluginEntry()).toBe(true);
|
|
86
|
+
expect(ensureTuiPluginEntry({ configDir: root })).toBe(true);
|
|
95
87
|
|
|
96
88
|
// The plugin entry must land in tui.jsonc (higher precedence), and the
|
|
97
89
|
// user's keybinds must survive; tui.json must be left untouched.
|
package/src/shared/tui-config.ts
CHANGED
|
@@ -62,8 +62,8 @@ function writeTuiConfigAtomic(configPath: string, config: Record<string, unknown
|
|
|
62
62
|
renameSync(tmpPath, configPath);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function resolveTuiConfigPath(): string {
|
|
66
|
-
const configDir = getOpenCodeConfigPaths({ binary: "opencode" }).configDir;
|
|
65
|
+
function resolveTuiConfigPath(configDirOverride?: string): string {
|
|
66
|
+
const configDir = configDirOverride ?? getOpenCodeConfigPaths({ binary: "opencode" }).configDir;
|
|
67
67
|
const jsoncPath = join(configDir, "tui.jsonc");
|
|
68
68
|
const jsonPath = join(configDir, "tui.json");
|
|
69
69
|
|
|
@@ -80,12 +80,12 @@ function resolveTuiConfigPath(): string {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
* Ensure
|
|
84
|
-
* Creates tui.
|
|
83
|
+
* Ensure the selected TUI config has the magic-context plugin entry.
|
|
84
|
+
* Creates tui.jsonc if neither TUI config exists. Silently skips if already present.
|
|
85
85
|
*/
|
|
86
|
-
export function ensureTuiPluginEntry(): boolean {
|
|
86
|
+
export function ensureTuiPluginEntry(options: { configDir?: string } = {}): boolean {
|
|
87
87
|
try {
|
|
88
|
-
const configPath = resolveTuiConfigPath();
|
|
88
|
+
const configPath = resolveTuiConfigPath(options.configDir);
|
|
89
89
|
|
|
90
90
|
let config: Record<string, unknown> = {};
|
|
91
91
|
if (existsSync(configPath)) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { MagicContextRpcServer } from "../../shared/rpc-server";
|
|
6
|
+
import type { SidebarSnapshot } from "../../shared/rpc-types";
|
|
7
|
+
import { closeRpc, getCompartmentCount, initRpcClient, loadSidebarSnapshot } from "./context-db";
|
|
8
|
+
|
|
9
|
+
const originalXdgDataHome = process.env.XDG_DATA_HOME;
|
|
10
|
+
const tempDirs: string[] = [];
|
|
11
|
+
const servers: MagicContextRpcServer[] = [];
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
closeRpc();
|
|
15
|
+
for (const server of servers.splice(0)) server.stop();
|
|
16
|
+
for (const dir of tempDirs.splice(0)) {
|
|
17
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
18
|
+
}
|
|
19
|
+
if (originalXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
20
|
+
else process.env.XDG_DATA_HOME = originalXdgDataHome;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function makeDataHome(): string {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "mc-context-db-"));
|
|
25
|
+
tempDirs.push(dir);
|
|
26
|
+
process.env.XDG_DATA_HOME = dir;
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function snapshot(sessionId: string, inputTokens: number): SidebarSnapshot {
|
|
31
|
+
return {
|
|
32
|
+
sessionId,
|
|
33
|
+
usagePercentage: inputTokens > 0 ? 25 : 0,
|
|
34
|
+
inputTokens,
|
|
35
|
+
contextLimit: 1000,
|
|
36
|
+
systemPromptTokens: 0,
|
|
37
|
+
compartmentCount: inputTokens > 0 ? 2 : 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
|
+
docsTokens: 0,
|
|
52
|
+
profileTokens: 0,
|
|
53
|
+
conversationTokens: inputTokens,
|
|
54
|
+
toolCallTokens: 0,
|
|
55
|
+
toolDefinitionTokens: 0,
|
|
56
|
+
executeThreshold: 65,
|
|
57
|
+
newWorkTokens: null,
|
|
58
|
+
totalInputTokens: inputTokens,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function startServer(
|
|
63
|
+
dataHome: string,
|
|
64
|
+
directory: string,
|
|
65
|
+
sidebar: () => Record<string, unknown>,
|
|
66
|
+
): Promise<MagicContextRpcServer> {
|
|
67
|
+
const server = new MagicContextRpcServer(
|
|
68
|
+
join(dataHome, "cortexkit", "magic-context"),
|
|
69
|
+
directory,
|
|
70
|
+
);
|
|
71
|
+
server.handle("sidebar-snapshot", async () => sidebar());
|
|
72
|
+
await server.start();
|
|
73
|
+
servers.push(server);
|
|
74
|
+
return server;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("TUI context RPC data", () => {
|
|
78
|
+
test("uses an unexpired sticky snapshot only for failed responses", async () => {
|
|
79
|
+
const dataHome = makeDataHome();
|
|
80
|
+
const directory = "/repo-sticky";
|
|
81
|
+
const sessionId = "ses_sticky";
|
|
82
|
+
let response: Record<string, unknown> = snapshot(sessionId, 250) as unknown as Record<
|
|
83
|
+
string,
|
|
84
|
+
unknown
|
|
85
|
+
>;
|
|
86
|
+
await startServer(dataHome, directory, () => response);
|
|
87
|
+
initRpcClient(directory);
|
|
88
|
+
|
|
89
|
+
expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(250);
|
|
90
|
+
response = { error: "database busy" };
|
|
91
|
+
expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(250);
|
|
92
|
+
|
|
93
|
+
response = snapshot(sessionId, 0) as unknown as Record<string, unknown>;
|
|
94
|
+
expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(0);
|
|
95
|
+
response = { error: "database busy again" };
|
|
96
|
+
expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("distinguishes a real zero compartment count from an RPC failure", async () => {
|
|
100
|
+
const dataHome = makeDataHome();
|
|
101
|
+
const directory = "/repo-count";
|
|
102
|
+
const server = await startServer(dataHome, directory, () => ({}));
|
|
103
|
+
server.handle("compartment-count", async () => ({ count: 0 }));
|
|
104
|
+
initRpcClient(directory);
|
|
105
|
+
|
|
106
|
+
expect(await getCompartmentCount("ses_zero")).toEqual({ ok: true, count: 0 });
|
|
107
|
+
server.handle("compartment-count", async () => {
|
|
108
|
+
throw new Error("database unavailable");
|
|
109
|
+
});
|
|
110
|
+
const failed = await getCompartmentCount("ses_zero");
|
|
111
|
+
expect(failed.ok).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -102,7 +102,13 @@ const STICKY_MAX_ENTRIES = 100;
|
|
|
102
102
|
const stickySidebarCache = new Map<string, CachedSnapshot>();
|
|
103
103
|
|
|
104
104
|
function rememberSidebarSnapshot(snapshot: SidebarSnapshot): void {
|
|
105
|
-
if (!snapshot.sessionId
|
|
105
|
+
if (!snapshot.sessionId) return;
|
|
106
|
+
if (snapshot.inputTokens <= 0) {
|
|
107
|
+
// A successful zero is authoritative (new/deleted/reverted session) and
|
|
108
|
+
// must prevent a later transport failure from resurrecting old values.
|
|
109
|
+
stickySidebarCache.delete(snapshot.sessionId);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
106
112
|
// LRU-style bound: drop the oldest entry once we hit the cap. With a
|
|
107
113
|
// 5-min TTL most stale entries time out naturally; this just prevents
|
|
108
114
|
// unbounded growth across many session switches in a long TUI session.
|
|
@@ -126,19 +132,9 @@ function recallSidebarSnapshot(sessionId: string, fallback: SidebarSnapshot): Si
|
|
|
126
132
|
stickySidebarCache.delete(sessionId);
|
|
127
133
|
return fallback;
|
|
128
134
|
}
|
|
129
|
-
if (!hasInFlightEvidence(fallback)) {
|
|
130
|
-
stickySidebarCache.delete(sessionId);
|
|
131
|
-
return fallback;
|
|
132
|
-
}
|
|
133
135
|
return cached.snapshot;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
function hasInFlightEvidence(snapshot: SidebarSnapshot): boolean {
|
|
137
|
-
return (
|
|
138
|
-
snapshot.compartmentInProgress || snapshot.historianRunning || snapshot.pendingOpsCount > 0
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
138
|
/** Fetch sidebar snapshot from the server via RPC. */
|
|
143
139
|
export async function loadSidebarSnapshot(
|
|
144
140
|
sessionId: string,
|
|
@@ -152,9 +148,11 @@ export async function loadSidebarSnapshot(
|
|
|
152
148
|
directory,
|
|
153
149
|
});
|
|
154
150
|
if ((result as unknown as Record<string, unknown>).error) {
|
|
151
|
+
// Snapshot-build errors are explicit failure envelopes, equivalent to
|
|
152
|
+
// a transport failure: retain the last known-good client snapshot.
|
|
155
153
|
return recallSidebarSnapshot(sessionId, empty);
|
|
156
154
|
}
|
|
157
|
-
// Trust successful server responses. The server has its own sticky
|
|
155
|
+
// Trust successful server responses, including authoritative zeroes. The server has its own sticky
|
|
158
156
|
// sidebar cache (`sidebar-snapshot-cache.ts`) that handles transient
|
|
159
157
|
// zero-token windows by hybriding cached breakdown values into a
|
|
160
158
|
// fresh snapshot, AND clears that cache on `session.deleted`. If the
|
|
@@ -249,14 +247,23 @@ export async function loadEmbedDetail(sessionId: string, directory: string): Pro
|
|
|
249
247
|
}
|
|
250
248
|
}
|
|
251
249
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
250
|
+
export type CompartmentCountResult = { ok: true; count: number } | { ok: false; error: string };
|
|
251
|
+
|
|
252
|
+
/** Get compartment count without making transport failure look like a real zero. */
|
|
253
|
+
export async function getCompartmentCount(sessionId: string): Promise<CompartmentCountResult> {
|
|
254
|
+
if (!rpcClient) return { ok: false, error: "RPC client is not initialized" };
|
|
255
255
|
try {
|
|
256
|
-
const result = await rpcClient.call<{ count
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
256
|
+
const result = await rpcClient.call<{ count?: number; error?: string }>(
|
|
257
|
+
"compartment-count",
|
|
258
|
+
{ sessionId },
|
|
259
|
+
);
|
|
260
|
+
if (typeof result.error === "string") return { ok: false, error: result.error };
|
|
261
|
+
if (typeof result.count !== "number" || !Number.isFinite(result.count)) {
|
|
262
|
+
return { ok: false, error: "Invalid compartment count response" };
|
|
263
|
+
}
|
|
264
|
+
return { ok: true, count: result.count };
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
260
267
|
}
|
|
261
268
|
}
|
|
262
269
|
|