yarramate 0.16.0 → 0.18.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 +2 -1
- package/dist/adapters/graphify-cli.js +0 -0
- package/dist/adapters/likec4-cli.js +0 -0
- package/dist/adapters/mcp-cli.js +0 -0
- package/dist/adapters/visual/client.d.ts +74 -0
- package/dist/adapters/visual/client.js +448 -0
- package/dist/adapters/visual/likec4-compiler.d.ts +55 -0
- package/dist/adapters/visual/likec4-compiler.js +460 -0
- package/dist/adapters/visual/protocol-contract.d.ts +246 -0
- package/dist/adapters/visual/protocol-contract.js +19 -0
- package/dist/adapters/visual/protocol.d.ts +13 -0
- package/dist/adapters/visual/protocol.js +258 -0
- package/dist/adapters/visual/session-server.d.ts +130 -0
- package/dist/adapters/visual/session-server.js +1406 -0
- package/dist/adapters/visual/session-store.d.ts +126 -0
- package/dist/adapters/visual/session-store.js +755 -0
- package/dist/adapters/visual/wire.d.ts +93 -0
- package/dist/adapters/visual/wire.js +1 -0
- package/dist/adapters/visual-cli.d.ts +42 -0
- package/dist/adapters/visual-cli.js +274 -0
- package/dist/attestation-staleness.js +8 -1
- package/dist/cli.js +0 -0
- package/dist/compiler.d.ts +12 -0
- package/dist/compiler.js +46 -2
- package/dist/reconciliation.d.ts +17 -1
- package/dist/reconciliation.js +48 -1
- package/dist/rtm.d.ts +1 -0
- package/dist/rtm.js +9 -7
- package/dist/visual-app/assets/index-CGvC8e3R.css +1 -0
- package/dist/visual-app/assets/index-CXuW1tOj.js +209 -0
- package/dist/visual-app/index.html +18 -0
- package/docs/CONSUMING-YARRAMATE.md +49 -0
- package/package.json +25 -5
- package/schema/yarramate-ask-result.schema.json +4 -0
- package/schema/yarramate-document.schema.json +5 -0
- package/schema/yarramate-operations.schema.json +7 -1
- package/schema/yarramate-reconciliation-report.schema.json +36 -1
- package/schema/yarramate-visual-diagnostic-result.schema.json +67 -0
- package/schema/yarramate-visual-event.schema.json +255 -0
- package/schema/yarramate-visual-handoff.schema.json +120 -0
- package/schema/yarramate-visual-model.schema.json +111 -0
- package/schema/yarramate-visual-response.schema.json +230 -0
- package/schema/yarramate-visual-session-descriptor.schema.json +45 -0
- package/schema/yarramate-visual-session-request.schema.json +110 -0
- package/schema/yarramate-visual-session-started.schema.json +121 -0
- package/schema/yarramate-visual-status.schema.json +123 -0
- package/skills/yarramate-architecture/SKILL.md +28 -1
- package/skills/yarramate-architecture/references/native-authoring.md +15 -3
- package/skills/yarramate-architecture/references/visual-conversations.md +263 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every visual protocol declaration, and the limits they are measured against,
|
|
3
|
+
* with no runtime dependency of any kind.
|
|
4
|
+
*
|
|
5
|
+
* The validators that police these documents live in `./protocol.js`, which
|
|
6
|
+
* re-exports this module so a Node consumer still has one import for the whole
|
|
7
|
+
* protocol. The split exists because the browser application ships the same
|
|
8
|
+
* contract: it can import these declarations and limits without pulling Ajv,
|
|
9
|
+
* `node:path`, or the schema documents into its bundle.
|
|
10
|
+
*/
|
|
11
|
+
export declare const VISUAL_PROTOCOL_VERSION: 'yarramate/visual-protocol/v1';
|
|
12
|
+
export declare const VISUAL_LIMITS: {
|
|
13
|
+
readonly messageBytes: number;
|
|
14
|
+
readonly modelBytes: number;
|
|
15
|
+
readonly transcriptBytes: number;
|
|
16
|
+
readonly pendingEvents: 32;
|
|
17
|
+
readonly reconnectMs: number;
|
|
18
|
+
readonly staleSessionMs: number;
|
|
19
|
+
};
|
|
20
|
+
export type VisualAuthority = 'canonical' | 'ad-hoc';
|
|
21
|
+
export interface VisualDiagnostic {
|
|
22
|
+
readonly severity: 'error';
|
|
23
|
+
readonly code: string;
|
|
24
|
+
readonly message: string;
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly pointer: string;
|
|
27
|
+
readonly line: number;
|
|
28
|
+
readonly column: number;
|
|
29
|
+
}
|
|
30
|
+
export interface VisualDiagnosticResult {
|
|
31
|
+
readonly format: 'yarramate/visual-diagnostic-result/v1';
|
|
32
|
+
readonly diagnostics: readonly VisualDiagnostic[];
|
|
33
|
+
}
|
|
34
|
+
export interface VisualCapabilities {
|
|
35
|
+
readonly chat: boolean;
|
|
36
|
+
readonly choices: boolean;
|
|
37
|
+
readonly navigation: boolean;
|
|
38
|
+
readonly modelReplacement: boolean;
|
|
39
|
+
readonly transcript: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface VisualModel {
|
|
42
|
+
readonly format: 'yarramate/visual-model/v1';
|
|
43
|
+
readonly authority: VisualAuthority;
|
|
44
|
+
readonly initialView: string;
|
|
45
|
+
readonly sourceDigests: Readonly<Record<string, string>>;
|
|
46
|
+
readonly files: Readonly<Record<string, string>>;
|
|
47
|
+
}
|
|
48
|
+
export interface VisualCompilerCommand {
|
|
49
|
+
readonly command: string;
|
|
50
|
+
readonly args: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
export interface VisualSessionRequest {
|
|
53
|
+
readonly format: 'yarramate/visual-session-request/v1';
|
|
54
|
+
readonly authority: VisualModel['authority'];
|
|
55
|
+
readonly title: string;
|
|
56
|
+
readonly description: string;
|
|
57
|
+
readonly chatEnabled: boolean;
|
|
58
|
+
readonly compiler: VisualCompilerCommand;
|
|
59
|
+
readonly initialModel: VisualModel;
|
|
60
|
+
}
|
|
61
|
+
export interface VisualSessionStarted {
|
|
62
|
+
readonly format: 'yarramate/visual-session-started/v1';
|
|
63
|
+
readonly protocolVersion: typeof VISUAL_PROTOCOL_VERSION;
|
|
64
|
+
readonly sessionId: string;
|
|
65
|
+
readonly authority: VisualAuthority;
|
|
66
|
+
readonly title: string;
|
|
67
|
+
readonly chatEnabled: boolean;
|
|
68
|
+
readonly browserUrl: string;
|
|
69
|
+
readonly webSocketUrl: string;
|
|
70
|
+
readonly origin: string;
|
|
71
|
+
readonly descriptorPath: string;
|
|
72
|
+
readonly sessionRoot: string;
|
|
73
|
+
readonly capabilities: VisualCapabilities;
|
|
74
|
+
readonly startedAt: string;
|
|
75
|
+
}
|
|
76
|
+
export interface VisualSessionDescriptor {
|
|
77
|
+
readonly format: 'yarramate/visual-session-descriptor/v1';
|
|
78
|
+
readonly protocolVersion: typeof VISUAL_PROTOCOL_VERSION;
|
|
79
|
+
readonly sessionId: string;
|
|
80
|
+
readonly origin: string;
|
|
81
|
+
readonly agentCapability: string;
|
|
82
|
+
readonly sessionRoot: string;
|
|
83
|
+
readonly journalPath: string;
|
|
84
|
+
readonly createdAt: string;
|
|
85
|
+
}
|
|
86
|
+
export interface VisualChatMessagePayload {
|
|
87
|
+
readonly text: string;
|
|
88
|
+
}
|
|
89
|
+
export interface VisualChoiceSelectedPayload {
|
|
90
|
+
readonly choiceId: string;
|
|
91
|
+
readonly optionId: string;
|
|
92
|
+
}
|
|
93
|
+
export interface VisualViewNavigatePayload {
|
|
94
|
+
readonly viewId: string;
|
|
95
|
+
readonly requiresAttention: boolean;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Terminal event payload. Every reason is the runtime's to choose: only it
|
|
99
|
+
* knows whether a session ended by request, by a failing child, by a browser
|
|
100
|
+
* that never came back, or by its own cancellation.
|
|
101
|
+
*/
|
|
102
|
+
export interface VisualSessionEndPayload {
|
|
103
|
+
readonly reason: VisualTerminationReason;
|
|
104
|
+
}
|
|
105
|
+
/** End as an untrusted browser may ask for it, and nothing more. */
|
|
106
|
+
export interface VisualBrowserSessionEndPayload {
|
|
107
|
+
readonly reason: 'user-ended';
|
|
108
|
+
}
|
|
109
|
+
export interface VisualBrowserConnectedPayload {
|
|
110
|
+
readonly connectionId: string;
|
|
111
|
+
}
|
|
112
|
+
export interface VisualBrowserDisconnectedPayload {
|
|
113
|
+
readonly connectionId: string;
|
|
114
|
+
readonly code: number;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Untrusted browser message. The runtime owns session identifiers, sequence
|
|
118
|
+
* numbers, event identifiers, and timestamps, so the browser may send only a
|
|
119
|
+
* discriminant, the last sequence it saw acknowledged, and its payload.
|
|
120
|
+
*
|
|
121
|
+
* `lastAcknowledgedSequence` is the browser's own view of the journal, carried
|
|
122
|
+
* on every frame so a session that reconnected mid-turn can be told it is
|
|
123
|
+
* ahead of the journal it claims to have read. It is 0 before the first
|
|
124
|
+
* acknowledgement, and never authorises anything: admission still assigns the
|
|
125
|
+
* sequence and event identifier.
|
|
126
|
+
*/
|
|
127
|
+
export type VisualBrowserInput = {
|
|
128
|
+
readonly type: 'chat.message';
|
|
129
|
+
readonly lastAcknowledgedSequence: number;
|
|
130
|
+
readonly payload: VisualChatMessagePayload;
|
|
131
|
+
} | {
|
|
132
|
+
readonly type: 'choice.selected';
|
|
133
|
+
readonly lastAcknowledgedSequence: number;
|
|
134
|
+
readonly payload: VisualChoiceSelectedPayload;
|
|
135
|
+
} | {
|
|
136
|
+
readonly type: 'view.navigate';
|
|
137
|
+
readonly lastAcknowledgedSequence: number;
|
|
138
|
+
readonly payload: VisualViewNavigatePayload;
|
|
139
|
+
} | {
|
|
140
|
+
readonly type: 'session.end';
|
|
141
|
+
readonly lastAcknowledgedSequence: number;
|
|
142
|
+
readonly payload: VisualBrowserSessionEndPayload;
|
|
143
|
+
};
|
|
144
|
+
interface VisualEventEnvelope<Type extends string, Payload> {
|
|
145
|
+
readonly format: 'yarramate/visual-event/v1';
|
|
146
|
+
readonly sessionId: string;
|
|
147
|
+
readonly sequence: number;
|
|
148
|
+
readonly eventId: string;
|
|
149
|
+
readonly type: Type;
|
|
150
|
+
readonly timestamp: string;
|
|
151
|
+
readonly payload: Payload;
|
|
152
|
+
}
|
|
153
|
+
export type VisualEvent = VisualEventEnvelope<'chat.message', VisualChatMessagePayload> | VisualEventEnvelope<'choice.selected', VisualChoiceSelectedPayload> | VisualEventEnvelope<'view.navigate', VisualViewNavigatePayload> | VisualEventEnvelope<'session.end', VisualSessionEndPayload> | VisualEventEnvelope<'browser.connected', VisualBrowserConnectedPayload> | VisualEventEnvelope<'browser.disconnected', VisualBrowserDisconnectedPayload>;
|
|
154
|
+
export interface VisualChatResponsePayload {
|
|
155
|
+
readonly text: string;
|
|
156
|
+
}
|
|
157
|
+
export interface VisualAgentStatusPayload {
|
|
158
|
+
readonly state: 'thinking' | 'compiling' | 'waiting' | 'idle';
|
|
159
|
+
readonly detail?: string;
|
|
160
|
+
}
|
|
161
|
+
export interface VisualChoiceOption {
|
|
162
|
+
readonly id: string;
|
|
163
|
+
readonly label: string;
|
|
164
|
+
readonly description?: string;
|
|
165
|
+
}
|
|
166
|
+
export interface VisualChoicePresentPayload {
|
|
167
|
+
readonly choiceId: string;
|
|
168
|
+
readonly question: string;
|
|
169
|
+
readonly options: readonly VisualChoiceOption[];
|
|
170
|
+
}
|
|
171
|
+
export interface VisualModelReplacePayload {
|
|
172
|
+
readonly model: VisualModel;
|
|
173
|
+
}
|
|
174
|
+
export interface VisualHandoffSummary {
|
|
175
|
+
readonly summary: string;
|
|
176
|
+
readonly confirmedDecisions: readonly string[];
|
|
177
|
+
readonly requestedChanges: readonly string[];
|
|
178
|
+
readonly unresolvedQuestions: readonly string[];
|
|
179
|
+
readonly finalViews: readonly string[];
|
|
180
|
+
}
|
|
181
|
+
export interface VisualDiagnosticPayload {
|
|
182
|
+
readonly diagnostics: readonly VisualDiagnostic[];
|
|
183
|
+
}
|
|
184
|
+
interface VisualResponseEnvelope<Type extends string, Payload> {
|
|
185
|
+
readonly format: 'yarramate/visual-response/v1';
|
|
186
|
+
readonly sessionId: string;
|
|
187
|
+
readonly responseId: string;
|
|
188
|
+
readonly eventId: string;
|
|
189
|
+
readonly type: Type;
|
|
190
|
+
readonly timestamp: string;
|
|
191
|
+
readonly payload: Payload;
|
|
192
|
+
}
|
|
193
|
+
export type VisualResponse = VisualResponseEnvelope<'chat.response', VisualChatResponsePayload> | VisualResponseEnvelope<'agent.status', VisualAgentStatusPayload> | VisualResponseEnvelope<'choice.present', VisualChoicePresentPayload> | VisualResponseEnvelope<'model.replace', VisualModelReplacePayload> | VisualResponseEnvelope<'handoff.complete', VisualHandoffSummary> | VisualResponseEnvelope<'diagnostic', VisualDiagnosticPayload>;
|
|
194
|
+
export type VisualHandoffDecision = 'completed' | 'cancelled' | 'failed';
|
|
195
|
+
export type VisualTerminationReason = 'user-ended' | 'child-failed' | 'browser-timeout' | 'main-cancelled' | 'server-failed' | 'compiler-failed';
|
|
196
|
+
export interface VisualHandoff extends VisualHandoffSummary {
|
|
197
|
+
readonly format: 'yarramate/visual-handoff/v1';
|
|
198
|
+
readonly sessionId: string;
|
|
199
|
+
readonly authority: VisualAuthority;
|
|
200
|
+
readonly decision: VisualHandoffDecision;
|
|
201
|
+
readonly terminationReason: VisualTerminationReason;
|
|
202
|
+
readonly lastSequence: number;
|
|
203
|
+
readonly transcriptPath: string;
|
|
204
|
+
readonly transcript?: readonly (VisualEvent | VisualResponse)[];
|
|
205
|
+
readonly completedAt: string;
|
|
206
|
+
}
|
|
207
|
+
export type VisualLifecycle = 'starting' | 'running' | 'draining' | 'stopped';
|
|
208
|
+
export type VisualFreezeReason = 'message-bytes' | 'model-bytes' | 'transcript-bytes' | 'pending-events' | 'browser-disconnected' | 'terminal-event';
|
|
209
|
+
export interface VisualStatus {
|
|
210
|
+
readonly format: 'yarramate/visual-status/v1';
|
|
211
|
+
readonly protocolVersion: typeof VISUAL_PROTOCOL_VERSION;
|
|
212
|
+
readonly sessionId: string;
|
|
213
|
+
readonly lifecycle: VisualLifecycle;
|
|
214
|
+
readonly alreadyStopped: boolean;
|
|
215
|
+
readonly server: {
|
|
216
|
+
readonly listening: boolean;
|
|
217
|
+
readonly origin: string;
|
|
218
|
+
};
|
|
219
|
+
readonly browser: {
|
|
220
|
+
readonly connected: boolean;
|
|
221
|
+
readonly connections: number;
|
|
222
|
+
readonly lastSeenAt?: string;
|
|
223
|
+
readonly graceExpiresAt?: string;
|
|
224
|
+
};
|
|
225
|
+
readonly agent: {
|
|
226
|
+
readonly attached: boolean;
|
|
227
|
+
readonly inFlightEventId: string | null;
|
|
228
|
+
};
|
|
229
|
+
readonly queue: {
|
|
230
|
+
readonly pendingEvents: number;
|
|
231
|
+
readonly lastSequence: number;
|
|
232
|
+
readonly frozen: boolean;
|
|
233
|
+
readonly frozenReason?: VisualFreezeReason;
|
|
234
|
+
};
|
|
235
|
+
readonly capabilities: VisualCapabilities;
|
|
236
|
+
readonly transcriptBytes: number;
|
|
237
|
+
readonly updatedAt: string;
|
|
238
|
+
}
|
|
239
|
+
export type ParseResult<T> = {
|
|
240
|
+
readonly ok: true;
|
|
241
|
+
readonly value: T;
|
|
242
|
+
} | {
|
|
243
|
+
readonly ok: false;
|
|
244
|
+
readonly diagnostics: readonly VisualDiagnostic[];
|
|
245
|
+
};
|
|
246
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every visual protocol declaration, and the limits they are measured against,
|
|
3
|
+
* with no runtime dependency of any kind.
|
|
4
|
+
*
|
|
5
|
+
* The validators that police these documents live in `./protocol.js`, which
|
|
6
|
+
* re-exports this module so a Node consumer still has one import for the whole
|
|
7
|
+
* protocol. The split exists because the browser application ships the same
|
|
8
|
+
* contract: it can import these declarations and limits without pulling Ajv,
|
|
9
|
+
* `node:path`, or the schema documents into its bundle.
|
|
10
|
+
*/
|
|
11
|
+
export const VISUAL_PROTOCOL_VERSION = 'yarramate/visual-protocol/v1';
|
|
12
|
+
export const VISUAL_LIMITS = {
|
|
13
|
+
messageBytes: 64 * 1024,
|
|
14
|
+
modelBytes: 5 * 1024 * 1024,
|
|
15
|
+
transcriptBytes: 5 * 1024 * 1024,
|
|
16
|
+
pendingEvents: 32,
|
|
17
|
+
reconnectMs: 5 * 60 * 1000,
|
|
18
|
+
staleSessionMs: 24 * 60 * 60 * 1000,
|
|
19
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ParseResult, type VisualBrowserInput, type VisualDiagnosticResult, type VisualEvent, type VisualHandoff, type VisualModel, type VisualResponse, type VisualSessionDescriptor, type VisualSessionRequest, type VisualSessionStarted, type VisualStatus } from './protocol-contract.js';
|
|
2
|
+
export * from './protocol-contract.js';
|
|
3
|
+
export declare const isSafeVisualModelPath: (candidate: string) => boolean;
|
|
4
|
+
export declare const parseVisualModel: (input: unknown) => ParseResult<VisualModel>;
|
|
5
|
+
export declare const parseVisualSessionRequest: (input: unknown) => ParseResult<VisualSessionRequest>;
|
|
6
|
+
export declare const parseVisualSessionStarted: (input: unknown) => ParseResult<VisualSessionStarted>;
|
|
7
|
+
export declare const parseVisualSessionDescriptor: (input: unknown) => ParseResult<VisualSessionDescriptor>;
|
|
8
|
+
export declare const parseVisualBrowserInput: (input: unknown) => ParseResult<VisualBrowserInput>;
|
|
9
|
+
export declare const parseVisualEvent: (input: unknown) => ParseResult<VisualEvent>;
|
|
10
|
+
export declare const parseVisualResponse: (input: unknown) => ParseResult<VisualResponse>;
|
|
11
|
+
export declare const parseVisualHandoff: (input: unknown) => ParseResult<VisualHandoff>;
|
|
12
|
+
export declare const parseVisualStatus: (input: unknown) => ParseResult<VisualStatus>;
|
|
13
|
+
export declare const parseVisualDiagnosticResult: (input: unknown) => ParseResult<VisualDiagnosticResult>;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import Ajv2020Module from 'ajv/dist/2020.js';
|
|
3
|
+
import { describeSchemaViolation } from '../../source-document.js';
|
|
4
|
+
import visualDiagnosticResultSchema from '../../../schema/yarramate-visual-diagnostic-result.schema.json' with { type: 'json'
|
|
5
|
+
};
|
|
6
|
+
import visualEventSchema from '../../../schema/yarramate-visual-event.schema.json' with { type: 'json'
|
|
7
|
+
};
|
|
8
|
+
import visualHandoffSchema from '../../../schema/yarramate-visual-handoff.schema.json' with { type: 'json'
|
|
9
|
+
};
|
|
10
|
+
import visualModelSchema from '../../../schema/yarramate-visual-model.schema.json' with { type: 'json'
|
|
11
|
+
};
|
|
12
|
+
import visualResponseSchema from '../../../schema/yarramate-visual-response.schema.json' with { type: 'json'
|
|
13
|
+
};
|
|
14
|
+
import visualSessionDescriptorSchema from '../../../schema/yarramate-visual-session-descriptor.schema.json' with { type: 'json'
|
|
15
|
+
};
|
|
16
|
+
import visualSessionRequestSchema from '../../../schema/yarramate-visual-session-request.schema.json' with { type: 'json'
|
|
17
|
+
};
|
|
18
|
+
import visualSessionStartedSchema from '../../../schema/yarramate-visual-session-started.schema.json' with { type: 'json'
|
|
19
|
+
};
|
|
20
|
+
import visualStatusSchema from '../../../schema/yarramate-visual-status.schema.json' with { type: 'json'
|
|
21
|
+
};
|
|
22
|
+
import { VISUAL_LIMITS, } from './protocol-contract.js';
|
|
23
|
+
// One import site for the whole protocol: the declarations this module
|
|
24
|
+
// validates travel with the validators.
|
|
25
|
+
export * from './protocol-contract.js';
|
|
26
|
+
const Ajv2020 = Ajv2020Module.default;
|
|
27
|
+
const ajv = new Ajv2020({ allErrors: true });
|
|
28
|
+
ajv.addSchema([
|
|
29
|
+
visualDiagnosticResultSchema,
|
|
30
|
+
visualModelSchema,
|
|
31
|
+
visualEventSchema,
|
|
32
|
+
visualResponseSchema,
|
|
33
|
+
visualSessionRequestSchema,
|
|
34
|
+
visualSessionStartedSchema,
|
|
35
|
+
visualSessionDescriptorSchema,
|
|
36
|
+
visualHandoffSchema,
|
|
37
|
+
visualStatusSchema,
|
|
38
|
+
]);
|
|
39
|
+
// Diagnostics report the document they came from rather than a source file,
|
|
40
|
+
// because visual protocol documents arrive as parsed JSON over the wire.
|
|
41
|
+
const documentPaths = new WeakMap();
|
|
42
|
+
const visualValidator = (document, reference = `https://yarramate.org/schema/${document}`) => {
|
|
43
|
+
const compiled = ajv.getSchema(reference);
|
|
44
|
+
if (!compiled)
|
|
45
|
+
throw new Error(`Unknown visual schema reference: ${reference}`);
|
|
46
|
+
documentPaths.set(compiled, document);
|
|
47
|
+
return compiled;
|
|
48
|
+
};
|
|
49
|
+
const validateVisualModel = visualValidator('visual-model/v1');
|
|
50
|
+
const validateVisualSessionRequest = visualValidator('visual-session-request/v1');
|
|
51
|
+
const validateVisualSessionStarted = visualValidator('visual-session-started/v1');
|
|
52
|
+
const validateVisualSessionDescriptor = visualValidator('visual-session-descriptor/v1');
|
|
53
|
+
const validateVisualBrowserInput = visualValidator('visual-browser-input/v1', 'https://yarramate.org/schema/visual-event/v1#/$defs/browserInput');
|
|
54
|
+
const validateVisualEvent = visualValidator('visual-event/v1');
|
|
55
|
+
const validateVisualResponse = visualValidator('visual-response/v1');
|
|
56
|
+
const validateVisualHandoff = visualValidator('visual-handoff/v1');
|
|
57
|
+
const validateVisualStatus = visualValidator('visual-status/v1');
|
|
58
|
+
const validateVisualDiagnosticResult = visualValidator('visual-diagnostic-result/v1');
|
|
59
|
+
/**
|
|
60
|
+
* Reads an untrusted document's own fields. Semantic checks run beside schema
|
|
61
|
+
* validation on raw input, so anything that is not a plain object simply has
|
|
62
|
+
* no fields to inspect and the schema reports the type violation.
|
|
63
|
+
*/
|
|
64
|
+
const documentFields = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
65
|
+
? value
|
|
66
|
+
: {};
|
|
67
|
+
const pointerSegment = (segment) => segment.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
68
|
+
const visualDiagnostic = (code, message, path, pointer) => ({
|
|
69
|
+
severity: 'error',
|
|
70
|
+
code,
|
|
71
|
+
message,
|
|
72
|
+
path,
|
|
73
|
+
pointer,
|
|
74
|
+
line: 1,
|
|
75
|
+
column: 1,
|
|
76
|
+
});
|
|
77
|
+
const visualDiagnosticOrder = (left, right) => left.pointer.localeCompare(right.pointer) ||
|
|
78
|
+
left.code.localeCompare(right.code) ||
|
|
79
|
+
left.message.localeCompare(right.message);
|
|
80
|
+
const jsonBytes = (value) => {
|
|
81
|
+
const encoded = JSON.stringify(value);
|
|
82
|
+
if (encoded === undefined)
|
|
83
|
+
return 0;
|
|
84
|
+
return Buffer.byteLength(encoded, 'utf8');
|
|
85
|
+
};
|
|
86
|
+
const MODEL_ROOT_CONFIG = 'likec4.config.json';
|
|
87
|
+
const MODEL_SOURCE_EXTENSIONS = ['.c4', '.likec4'];
|
|
88
|
+
/**
|
|
89
|
+
* Relative POSIX path confined to the candidate root. Absolute paths, parent
|
|
90
|
+
* traversal, empty segments, dot-prefixed segments (how a staged symlink or
|
|
91
|
+
* metadata entry would be smuggled in), Windows separators, and NUL bytes are
|
|
92
|
+
* all rejected.
|
|
93
|
+
*/
|
|
94
|
+
const isConfinedRelativePath = (candidate) => {
|
|
95
|
+
if (candidate.length === 0 || candidate.length > 1024)
|
|
96
|
+
return false;
|
|
97
|
+
if (candidate.includes('\\') || candidate.includes('\u0000'))
|
|
98
|
+
return false;
|
|
99
|
+
if (posix.isAbsolute(candidate))
|
|
100
|
+
return false;
|
|
101
|
+
if (posix.normalize(candidate) !== candidate)
|
|
102
|
+
return false;
|
|
103
|
+
return candidate
|
|
104
|
+
.split('/')
|
|
105
|
+
.every((segment) => segment.length > 0 && !segment.startsWith('.'));
|
|
106
|
+
};
|
|
107
|
+
export const isSafeVisualModelPath = (candidate) => {
|
|
108
|
+
if (!isConfinedRelativePath(candidate))
|
|
109
|
+
return false;
|
|
110
|
+
if (candidate === MODEL_ROOT_CONFIG)
|
|
111
|
+
return true;
|
|
112
|
+
const extension = posix.extname(candidate);
|
|
113
|
+
return MODEL_SOURCE_EXTENSIONS.some((allowed) => allowed === extension);
|
|
114
|
+
};
|
|
115
|
+
const modelSemantics = (input, path, prefix) => {
|
|
116
|
+
const fields = documentFields(input);
|
|
117
|
+
const diagnostics = [];
|
|
118
|
+
const files = documentFields(fields.files);
|
|
119
|
+
const fileKeys = Object.keys(files);
|
|
120
|
+
if (fileKeys.length > 0) {
|
|
121
|
+
let sources = 0;
|
|
122
|
+
for (const key of fileKeys) {
|
|
123
|
+
if (!isSafeVisualModelPath(key)) {
|
|
124
|
+
diagnostics.push(visualDiagnostic('YMVS113', `Model file "${key}" escapes the candidate root or is not a LikeC4 source`, path, `${prefix}/files/${pointerSegment(key)}`));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (key !== MODEL_ROOT_CONFIG)
|
|
128
|
+
sources += 1;
|
|
129
|
+
}
|
|
130
|
+
if (sources === 0) {
|
|
131
|
+
diagnostics.push(visualDiagnostic('YMVS114', 'A visual model requires at least one .c4 or .likec4 source file', path, `${prefix}/files`));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const digestKeys = Object.keys(documentFields(fields.sourceDigests));
|
|
135
|
+
for (const key of digestKeys) {
|
|
136
|
+
if (isConfinedRelativePath(key))
|
|
137
|
+
continue;
|
|
138
|
+
diagnostics.push(visualDiagnostic('YMVS113', `Source digest "${key}" escapes the candidate root`, path, `${prefix}/sourceDigests/${pointerSegment(key)}`));
|
|
139
|
+
}
|
|
140
|
+
if (fields.authority === 'canonical' && digestKeys.length === 0) {
|
|
141
|
+
diagnostics.push(visualDiagnostic('YMVS112', 'A canonical visual model must record the source digests it was derived from', path, `${prefix}/sourceDigests`));
|
|
142
|
+
}
|
|
143
|
+
if (fields.authority === 'ad-hoc' && digestKeys.length > 0) {
|
|
144
|
+
diagnostics.push(visualDiagnostic('YMVS112', 'An ad-hoc visual model must not claim canonical source digests', path, `${prefix}/sourceDigests`));
|
|
145
|
+
}
|
|
146
|
+
if (jsonBytes(input) > VISUAL_LIMITS.modelBytes) {
|
|
147
|
+
diagnostics.push(visualDiagnostic('YMVS115', `A visual model must not exceed ${VISUAL_LIMITS.modelBytes} bytes`, path, prefix === '' ? '/' : prefix));
|
|
148
|
+
}
|
|
149
|
+
return diagnostics;
|
|
150
|
+
};
|
|
151
|
+
const chatTextSemantics = (payload, path, prefix) => {
|
|
152
|
+
const text = documentFields(payload).text;
|
|
153
|
+
if (typeof text !== 'string' ||
|
|
154
|
+
Buffer.byteLength(text, 'utf8') <= VISUAL_LIMITS.messageBytes) {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
return [
|
|
158
|
+
visualDiagnostic('YMVS115', `A chat message must not exceed ${VISUAL_LIMITS.messageBytes} bytes`, path, `${prefix}/text`),
|
|
159
|
+
];
|
|
160
|
+
};
|
|
161
|
+
const sessionRequestSemantics = (input, path) => {
|
|
162
|
+
const fields = documentFields(input);
|
|
163
|
+
const modelAuthority = documentFields(fields.initialModel).authority;
|
|
164
|
+
const diagnostics = [
|
|
165
|
+
...modelSemantics(fields.initialModel, path, '/initialModel'),
|
|
166
|
+
];
|
|
167
|
+
if (typeof fields.authority === 'string' &&
|
|
168
|
+
typeof modelAuthority === 'string' &&
|
|
169
|
+
fields.authority !== modelAuthority) {
|
|
170
|
+
diagnostics.push(visualDiagnostic('YMVS111', `Session authority "${fields.authority}" does not match the initial model authority "${modelAuthority}"`, path, '/initialModel/authority'));
|
|
171
|
+
}
|
|
172
|
+
return diagnostics;
|
|
173
|
+
};
|
|
174
|
+
const chatCarryingSemantics = (input, path) => {
|
|
175
|
+
const fields = documentFields(input);
|
|
176
|
+
if (fields.type !== 'chat.message')
|
|
177
|
+
return [];
|
|
178
|
+
return chatTextSemantics(fields.payload, path, '/payload');
|
|
179
|
+
};
|
|
180
|
+
const responseSemantics = (input, path) => {
|
|
181
|
+
const fields = documentFields(input);
|
|
182
|
+
if (fields.type === 'chat.response') {
|
|
183
|
+
return chatTextSemantics(fields.payload, path, '/payload');
|
|
184
|
+
}
|
|
185
|
+
if (fields.type === 'model.replace') {
|
|
186
|
+
return modelSemantics(documentFields(fields.payload).model, path, '/payload/model');
|
|
187
|
+
}
|
|
188
|
+
return [];
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* A handoff is one session's record. The schema validates each transcript entry
|
|
192
|
+
* as a well-formed event or response but cannot relate it to the document that
|
|
193
|
+
* carries it, so the one cross-session check the nine documents would otherwise
|
|
194
|
+
* be missing is made here: every entry names the session the handoff does.
|
|
195
|
+
*/
|
|
196
|
+
const handoffSemantics = (input, path) => {
|
|
197
|
+
const fields = documentFields(input);
|
|
198
|
+
const transcript = fields.transcript;
|
|
199
|
+
if (transcript === undefined)
|
|
200
|
+
return [];
|
|
201
|
+
const diagnostics = [];
|
|
202
|
+
if (jsonBytes(transcript) > VISUAL_LIMITS.transcriptBytes) {
|
|
203
|
+
diagnostics.push(visualDiagnostic('YMVS115', `A raw transcript must not exceed ${VISUAL_LIMITS.transcriptBytes} bytes`, path, '/transcript'));
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(transcript)) {
|
|
206
|
+
for (const [index, entry] of transcript.entries()) {
|
|
207
|
+
const owner = documentFields(entry).sessionId;
|
|
208
|
+
if (owner === fields.sessionId)
|
|
209
|
+
continue;
|
|
210
|
+
diagnostics.push(visualDiagnostic('YMVS116', `Transcript entry ${index} belongs to session "${String(owner)}", not "${String(fields.sessionId)}"`, path, `/transcript/${index}/sessionId`));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return diagnostics;
|
|
214
|
+
};
|
|
215
|
+
// Byte limits and path confinement are enforced here as well as in the schemas
|
|
216
|
+
// so a schema regression cannot silently widen the untrusted input surface.
|
|
217
|
+
const semanticsByDocument = {
|
|
218
|
+
'visual-model/v1': (input, path) => modelSemantics(input, path, ''),
|
|
219
|
+
'visual-session-request/v1': sessionRequestSemantics,
|
|
220
|
+
'visual-browser-input/v1': chatCarryingSemantics,
|
|
221
|
+
'visual-event/v1': chatCarryingSemantics,
|
|
222
|
+
'visual-response/v1': responseSemantics,
|
|
223
|
+
'visual-handoff/v1': handoffSemantics,
|
|
224
|
+
};
|
|
225
|
+
const schemaDiagnostics = (errors, code, path) => errors.map((error) => {
|
|
226
|
+
const property = error.keyword === 'additionalProperties'
|
|
227
|
+
? String(error.params.additionalProperty)
|
|
228
|
+
: undefined;
|
|
229
|
+
const pointer = property
|
|
230
|
+
? `${error.instancePath}/${pointerSegment(property)}`
|
|
231
|
+
: error.instancePath || '/';
|
|
232
|
+
return visualDiagnostic(code, property
|
|
233
|
+
? `Property "${property}" is not allowed`
|
|
234
|
+
: `${path} schema violation: ${describeSchemaViolation(error)}`, path, pointer);
|
|
235
|
+
});
|
|
236
|
+
const parseWith = (validate, input, code) => {
|
|
237
|
+
const path = documentPaths.get(validate) ?? 'visual-document';
|
|
238
|
+
const semantics = semanticsByDocument[path];
|
|
239
|
+
const diagnostics = [
|
|
240
|
+
...(validate(input)
|
|
241
|
+
? []
|
|
242
|
+
: schemaDiagnostics(validate.errors ?? [], code, path)),
|
|
243
|
+
...(semantics ? semantics(input, path) : []),
|
|
244
|
+
].sort(visualDiagnosticOrder);
|
|
245
|
+
if (diagnostics.length > 0)
|
|
246
|
+
return { ok: false, diagnostics };
|
|
247
|
+
return { ok: true, value: input };
|
|
248
|
+
};
|
|
249
|
+
export const parseVisualModel = (input) => parseWith(validateVisualModel, input, 'YMVS106');
|
|
250
|
+
export const parseVisualSessionRequest = (input) => parseWith(validateVisualSessionRequest, input, 'YMVS101');
|
|
251
|
+
export const parseVisualSessionStarted = (input) => parseWith(validateVisualSessionStarted, input, 'YMVS102');
|
|
252
|
+
export const parseVisualSessionDescriptor = (input) => parseWith(validateVisualSessionDescriptor, input, 'YMVS103');
|
|
253
|
+
export const parseVisualBrowserInput = (input) => parseWith(validateVisualBrowserInput, input, 'YMVS109');
|
|
254
|
+
export const parseVisualEvent = (input) => parseWith(validateVisualEvent, input, 'YMVS104');
|
|
255
|
+
export const parseVisualResponse = (input) => parseWith(validateVisualResponse, input, 'YMVS105');
|
|
256
|
+
export const parseVisualHandoff = (input) => parseWith(validateVisualHandoff, input, 'YMVS107');
|
|
257
|
+
export const parseVisualStatus = (input) => parseWith(validateVisualStatus, input, 'YMVS108');
|
|
258
|
+
export const parseVisualDiagnosticResult = (input) => parseWith(validateVisualDiagnosticResult, input, 'YMVS110');
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { type VisualDiagnostic, type VisualEvent, type VisualFreezeReason, type VisualHandoff, type VisualLifecycle, type VisualSessionRequest, type VisualSessionStarted, type VisualStatus, type VisualTerminationReason } from './protocol.js';
|
|
2
|
+
import { type TerminalEventDependencies, type VisualSessionPaths } from './session-store.js';
|
|
3
|
+
import type { VisualRenderedModel, VisualServerFrame, VisualSessionSnapshot, VisualTranscriptRecord } from './wire.js';
|
|
4
|
+
export type { VisualRenderedModel, VisualServerFrame, VisualSessionSnapshot, VisualTranscriptRecord, };
|
|
5
|
+
/**
|
|
6
|
+
* Document name reported by diagnostics the server itself raises — a refused
|
|
7
|
+
* frame, a frozen queue, or a transport violation that no protocol document
|
|
8
|
+
* owns.
|
|
9
|
+
*/
|
|
10
|
+
export declare const VISUAL_SERVER_DOCUMENT = "visual-session-server";
|
|
11
|
+
/**
|
|
12
|
+
* Returned on every response. The policy admits no external origin at all, so
|
|
13
|
+
* neither a compromised model nor a hostile chat message can reach the network,
|
|
14
|
+
* and the page can be neither framed nor used as a base for relative fetches.
|
|
15
|
+
*/
|
|
16
|
+
export declare const VISUAL_BROWSER_HEADERS: {
|
|
17
|
+
readonly 'Referrer-Policy': 'no-referrer';
|
|
18
|
+
readonly 'X-Content-Type-Options': 'nosniff';
|
|
19
|
+
readonly 'Cache-Control': 'no-store';
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* This session's content policy. Every source stays `'self'`; the one addition
|
|
23
|
+
* is a per-session nonce for inline style, because the diagram renderer injects
|
|
24
|
+
* its own stylesheets at runtime. A nonce admits exactly the styles this
|
|
25
|
+
* application emits, where `'unsafe-inline'` would admit anyone's.
|
|
26
|
+
*/
|
|
27
|
+
export declare const visualContentSecurityPolicy: (styleNonce: string) => string;
|
|
28
|
+
export declare const VISUAL_SERVER_LIMITS: {
|
|
29
|
+
/** WebSocket frame ceiling: one 64 KiB chat message plus its envelope. */
|
|
30
|
+
readonly browserFrameBytes: number;
|
|
31
|
+
/** Agent request body ceiling: one 5 MiB candidate model plus its envelope. */
|
|
32
|
+
readonly agentBodyBytes: number;
|
|
33
|
+
/** Agent stop requests carry a termination reason and nothing else. */
|
|
34
|
+
readonly agentControlBytes: 4096;
|
|
35
|
+
/** How long one agent long poll waits before answering "still idle". */
|
|
36
|
+
readonly agentPollMs: 30000;
|
|
37
|
+
/** Browser sockets one session serves at once. */
|
|
38
|
+
readonly browserConnections: 8;
|
|
39
|
+
};
|
|
40
|
+
export declare const VISUAL_SOCKET_PATH = "/socket";
|
|
41
|
+
export type VisualEventDelivery = {
|
|
42
|
+
readonly waiting: false;
|
|
43
|
+
readonly event: VisualEvent;
|
|
44
|
+
readonly lastSequence: number;
|
|
45
|
+
readonly pendingEvents: number;
|
|
46
|
+
} | {
|
|
47
|
+
readonly waiting: true;
|
|
48
|
+
readonly lastSequence: number;
|
|
49
|
+
readonly pendingEvents: number;
|
|
50
|
+
};
|
|
51
|
+
export type VisualResponseAcceptance = {
|
|
52
|
+
readonly accepted: true;
|
|
53
|
+
readonly duplicate: boolean;
|
|
54
|
+
readonly lastSequence: number;
|
|
55
|
+
readonly model?: VisualRenderedModel;
|
|
56
|
+
readonly diagnostics: readonly VisualDiagnostic[];
|
|
57
|
+
} | {
|
|
58
|
+
readonly accepted: false;
|
|
59
|
+
readonly diagnostics: readonly VisualDiagnostic[];
|
|
60
|
+
};
|
|
61
|
+
export interface VisualServerClosed {
|
|
62
|
+
readonly reason: VisualTerminationReason;
|
|
63
|
+
readonly alreadyStopped: boolean;
|
|
64
|
+
readonly handoff: VisualHandoff | undefined;
|
|
65
|
+
}
|
|
66
|
+
export interface VisualServerOptions {
|
|
67
|
+
readonly request: VisualSessionRequest;
|
|
68
|
+
/** Directory that holds one directory per live session. */
|
|
69
|
+
readonly baseDir: string;
|
|
70
|
+
/** Root of the self-contained browser application. */
|
|
71
|
+
readonly assetRoot?: string;
|
|
72
|
+
readonly now?: () => Date;
|
|
73
|
+
readonly randomBytes?: (size: number) => Buffer;
|
|
74
|
+
readonly agentPollMs?: number;
|
|
75
|
+
/** Whether the handoff a stop returns carries the raw transcript. */
|
|
76
|
+
readonly includeTranscript?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Arms the browser reconnect grace, and answers with the cancellation of the
|
|
79
|
+
* window it armed. Injected so the exact window a session waits is
|
|
80
|
+
* observable, and so it can elapse without waiting out five real minutes.
|
|
81
|
+
*/
|
|
82
|
+
readonly schedule?: (task: () => Promise<void>, ms: number) => () => void;
|
|
83
|
+
}
|
|
84
|
+
export interface VisualServerHandle {
|
|
85
|
+
readonly started: VisualSessionStarted;
|
|
86
|
+
readonly closed: Promise<VisualServerClosed>;
|
|
87
|
+
status(): VisualStatus;
|
|
88
|
+
stop(reason: VisualTerminationReason): Promise<VisualServerClosed>;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The runtime half of one live session: everything the terminal transition
|
|
92
|
+
* mutates, and the hooks it needs to quiet the rest down first.
|
|
93
|
+
*
|
|
94
|
+
* `lifecycle` is the whole admission gate — only a `running` session takes a
|
|
95
|
+
* browser frame or an agent response — so freezing input is one assignment
|
|
96
|
+
* rather than a second flag that could disagree with it.
|
|
97
|
+
*/
|
|
98
|
+
export interface ActiveVisualSession {
|
|
99
|
+
readonly paths: VisualSessionPaths;
|
|
100
|
+
lifecycle: VisualLifecycle;
|
|
101
|
+
/** Cancels whatever the trusted compiler is doing for this session. */
|
|
102
|
+
readonly compilerAbort: AbortController;
|
|
103
|
+
/** Records why the browser stopped being able to speak. */
|
|
104
|
+
readonly freeze: (reason: VisualFreezeReason) => void;
|
|
105
|
+
/** Settles work already admitted, so recovery reads a quiet journal. */
|
|
106
|
+
readonly quiesce: () => Promise<void>;
|
|
107
|
+
readonly terminalEvent: TerminalEventDependencies;
|
|
108
|
+
/** The transition in flight, so concurrent causes converge on one run. */
|
|
109
|
+
terminating: Promise<VisualHandoff> | undefined;
|
|
110
|
+
/** What this session ended with, once it has ended. */
|
|
111
|
+
handoff: VisualHandoff | undefined;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Takes one session terminal, whatever caused it: a reviewer's End, a child
|
|
115
|
+
* that failed, a browser that never came back, a cancelling main agent, or a
|
|
116
|
+
* runtime shutting itself down.
|
|
117
|
+
*
|
|
118
|
+
* The order is the invariant. Input freezes before anything is read; the
|
|
119
|
+
* compiler is cancelled rather than waited out; work already admitted is still
|
|
120
|
+
* allowed to finish; exactly one terminal event is journaled; and the handoff
|
|
121
|
+
* is recovered without deleting anything, so `stop` — and only `stop` — is what
|
|
122
|
+
* removes the session directory.
|
|
123
|
+
*/
|
|
124
|
+
export declare const terminateVisualSession: (session: ActiveVisualSession, reason: VisualTerminationReason) => Promise<VisualHandoff>;
|
|
125
|
+
/**
|
|
126
|
+
* Serves one authenticated visual session over loopback. The handle owns the
|
|
127
|
+
* session directory it created: `stop` recovers the handoff before deleting it,
|
|
128
|
+
* and answers every later call with that same outcome.
|
|
129
|
+
*/
|
|
130
|
+
export declare const startVisualServer: (options: VisualServerOptions) => Promise<VisualServerHandle>;
|