@cortexkit/opencode-magic-context 0.31.5 → 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/agents/magic-context-prompt.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +2 -0
- package/dist/config/project-security.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +9 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- 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 +6 -2
- 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-clone.d.ts +34 -0
- package/dist/features/magic-context/storage-clone.d.ts.map +1 -0
- 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 +11 -0
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +2 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/tagger.d.ts +10 -0
- package/dist/features/magic-context/tagger.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/apply-operations.d.ts +24 -0
- package/dist/hooks/magic-context/apply-operations.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/compartment-trigger.d.ts +7 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
- package/dist/hooks/magic-context/decay-render.d.ts +2 -0
- 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/hook.d.ts +3 -0
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +8 -9
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/m0-token-breakdown.d.ts +5 -4
- package/dist/hooks/magic-context/m0-token-breakdown.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts +17 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-db.d.ts +2 -2
- package/dist/hooks/magic-context/read-session-raw.d.ts +32 -2
- package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
- package/dist/hooks/magic-context/shadow-sender.d.ts +300 -0
- package/dist/hooks/magic-context/shadow-sender.d.ts.map +1 -0
- package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +8 -0
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/temporal-awareness.d.ts +3 -3
- package/dist/hooks/magic-context/transform-operations.d.ts +1 -1
- package/dist/hooks/magic-context/transform-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +32 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +4 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5418 -972
- package/dist/plugin/hooks/create-session-hooks.d.ts +48 -175
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- 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/tag-transcript.d.ts +18 -0
- package/dist/shared/tag-transcript.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/tools/ctx-expand/constants.d.ts +1 -1
- package/dist/tools/ctx-expand/constants.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 +77 -33
- package/src/shared/sqlite.ts +22 -3
- package/src/shared/tag-transcript.test.ts +253 -7
- package/src/shared/tag-transcript.ts +422 -114
- 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
|
@@ -26,6 +26,8 @@ let nextNotificationId = 1;
|
|
|
26
26
|
export interface NotificationSink {
|
|
27
27
|
/** The TUI's active session at connect time (its hello scope). */
|
|
28
28
|
sessionId?: string;
|
|
29
|
+
/** Protocol 2 clients use strict session scoping; absent means legacy behavior. */
|
|
30
|
+
protocol?: number;
|
|
29
31
|
/** Deliver one notification over this sink's live socket. */
|
|
30
32
|
send: (notification: RpcNotification) => void;
|
|
31
33
|
}
|
|
@@ -48,23 +50,19 @@ export function registerNotificationSink(sink: NotificationSink): () => void {
|
|
|
48
50
|
};
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
/** Whether a
|
|
52
|
-
*
|
|
53
|
-
* only sinks for that session (or session-less sinks). Mirrors the drain filter
|
|
54
|
-
* from the sink's perspective. */
|
|
53
|
+
/** Whether a notification may be delivered to a sink. Protocol 2 makes a
|
|
54
|
+
* session-less socket global-only; legacy sockets retain broad compatibility. */
|
|
55
55
|
function notificationMatchesSink(notification: RpcNotification, sink: NotificationSink): boolean {
|
|
56
|
-
return
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
notification.sessionId === sink.sessionId
|
|
60
|
-
);
|
|
56
|
+
if (notification.sessionId === undefined) return true;
|
|
57
|
+
if (sink.sessionId !== undefined) return notification.sessionId === sink.sessionId;
|
|
58
|
+
return sink.protocol !== 2;
|
|
61
59
|
}
|
|
62
60
|
|
|
63
61
|
/** Push a notification to the TUI. Fans out to any live WS sink immediately and
|
|
64
62
|
* also enqueues it so a TUI that is momentarily disconnected (reconnecting, or
|
|
65
63
|
* not yet connected) still receives it on its next hello via the backlog drain.
|
|
66
64
|
* At-least-once: a live push that the socket drops is re-delivered from the
|
|
67
|
-
* queue on reconnect (pruned only when the client
|
|
65
|
+
* queue on reconnect (pruned only when the client acknowledges it). */
|
|
68
66
|
export function pushNotification(
|
|
69
67
|
type: string,
|
|
70
68
|
payload: Record<string, unknown>,
|
|
@@ -83,30 +81,40 @@ export function pushNotification(
|
|
|
83
81
|
// unregister it, and the queue backlog re-delivers on reconnect.
|
|
84
82
|
}
|
|
85
83
|
}
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// fill the rest of the budget with the newest overall — no session can
|
|
91
|
-
// starve another's pending dialog out of the window.
|
|
84
|
+
// Keep a strict global bound. Reserve the newest item for at most 25 recently
|
|
85
|
+
// active scopes, then evict the oldest unreserved item. This protects a quiet
|
|
86
|
+
// session from one noisy peer without allowing one item per session to grow
|
|
87
|
+
// the queue without limit.
|
|
92
88
|
if (queue.length > 100) {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
89
|
+
const reservedIds = new Set<number>();
|
|
90
|
+
const reservedScopes = new Set<string>();
|
|
91
|
+
for (let i = queue.length - 1; i >= 0 && reservedScopes.size < 25; i -= 1) {
|
|
92
|
+
const candidate = queue[i];
|
|
93
|
+
const scope = candidate.sessionId ?? "\0global";
|
|
94
|
+
if (reservedScopes.has(scope)) continue;
|
|
95
|
+
reservedScopes.add(scope);
|
|
96
|
+
reservedIds.add(candidate.id);
|
|
99
97
|
}
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
const kept: RpcNotification[] = [];
|
|
103
|
-
for (const n of byNewest) {
|
|
104
|
-
if (kept.length < 50 || mustKeep.has(n.id)) kept.push(n);
|
|
105
|
-
}
|
|
106
|
-
queue = kept.sort((a, b) => a.id - b.id);
|
|
98
|
+
const evictionIndex = queue.findIndex((candidate) => !reservedIds.has(candidate.id));
|
|
99
|
+
queue.splice(evictionIndex >= 0 ? evictionIndex : 0, 1);
|
|
107
100
|
}
|
|
108
101
|
}
|
|
109
102
|
|
|
103
|
+
export function acknowledgeNotifications(ids: readonly number[]): void {
|
|
104
|
+
const acknowledged = new Set(ids.filter((id) => Number.isSafeInteger(id) && id > 0));
|
|
105
|
+
if (acknowledged.size === 0) return;
|
|
106
|
+
// Exact removal preserves an earlier notification when a later handler finishes
|
|
107
|
+
// first or the earlier handler declines to consume its notification.
|
|
108
|
+
queue = queue.filter((notification) => !acknowledged.has(notification.id));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Reset process-local state to simulate a fresh server module in protocol tests. */
|
|
112
|
+
export function __resetNotificationStateForTests(): void {
|
|
113
|
+
queue = [];
|
|
114
|
+
nextNotificationId = 1;
|
|
115
|
+
sinks.clear();
|
|
116
|
+
}
|
|
117
|
+
|
|
110
118
|
export interface DrainNotificationsOptions {
|
|
111
119
|
/**
|
|
112
120
|
* Cursor for global notifications when a session-scoped client sends separate
|
|
@@ -133,8 +141,8 @@ function cursor(value: number | undefined): number {
|
|
|
133
141
|
* behavior.
|
|
134
142
|
*
|
|
135
143
|
* Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
|
|
136
|
-
* notification stays queued until
|
|
137
|
-
*
|
|
144
|
+
* notification stays queued until an exact acknowledgement or a legacy cursor
|
|
145
|
+
* removes it, so a dropped WS socket re-delivers unhandled backlog on reconnect. */
|
|
138
146
|
export function drainNotifications(
|
|
139
147
|
lastReceivedId = 0,
|
|
140
148
|
sessionId?: string,
|
|
@@ -200,14 +208,15 @@ export function drainNotifications(
|
|
|
200
208
|
* Pass `sessionId` (preferred) to ask whether a TUI is connected FOR THAT
|
|
201
209
|
* SESSION — this is what producers (`/ctx-status`, `/ctx-recomp`, the upgrade
|
|
202
210
|
* reminder) must use to decide dialog-vs-message, so a TUI on a different
|
|
203
|
-
* session in the same process does not misroute their delivery. A
|
|
204
|
-
* sink
|
|
205
|
-
* callers with no session context
|
|
211
|
+
* session in the same process does not misroute their delivery. A modern
|
|
212
|
+
* session-less sink is global-only; a legacy sink retains broad compatibility.
|
|
213
|
+
* Omit `sessionId` only for callers with no session context. */
|
|
206
214
|
export function isTuiConnected(sessionId?: string): boolean {
|
|
207
215
|
if (sinks.size === 0) return false;
|
|
208
216
|
if (sessionId === undefined) return true;
|
|
209
217
|
for (const sink of sinks) {
|
|
210
|
-
if (sink.sessionId ===
|
|
218
|
+
if (sink.sessionId === sessionId) return true;
|
|
219
|
+
if (sink.sessionId === undefined && sink.protocol !== 2) return true;
|
|
211
220
|
}
|
|
212
221
|
return false;
|
|
213
222
|
}
|
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
|
/**
|
|
@@ -102,6 +107,14 @@ export class MagicContextRpcServer {
|
|
|
102
107
|
|
|
103
108
|
/** Start the server on a random port, write port to disk. */
|
|
104
109
|
async start(): Promise<number> {
|
|
110
|
+
if (typeof Bun === "undefined") {
|
|
111
|
+
// The only RPC consumer is the terminal-TUI sidebar, which exists only
|
|
112
|
+
// under the Bun runtime (OpenCode CLI). On Node/Electron (Desktop) there
|
|
113
|
+
// is no consumer, and Bun.serve would throw — skip cleanly instead of
|
|
114
|
+
// logging a misleading boot error.
|
|
115
|
+
log("rpc server skipped: Bun runtime not available (no TUI consumer)");
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
105
118
|
this.startedAt = Date.now();
|
|
106
119
|
const self = this;
|
|
107
120
|
const server = Bun.serve<WsData>({
|
|
@@ -234,7 +247,7 @@ export class MagicContextRpcServer {
|
|
|
234
247
|
// WebSocket upgrade — the persistent push channel. Authenticate before
|
|
235
248
|
// `srv.upgrade` so an unauthorized request never becomes a live socket.
|
|
236
249
|
if (url.pathname === "/ws") {
|
|
237
|
-
if (!tokensMatch(websocketToken(req
|
|
250
|
+
if (!tokensMatch(websocketToken(req), this.token)) {
|
|
238
251
|
return new Response("Unauthorized", { status: 401 });
|
|
239
252
|
}
|
|
240
253
|
const ok = srv.upgrade(req, { data: { authed: false } });
|
|
@@ -245,7 +258,7 @@ export class MagicContextRpcServer {
|
|
|
245
258
|
// No wildcard CORS: the only legitimate client is the in-process TUI
|
|
246
259
|
// client, not a browser origin.
|
|
247
260
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
248
|
-
return json({ ok: true, pid: process.pid });
|
|
261
|
+
return json({ ok: true, pid: process.pid, instance_id: this.instanceId });
|
|
249
262
|
}
|
|
250
263
|
|
|
251
264
|
if (req.method !== "POST" || !url.pathname.startsWith("/rpc/")) {
|
|
@@ -285,8 +298,8 @@ export class MagicContextRpcServer {
|
|
|
285
298
|
}
|
|
286
299
|
}
|
|
287
300
|
|
|
288
|
-
/** WS message handler: hello (auth + sink registration + backlog
|
|
289
|
-
* 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. */
|
|
290
303
|
private handleWsMessage(ws: ServerWebSocket<WsData>, raw: string | Buffer): void {
|
|
291
304
|
let msg: {
|
|
292
305
|
type?: string;
|
|
@@ -295,6 +308,10 @@ export class MagicContextRpcServer {
|
|
|
295
308
|
lastReceivedId?: number;
|
|
296
309
|
globalLastReceivedId?: number;
|
|
297
310
|
ackScope?: string;
|
|
311
|
+
protocol?: number;
|
|
312
|
+
instanceId?: string;
|
|
313
|
+
ids?: unknown;
|
|
314
|
+
cursor?: number;
|
|
298
315
|
};
|
|
299
316
|
try {
|
|
300
317
|
msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
@@ -328,6 +345,7 @@ export class MagicContextRpcServer {
|
|
|
328
345
|
// Register a live sink so future pushes reach this socket immediately.
|
|
329
346
|
const sink: NotificationSink = {
|
|
330
347
|
sessionId: ws.data.sessionId,
|
|
348
|
+
protocol: msg.protocol,
|
|
331
349
|
send: (notification) => {
|
|
332
350
|
ws.send(JSON.stringify({ type: "notification", notification }));
|
|
333
351
|
},
|
|
@@ -335,40 +353,66 @@ export class MagicContextRpcServer {
|
|
|
335
353
|
ws.data.unregister = registerNotificationSink(sink);
|
|
336
354
|
this.sockets.add(ws);
|
|
337
355
|
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
:
|
|
359
|
-
|
|
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
|
+
}
|
|
360
399
|
for (const notification of backlog) {
|
|
361
400
|
ws.send(JSON.stringify({ type: "notification", notification }));
|
|
362
401
|
}
|
|
363
|
-
ws.send(JSON.stringify({ type: "hello-ack" }));
|
|
364
402
|
return;
|
|
365
403
|
}
|
|
366
404
|
|
|
367
405
|
if (msg.type === "ack") {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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);
|
|
372
416
|
if (Number.isFinite(lastReceivedId) && lastReceivedId > 0) {
|
|
373
417
|
if (msg.ackScope === "global") {
|
|
374
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
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/// <reference types="bun-types" />
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { runMigrations } from "../features/magic-context/migrations";
|
|
4
5
|
import type { ContextDatabase } from "../features/magic-context/storage";
|
|
5
|
-
import
|
|
6
|
+
import { initializeDatabase } from "../features/magic-context/storage-db";
|
|
7
|
+
import type { Tagger, ToolTagAccounting } from "../features/magic-context/tagger";
|
|
8
|
+
import { createTagger } from "../features/magic-context/tagger";
|
|
9
|
+
import { Database } from "./sqlite";
|
|
6
10
|
import { tagTranscript } from "./tag-transcript";
|
|
7
11
|
import type { Transcript, TranscriptPart, TranscriptPartKind } from "./transcript";
|
|
8
12
|
|
|
@@ -11,6 +15,7 @@ class FakeTagger implements Tagger {
|
|
|
11
15
|
private toolTags = new Map<string, number>();
|
|
12
16
|
readonly owners: string[] = [];
|
|
13
17
|
readonly byteSizes = new Map<number, number>();
|
|
18
|
+
private readonly toolAccounting = new Map<number, ToolTagAccounting>();
|
|
14
19
|
|
|
15
20
|
assignTag(): number {
|
|
16
21
|
return this.nextTag++;
|
|
@@ -40,6 +45,23 @@ class FakeTagger implements Tagger {
|
|
|
40
45
|
return this.toolTags.get(`${ownerMsgId}\0${callId}`);
|
|
41
46
|
}
|
|
42
47
|
|
|
48
|
+
getToolTagAccounting(
|
|
49
|
+
_sessionId: string,
|
|
50
|
+
callId: string,
|
|
51
|
+
ownerMsgId: string,
|
|
52
|
+
): ToolTagAccounting | undefined {
|
|
53
|
+
const tag = this.getToolTag(_sessionId, callId, ownerMsgId);
|
|
54
|
+
return tag === undefined ? undefined : this.toolAccounting.get(tag);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setToolTagAccounting(
|
|
58
|
+
_sessionId: string,
|
|
59
|
+
tagNumber: number,
|
|
60
|
+
accounting: ToolTagAccounting,
|
|
61
|
+
): void {
|
|
62
|
+
this.toolAccounting.set(tagNumber, accounting);
|
|
63
|
+
}
|
|
64
|
+
|
|
43
65
|
bindTag(): void {}
|
|
44
66
|
|
|
45
67
|
bindToolTag(_sessionId: string, callId: string, ownerMsgId: string, tagNumber: number): void {
|
|
@@ -123,18 +145,31 @@ class NonTextToolResultPart extends TestPart {
|
|
|
123
145
|
class FakeDb {
|
|
124
146
|
readonly byteSizeUpdates: Array<{ byteSize: number; sessionId: string; tagNumber: number }> =
|
|
125
147
|
[];
|
|
148
|
+
readonly tokenCountUpdates: Array<{
|
|
149
|
+
sessionId: string;
|
|
150
|
+
tagNumber: number;
|
|
151
|
+
tokenCount: number;
|
|
152
|
+
}> = [];
|
|
126
153
|
|
|
127
154
|
prepare(sql: string): { run: (...args: unknown[]) => void } {
|
|
128
155
|
return {
|
|
129
156
|
run: (...args: unknown[]) => {
|
|
130
|
-
|
|
131
|
-
const [byteSize, sessionId, tagNumber] = args;
|
|
157
|
+
const [value, sessionId, tagNumber] = args;
|
|
132
158
|
if (
|
|
133
|
-
typeof
|
|
134
|
-
typeof sessionId
|
|
135
|
-
typeof tagNumber
|
|
159
|
+
typeof value !== "number" ||
|
|
160
|
+
typeof sessionId !== "string" ||
|
|
161
|
+
typeof tagNumber !== "number"
|
|
136
162
|
) {
|
|
137
|
-
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (sql.startsWith("UPDATE tags SET byte_size =")) {
|
|
166
|
+
this.byteSizeUpdates.push({ byteSize: value, sessionId, tagNumber });
|
|
167
|
+
} else if (sql.startsWith("UPDATE tags SET token_count =")) {
|
|
168
|
+
this.tokenCountUpdates.push({
|
|
169
|
+
sessionId,
|
|
170
|
+
tagNumber,
|
|
171
|
+
tokenCount: value,
|
|
172
|
+
});
|
|
138
173
|
}
|
|
139
174
|
},
|
|
140
175
|
};
|
|
@@ -271,6 +306,217 @@ describe("tagTranscript tool aggregation", () => {
|
|
|
271
306
|
expect(result.getText()).toBe(`[dropped §${nearestTag}§]`);
|
|
272
307
|
});
|
|
273
308
|
|
|
309
|
+
it("reuses owner-scoped identities without collapsing duplicate callIds", () => {
|
|
310
|
+
const tagger = new FakeTagger();
|
|
311
|
+
const seedDb = new FakeDb();
|
|
312
|
+
const seedTranscript: Transcript = {
|
|
313
|
+
harness: "pi",
|
|
314
|
+
messages: [
|
|
315
|
+
{
|
|
316
|
+
info: { id: "assistant-old", role: "assistant" },
|
|
317
|
+
parts: [new TestPart("tool_use", "duplicate-call", '{"turn":1}')],
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
info: { id: "user-old", role: "user" },
|
|
321
|
+
parts: [new TestPart("tool_result", "duplicate-call", "old result")],
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
info: { id: "assistant-new", role: "assistant" },
|
|
325
|
+
parts: [new TestPart("tool_use", "duplicate-call", '{"turn":2}')],
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
info: { id: "user-new", role: "user" },
|
|
329
|
+
parts: [new TestPart("tool_result", "duplicate-call", "new result")],
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
commit() {},
|
|
333
|
+
};
|
|
334
|
+
tagTranscript(
|
|
335
|
+
"session-duplicate-reuse",
|
|
336
|
+
seedTranscript,
|
|
337
|
+
tagger,
|
|
338
|
+
seedDb as unknown as ContextDatabase,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const reusedParts = {
|
|
342
|
+
oldUse: new TestPart("tool_use", "duplicate-call", '{"turn":1}'),
|
|
343
|
+
oldResult: new TestPart("tool_result", "duplicate-call", "old result"),
|
|
344
|
+
newUse: new TestPart("tool_use", "duplicate-call", '{"turn":2}'),
|
|
345
|
+
newResult: new TestPart("tool_result", "duplicate-call", "new result"),
|
|
346
|
+
};
|
|
347
|
+
const reusedTranscript: Transcript = {
|
|
348
|
+
harness: "pi",
|
|
349
|
+
messages: [
|
|
350
|
+
{
|
|
351
|
+
info: { id: "assistant-old", role: "assistant" },
|
|
352
|
+
parts: [reusedParts.oldUse],
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
info: { id: "user-old", role: "user" },
|
|
356
|
+
parts: [reusedParts.oldResult],
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
info: { id: "assistant-new", role: "assistant" },
|
|
360
|
+
parts: [reusedParts.newUse],
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
info: { id: "user-new", role: "user" },
|
|
364
|
+
parts: [reusedParts.newResult],
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
commit() {},
|
|
368
|
+
};
|
|
369
|
+
const reuseDb = new FakeDb();
|
|
370
|
+
const reused = tagTranscript(
|
|
371
|
+
"session-duplicate-reuse",
|
|
372
|
+
reusedTranscript,
|
|
373
|
+
tagger,
|
|
374
|
+
reuseDb as unknown as ContextDatabase,
|
|
375
|
+
{
|
|
376
|
+
reuseMessageIds: new Set([
|
|
377
|
+
"assistant-old",
|
|
378
|
+
"user-old",
|
|
379
|
+
"assistant-new",
|
|
380
|
+
"user-new",
|
|
381
|
+
]),
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const oldTag = tagger.getToolTag(
|
|
386
|
+
"session-duplicate-reuse",
|
|
387
|
+
"duplicate-call",
|
|
388
|
+
"assistant-old",
|
|
389
|
+
);
|
|
390
|
+
const newTag = tagger.getToolTag(
|
|
391
|
+
"session-duplicate-reuse",
|
|
392
|
+
"duplicate-call",
|
|
393
|
+
"assistant-new",
|
|
394
|
+
);
|
|
395
|
+
expect(oldTag).toBeDefined();
|
|
396
|
+
expect(newTag).toBeDefined();
|
|
397
|
+
expect(oldTag).not.toBe(newTag);
|
|
398
|
+
expect(reused.targets.size).toBe(2);
|
|
399
|
+
expect(reusedParts.oldResult.getText()).toBe(`§${oldTag}§ old result`);
|
|
400
|
+
expect(reusedParts.newResult.getText()).toBe(`§${newTag}§ new result`);
|
|
401
|
+
expect(reuseDb.byteSizeUpdates).toEqual([]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("backfills a newly appended result after reusing its stable invocation", () => {
|
|
405
|
+
const tagger = new FakeTagger();
|
|
406
|
+
const callId = "late-result";
|
|
407
|
+
tagTranscript(
|
|
408
|
+
"session-late-result",
|
|
409
|
+
{
|
|
410
|
+
harness: "pi",
|
|
411
|
+
messages: [
|
|
412
|
+
{
|
|
413
|
+
info: { id: "assistant-owner", role: "assistant" },
|
|
414
|
+
parts: [new TestPart("tool_use", callId, '{"path":"a"}')],
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
commit() {},
|
|
418
|
+
},
|
|
419
|
+
tagger,
|
|
420
|
+
new FakeDb() as unknown as ContextDatabase,
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const result = new TestPart("tool_result", callId, "late output");
|
|
424
|
+
const db = new FakeDb();
|
|
425
|
+
tagTranscript(
|
|
426
|
+
"session-late-result",
|
|
427
|
+
{
|
|
428
|
+
harness: "pi",
|
|
429
|
+
messages: [
|
|
430
|
+
{
|
|
431
|
+
info: { id: "assistant-owner", role: "assistant" },
|
|
432
|
+
parts: [new TestPart("tool_use", callId, '{"path":"a"}')],
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
info: { id: "user-result", role: "user" },
|
|
436
|
+
parts: [result],
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
commit() {},
|
|
440
|
+
},
|
|
441
|
+
tagger,
|
|
442
|
+
db as unknown as ContextDatabase,
|
|
443
|
+
{ reuseMessageIds: new Set(["assistant-owner"]) },
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
const tag = tagger.getToolTag("session-late-result", callId, "assistant-owner");
|
|
447
|
+
expect(result.getText()).toBe(`§${tag}§ late output`);
|
|
448
|
+
expect(db.byteSizeUpdates.some((update) => update.tagNumber === tag)).toBe(true);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("persists the same grown folded-result accounting with reuse or full derivation", () => {
|
|
452
|
+
interface AccountingRow {
|
|
453
|
+
tagNumber: number;
|
|
454
|
+
byteSize: number;
|
|
455
|
+
tokenCount: number;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const runScenario = (reuse: boolean): { before: AccountingRow; after: AccountingRow } => {
|
|
459
|
+
const db = new Database(":memory:");
|
|
460
|
+
initializeDatabase(db);
|
|
461
|
+
runMigrations(db);
|
|
462
|
+
const sessionId = reuse ? "session-grown-reuse" : "session-grown-derive";
|
|
463
|
+
const tagger = createTagger();
|
|
464
|
+
tagger.initFromDb(sessionId, db);
|
|
465
|
+
const callId = "folded-growth";
|
|
466
|
+
const makeTranscript = (resultText: string): Transcript => ({
|
|
467
|
+
harness: "pi",
|
|
468
|
+
messages: [
|
|
469
|
+
{
|
|
470
|
+
info: { id: "assistant-owner", role: "assistant" },
|
|
471
|
+
parts: [new TestPart("tool_use", callId, '{"path":"a"}')],
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
// Pi folds the tool result into this following user entry, so
|
|
475
|
+
// reuse is decided from the user's stable id.
|
|
476
|
+
info: { id: "user-fold-target", role: "user" },
|
|
477
|
+
parts: [new TestPart("tool_result", callId, resultText)],
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
commit() {},
|
|
481
|
+
});
|
|
482
|
+
const readAccounting = (): AccountingRow => {
|
|
483
|
+
const row = db
|
|
484
|
+
.prepare(
|
|
485
|
+
"SELECT tag_number AS tagNumber, byte_size AS byteSize, token_count AS tokenCount FROM tags WHERE session_id = ? AND type = 'tool'",
|
|
486
|
+
)
|
|
487
|
+
.get(sessionId) as AccountingRow | null;
|
|
488
|
+
if (!row) throw new Error("missing tool accounting row");
|
|
489
|
+
return row;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
tagTranscript(sessionId, makeTranscript("small result"), tagger, db);
|
|
493
|
+
const before = readAccounting();
|
|
494
|
+
const nextPassTagger = createTagger();
|
|
495
|
+
nextPassTagger.initFromDb(sessionId, db);
|
|
496
|
+
tagTranscript(
|
|
497
|
+
sessionId,
|
|
498
|
+
makeTranscript("larger result ".repeat(80)),
|
|
499
|
+
nextPassTagger,
|
|
500
|
+
db,
|
|
501
|
+
{
|
|
502
|
+
reuseMessageIds: reuse
|
|
503
|
+
? new Set(["assistant-owner", "user-fold-target"])
|
|
504
|
+
: undefined,
|
|
505
|
+
},
|
|
506
|
+
);
|
|
507
|
+
const after = readAccounting();
|
|
508
|
+
db.close();
|
|
509
|
+
return { before, after };
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const reused = runScenario(true);
|
|
513
|
+
const derived = runScenario(false);
|
|
514
|
+
expect(reused.after.tagNumber).toBe(reused.before.tagNumber);
|
|
515
|
+
expect(reused.after.byteSize).toBeGreaterThan(reused.before.byteSize);
|
|
516
|
+
expect(reused.after.tokenCount).toBeGreaterThan(reused.before.tokenCount);
|
|
517
|
+
expect(reused.after).toEqual(derived.after);
|
|
518
|
+
});
|
|
519
|
+
|
|
274
520
|
it("accounts non-text tool_result content when ranking tool output byte size", () => {
|
|
275
521
|
const tagger = new FakeTagger();
|
|
276
522
|
const db = new FakeDb();
|