opencode-muse 0.1.1 → 0.2.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 +15 -4
- package/dist/model/language-model.js +24 -16
- package/dist/model/stream.d.ts +1 -1
- package/dist/model/stream.js +5 -3
- package/dist/msp/host.d.ts +3 -1
- package/dist/msp/host.js +22 -6
- package/dist/msp/notification.d.ts +9 -0
- package/dist/msp/notification.js +26 -0
- package/dist/msp/schema-diff.d.ts +73 -0
- package/dist/msp/schema-diff.js +339 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Add the published package to your project-level `opencode.jsonc` (or
|
|
|
16
16
|
```jsonc
|
|
17
17
|
{
|
|
18
18
|
"$schema": "https://opencode.ai/config.json",
|
|
19
|
-
"plugins": ["opencode-muse@0.
|
|
19
|
+
"plugins": ["opencode-muse@0.2.0"],
|
|
20
20
|
}
|
|
21
21
|
```
|
|
22
22
|
|
|
@@ -96,14 +96,25 @@ setup placeholder rather than advertising a model that will fail on use.
|
|
|
96
96
|
been tested, but recovery from a real Muse event gap has only been tested
|
|
97
97
|
with a simulated gap. See
|
|
98
98
|
[#23](https://github.com/pgquiles/opencode-muse/issues/23).
|
|
99
|
-
- **Muse CLI compatibility:** The plugin is pinned
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
- **Muse CLI compatibility:** The plugin is pinned to SDK 1.4.12 and speaks
|
|
100
|
+
envelope schema version 1. A newer CLI fingerprint starts with a recorded
|
|
101
|
+
warning instead of failing closed; a missing/malformed fingerprint or an
|
|
102
|
+
unsupported envelope version still fails closed. See
|
|
102
103
|
[#21](https://github.com/pgquiles/opencode-muse/issues/21).
|
|
103
104
|
- **Billing and simultaneous work:** Pay-as-you-go behavior has not yet been
|
|
104
105
|
tested. The plugin also allows only one active writer per workspace, so a
|
|
105
106
|
new turn may be reported as busy while another is still finishing.
|
|
106
107
|
|
|
108
|
+
## Troubleshooting
|
|
109
|
+
|
|
110
|
+
- **"Muse Spark 1.3 Contributor" never connects after a model switch:** that
|
|
111
|
+
entry is a retired OpenCode-gateway model (`opencode/muse-spark-1.3-contributor-free`),
|
|
112
|
+
not this plugin's model. It no longer resolves anywhere, so OpenCode keeps
|
|
113
|
+
retrying and the session appears stuck on "connecting". Pick the model under
|
|
114
|
+
provider **Muse Code** — `muse/muse-profile-tbh-model-muse-spark-1.3` —
|
|
115
|
+
instead of any historical "contributor-free" entry in recent-model lists. See
|
|
116
|
+
[#26](https://github.com/pgquiles/opencode-muse/issues/26).
|
|
117
|
+
|
|
107
118
|
## Credits and references
|
|
108
119
|
|
|
109
120
|
- [opencode-claude](https://github.com/openchamber/opencode-claude) — a
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { MspCommandRejectedError, MuseBridgeError, MuseTranscriptDivergedError, MuseUnsupportedInputError, MuseUnsupportedRequestError, MspProtocolError, MspTransportError } from "../errors.js";
|
|
3
|
+
import { notificationCursorKey } from "../msp/notification.js";
|
|
3
4
|
import { MUSE_CONTEXT_HEADERS, readRequestContext } from "./context.js";
|
|
4
5
|
import { classifyMetaRequest, makeCompactionDigest, makeLocalTitle } from "./meta.js";
|
|
5
6
|
import { promptContainsCompactionFingerprint } from "../session/fingerprint.js";
|
|
@@ -93,7 +94,7 @@ function eventTurnId(notification, itemTurnIds) {
|
|
|
93
94
|
async function* pageGapNotifications(host, sessionId, gap) {
|
|
94
95
|
let cursor = gap.after;
|
|
95
96
|
const requestedCursors = new Set([gap.after]);
|
|
96
|
-
const
|
|
97
|
+
const eventKeys = new Set();
|
|
97
98
|
for (let pageNumber = 0; pageNumber < 100; pageNumber += 1) {
|
|
98
99
|
const requestCursor = cursor;
|
|
99
100
|
const page = await host.request("view/page", {
|
|
@@ -104,6 +105,7 @@ async function* pageGapNotifications(host, sessionId, gap) {
|
|
|
104
105
|
});
|
|
105
106
|
if (!Array.isArray(page.events))
|
|
106
107
|
throw new MspProtocolError("view/page");
|
|
108
|
+
let boundaryReached = false;
|
|
107
109
|
for (const event of page.events) {
|
|
108
110
|
if (typeof event !== "object" || event === null)
|
|
109
111
|
throw new MspProtocolError("view/page");
|
|
@@ -112,14 +114,20 @@ async function* pageGapNotifications(host, sessionId, gap) {
|
|
|
112
114
|
if (typeof record.method !== "string" || !params || params.sessionId !== sessionId || typeof params.viewCursor !== "string") {
|
|
113
115
|
throw new MspProtocolError("view/page");
|
|
114
116
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
throw new MspProtocolError("view/page");
|
|
119
|
-
eventCursors.add(params.viewCursor);
|
|
117
|
+
const atBoundary = params.viewCursor === gap.next;
|
|
118
|
+
if (atBoundary)
|
|
119
|
+
boundaryReached = true;
|
|
120
120
|
cursor = params.viewCursor;
|
|
121
|
-
|
|
121
|
+
const notification = { jsonrpc: "2.0", method: record.method, params };
|
|
122
|
+
const key = notificationCursorKey(notification);
|
|
123
|
+
if (key && eventKeys.has(key))
|
|
124
|
+
throw new MspProtocolError("view/page");
|
|
125
|
+
if (key)
|
|
126
|
+
eventKeys.add(key);
|
|
127
|
+
yield notification;
|
|
122
128
|
}
|
|
129
|
+
if (boundaryReached)
|
|
130
|
+
return;
|
|
123
131
|
if (page.nextCursor === null || typeof page.nextCursor !== "string"
|
|
124
132
|
|| page.nextCursor === requestCursor || requestedCursors.has(page.nextCursor)) {
|
|
125
133
|
throw new MspProtocolError("view/page");
|
|
@@ -196,7 +204,7 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
|
|
|
196
204
|
const events = {
|
|
197
205
|
[Symbol.asyncIterator]: async function* () {
|
|
198
206
|
const itemTurnIds = new Map();
|
|
199
|
-
const
|
|
207
|
+
const seenNotifications = new Set();
|
|
200
208
|
const processNotification = async function* (notification) {
|
|
201
209
|
const params = notification.params ?? {};
|
|
202
210
|
if (params.sessionId !== session.sessionId)
|
|
@@ -227,11 +235,11 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
|
|
|
227
235
|
if (typeof params.after !== "string" || typeof params.next !== "string")
|
|
228
236
|
throw new MspProtocolError("view/gap");
|
|
229
237
|
for await (const replay of pageGapNotifications(session.host, session.sessionId, { after: params.after, next: params.next })) {
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
238
|
+
const replayKey = notificationCursorKey(replay);
|
|
239
|
+
if (replayKey && seenNotifications.has(replayKey))
|
|
232
240
|
continue;
|
|
233
|
-
if (
|
|
234
|
-
|
|
241
|
+
if (replayKey)
|
|
242
|
+
seenNotifications.add(replayKey);
|
|
235
243
|
for await (const output of processNotification(replay))
|
|
236
244
|
yield output;
|
|
237
245
|
if (terminalSeen)
|
|
@@ -239,13 +247,13 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
|
|
|
239
247
|
}
|
|
240
248
|
}
|
|
241
249
|
else {
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
250
|
+
const notificationKey = notificationCursorKey(notification);
|
|
251
|
+
if (notificationKey && seenNotifications.has(notificationKey)) {
|
|
244
252
|
next = await subscription.next();
|
|
245
253
|
continue;
|
|
246
254
|
}
|
|
247
|
-
if (
|
|
248
|
-
|
|
255
|
+
if (notificationKey)
|
|
256
|
+
seenNotifications.add(notificationKey);
|
|
249
257
|
for await (const output of processNotification(notification))
|
|
250
258
|
yield output;
|
|
251
259
|
if (terminalSeen)
|
package/dist/model/stream.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export declare class MuseStreamMapper {
|
|
|
14
14
|
private readonly reasoningStarted;
|
|
15
15
|
private readonly itemDeltaCounts;
|
|
16
16
|
private readonly itemText;
|
|
17
|
-
private readonly
|
|
17
|
+
private readonly seenUsageNotifications;
|
|
18
18
|
private readonly textParts;
|
|
19
19
|
private terminalUsage;
|
|
20
20
|
private callUsage;
|
package/dist/model/stream.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MspProtocolError, MuseTurnFailedError, MuseTurnUnqueuedError, } from "../errors.js";
|
|
2
|
+
import { notificationCursorKey } from "../msp/notification.js";
|
|
2
3
|
function isNumber(value) {
|
|
3
4
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
4
5
|
}
|
|
@@ -17,7 +18,7 @@ export class MuseStreamMapper {
|
|
|
17
18
|
reasoningStarted = new Set();
|
|
18
19
|
itemDeltaCounts = new Map();
|
|
19
20
|
itemText = new Map();
|
|
20
|
-
|
|
21
|
+
seenUsageNotifications = new Set();
|
|
21
22
|
textParts = [];
|
|
22
23
|
terminalUsage;
|
|
23
24
|
callUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, reasoningTokens: 0 };
|
|
@@ -168,9 +169,10 @@ export class MuseStreamMapper {
|
|
|
168
169
|
tokenUsageEvent(params) {
|
|
169
170
|
if (params.turnId !== this.turnId || typeof params.viewCursor !== "string")
|
|
170
171
|
return;
|
|
171
|
-
|
|
172
|
+
const key = notificationCursorKey({ method: "session/tokenUsage", params });
|
|
173
|
+
if (!key || this.seenUsageNotifications.has(key))
|
|
172
174
|
return;
|
|
173
|
-
this.
|
|
175
|
+
this.seenUsageNotifications.add(key);
|
|
174
176
|
const raw = params.usage;
|
|
175
177
|
if (!raw)
|
|
176
178
|
return;
|
package/dist/msp/host.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type MspHandshake, type SpawnMspConnectionOptions } from "@muse-code/sdk";
|
|
1
|
+
import { type FingerprintWarning, type MspHandshake, type SpawnMspConnectionOptions } from "@muse-code/sdk";
|
|
2
2
|
import { MspClient, type MspConnectionLike, type MspNotification } from "./client.js";
|
|
3
3
|
export interface MspHostRuntime {
|
|
4
4
|
connection: MspConnectionLike;
|
|
@@ -39,6 +39,8 @@ export declare class MspHost {
|
|
|
39
39
|
private readonly runtime;
|
|
40
40
|
readonly client: MspClient;
|
|
41
41
|
readonly initializeResult: Record<string, unknown>;
|
|
42
|
+
/** Set when the served schema fingerprint differs from the SDK pin. Warns, never blocks. */
|
|
43
|
+
readonly fingerprintWarning?: FingerprintWarning;
|
|
42
44
|
readonly exited: Promise<MspHostExit>;
|
|
43
45
|
private state;
|
|
44
46
|
private closePromise;
|
package/dist/msp/host.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MspError, EXPECTED_SCHEMA_FINGERPRINT, MuseHostStartError as SdkHostStartError, spawnMspConnection, } from "@muse-code/sdk";
|
|
1
|
+
import { MspError, checkServedFingerprint, EXPECTED_SCHEMA_FINGERPRINT, MuseHostStartError as SdkHostStartError, spawnMspConnection, } from "@muse-code/sdk";
|
|
2
2
|
import { MspCommandTimeoutError, MspHostClosedError, MspHostStartError, MspInitializeRejectedError, MspSchemaMismatchError, MspStartupTimeoutError, MspTransportError, } from "../errors.js";
|
|
3
3
|
import { MspClient } from "./client.js";
|
|
4
4
|
function defaultHandshake(options) {
|
|
@@ -32,6 +32,17 @@ function sanitizeExit(value) {
|
|
|
32
32
|
// Deliberately omit stderrTail, raw messages, and arbitrary child properties.
|
|
33
33
|
return safe;
|
|
34
34
|
}
|
|
35
|
+
const SCHEMA_FINGERPRINT_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
36
|
+
/** Envelope schema versions this plugin speaks. The SDK documents version 1 as long-lived. */
|
|
37
|
+
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
38
|
+
function readSchemaInfo(value) {
|
|
39
|
+
if (typeof value !== "object" || value === null)
|
|
40
|
+
return undefined;
|
|
41
|
+
const record = value;
|
|
42
|
+
if (typeof record.fingerprint !== "string")
|
|
43
|
+
return undefined;
|
|
44
|
+
return { fingerprint: record.fingerprint, version: record.version };
|
|
45
|
+
}
|
|
35
46
|
function withTimeout(promise, timeoutMs) {
|
|
36
47
|
let timer;
|
|
37
48
|
return Promise.race([
|
|
@@ -57,12 +68,15 @@ export class MspHost {
|
|
|
57
68
|
runtime;
|
|
58
69
|
client;
|
|
59
70
|
initializeResult;
|
|
71
|
+
/** Set when the served schema fingerprint differs from the SDK pin. Warns, never blocks. */
|
|
72
|
+
fingerprintWarning;
|
|
60
73
|
exited;
|
|
61
74
|
state = "open";
|
|
62
75
|
closePromise;
|
|
63
|
-
constructor(runtime, options) {
|
|
76
|
+
constructor(runtime, options, fingerprintWarning) {
|
|
64
77
|
this.runtime = runtime;
|
|
65
78
|
this.initializeResult = runtime.initializeResult;
|
|
79
|
+
this.fingerprintWarning = fingerprintWarning;
|
|
66
80
|
this.client = new MspClient(runtime.connection, {
|
|
67
81
|
commandTimeoutMs: options.commandTimeoutMs,
|
|
68
82
|
requestTimeoutMs: options.requestTimeoutMs,
|
|
@@ -107,13 +121,15 @@ export class MspHost {
|
|
|
107
121
|
const initializing = handshake.initialize(initializeParams);
|
|
108
122
|
try {
|
|
109
123
|
const runtime = await withTimeout(initializing, timeoutMs);
|
|
110
|
-
const schema = runtime.initializeResult.schema;
|
|
111
|
-
const servedFingerprint =
|
|
112
|
-
if (
|
|
124
|
+
const schema = readSchemaInfo(runtime.initializeResult.schema);
|
|
125
|
+
const servedFingerprint = schema?.fingerprint ?? "missing";
|
|
126
|
+
if (schema === undefined ||
|
|
127
|
+
!SCHEMA_FINGERPRINT_PATTERN.test(schema.fingerprint) ||
|
|
128
|
+
(schema.version !== undefined && schema.version !== SUPPORTED_SCHEMA_VERSION)) {
|
|
113
129
|
await closeQuietly(runtime.close.bind(runtime));
|
|
114
130
|
throw new MspSchemaMismatchError(EXPECTED_SCHEMA_FINGERPRINT, servedFingerprint);
|
|
115
131
|
}
|
|
116
|
-
return new MspHost(runtime, options);
|
|
132
|
+
return new MspHost(runtime, options, checkServedFingerprint(schema.fingerprint));
|
|
117
133
|
}
|
|
118
134
|
catch (error) {
|
|
119
135
|
if (error instanceof MspSchemaMismatchError)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable identity for exact notification replay suppression.
|
|
3
|
+
* A view cursor may be shared by multiple distinct notification methods or
|
|
4
|
+
* payloads, so the cursor alone is not an event identity.
|
|
5
|
+
*/
|
|
6
|
+
export declare function notificationCursorKey(notification: {
|
|
7
|
+
method: string;
|
|
8
|
+
params?: Record<string, unknown>;
|
|
9
|
+
}): string | undefined;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
function stableJson(value) {
|
|
3
|
+
if (value === null || typeof value !== "object")
|
|
4
|
+
return JSON.stringify(value) ?? "null";
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
7
|
+
const entries = Object.entries(value)
|
|
8
|
+
.filter(([, child]) => child !== undefined)
|
|
9
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
|
10
|
+
return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`).join(",")}}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Stable identity for exact notification replay suppression.
|
|
14
|
+
* A view cursor may be shared by multiple distinct notification methods or
|
|
15
|
+
* payloads, so the cursor alone is not an event identity.
|
|
16
|
+
*/
|
|
17
|
+
export function notificationCursorKey(notification) {
|
|
18
|
+
const params = notification.params ?? {};
|
|
19
|
+
const cursor = params.viewCursor;
|
|
20
|
+
if (typeof cursor !== "string")
|
|
21
|
+
return undefined;
|
|
22
|
+
const digest = createHash("sha256")
|
|
23
|
+
.update(stableJson({ method: notification.method, params }))
|
|
24
|
+
.digest("hex");
|
|
25
|
+
return `${cursor}:${digest}`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline schema-evolution check for policy #21.
|
|
3
|
+
*
|
|
4
|
+
* Compares MSP stable-schema snapshots and classifies the delta on the
|
|
5
|
+
* protocol surfaces this plugin actually uses. Additive growth passes with
|
|
6
|
+
* notes; removals, new required fields, dropped properties, or envelope
|
|
7
|
+
* version bumps on the curated surface fail closed for human review.
|
|
8
|
+
*
|
|
9
|
+
* Failing closed on uncertainty is deliberate: a false positive costs a
|
|
10
|
+
* human classification pass, while a false negative ships against a
|
|
11
|
+
* contract the bridge was never tested with.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SCHEMA_SECTIONS: readonly ["methods", "requests", "notifications", "errors", "capabilities"];
|
|
14
|
+
export type SchemaSection = (typeof SCHEMA_SECTIONS)[number];
|
|
15
|
+
/** Methods the bridge sends. Each name verified at a call site in src (manager, provider, language-model). */
|
|
16
|
+
export declare const CURATED_METHODS: readonly string[];
|
|
17
|
+
/** Server-to-client requests the bridge explicitly fails closed on (no fabricated answers). */
|
|
18
|
+
export declare const CURATED_REQUESTS: readonly string[];
|
|
19
|
+
/** Notifications the bridge consumes in src/model/stream.ts and src/model/language-model.ts. */
|
|
20
|
+
export declare const CURATED_NOTIFICATIONS: readonly string[];
|
|
21
|
+
export interface SchemaExportManifest {
|
|
22
|
+
fingerprint: string;
|
|
23
|
+
schemaVersion: number;
|
|
24
|
+
experimental: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface SchemaExport {
|
|
27
|
+
manifest: SchemaExportManifest;
|
|
28
|
+
schema: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
export interface DefRecord {
|
|
31
|
+
digest: string;
|
|
32
|
+
required: string[] | null;
|
|
33
|
+
props: string[] | null;
|
|
34
|
+
}
|
|
35
|
+
export interface SchemaSnapshot {
|
|
36
|
+
cli_version: string;
|
|
37
|
+
fingerprint: string;
|
|
38
|
+
schema_version: number | null;
|
|
39
|
+
experimental?: boolean;
|
|
40
|
+
provenance?: string;
|
|
41
|
+
surface: Record<SchemaSection, string[] | null>;
|
|
42
|
+
member_digests?: Record<string, Record<string, string>>;
|
|
43
|
+
defs?: Record<string, DefRecord>;
|
|
44
|
+
/** Legacy v1 field: whole-section $defs digest only. Superseded by defs. */
|
|
45
|
+
defs_digests?: Record<string, string>;
|
|
46
|
+
sections_digest: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
export type FindingSeverity = "breaking" | "note";
|
|
49
|
+
export interface DiffFinding {
|
|
50
|
+
severity: FindingSeverity;
|
|
51
|
+
kind: string;
|
|
52
|
+
detail: string;
|
|
53
|
+
}
|
|
54
|
+
export interface Classification {
|
|
55
|
+
ok: boolean;
|
|
56
|
+
findings: DiffFinding[];
|
|
57
|
+
}
|
|
58
|
+
/** Builds the ledger snapshot for one offline CLI schema export. */
|
|
59
|
+
export declare function snapshotExport(cliVersion: string, provenance: string, exp: SchemaExport): SchemaSnapshot;
|
|
60
|
+
/** Collects `#/$defs/<Name>` targets reachable from roots over new-side bodies. */
|
|
61
|
+
export declare function collectRefs(roots: Record<string, unknown>, defs: Record<string, unknown>): Set<string>;
|
|
62
|
+
export interface ClassifyOptions {
|
|
63
|
+
/**
|
|
64
|
+
* New-side bodies for reachability attribution: { members: name->body, defs: name->body }.
|
|
65
|
+
* When absent (snapshot-vs-snapshot), changed/removed defs fall back to conservative rules.
|
|
66
|
+
*/
|
|
67
|
+
newBodies?: {
|
|
68
|
+
members: Record<string, unknown>;
|
|
69
|
+
defs: Record<string, unknown>;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Classifies old-vs-new snapshots. Breaking findings fail closed for human review. */
|
|
73
|
+
export declare function classifySnapshots(oldSnap: SchemaSnapshot, newSnap: SchemaSnapshot, options?: ClassifyOptions): Classification;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Offline schema-evolution check for policy #21.
|
|
4
|
+
*
|
|
5
|
+
* Compares MSP stable-schema snapshots and classifies the delta on the
|
|
6
|
+
* protocol surfaces this plugin actually uses. Additive growth passes with
|
|
7
|
+
* notes; removals, new required fields, dropped properties, or envelope
|
|
8
|
+
* version bumps on the curated surface fail closed for human review.
|
|
9
|
+
*
|
|
10
|
+
* Failing closed on uncertainty is deliberate: a false positive costs a
|
|
11
|
+
* human classification pass, while a false negative ships against a
|
|
12
|
+
* contract the bridge was never tested with.
|
|
13
|
+
*/
|
|
14
|
+
export const SCHEMA_SECTIONS = ["methods", "requests", "notifications", "errors", "capabilities"];
|
|
15
|
+
/** Methods the bridge sends. Each name verified at a call site in src (manager, provider, language-model). */
|
|
16
|
+
export const CURATED_METHODS = [
|
|
17
|
+
"model/list", // src/provider.ts catalog fetch
|
|
18
|
+
"session/start", // src/session/manager.ts resolveSession
|
|
19
|
+
"session/resume", // src/session/manager.ts restart recovery
|
|
20
|
+
"session/setModel", // src/session/manager.ts + src/model/language-model.ts selection routing
|
|
21
|
+
"turn/start", // src/model/language-model.ts agent turns
|
|
22
|
+
"turn/interrupt", // src/model/language-model.ts abort path
|
|
23
|
+
"turn/unqueue", // src/model/language-model.ts abort path
|
|
24
|
+
"media/upload", // src/model/language-model.ts video uploads
|
|
25
|
+
"view/subscribe", // src/msp/host.ts cursor replay via MspClient
|
|
26
|
+
"view/page", // src/model/language-model.ts view/gap recovery
|
|
27
|
+
];
|
|
28
|
+
/** Server-to-client requests the bridge explicitly fails closed on (no fabricated answers). */
|
|
29
|
+
export const CURATED_REQUESTS = [
|
|
30
|
+
"approval/request", // src/msp/host.ts answers method-not-found
|
|
31
|
+
"userInput/request", // src/msp/host.ts answers method-not-found
|
|
32
|
+
];
|
|
33
|
+
/** Notifications the bridge consumes in src/model/stream.ts and src/model/language-model.ts. */
|
|
34
|
+
export const CURATED_NOTIFICATIONS = [
|
|
35
|
+
"item/started",
|
|
36
|
+
"item/delta",
|
|
37
|
+
"item/completed",
|
|
38
|
+
"turn/completed",
|
|
39
|
+
"turn/foregroundCompleted",
|
|
40
|
+
"turn/retracted",
|
|
41
|
+
"turn/unqueued",
|
|
42
|
+
"session/tokenUsage",
|
|
43
|
+
"view/gap",
|
|
44
|
+
];
|
|
45
|
+
function sortKeys(value) {
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return value.map(sortKeys);
|
|
48
|
+
if (value !== null && typeof value === "object") {
|
|
49
|
+
return Object.fromEntries(Object.entries(value)
|
|
50
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
51
|
+
.map(([k, v]) => [k, sortKeys(v)]));
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function sha256Hex(canonicalJson) {
|
|
56
|
+
return createHash("sha256").update(canonicalJson, "utf8").digest("hex");
|
|
57
|
+
}
|
|
58
|
+
function canonical(value) {
|
|
59
|
+
return JSON.stringify(sortKeys(value));
|
|
60
|
+
}
|
|
61
|
+
function stringArray(value) {
|
|
62
|
+
if (!Array.isArray(value))
|
|
63
|
+
return null;
|
|
64
|
+
if (!value.every((v) => typeof v === "string"))
|
|
65
|
+
return null;
|
|
66
|
+
return [...value].sort();
|
|
67
|
+
}
|
|
68
|
+
/** Stable name for one section member, or null when it cannot be named. */
|
|
69
|
+
function memberName(section, entry) {
|
|
70
|
+
if (typeof entry === "string")
|
|
71
|
+
return entry;
|
|
72
|
+
if (entry !== null && typeof entry === "object") {
|
|
73
|
+
const record = entry;
|
|
74
|
+
if (section === "errors" && typeof record.kind === "string")
|
|
75
|
+
return record.kind;
|
|
76
|
+
if (typeof record.name === "string")
|
|
77
|
+
return record.name;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
function sectionEntries(section, schema) {
|
|
82
|
+
const raw = schema[section];
|
|
83
|
+
const entries = [];
|
|
84
|
+
if (Array.isArray(raw)) {
|
|
85
|
+
for (const entry of raw) {
|
|
86
|
+
const name = memberName(section, entry);
|
|
87
|
+
if (name === null)
|
|
88
|
+
return null;
|
|
89
|
+
entries.push({ name, digest: sha256Hex(canonical(entry)) });
|
|
90
|
+
}
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
if (raw !== null && typeof raw === "object") {
|
|
94
|
+
if (section === "capabilities") {
|
|
95
|
+
const record = raw;
|
|
96
|
+
const grantable = record.grantable;
|
|
97
|
+
if (!Array.isArray(grantable))
|
|
98
|
+
return null;
|
|
99
|
+
for (const entry of grantable) {
|
|
100
|
+
const name = memberName(section, entry);
|
|
101
|
+
if (name === null)
|
|
102
|
+
return null;
|
|
103
|
+
entries.push({ name, digest: sha256Hex(canonical(entry)) });
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
for (const [name, body] of Object.entries(raw)) {
|
|
108
|
+
entries.push({ name, digest: sha256Hex(canonical(body)) });
|
|
109
|
+
}
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
function defRecord(body) {
|
|
115
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
116
|
+
return null;
|
|
117
|
+
const record = body;
|
|
118
|
+
const properties = record.properties;
|
|
119
|
+
return {
|
|
120
|
+
digest: sha256Hex(canonical(body)),
|
|
121
|
+
required: stringArray(record.required),
|
|
122
|
+
props: properties !== null && typeof properties === "object" && !Array.isArray(properties)
|
|
123
|
+
? Object.keys(properties).sort()
|
|
124
|
+
: null,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Builds the ledger snapshot for one offline CLI schema export. */
|
|
128
|
+
export function snapshotExport(cliVersion, provenance, exp) {
|
|
129
|
+
if (typeof exp?.manifest?.fingerprint !== "string" || typeof exp?.manifest?.schemaVersion !== "number") {
|
|
130
|
+
throw new Error("schema-diff: export manifest must carry a fingerprint string and numeric schemaVersion");
|
|
131
|
+
}
|
|
132
|
+
if (exp.schema === null || typeof exp.schema !== "object" || Array.isArray(exp.schema)) {
|
|
133
|
+
throw new Error("schema-diff: export schema must be an object");
|
|
134
|
+
}
|
|
135
|
+
const surface = {};
|
|
136
|
+
const member_digests = {};
|
|
137
|
+
const sections_digest = {};
|
|
138
|
+
for (const section of SCHEMA_SECTIONS) {
|
|
139
|
+
const entries = sectionEntries(section, exp.schema);
|
|
140
|
+
surface[section] = entries === null ? null : entries.map((e) => e.name).sort();
|
|
141
|
+
if (entries !== null && (section === "methods" || section === "requests" || section === "notifications")) {
|
|
142
|
+
member_digests[section] = Object.fromEntries(entries.map((e) => [e.name, e.digest]));
|
|
143
|
+
}
|
|
144
|
+
sections_digest[section] = sha256Hex(canonical(exp.schema[section] ?? null));
|
|
145
|
+
}
|
|
146
|
+
const rawDefs = exp.schema.$defs;
|
|
147
|
+
if (rawDefs === null || typeof rawDefs !== "object" || Array.isArray(rawDefs)) {
|
|
148
|
+
throw new Error("schema-diff: export schema must carry a $defs object");
|
|
149
|
+
}
|
|
150
|
+
const defs = {};
|
|
151
|
+
for (const [name, body] of Object.entries(rawDefs)) {
|
|
152
|
+
const record = defRecord(body);
|
|
153
|
+
if (record === null)
|
|
154
|
+
throw new Error(`schema-diff: $defs member is not an object: ${name}`);
|
|
155
|
+
defs[name] = record;
|
|
156
|
+
}
|
|
157
|
+
sections_digest.$defs = sha256Hex(canonical(exp.schema.$defs));
|
|
158
|
+
return {
|
|
159
|
+
cli_version: cliVersion,
|
|
160
|
+
fingerprint: exp.manifest.fingerprint,
|
|
161
|
+
schema_version: exp.manifest.schemaVersion,
|
|
162
|
+
experimental: exp.manifest.experimental,
|
|
163
|
+
provenance,
|
|
164
|
+
surface,
|
|
165
|
+
member_digests,
|
|
166
|
+
defs,
|
|
167
|
+
sections_digest,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** Collects `#/$defs/<Name>` targets reachable from roots over new-side bodies. */
|
|
171
|
+
export function collectRefs(roots, defs) {
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
const visit = (value) => {
|
|
174
|
+
if (typeof value === "string") {
|
|
175
|
+
const match = /^#\/\$defs\/([A-Za-z0-9_]+)$/.exec(value);
|
|
176
|
+
if (match && !seen.has(match[1])) {
|
|
177
|
+
seen.add(match[1]);
|
|
178
|
+
const body = defs[match[1]];
|
|
179
|
+
if (body !== undefined)
|
|
180
|
+
visit(body);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(value)) {
|
|
185
|
+
for (const v of value)
|
|
186
|
+
visit(v);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (value !== null && typeof value === "object") {
|
|
190
|
+
for (const v of Object.values(value))
|
|
191
|
+
visit(v);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
for (const root of Object.values(roots))
|
|
195
|
+
visit(root);
|
|
196
|
+
return seen;
|
|
197
|
+
}
|
|
198
|
+
function curatedFor(section) {
|
|
199
|
+
if (section === "methods")
|
|
200
|
+
return new Set(CURATED_METHODS);
|
|
201
|
+
if (section === "requests")
|
|
202
|
+
return new Set(CURATED_REQUESTS);
|
|
203
|
+
if (section === "notifications")
|
|
204
|
+
return new Set(CURATED_NOTIFICATIONS);
|
|
205
|
+
return new Set();
|
|
206
|
+
}
|
|
207
|
+
function requireSurface(snap, which) {
|
|
208
|
+
if (snap === null || typeof snap !== "object")
|
|
209
|
+
throw new Error(`schema-diff: ${which} snapshot is not an object`);
|
|
210
|
+
const surface = snap.surface;
|
|
211
|
+
if (surface === null || typeof surface !== "object" || Array.isArray(surface)) {
|
|
212
|
+
throw new Error(`schema-diff: ${which} snapshot has no surface record`);
|
|
213
|
+
}
|
|
214
|
+
for (const section of SCHEMA_SECTIONS) {
|
|
215
|
+
const value = surface[section];
|
|
216
|
+
if (!Array.isArray(value) && value !== null) {
|
|
217
|
+
throw new Error(`schema-diff: ${which} snapshot surface.${section} is malformed`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Classifies old-vs-new snapshots. Breaking findings fail closed for human review. */
|
|
222
|
+
export function classifySnapshots(oldSnap, newSnap, options = {}) {
|
|
223
|
+
requireSurface(oldSnap, "old");
|
|
224
|
+
requireSurface(newSnap, "new");
|
|
225
|
+
const findings = [];
|
|
226
|
+
if (oldSnap.schema_version === null || oldSnap.schema_version === undefined ||
|
|
227
|
+
newSnap.schema_version === null || newSnap.schema_version === undefined) {
|
|
228
|
+
throw new Error("schema-diff: both snapshots must carry a schema_version");
|
|
229
|
+
}
|
|
230
|
+
if (oldSnap.schema_version !== newSnap.schema_version) {
|
|
231
|
+
findings.push({
|
|
232
|
+
severity: "breaking",
|
|
233
|
+
kind: "envelope-version-changed",
|
|
234
|
+
detail: `envelope schema version ${String(oldSnap.schema_version)} -> ${String(newSnap.schema_version)}`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const oldMembers = oldSnap.member_digests;
|
|
238
|
+
const newMembers = newSnap.member_digests;
|
|
239
|
+
const memberLevel = oldMembers !== undefined && oldMembers !== null && newMembers !== undefined && newMembers !== null;
|
|
240
|
+
if (!memberLevel) {
|
|
241
|
+
findings.push({
|
|
242
|
+
severity: "note",
|
|
243
|
+
kind: "member-level-unavailable",
|
|
244
|
+
detail: "member-level digests absent on one side; falling back to set-level comparison",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
for (const section of SCHEMA_SECTIONS) {
|
|
248
|
+
const oldNames = new Set(oldSnap.surface[section] ?? []);
|
|
249
|
+
const newNames = new Set(newSnap.surface[section] ?? []);
|
|
250
|
+
const curated = curatedFor(section);
|
|
251
|
+
for (const name of [...newNames].filter((n) => !oldNames.has(n)).sort()) {
|
|
252
|
+
findings.push({ severity: "note", kind: `${section}-added`, detail: `${section} added: ${name}` });
|
|
253
|
+
}
|
|
254
|
+
for (const name of [...oldNames].filter((n) => !newNames.has(n)).sort()) {
|
|
255
|
+
if (curated.has(name)) {
|
|
256
|
+
findings.push({ severity: "breaking", kind: `${section}-removed`, detail: `${section} removed curated member: ${name}` });
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
findings.push({ severity: "note", kind: `${section}-removed`, detail: `${section} removed non-curated member: ${name}` });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (memberLevel && (section === "methods" || section === "requests" || section === "notifications")) {
|
|
263
|
+
const oldMap = oldMembers?.[section] ?? {};
|
|
264
|
+
const newMap = newMembers?.[section] ?? {};
|
|
265
|
+
for (const name of [...oldNames].filter((n) => newNames.has(n)).sort()) {
|
|
266
|
+
if (oldMap[name] !== newMap[name]) {
|
|
267
|
+
if (curated.has(name)) {
|
|
268
|
+
findings.push({ severity: "breaking", kind: `${section}-changed`, detail: `${section} changed curated member shape: ${name}` });
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
findings.push({ severity: "note", kind: `${section}-changed`, detail: `${section} changed non-curated member shape: ${name}` });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Reachability over new-side bodies scopes nested def changes to what the bridge can observe.
|
|
278
|
+
let reachable = null;
|
|
279
|
+
if (options.newBodies) {
|
|
280
|
+
const roots = {};
|
|
281
|
+
for (const name of [...CURATED_METHODS, ...CURATED_REQUESTS, ...CURATED_NOTIFICATIONS]) {
|
|
282
|
+
if (options.newBodies.members[name] !== undefined)
|
|
283
|
+
roots[name] = options.newBodies.members[name];
|
|
284
|
+
}
|
|
285
|
+
reachable = collectRefs(roots, options.newBodies.defs);
|
|
286
|
+
}
|
|
287
|
+
const oldDefs = oldSnap.defs ?? {};
|
|
288
|
+
const newDefs = newSnap.defs ?? {};
|
|
289
|
+
const defsDetailed = oldSnap.defs !== undefined && newSnap.defs !== undefined;
|
|
290
|
+
if (!defsDetailed) {
|
|
291
|
+
findings.push({
|
|
292
|
+
severity: "note",
|
|
293
|
+
kind: "defs-unavailable",
|
|
294
|
+
detail: "per-def records absent on one side; nested changes covered only by member shapes where available",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
const names = new Set([...Object.keys(oldDefs), ...Object.keys(newDefs)]);
|
|
299
|
+
for (const name of [...names].sort()) {
|
|
300
|
+
const oldDef = oldDefs[name];
|
|
301
|
+
const newDef = newDefs[name];
|
|
302
|
+
if (!oldDef) {
|
|
303
|
+
findings.push({ severity: "note", kind: "def-added", detail: `$defs added: ${name}` });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!newDef) {
|
|
307
|
+
if (reachable !== null && reachable.has(name)) {
|
|
308
|
+
findings.push({ severity: "breaking", kind: "def-removed", detail: `$defs removed reachable def: ${name}` });
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
findings.push({ severity: "note", kind: "def-removed", detail: `$defs removed: ${name}${reachable === null ? " (reachability unknown: snapshot-vs-snapshot)" : " (unreachable from curated surface)"}` });
|
|
312
|
+
}
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (oldDef.digest === newDef.digest)
|
|
316
|
+
continue;
|
|
317
|
+
const requiredAdded = (newDef.required ?? []).filter((r) => !(oldDef.required ?? []).includes(r));
|
|
318
|
+
const propsRemoved = (oldDef.props ?? []).filter((p) => !(newDef.props ?? []).includes(p));
|
|
319
|
+
const breakingShape = requiredAdded.length > 0 || propsRemoved.length > 0;
|
|
320
|
+
const isReachable = reachable === null ? null : reachable.has(name);
|
|
321
|
+
if (breakingShape && (isReachable === null || isReachable)) {
|
|
322
|
+
findings.push({
|
|
323
|
+
severity: "breaking",
|
|
324
|
+
kind: "def-changed",
|
|
325
|
+
detail: `$defs changed${isReachable === null ? " (reachability unknown: snapshot-vs-snapshot)" : " (reachable from curated surface)"}: ${name}` +
|
|
326
|
+
`${requiredAdded.length > 0 ? ` required+${requiredAdded.join(",")}` : ""}${propsRemoved.length > 0 ? ` props-${propsRemoved.join(",")}` : ""}`,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
findings.push({
|
|
331
|
+
severity: "note",
|
|
332
|
+
kind: "def-changed",
|
|
333
|
+
detail: `$defs changed: ${name} (additive or unreachable: breaking only if a curated member shape changed, reported above)`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return { ok: !findings.some((f) => f.severity === "breaking"), findings };
|
|
339
|
+
}
|