@lazyingart/agent-web 0.1.40

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.
Files changed (42) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/docs/architecture.md +503 -0
  4. package/package.json +43 -0
  5. package/src/aginti-adapter.js +602 -0
  6. package/src/chat-context.js +1020 -0
  7. package/src/chat-migrations.js +947 -0
  8. package/src/chat-store.js +3308 -0
  9. package/src/cli.js +134 -0
  10. package/src/cloud-server.js +2043 -0
  11. package/src/contracts.js +103 -0
  12. package/src/deterministic-context-summarizer.js +254 -0
  13. package/src/direct-chat-capability-limits.js +66 -0
  14. package/src/direct-chat-contract.js +3 -0
  15. package/src/errors.js +50 -0
  16. package/src/http-contract.js +592 -0
  17. package/src/index.js +88 -0
  18. package/src/localllm-connector.js +667 -0
  19. package/src/migrations.js +231 -0
  20. package/src/operator-health.js +184 -0
  21. package/src/password-verifier.js +131 -0
  22. package/src/service-config.js +547 -0
  23. package/src/service.js +408 -0
  24. package/src/sqlite-health.js +83 -0
  25. package/src/storage-path.js +130 -0
  26. package/src/store.js +914 -0
  27. package/src/validation.js +181 -0
  28. package/src/vision-attachment.js +404 -0
  29. package/src/web/aginti-client.js +552 -0
  30. package/src/web/aginti-protocol.js +1146 -0
  31. package/src/web/asset-map.js +462 -0
  32. package/src/web/browser-app.js +6491 -0
  33. package/src/web/cloud-session-client.js +427 -0
  34. package/src/web/direct-chat-client.js +1482 -0
  35. package/src/web/index.js +10 -0
  36. package/src/web/presentation-state.js +107 -0
  37. package/src/web/pwa-assets.js +854 -0
  38. package/src/web/pwa-update-handoff-store.js +179 -0
  39. package/src/web/safe-rendering.js +836 -0
  40. package/src/web/vision-image-client.js +546 -0
  41. package/src/web/vision-image-sanitizer.js +168 -0
  42. package/src/web/web-release.js +28 -0
@@ -0,0 +1,1146 @@
1
+ /*
2
+ * Browser-safe public protocol for the AgInTi integration API.
3
+ *
4
+ * This module deliberately contains no agent implementation. It accepts only
5
+ * AgInTi-owned thread, run, event, and artifact envelopes and returns frozen
6
+ * presentation data. Unknown fields fail closed so private runtime state can
7
+ * never silently become part of the cloud UI contract.
8
+ */
9
+
10
+ export const AGINTI_SCHEMA_VERSION = "1";
11
+ export const AGINTI_MAX_FILE_ARTIFACT_BYTES = 16 * 1024 * 1024;
12
+
13
+ export const AGINTI_RPC_PATHS = Object.freeze({
14
+ capabilities: "/agent/v1/capabilities",
15
+ threadsList: "/agent/v1/threads/list",
16
+ threadsCreate: "/agent/v1/threads/create",
17
+ threadsGet: "/agent/v1/threads/get",
18
+ threadsUpdate: "/agent/v1/threads/update",
19
+ threadsDelete: "/agent/v1/threads/delete",
20
+ runsStart: "/agent/v1/runs/start",
21
+ runsStatus: "/agent/v1/runs/status",
22
+ runsEvents: "/agent/v1/runs/events",
23
+ runsCancel: "/agent/v1/runs/cancel",
24
+ runsResume: "/agent/v1/runs/resume",
25
+ artifactsList: "/agent/v1/artifacts/list",
26
+ artifactsGet: "/agent/v1/artifacts/get",
27
+ });
28
+
29
+ export const AGINTI_EVENT_TYPES = Object.freeze([
30
+ "run.status",
31
+ "plan.updated",
32
+ "context.compacted",
33
+ "tool.started",
34
+ "tool.progress",
35
+ "tool.completed",
36
+ "tool.failed",
37
+ "output.delta",
38
+ "output.completed",
39
+ "artifact.created",
40
+ "artifact.updated",
41
+ "run.completed",
42
+ "run.failed",
43
+ "run.cancelled",
44
+ ]);
45
+
46
+ export const AGINTI_RUN_STATUSES = Object.freeze([
47
+ "starting",
48
+ "running",
49
+ "completed",
50
+ "failed",
51
+ "cancelled",
52
+ ]);
53
+
54
+ export const AGINTI_SEARCH_MODES = Object.freeze(["web", "papers", "both"]);
55
+
56
+ export const FAIL_CLOSED_AGENT_CAPABILITIES = Object.freeze({
57
+ schemaVersion: AGINTI_SCHEMA_VERSION,
58
+ enabled: false,
59
+ agent: Object.freeze({ kind: "aginti", label: "AgInTi Agent" }),
60
+ model: Object.freeze({ label: "LocalLLM" }),
61
+ actions: Object.freeze({ cancel: false, resume: false, retry: false }),
62
+ attachments: Object.freeze({ enabled: false }),
63
+ artifacts: Object.freeze({
64
+ kinds: Object.freeze(["plot", "table", "markdown"]),
65
+ schemaVersion: AGINTI_SCHEMA_VERSION,
66
+ }),
67
+ });
68
+
69
+ const EVENT_TYPES = new Set(AGINTI_EVENT_TYPES);
70
+ const RUN_STATUSES = new Set(AGINTI_RUN_STATUSES);
71
+ const MUTATIONS = new Set([
72
+ AGINTI_RPC_PATHS.threadsCreate,
73
+ AGINTI_RPC_PATHS.threadsUpdate,
74
+ AGINTI_RPC_PATHS.threadsDelete,
75
+ AGINTI_RPC_PATHS.runsStart,
76
+ AGINTI_RPC_PATHS.runsCancel,
77
+ AGINTI_RPC_PATHS.runsResume,
78
+ ]);
79
+ const THREAD_ID = /^thr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
80
+ const RUN_ID = /^run_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
81
+ const ARTIFACT_ID = /^art_[A-Za-z0-9_-]{32,86}$/u;
82
+ const DIGEST = /^[a-f0-9]{64}$/u;
83
+ const FILE_ARTIFACT_MIMES = new Set(["application/pdf", "application/x-tex", "text/x-tex"]);
84
+ const PRIVATE_PATH = /(?:^|[\s("'`])\/(?:workspace|home|users|root|etc|usr|var|opt|srv|run|tmp|proc|sys|dev|mnt|media|aginti-(?:home|cache|env))(?:\/|\b)|(?:^|[\s("'`])[A-Za-z]:\\/iu;
85
+ const UNSAFE_PRESENTATION = /[<>]|(?:javascript\s*:|(?:https?|data|file)\s*:\/\/)/iu;
86
+ const CONTROL = /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
87
+ const ZERO_HASH = "0".repeat(64);
88
+ const MAX_PLOT_MAGNITUDE = Number.MAX_SAFE_INTEGER;
89
+ const SEARCH_MODES = new Set(AGINTI_SEARCH_MODES);
90
+ const CREDENTIAL_QUERY_NAME = /(?:(?:^|[_-])(?:access[_-]?token|api[_-]?key|auth(?:orization)?|credential|key|password|secret|signature|token)(?:$|[_-])|^(?:(?:aws|google)?accesskeyid|googleaccessid|sig)$)/iu;
91
+ const utf8 = new TextEncoder();
92
+ const verifiedEvents = new WeakSet();
93
+
94
+ export class AgintiProtocolError extends Error {
95
+ constructor(message, { code = "AGINTI_PROTOCOL_ERROR" } = {}) {
96
+ super(message);
97
+ this.name = "AgintiProtocolError";
98
+ this.code = code;
99
+ }
100
+ }
101
+
102
+ function invalid(message, code) {
103
+ throw new AgintiProtocolError(message, code ? { code } : undefined);
104
+ }
105
+
106
+ function dataProperties(value, label) {
107
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
108
+ invalid(`${label} must be a plain JSON object`);
109
+ }
110
+ const prototype = Object.getPrototypeOf(value);
111
+ if (prototype !== Object.prototype && prototype !== null) {
112
+ invalid(`${label} must be a plain JSON object`);
113
+ }
114
+ const descriptors = Object.getOwnPropertyDescriptors(value);
115
+ const keys = Reflect.ownKeys(descriptors);
116
+ for (const key of keys) {
117
+ if (typeof key !== "string") invalid(`${label} may not contain symbol keys`);
118
+ const descriptor = descriptors[key];
119
+ if (!descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
120
+ invalid(`${label} must contain only enumerable data properties`);
121
+ }
122
+ }
123
+ return { descriptors, keys };
124
+ }
125
+
126
+ function exact(value, allowed, label, required = allowed) {
127
+ const { keys } = dataProperties(value, label);
128
+ const permitted = new Set(allowed);
129
+ for (const key of keys) {
130
+ if (!permitted.has(key)) invalid(`${label} contains unsupported field ${JSON.stringify(key)}`, "UNSUPPORTED_FIELD");
131
+ }
132
+ for (const key of required) {
133
+ if (!Object.hasOwn(value, key)) invalid(`${label}.${key} is required`);
134
+ }
135
+ return value;
136
+ }
137
+
138
+ function denseDataArray(value, label, { minimum = 0, maximum } = {}) {
139
+ if (!Array.isArray(value) || value.length < minimum || value.length > maximum) {
140
+ invalid(`${label} must contain ${minimum}-${maximum} entries`);
141
+ }
142
+ const descriptors = Object.getOwnPropertyDescriptors(value);
143
+ for (const key of Reflect.ownKeys(descriptors)) {
144
+ if (key === "length") continue;
145
+ if (typeof key !== "string" || !/^(?:0|[1-9]\d*)$/u.test(key)
146
+ || Number(key) >= value.length) invalid(`${label} contains an unsupported field`);
147
+ const descriptor = descriptors[key];
148
+ if (!descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
149
+ invalid(`${label} must contain only enumerable data entries`);
150
+ }
151
+ }
152
+ for (let index = 0; index < value.length; index += 1) {
153
+ if (!Object.hasOwn(descriptors, String(index))) invalid(`${label} may not contain sparse entries`);
154
+ }
155
+ return value;
156
+ }
157
+
158
+ function boundedInteger(value, label, { minimum = 0, maximum = Number.MAX_SAFE_INTEGER } = {}) {
159
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
160
+ invalid(`${label} must be an integer from ${minimum} through ${maximum}`);
161
+ }
162
+ return value;
163
+ }
164
+
165
+ function isUnicodeScalarText(value) {
166
+ for (let index = 0; index < value.length; index += 1) {
167
+ const code = value.charCodeAt(index);
168
+ if (code >= 0xd800 && code <= 0xdbff) {
169
+ const next = value.charCodeAt(index + 1);
170
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
171
+ index += 1;
172
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
173
+ return false;
174
+ }
175
+ }
176
+ return true;
177
+ }
178
+
179
+ function boundedText(value, label, maximum, { minimum = 0, presentation = false } = {}) {
180
+ if (typeof value !== "string" || value.length < minimum || value.length > maximum) {
181
+ invalid(`${label} must contain ${minimum}-${maximum} characters`);
182
+ }
183
+ if (!isUnicodeScalarText(value)) invalid(`${label} contains malformed Unicode text`);
184
+ if (CONTROL.test(value)) invalid(`${label} contains forbidden control characters`);
185
+ if (presentation && (UNSAFE_PRESENTATION.test(value) || PRIVATE_PATH.test(value))) {
186
+ invalid(`${label} contains markup, a URL, or a private runtime path`, "UNSAFE_PRESENTATION");
187
+ }
188
+ return value;
189
+ }
190
+
191
+ function label(value, name, maximum = 120) {
192
+ const result = boundedText(value, name, maximum, { minimum: 1, presentation: true }).trim();
193
+ if (!result) invalid(`${name} must contain non-whitespace text`);
194
+ return result;
195
+ }
196
+
197
+ function finite(value, name) {
198
+ if (typeof value !== "number" || !Number.isFinite(value)) invalid(`${name} must be a finite number`);
199
+ return value;
200
+ }
201
+
202
+ function plotNumber(value, name) {
203
+ const result = finite(value, name);
204
+ if (Math.abs(result) > MAX_PLOT_MAGNITUDE) {
205
+ invalid(`${name} exceeds the supported plot magnitude`);
206
+ }
207
+ return result;
208
+ }
209
+
210
+ function validatePlotRange(values, name, { includeZero = false } = {}) {
211
+ let minimum = includeZero ? 0 : Math.min(...values);
212
+ let maximum = includeZero ? 0 : Math.max(...values);
213
+ if (includeZero) {
214
+ minimum = Math.min(minimum, ...values);
215
+ maximum = Math.max(maximum, ...values);
216
+ }
217
+ if (minimum === maximum) {
218
+ minimum -= 1;
219
+ maximum += 1;
220
+ }
221
+ const span = maximum - minimum;
222
+ if (![minimum, maximum, span].every(Number.isFinite) || span <= 0) {
223
+ invalid(`${name} produces an unsupported numeric range`);
224
+ }
225
+ }
226
+
227
+ function timestamp(value, name, { nullable = false } = {}) {
228
+ if (nullable && value === null) return null;
229
+ const result = boundedText(value, name, 40, { minimum: 20 });
230
+ const parsed = new Date(result);
231
+ if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== result) {
232
+ invalid(`${name} must be a canonical UTC ISO timestamp`);
233
+ }
234
+ return result;
235
+ }
236
+
237
+ export function validateThreadId(value) {
238
+ if (typeof value !== "string" || !THREAD_ID.test(value)) invalid("threadId is invalid");
239
+ return value;
240
+ }
241
+
242
+ export function validateRunId(value) {
243
+ if (typeof value !== "string" || !RUN_ID.test(value)) invalid("runId is invalid");
244
+ return value;
245
+ }
246
+
247
+ export function validateArtifactId(value) {
248
+ if (typeof value !== "string" || !ARTIFACT_ID.test(value)) invalid("artifactId is invalid");
249
+ return value;
250
+ }
251
+
252
+ export function validateIdempotencyKey(value) {
253
+ if (typeof value !== "string" || !/^[A-Za-z0-9._~-]{16,160}$/u.test(value)) {
254
+ invalid("idempotency key must be an opaque 16-160 character identifier", "INVALID_IDEMPOTENCY_KEY");
255
+ }
256
+ return value;
257
+ }
258
+
259
+ export function rpcPathIsMutation(pathname) {
260
+ return MUTATIONS.has(pathname);
261
+ }
262
+
263
+ function title(value, { optional = false } = {}) {
264
+ if (optional && value === undefined) return undefined;
265
+ return label(value, "title", 120);
266
+ }
267
+
268
+ export function validateAgentSearch(value) {
269
+ const search = exact(value, ["mode", "limit"], "input.search");
270
+ if (!SEARCH_MODES.has(search.mode)) invalid("input.search.mode must be web, papers, or both");
271
+ return Object.freeze({
272
+ mode: search.mode,
273
+ limit: boundedInteger(search.limit, "input.search.limit", { minimum: 1, maximum: 20 }),
274
+ });
275
+ }
276
+
277
+ function input(value, { optional = false } = {}) {
278
+ if (optional && value === undefined) return undefined;
279
+ const object = exact(value, ["text", "search"], "input", ["text"]);
280
+ const text = boundedText(object.text, "input.text", 32_000, { minimum: 1 }).trim();
281
+ if (!text) invalid("input.text must contain non-whitespace text");
282
+ if (utf8.encode(text).byteLength > 32 * 1024) invalid("input.text exceeds the UTF-8 byte limit");
283
+ return Object.freeze({
284
+ text,
285
+ ...(object.search === undefined ? {} : { search: validateAgentSearch(object.search) }),
286
+ });
287
+ }
288
+
289
+ export function validateAgentRequest(pathname, value = {}) {
290
+ switch (pathname) {
291
+ case AGINTI_RPC_PATHS.capabilities:
292
+ exact(value, [], "request");
293
+ return Object.freeze({});
294
+ case AGINTI_RPC_PATHS.threadsList: {
295
+ const object = exact(value, ["limit", "before"], "request", []);
296
+ return Object.freeze({
297
+ limit: object.limit === undefined ? 50 : boundedInteger(object.limit, "limit", { minimum: 1, maximum: 100 }),
298
+ before: object.before === undefined ? "" : boundedText(object.before, "before", 128),
299
+ });
300
+ }
301
+ case AGINTI_RPC_PATHS.threadsCreate: {
302
+ const object = exact(value, ["title"], "request", []);
303
+ return Object.freeze({ title: title(object.title, { optional: true }) ?? "New agent thread" });
304
+ }
305
+ case AGINTI_RPC_PATHS.threadsGet:
306
+ case AGINTI_RPC_PATHS.threadsDelete: {
307
+ const object = exact(value, ["threadId"], "request");
308
+ return Object.freeze({ threadId: validateThreadId(object.threadId) });
309
+ }
310
+ case AGINTI_RPC_PATHS.threadsUpdate: {
311
+ const object = exact(value, ["threadId", "title"], "request");
312
+ return Object.freeze({ threadId: validateThreadId(object.threadId), title: title(object.title) });
313
+ }
314
+ case AGINTI_RPC_PATHS.runsStart: {
315
+ const object = exact(value, ["threadId", "input"], "request");
316
+ return Object.freeze({ threadId: validateThreadId(object.threadId), input: input(object.input) });
317
+ }
318
+ case AGINTI_RPC_PATHS.runsStatus:
319
+ case AGINTI_RPC_PATHS.runsCancel: {
320
+ const object = exact(value, ["runId"], "request");
321
+ return Object.freeze({ runId: validateRunId(object.runId) });
322
+ }
323
+ case AGINTI_RPC_PATHS.runsEvents: {
324
+ const object = exact(value, ["runId", "afterSeq", "afterHash"], "request");
325
+ const afterSeq = boundedInteger(object.afterSeq, "afterSeq", { maximum: 10_000_000_000 });
326
+ if (typeof object.afterHash !== "string" || !DIGEST.test(object.afterHash)) {
327
+ invalid("afterHash must be a lowercase SHA-256 digest");
328
+ }
329
+ if (afterSeq === 0 && object.afterHash !== ZERO_HASH) {
330
+ invalid("afterHash must be the zero hash when afterSeq is 0");
331
+ }
332
+ if (afterSeq > 0 && object.afterHash === ZERO_HASH) {
333
+ invalid("afterHash must not be the zero hash when afterSeq is greater than 0");
334
+ }
335
+ return Object.freeze({
336
+ runId: validateRunId(object.runId),
337
+ afterSeq,
338
+ afterHash: object.afterHash,
339
+ });
340
+ }
341
+ case AGINTI_RPC_PATHS.runsResume: {
342
+ const object = exact(value, ["runId", "input"], "request", ["runId"]);
343
+ const nextInput = input(object.input, { optional: true });
344
+ return Object.freeze({
345
+ runId: validateRunId(object.runId),
346
+ ...(nextInput === undefined ? {} : { input: nextInput }),
347
+ });
348
+ }
349
+ case AGINTI_RPC_PATHS.artifactsList: {
350
+ const object = exact(value, ["threadId", "runId"], "request", []);
351
+ if ((object.threadId === undefined) === (object.runId === undefined)) {
352
+ invalid("exactly one of threadId or runId is required");
353
+ }
354
+ return Object.freeze(object.threadId === undefined
355
+ ? { threadId: "", runId: validateRunId(object.runId) }
356
+ : { threadId: validateThreadId(object.threadId), runId: "" });
357
+ }
358
+ case AGINTI_RPC_PATHS.artifactsGet: {
359
+ const object = exact(value, ["artifactId"], "request");
360
+ return Object.freeze({ artifactId: validateArtifactId(object.artifactId) });
361
+ }
362
+ default:
363
+ invalid("unknown AgInTi RPC path", "NOT_FOUND");
364
+ }
365
+ }
366
+
367
+ export function validatePlotSpec(value) {
368
+ const spec = exact(
369
+ value,
370
+ ["schemaVersion", "type", "xLabel", "yLabel", "labels", "series"],
371
+ "plot spec",
372
+ ["schemaVersion", "type", "series"],
373
+ );
374
+ if (spec.schemaVersion !== AGINTI_SCHEMA_VERSION || !["line", "bar", "scatter", "area"].includes(spec.type)) {
375
+ invalid("plot schema version or type is unsupported");
376
+ }
377
+ if (!Array.isArray(spec.series) || spec.series.length < 1 || spec.series.length > 8) {
378
+ invalid("plot series must contain 1-8 entries");
379
+ }
380
+ const categorical = spec.type !== "scatter";
381
+ let labels;
382
+ if (categorical) {
383
+ if (!Array.isArray(spec.labels) || spec.labels.length < 1 || spec.labels.length > 128) {
384
+ invalid("categorical plots require 1-128 labels");
385
+ }
386
+ labels = spec.labels.map((item, index) => label(item, `plot labels[${index}]`, 160));
387
+ } else if (spec.labels !== undefined) {
388
+ invalid("scatter plots do not accept labels");
389
+ }
390
+ let points = 0;
391
+ const names = new Set();
392
+ const series = spec.series.map((entry, index) => {
393
+ const item = exact(
394
+ entry,
395
+ categorical ? ["name", "data"] : ["name", "points"],
396
+ `plot series[${index}]`,
397
+ );
398
+ const name = label(item.name, `plot series[${index}].name`);
399
+ if (names.has(name)) invalid("plot series names must be unique");
400
+ names.add(name);
401
+ if (categorical) {
402
+ if (!Array.isArray(item.data) || item.data.length !== labels.length) {
403
+ invalid(`plot series[${index}].data must match labels length`);
404
+ }
405
+ points += item.data.length;
406
+ return Object.freeze({
407
+ name,
408
+ data: Object.freeze(item.data.map((point, pointIndex) => plotNumber(point, `plot series[${index}].data[${pointIndex}]`))),
409
+ });
410
+ }
411
+ if (!Array.isArray(item.points) || item.points.length < 1) invalid("scatter points must not be empty");
412
+ points += item.points.length;
413
+ return Object.freeze({
414
+ name,
415
+ points: Object.freeze(item.points.map((point, pointIndex) => {
416
+ const pair = exact(point, ["x", "y"], `plot series[${index}].points[${pointIndex}]`);
417
+ return Object.freeze({
418
+ x: plotNumber(pair.x, `plot series[${index}].points[${pointIndex}].x`),
419
+ y: plotNumber(pair.y, `plot series[${index}].points[${pointIndex}].y`),
420
+ });
421
+ })),
422
+ });
423
+ });
424
+ if (points > 500) invalid("plot contains more than 500 total points");
425
+ const normalizedPoints = series.flatMap((entry) => categorical
426
+ ? entry.data.map((y, x) => ({ x, y }))
427
+ : entry.points);
428
+ validatePlotRange(normalizedPoints.map(({ y }) => y), "plot y values", { includeZero: true });
429
+ validatePlotRange(normalizedPoints.map(({ x }) => x), "plot x values");
430
+ return Object.freeze({
431
+ schemaVersion: AGINTI_SCHEMA_VERSION,
432
+ type: spec.type,
433
+ ...(spec.xLabel === undefined ? {} : { xLabel: label(spec.xLabel, "plot xLabel") }),
434
+ ...(spec.yLabel === undefined ? {} : { yLabel: label(spec.yLabel, "plot yLabel") }),
435
+ ...(labels === undefined ? {} : { labels: Object.freeze(labels) }),
436
+ series: Object.freeze(series),
437
+ });
438
+ }
439
+
440
+ export function validateTableSpec(value) {
441
+ const spec = exact(value, ["schemaVersion", "columns", "rows"], "table spec");
442
+ if (spec.schemaVersion !== AGINTI_SCHEMA_VERSION) invalid("table spec schemaVersion must be 1");
443
+ if (!Array.isArray(spec.columns) || spec.columns.length < 1 || spec.columns.length > 12) {
444
+ invalid("table columns must contain 1-12 entries");
445
+ }
446
+ if (!Array.isArray(spec.rows) || spec.rows.length > 200) invalid("table rows may contain at most 200 entries");
447
+ const keys = new Set();
448
+ const columns = spec.columns.map((column, index) => {
449
+ const item = exact(column, ["key", "label"], `table columns[${index}]`);
450
+ if (typeof item.key !== "string" || !/^[A-Za-z][A-Za-z0-9_]{0,47}$/u.test(item.key) || keys.has(item.key)) {
451
+ invalid(`table columns[${index}].key is invalid or duplicated`);
452
+ }
453
+ keys.add(item.key);
454
+ return Object.freeze({ key: item.key, label: label(item.label, `table columns[${index}].label`) });
455
+ });
456
+ const rows = spec.rows.map((row, rowIndex) => {
457
+ const { keys: rowKeys } = dataProperties(row, `table rows[${rowIndex}]`);
458
+ if (rowKeys.some((key) => !keys.has(key))) invalid(`table rows[${rowIndex}] contains an unknown column`);
459
+ return Object.freeze(Object.fromEntries(columns.map(({ key }) => {
460
+ const cell = row[key] ?? null;
461
+ if (cell === null || typeof cell === "boolean") return [key, cell];
462
+ if (typeof cell === "number") return [key, finite(cell, `table rows[${rowIndex}].${key}`)];
463
+ return [key, boundedText(cell, `table rows[${rowIndex}].${key}`, 2_000, { presentation: true })];
464
+ })));
465
+ });
466
+ return Object.freeze({ schemaVersion: AGINTI_SCHEMA_VERSION, columns: Object.freeze(columns), rows: Object.freeze(rows) });
467
+ }
468
+
469
+ export function validateMarkdownSpec(value) {
470
+ const spec = exact(value, ["schemaVersion", "markdown"], "markdown spec");
471
+ if (spec.schemaVersion !== AGINTI_SCHEMA_VERSION) invalid("markdown spec schemaVersion must be 1");
472
+ const markdown = boundedText(spec.markdown, "markdown", 32_000);
473
+ if (/<\/?[A-Za-z][^>]*>|!\[[^\]]*\]\s*\(|\[[^\]]+\]\s*\([^)]*\)|(?:https?|data|file|javascript)\s*:|(?:^|[\s("'`])\/(?:workspace|home|users|root|etc|usr|var|opt|srv|run|tmp|proc|sys|dev|mnt|media|aginti-(?:home|cache|env))(?:\/|\b)|(?:^|[\s("'`])[A-Za-z]:\\/imu.test(markdown)) {
474
+ invalid("markdown artifacts may not contain HTML, links, images, URLs, or private runtime paths", "UNSAFE_PRESENTATION");
475
+ }
476
+ return Object.freeze({ schemaVersion: AGINTI_SCHEMA_VERSION, markdown });
477
+ }
478
+
479
+ function sourceUrl(value, name) {
480
+ const raw = boundedText(value, name, 2_048, { minimum: 1 });
481
+ let parsed;
482
+ try { parsed = new URL(raw); }
483
+ catch { invalid(`${name} must be an HTTPS URL`); }
484
+ if (parsed.protocol !== "https:" || !parsed.hostname || parsed.username || parsed.password
485
+ || (parsed.port && parsed.port !== "443") || parsed.hash) {
486
+ invalid(`${name} must be a credential-free HTTPS URL without a fragment`);
487
+ }
488
+ for (const [key] of parsed.searchParams) {
489
+ if (CREDENTIAL_QUERY_NAME.test(key)) invalid(`${name} may not contain credential query fields`);
490
+ }
491
+ return parsed.href;
492
+ }
493
+
494
+ function sourceDate(value, name) {
495
+ if (value === null) return null;
496
+ const result = boundedText(value, name, 10, { minimum: 10 });
497
+ const parsed = new Date(`${result}T00:00:00.000Z`);
498
+ if (!/^\d{4}-\d{2}-\d{2}$/u.test(result) || !Number.isFinite(parsed.getTime())
499
+ || parsed.toISOString().slice(0, 10) !== result) {
500
+ invalid(`${name} must be a canonical calendar date or null`);
501
+ }
502
+ return result;
503
+ }
504
+
505
+ function sourceDoi(value, name) {
506
+ if (value === null) return null;
507
+ const result = boundedText(value, name, 300, { minimum: 7, presentation: true }).trim();
508
+ if (!/^10\.\d{4,9}\/[A-Za-z0-9][A-Za-z0-9._;()/:+-]*$/u.test(result)) {
509
+ invalid(`${name} must be a DOI or null`);
510
+ }
511
+ return result;
512
+ }
513
+
514
+ export function validateSourcesSpec(value) {
515
+ const spec = exact(value, ["schemaVersion", "sources"], "sources spec");
516
+ if (spec.schemaVersion !== AGINTI_SCHEMA_VERSION) invalid("sources spec schemaVersion must be 1");
517
+ const sourceItems = denseDataArray(spec.sources, "sources", { minimum: 1, maximum: 20 });
518
+ const sources = sourceItems.map((source, offset) => {
519
+ const item = exact(
520
+ source,
521
+ ["index", "title", "url", "snippet", "providers", "kind", "publishedDate", "doi"],
522
+ `sources[${offset}]`,
523
+ );
524
+ if (item.index !== offset + 1) invalid(`sources[${offset}].index must match its one-based position`);
525
+ const providerItems = denseDataArray(item.providers, `sources[${offset}].providers`, { minimum: 1, maximum: 12 });
526
+ const providers = providerItems.map((provider, index) => label(
527
+ provider,
528
+ `sources[${offset}].providers[${index}]`,
529
+ 100,
530
+ ));
531
+ if (new Set(providers).size !== providers.length) invalid(`sources[${offset}].providers must be unique`);
532
+ if (!["web", "paper"].includes(item.kind)) invalid(`sources[${offset}].kind must be web or paper`);
533
+ return Object.freeze({
534
+ index: item.index,
535
+ title: label(item.title, `sources[${offset}].title`, 500),
536
+ url: sourceUrl(item.url, `sources[${offset}].url`),
537
+ snippet: boundedText(item.snippet, `sources[${offset}].snippet`, 4_000, { presentation: true }).trim(),
538
+ providers: Object.freeze(providers),
539
+ kind: item.kind,
540
+ publishedDate: sourceDate(item.publishedDate, `sources[${offset}].publishedDate`),
541
+ doi: sourceDoi(item.doi, `sources[${offset}].doi`),
542
+ });
543
+ });
544
+ return Object.freeze({ schemaVersion: AGINTI_SCHEMA_VERSION, sources: Object.freeze(sources) });
545
+ }
546
+
547
+ export function validateFileSpec(value) {
548
+ const spec = exact(value, ["schemaVersion", "filename", "mime", "bytes", "sha256"], "file spec");
549
+ if (spec.schemaVersion !== AGINTI_SCHEMA_VERSION) invalid("file spec schemaVersion must be 1");
550
+ const filename = boundedText(spec.filename, "file filename", 240, { minimum: 1, presentation: true });
551
+ if (filename === "." || filename === ".." || filename.trim() !== filename
552
+ || filename.includes("/") || filename.includes("\\")) {
553
+ invalid("file filename must be a safe single basename");
554
+ }
555
+ if (typeof spec.mime !== "string" || !FILE_ARTIFACT_MIMES.has(spec.mime)) {
556
+ invalid("file mime is unsupported");
557
+ }
558
+ const extension = filename.toLowerCase().endsWith(".pdf")
559
+ ? "pdf"
560
+ : (filename.toLowerCase().endsWith(".tex") ? "tex" : null);
561
+ if ((spec.mime === "application/pdf" && extension !== "pdf")
562
+ || (spec.mime !== "application/pdf" && extension !== "tex")) {
563
+ invalid("file filename extension does not match its mime");
564
+ }
565
+ const bytes = boundedInteger(spec.bytes, "file bytes", { minimum: 1, maximum: AGINTI_MAX_FILE_ARTIFACT_BYTES });
566
+ if (typeof spec.sha256 !== "string" || !DIGEST.test(spec.sha256)) {
567
+ invalid("file sha256 must be a lowercase SHA-256 digest");
568
+ }
569
+ return Object.freeze({
570
+ schemaVersion: AGINTI_SCHEMA_VERSION,
571
+ filename,
572
+ mime: spec.mime,
573
+ bytes,
574
+ sha256: spec.sha256,
575
+ });
576
+ }
577
+
578
+ export function validateArtifact(value) {
579
+ const artifact = exact(value, ["id", "title", "kind", "spec"], "artifact");
580
+ const kind = artifact.kind;
581
+ if (!["plot", "table", "markdown", "sources", "file"].includes(kind)) invalid("artifact kind is unsupported");
582
+ const normalized = Object.freeze({
583
+ id: validateArtifactId(artifact.id),
584
+ title: title(artifact.title),
585
+ kind,
586
+ spec: kind === "plot"
587
+ ? validatePlotSpec(artifact.spec)
588
+ : (kind === "table"
589
+ ? validateTableSpec(artifact.spec)
590
+ : (kind === "markdown"
591
+ ? validateMarkdownSpec(artifact.spec)
592
+ : (kind === "sources" ? validateSourcesSpec(artifact.spec) : validateFileSpec(artifact.spec)))),
593
+ });
594
+ if (utf8.encode(JSON.stringify(normalized)).byteLength > 48 * 1024) {
595
+ invalid("artifact exceeds its 48 KiB public contract", "ARTIFACT_TOO_LARGE");
596
+ }
597
+ return normalized;
598
+ }
599
+
600
+ export function validateEventPayload(type, value) {
601
+ if (!EVENT_TYPES.has(type)) invalid(`unsupported event type ${JSON.stringify(type)}`);
602
+ if (type === "run.status") {
603
+ const payload = exact(value, ["status"], "run.status payload");
604
+ if (!RUN_STATUSES.has(payload.status)) invalid("run.status status is invalid");
605
+ return Object.freeze({ status: payload.status });
606
+ }
607
+ if (type === "plan.updated") {
608
+ const payload = exact(value, ["steps"], "plan.updated payload");
609
+ if (!Array.isArray(payload.steps) || payload.steps.length > 64) invalid("plan steps may contain at most 64 items");
610
+ return Object.freeze({
611
+ steps: Object.freeze(payload.steps.map((step, index) => {
612
+ const item = exact(step, ["id", "label", "status"], `plan step[${index}]`);
613
+ if (typeof item.id !== "string" || !/^[A-Za-z0-9._~-]{1,96}$/u.test(item.id)) invalid("plan step id is invalid");
614
+ if (!["pending", "in_progress", "completed", "failed"].includes(item.status)) invalid("plan step status is invalid");
615
+ return Object.freeze({ id: item.id, label: label(item.label, `plan step[${index}].label`, 240), status: item.status });
616
+ })),
617
+ });
618
+ }
619
+ if (type === "context.compacted") {
620
+ const payload = exact(value, ["compactedMessages", "tokensBefore", "tokensAfter"], "context.compacted payload");
621
+ return Object.freeze({
622
+ compactedMessages: boundedInteger(payload.compactedMessages, "compactedMessages", { maximum: 1_000_000 }),
623
+ tokensBefore: boundedInteger(payload.tokensBefore, "tokensBefore", { maximum: 10_000_000 }),
624
+ tokensAfter: boundedInteger(payload.tokensAfter, "tokensAfter", { maximum: 10_000_000 }),
625
+ });
626
+ }
627
+ if (type.startsWith("tool.")) {
628
+ const payload = exact(value, ["callId", "publicLabel", "publicSummary", "at"], `${type} payload`);
629
+ if (typeof payload.callId !== "string" || !/^[A-Za-z0-9._~-]{1,128}$/u.test(payload.callId)) invalid("tool callId is invalid");
630
+ return Object.freeze({
631
+ callId: payload.callId,
632
+ publicLabel: label(payload.publicLabel, `${type} publicLabel`),
633
+ publicSummary: label(payload.publicSummary, `${type} publicSummary`, 400),
634
+ at: timestamp(payload.at, `${type} at`),
635
+ });
636
+ }
637
+ if (type === "output.delta") {
638
+ const payload = exact(value, ["text"], "output.delta payload");
639
+ return Object.freeze({ text: boundedText(payload.text, "output.delta text", 4_000, { minimum: 1 }) });
640
+ }
641
+ if (type === "artifact.created" || type === "artifact.updated") {
642
+ const eventLabel = `${type} payload`;
643
+ const payload = exact(value, ["artifact", "receiptDigest"], eventLabel, ["artifact"]);
644
+ const artifact = validateArtifact(payload.artifact);
645
+ if (artifact.kind === "file") {
646
+ exact(value, ["artifact", "receiptDigest"], eventLabel);
647
+ if (typeof payload.receiptDigest !== "string" || !DIGEST.test(payload.receiptDigest)) {
648
+ invalid(`${type} file receiptDigest must be a lowercase SHA-256 digest`);
649
+ }
650
+ return Object.freeze({ artifact, receiptDigest: payload.receiptDigest });
651
+ }
652
+ exact(value, ["artifact"], eventLabel);
653
+ return Object.freeze({ artifact });
654
+ }
655
+ exact(value, [], `${type} payload`);
656
+ return Object.freeze({});
657
+ }
658
+
659
+ export function validateEventEnvelope(value) {
660
+ const event = exact(
661
+ value,
662
+ ["schemaVersion", "id", "seq", "type", "threadId", "runId", "createdAt", "payload", "previousHash", "hash"],
663
+ "agent event",
664
+ );
665
+ if (event.schemaVersion !== AGINTI_SCHEMA_VERSION) invalid("agent event schemaVersion must be 1");
666
+ const runId = validateRunId(event.runId);
667
+ const threadId = validateThreadId(event.threadId);
668
+ const seq = boundedInteger(event.seq, "agent event seq", { minimum: 1, maximum: 10_000_000_000 });
669
+ if (event.id !== `${runId}.${seq}`) invalid("agent event id does not match runId and seq");
670
+ if (!DIGEST.test(event.previousHash) || !DIGEST.test(event.hash)) invalid("agent event hashes are invalid");
671
+ const envelope = Object.freeze({
672
+ schemaVersion: AGINTI_SCHEMA_VERSION,
673
+ id: event.id,
674
+ seq,
675
+ type: event.type,
676
+ threadId,
677
+ runId,
678
+ createdAt: timestamp(event.createdAt, "agent event createdAt"),
679
+ payload: validateEventPayload(event.type, event.payload),
680
+ previousHash: event.previousHash,
681
+ });
682
+ return Object.freeze({ ...envelope, hash: event.hash });
683
+ }
684
+
685
+ function canonicalize(value, seen) {
686
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
687
+ if (typeof value === "number") {
688
+ if (!Number.isFinite(value)) invalid("canonical data contains a non-finite number");
689
+ return value;
690
+ }
691
+ if (typeof value !== "object") invalid("canonical data must be JSON-compatible");
692
+ if (seen.has(value)) invalid("canonical data may not be cyclic");
693
+ seen.add(value);
694
+ let result;
695
+ if (Array.isArray(value)) {
696
+ const descriptors = Object.getOwnPropertyDescriptors(value);
697
+ const allowedKeys = new Set(["length"]);
698
+ result = new Array(value.length);
699
+ for (let index = 0; index < value.length; index += 1) {
700
+ const key = String(index);
701
+ allowedKeys.add(key);
702
+ const descriptor = descriptors[key];
703
+ if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) invalid("canonical arrays may not be sparse");
704
+ result[index] = canonicalize(descriptor.value, seen);
705
+ }
706
+ if (Reflect.ownKeys(descriptors).some((key) => typeof key !== "string" || !allowedKeys.has(key))) {
707
+ invalid("canonical arrays may not contain extra properties");
708
+ }
709
+ } else {
710
+ const { descriptors, keys } = dataProperties(value, "canonical data");
711
+ result = {};
712
+ for (const key of [...keys].sort()) {
713
+ if (descriptors[key].value === undefined) invalid("canonical data may not contain undefined");
714
+ result[key] = canonicalize(descriptors[key].value, seen);
715
+ }
716
+ }
717
+ seen.delete(value);
718
+ return result;
719
+ }
720
+
721
+ export function canonicalJson(value) {
722
+ return JSON.stringify(canonicalize(value, new Set()));
723
+ }
724
+
725
+ async function sha256Hex(value, digest) {
726
+ if (digest !== undefined) {
727
+ if (typeof digest !== "function") invalid("digest must be a function");
728
+ const result = await digest(value);
729
+ if (typeof result !== "string" || !DIGEST.test(result)) invalid("digest function returned an invalid SHA-256 value");
730
+ return result;
731
+ }
732
+ const subtle = globalThis.crypto?.subtle;
733
+ if (!subtle || typeof subtle.digest !== "function") invalid("Web Crypto SHA-256 is unavailable", "CRYPTO_UNAVAILABLE");
734
+ const bytes = await subtle.digest("SHA-256", utf8.encode(value));
735
+ return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
736
+ }
737
+
738
+ export async function verifyAgentEvent(value, {
739
+ expectedRunId,
740
+ expectedThreadId,
741
+ afterSeq = 0,
742
+ previousHash = ZERO_HASH,
743
+ digest,
744
+ } = {}) {
745
+ const event = validateEventEnvelope(value);
746
+ validateRunId(expectedRunId);
747
+ if (expectedThreadId !== undefined) validateThreadId(expectedThreadId);
748
+ boundedInteger(afterSeq, "event cursor sequence", { maximum: 10_000_000_000 });
749
+ if (!DIGEST.test(previousHash)) invalid("event cursor hash is invalid");
750
+ if (event.runId !== expectedRunId || (expectedThreadId !== undefined && event.threadId !== expectedThreadId)) {
751
+ invalid("agent event ownership does not match the requested run", "LEDGER_OWNERSHIP_MISMATCH");
752
+ }
753
+ if (event.seq !== afterSeq + 1 || event.previousHash !== previousHash) {
754
+ invalid("agent event is not contiguous with the delivery cursor", "LEDGER_CURSOR_MISMATCH");
755
+ }
756
+ const envelope = {
757
+ schemaVersion: event.schemaVersion,
758
+ id: event.id,
759
+ seq: event.seq,
760
+ type: event.type,
761
+ threadId: event.threadId,
762
+ runId: event.runId,
763
+ createdAt: event.createdAt,
764
+ payload: event.payload,
765
+ previousHash: event.previousHash,
766
+ };
767
+ const computed = await sha256Hex(canonicalJson(envelope), digest);
768
+ if (computed !== event.hash) invalid("agent event hash verification failed", "LEDGER_HASH_MISMATCH");
769
+ verifiedEvents.add(event);
770
+ return event;
771
+ }
772
+
773
+ export function assertVerifiedAgentEvent(value) {
774
+ if (!value || typeof value !== "object" || !verifiedEvents.has(value)) {
775
+ invalid("agent event has not passed ledger verification", "UNVERIFIED_EVENT");
776
+ }
777
+ return value;
778
+ }
779
+
780
+ export function validateAgentCapabilities(value) {
781
+ const response = exact(
782
+ value,
783
+ ["schemaVersion", "enabled", "agent", "model", "actions", "attachments", "search", "artifacts"],
784
+ "agent capabilities",
785
+ ["schemaVersion", "enabled", "agent", "model", "actions", "attachments", "artifacts"],
786
+ );
787
+ if (response.schemaVersion !== AGINTI_SCHEMA_VERSION || typeof response.enabled !== "boolean") {
788
+ invalid("agent capabilities schemaVersion or enabled flag is invalid");
789
+ }
790
+ const agent = exact(response.agent, ["kind", "label"], "agent capabilities agent");
791
+ const model = exact(response.model, ["label"], "agent capabilities model");
792
+ const actions = exact(response.actions, ["cancel", "resume", "retry"], "agent capabilities actions");
793
+ const attachments = exact(response.attachments, ["enabled"], "agent capabilities attachments");
794
+ const search = response.search === undefined
795
+ ? { enabled: false, modes: [], maximumSources: 0 }
796
+ : exact(response.search, ["enabled", "modes", "maximumSources"], "agent capabilities search");
797
+ const artifacts = exact(response.artifacts, ["kinds", "schemaVersion"], "agent capabilities artifacts");
798
+ if (agent.kind !== "aginti" || agent.label !== "AgInTi Agent") invalid("agent authority must be AgInTi");
799
+ if (model.label !== "LocalLLM") invalid("agent inference label must be LocalLLM");
800
+ if (![actions.cancel, actions.resume, actions.retry, attachments.enabled, search.enabled].every((flag) => typeof flag === "boolean")) {
801
+ invalid("agent capability flags must be booleans");
802
+ }
803
+ if (actions.retry || attachments.enabled) invalid("retry and attachments are not enabled in protocol v1");
804
+ const searchModes = search.enabled ? AGINTI_SEARCH_MODES : [];
805
+ const maximumSources = search.enabled
806
+ ? boundedInteger(search.maximumSources, "agent capabilities search maximumSources", { minimum: 1, maximum: 20 })
807
+ : 0;
808
+ denseDataArray(search.modes, "agent capabilities search modes", {
809
+ minimum: searchModes.length,
810
+ maximum: searchModes.length,
811
+ });
812
+ if (canonicalJson(search.modes) !== canonicalJson(searchModes)
813
+ || (!search.enabled && search.maximumSources !== 0)) {
814
+ invalid("agent search capabilities are invalid");
815
+ }
816
+ if (search.enabled && !response.enabled) invalid("disabled capabilities may not advertise search");
817
+ const legacyArtifactKinds = search.enabled
818
+ ? ["plot", "table", "markdown", "sources"]
819
+ : ["plot", "table", "markdown"];
820
+ const fileArtifactKinds = Object.freeze([...legacyArtifactKinds, "file"]);
821
+ const artifactKinds = canonicalJson(artifacts.kinds) === canonicalJson(fileArtifactKinds)
822
+ ? fileArtifactKinds
823
+ : legacyArtifactKinds;
824
+ if (artifacts.schemaVersion !== AGINTI_SCHEMA_VERSION
825
+ || !Array.isArray(artifacts.kinds)
826
+ || canonicalJson(artifacts.kinds) !== canonicalJson(artifactKinds)) {
827
+ invalid("agent artifact capabilities are invalid");
828
+ }
829
+ if (!response.enabled && (actions.cancel || actions.resume)) invalid("disabled capabilities may not advertise actions");
830
+ return Object.freeze({
831
+ schemaVersion: AGINTI_SCHEMA_VERSION,
832
+ enabled: response.enabled,
833
+ agent: Object.freeze({ kind: "aginti", label: "AgInTi Agent" }),
834
+ model: Object.freeze({ label: "LocalLLM" }),
835
+ actions: Object.freeze({ cancel: actions.cancel, resume: actions.resume, retry: false }),
836
+ attachments: Object.freeze({ enabled: false }),
837
+ ...(search.enabled ? {
838
+ search: Object.freeze({ enabled: search.enabled, modes: Object.freeze(searchModes), maximumSources }),
839
+ } : {}),
840
+ artifacts: Object.freeze({ kinds: Object.freeze(artifactKinds), schemaVersion: AGINTI_SCHEMA_VERSION }),
841
+ });
842
+ }
843
+
844
+ function publicMessage(value, index) {
845
+ const message = exact(value, ["id", "role", "content", "runId", "createdAt", "digest"], `thread message[${index}]`);
846
+ if (typeof message.id !== "string" || !/^msg_[A-Za-z0-9_-]{16,96}$/u.test(message.id)) invalid("thread message id is invalid");
847
+ if (!["user", "assistant"].includes(message.role)) invalid("thread message role is invalid");
848
+ if (!DIGEST.test(message.digest)) invalid("thread message digest is invalid");
849
+ return Object.freeze({
850
+ id: message.id,
851
+ role: message.role,
852
+ content: boundedText(message.content, `thread message[${index}].content`, 32_000),
853
+ runId: validateRunId(message.runId),
854
+ createdAt: timestamp(message.createdAt, `thread message[${index}].createdAt`),
855
+ digest: message.digest,
856
+ });
857
+ }
858
+
859
+ export function validateThread(value) {
860
+ const thread = exact(
861
+ value,
862
+ ["id", "title", "status", "revision", "createdAt", "updatedAt", "lastRunId", "authority", "replay", "messages"],
863
+ "thread",
864
+ ["id", "title", "status", "revision", "createdAt", "updatedAt", "lastRunId", "authority", "replay"],
865
+ );
866
+ if (!["idle", "running", "deleting"].includes(thread.status)) invalid("thread status is invalid");
867
+ const authority = exact(
868
+ thread.authority,
869
+ ["kind", "mapped", "runtimeRevision", "contextDigest", "lastCompaction"],
870
+ "thread authority",
871
+ );
872
+ if (authority.kind !== "aginti" || typeof authority.mapped !== "boolean") invalid("thread authority is invalid");
873
+ if (authority.runtimeRevision !== null) boundedInteger(authority.runtimeRevision, "thread runtimeRevision", { minimum: 1 });
874
+ if (authority.contextDigest !== null && !DIGEST.test(authority.contextDigest)) invalid("thread contextDigest is invalid");
875
+ let lastCompaction = null;
876
+ if (authority.lastCompaction !== null) {
877
+ const item = exact(
878
+ authority.lastCompaction,
879
+ ["compactedMessages", "tokensBefore", "tokensAfter", "digest"],
880
+ "thread lastCompaction",
881
+ );
882
+ if (!DIGEST.test(item.digest)) invalid("lastCompaction digest is invalid");
883
+ lastCompaction = Object.freeze({
884
+ compactedMessages: boundedInteger(item.compactedMessages, "lastCompaction compactedMessages", { maximum: 1_000_000 }),
885
+ tokensBefore: boundedInteger(item.tokensBefore, "lastCompaction tokensBefore", { maximum: 10_000_000 }),
886
+ tokensAfter: boundedInteger(item.tokensAfter, "lastCompaction tokensAfter", { maximum: 10_000_000 }),
887
+ digest: item.digest,
888
+ });
889
+ }
890
+ const replay = exact(thread.replay, ["prunedMessageCount", "anchorDigest"], "thread replay");
891
+ if (!DIGEST.test(replay.anchorDigest)) invalid("thread replay anchorDigest is invalid");
892
+ const prunedMessageCount = boundedInteger(replay.prunedMessageCount, "thread prunedMessageCount", {
893
+ maximum: 10_000_000,
894
+ });
895
+ if ((prunedMessageCount === 0) !== (replay.anchorDigest === ZERO_HASH)) {
896
+ invalid("thread replay anchor is inconsistent");
897
+ }
898
+ const messages = thread.messages ?? [];
899
+ if (!Array.isArray(messages) || messages.length > 256) invalid("thread replay exceeds 256 messages");
900
+ const checkedMessages = messages.map(publicMessage);
901
+ if (checkedMessages.reduce((sum, message) => sum + message.content.length, 0) > 256_000) {
902
+ invalid("thread replay exceeds 256000 characters");
903
+ }
904
+ const lastRunId = thread.lastRunId === null ? null : validateRunId(thread.lastRunId);
905
+ if (lastRunId === null && checkedMessages.length !== 0) {
906
+ invalid("thread replay messages require a lastRunId");
907
+ }
908
+ if (lastRunId === null && thread.status === "running") {
909
+ invalid("a running thread requires a lastRunId");
910
+ }
911
+ if (lastRunId === null && prunedMessageCount !== 0) {
912
+ invalid("a pristine thread cannot declare a pruned replay prefix");
913
+ }
914
+ return Object.freeze({
915
+ id: validateThreadId(thread.id),
916
+ title: title(thread.title),
917
+ status: thread.status,
918
+ revision: boundedInteger(thread.revision, "thread revision", { minimum: 1 }),
919
+ createdAt: timestamp(thread.createdAt, "thread createdAt"),
920
+ updatedAt: timestamp(thread.updatedAt, "thread updatedAt"),
921
+ lastRunId,
922
+ authority: Object.freeze({
923
+ kind: "aginti",
924
+ mapped: authority.mapped,
925
+ runtimeRevision: authority.runtimeRevision,
926
+ contextDigest: authority.contextDigest,
927
+ lastCompaction,
928
+ }),
929
+ replay: Object.freeze({
930
+ prunedMessageCount,
931
+ anchorDigest: replay.anchorDigest,
932
+ }),
933
+ messages: Object.freeze(checkedMessages),
934
+ });
935
+ }
936
+
937
+ export function validateRun(value) {
938
+ const run = exact(
939
+ value,
940
+ ["id", "threadId", "previousRunId", "status", "createdAt", "startedAt", "completedAt", "cancelRequestedAt", "output", "error", "authority", "eventCursor"],
941
+ "run",
942
+ );
943
+ if (!RUN_STATUSES.has(run.status)) invalid("run status is invalid");
944
+ const authority = exact(run.authority, ["kind", "snapshotHash", "runtimeRevision", "contextDigest"], "run authority");
945
+ if (authority.kind !== "aginti") invalid("run authority must be AgInTi");
946
+ for (const [key, value] of [["snapshotHash", authority.snapshotHash], ["contextDigest", authority.contextDigest]]) {
947
+ if (value !== null && !DIGEST.test(value)) invalid(`run ${key} is invalid`);
948
+ }
949
+ if (authority.runtimeRevision !== null) boundedInteger(authority.runtimeRevision, "run runtimeRevision", { minimum: 1 });
950
+ const cursor = exact(run.eventCursor, ["firstSeq", "lastSeq", "lastHash", "prunedThroughSeq"], "run eventCursor");
951
+ const firstSeq = boundedInteger(cursor.firstSeq, "run firstSeq", { minimum: 1, maximum: 10_000_000_001 });
952
+ const lastSeq = boundedInteger(cursor.lastSeq, "run lastSeq", { maximum: 10_000_000_000 });
953
+ const prunedThroughSeq = boundedInteger(cursor.prunedThroughSeq, "run prunedThroughSeq", { maximum: 10_000_000_000 });
954
+ if (!DIGEST.test(cursor.lastHash) || firstSeq > lastSeq + 1 || prunedThroughSeq >= firstSeq) invalid("run event cursor is inconsistent");
955
+ if (firstSeq !== 1 || prunedThroughSeq !== 0) invalid("run event cursor v1 does not support pruned ledgers");
956
+ let error = null;
957
+ if (run.error !== null) {
958
+ const item = exact(run.error, ["code", "message"], "run error");
959
+ error = Object.freeze({
960
+ code: label(item.code, "run error code", 96),
961
+ message: label(item.message, "run error message", 600),
962
+ });
963
+ }
964
+ return Object.freeze({
965
+ id: validateRunId(run.id),
966
+ threadId: validateThreadId(run.threadId),
967
+ previousRunId: run.previousRunId === null ? null : validateRunId(run.previousRunId),
968
+ status: run.status,
969
+ createdAt: timestamp(run.createdAt, "run createdAt"),
970
+ startedAt: timestamp(run.startedAt, "run startedAt", { nullable: true }),
971
+ completedAt: timestamp(run.completedAt, "run completedAt", { nullable: true }),
972
+ cancelRequestedAt: timestamp(run.cancelRequestedAt, "run cancelRequestedAt", { nullable: true }),
973
+ output: boundedText(run.output, "run output", 32_000),
974
+ error,
975
+ authority: Object.freeze({
976
+ kind: "aginti",
977
+ snapshotHash: authority.snapshotHash,
978
+ runtimeRevision: authority.runtimeRevision,
979
+ contextDigest: authority.contextDigest,
980
+ }),
981
+ eventCursor: Object.freeze({ firstSeq, lastSeq, lastHash: cursor.lastHash, prunedThroughSeq }),
982
+ });
983
+ }
984
+
985
+ // This validator accepts a full threads/get replay projection plus the exact
986
+ // runs/status records named by its messages and lastRunId. Retained-native
987
+ // threads may expose an empty optional message projection even when their head
988
+ // extends older durable runs; in that case the exact head remains authoritative
989
+ // but the unseen prefix is deliberately opaque.
990
+ export function validateThreadRunAncestry(threadValue, runValues) {
991
+ const thread = validateThread(threadValue);
992
+ if (thread.status === "deleting") invalid("a deleting thread cannot unlock Agent follow-up");
993
+ denseDataArray(runValues, "thread replay runs", { maximum: 257 });
994
+ const runs = runValues.map(validateRun);
995
+ const expectedRunIds = new Set(thread.messages.map((message) => message.runId));
996
+ if (thread.lastRunId !== null) expectedRunIds.add(thread.lastRunId);
997
+ const runsById = new Map();
998
+ for (const run of runs) {
999
+ if (run.threadId !== thread.id) invalid("thread replay run belongs to a different thread");
1000
+ if (runsById.has(run.id)) invalid("thread replay contains a duplicate run");
1001
+ if (!expectedRunIds.has(run.id)) invalid("thread replay contains an unexpected run");
1002
+ runsById.set(run.id, run);
1003
+ }
1004
+ if (runsById.size !== expectedRunIds.size) invalid("thread replay is missing a run status");
1005
+ if (thread.lastRunId === null) {
1006
+ return Object.freeze({
1007
+ runs: Object.freeze([]),
1008
+ headRun: null,
1009
+ omittedPrefix: false,
1010
+ requiresThreadRefresh: false,
1011
+ });
1012
+ }
1013
+
1014
+ const headRun = runsById.get(thread.lastRunId);
1015
+ if (!headRun) invalid("thread replay is missing its declared run head");
1016
+ const active = (run) => run.status === "starting" || run.status === "running";
1017
+ const headIsActive = active(headRun);
1018
+ // Thread and run snapshots come from separate RPCs. A run may atomically
1019
+ // finish between them, but a terminal head can never become active again.
1020
+ if (thread.status !== "running" && headIsActive) {
1021
+ invalid("thread status does not match its replayed run head");
1022
+ }
1023
+ const requiresThreadRefresh = thread.status === "running" && !headIsActive;
1024
+ for (const run of runs) {
1025
+ if (run.id !== headRun.id && active(run)) invalid("a historical replay run is not terminal");
1026
+ }
1027
+ const assistantRuns = new Set(
1028
+ thread.messages.filter((message) => message.role === "assistant").map((message) => message.runId),
1029
+ );
1030
+ for (const runId of assistantRuns) {
1031
+ if (active(runsById.get(runId))) invalid("a persisted assistant replay run is not terminal");
1032
+ }
1033
+
1034
+ const successorCounts = new Map();
1035
+ for (const run of runs) {
1036
+ if (run.previousRunId === null) continue;
1037
+ const successors = (successorCounts.get(run.previousRunId) ?? 0) + 1;
1038
+ if (successors > 1) invalid("thread replay run ancestry branches");
1039
+ successorCounts.set(run.previousRunId, successors);
1040
+ const previous = runsById.get(run.previousRunId);
1041
+ if (previous && previous.createdAt > run.createdAt) invalid("thread replay predecessor is newer than its successor");
1042
+ }
1043
+ const completed = new Set();
1044
+ for (const origin of runs) {
1045
+ if (completed.has(origin.id)) continue;
1046
+ const path = new Set();
1047
+ let ancestor = origin;
1048
+ while (ancestor && !completed.has(ancestor.id)) {
1049
+ if (path.has(ancestor.id)) invalid("thread replay run ancestry contains a cycle");
1050
+ path.add(ancestor.id);
1051
+ ancestor = ancestor.previousRunId === null ? null : runsById.get(ancestor.previousRunId);
1052
+ }
1053
+ for (const runId of path) completed.add(runId);
1054
+ }
1055
+ const newestFirst = [];
1056
+ const visited = new Set();
1057
+ let omittedPrefix = false;
1058
+ let cursor = headRun;
1059
+ while (cursor) {
1060
+ if (visited.has(cursor.id)) invalid("thread replay run ancestry contains a cycle");
1061
+ visited.add(cursor.id);
1062
+ newestFirst.push(cursor);
1063
+ if (cursor.previousRunId === null) break;
1064
+ const previous = runsById.get(cursor.previousRunId);
1065
+ if (!previous) {
1066
+ if (thread.messages.length === 0) {
1067
+ omittedPrefix = true;
1068
+ break;
1069
+ }
1070
+ if (thread.replay.prunedMessageCount === 0 || thread.replay.anchorDigest === ZERO_HASH) {
1071
+ invalid("thread replay predecessor is missing without a pruned-prefix proof");
1072
+ }
1073
+ omittedPrefix = true;
1074
+ break;
1075
+ }
1076
+ cursor = previous;
1077
+ }
1078
+ if ((successorCounts.get(headRun.id) ?? 0) !== 0) {
1079
+ invalid("thread replay declared head has a successor");
1080
+ }
1081
+ if (visited.size !== runsById.size) invalid("thread replay contains a disconnected run ancestry");
1082
+ return Object.freeze({
1083
+ runs: Object.freeze(newestFirst.reverse()),
1084
+ headRun,
1085
+ omittedPrefix,
1086
+ requiresThreadRefresh,
1087
+ });
1088
+ }
1089
+
1090
+ export function validateAgentResponse(pathname, value) {
1091
+ if (pathname === AGINTI_RPC_PATHS.capabilities) return validateAgentCapabilities(value);
1092
+ if (pathname === AGINTI_RPC_PATHS.threadsList) {
1093
+ const response = exact(value, ["schemaVersion", "threads", "nextBefore"], "thread list response");
1094
+ if (response.schemaVersion !== AGINTI_SCHEMA_VERSION || !Array.isArray(response.threads) || response.threads.length > 100) {
1095
+ invalid("thread list response is invalid");
1096
+ }
1097
+ if (response.nextBefore !== null && (typeof response.nextBefore !== "string" || !THREAD_ID.test(response.nextBefore))) {
1098
+ invalid("thread list nextBefore is invalid");
1099
+ }
1100
+ return Object.freeze({
1101
+ schemaVersion: AGINTI_SCHEMA_VERSION,
1102
+ threads: Object.freeze(response.threads.map(validateThread)),
1103
+ nextBefore: response.nextBefore,
1104
+ });
1105
+ }
1106
+ if ([AGINTI_RPC_PATHS.threadsCreate, AGINTI_RPC_PATHS.threadsGet, AGINTI_RPC_PATHS.threadsUpdate].includes(pathname)) {
1107
+ const response = exact(value, ["schemaVersion", "thread"], "thread response");
1108
+ if (response.schemaVersion !== AGINTI_SCHEMA_VERSION) invalid("thread response schemaVersion must be 1");
1109
+ return Object.freeze({ schemaVersion: AGINTI_SCHEMA_VERSION, thread: validateThread(response.thread) });
1110
+ }
1111
+ if (pathname === AGINTI_RPC_PATHS.threadsDelete) {
1112
+ const response = exact(value, ["schemaVersion", "deleted", "threadId"], "thread delete response");
1113
+ if (response.schemaVersion !== AGINTI_SCHEMA_VERSION || response.deleted !== true) invalid("thread delete response is invalid");
1114
+ return Object.freeze({ schemaVersion: AGINTI_SCHEMA_VERSION, deleted: true, threadId: validateThreadId(response.threadId) });
1115
+ }
1116
+ if ([AGINTI_RPC_PATHS.runsStart, AGINTI_RPC_PATHS.runsStatus, AGINTI_RPC_PATHS.runsCancel, AGINTI_RPC_PATHS.runsResume].includes(pathname)) {
1117
+ const response = exact(value, ["schemaVersion", "run"], "run response");
1118
+ if (response.schemaVersion !== AGINTI_SCHEMA_VERSION) invalid("run response schemaVersion must be 1");
1119
+ return Object.freeze({ schemaVersion: AGINTI_SCHEMA_VERSION, run: validateRun(response.run) });
1120
+ }
1121
+ if (pathname === AGINTI_RPC_PATHS.artifactsList) {
1122
+ const response = exact(value, ["schemaVersion", "artifacts"], "artifact list response");
1123
+ if (response.schemaVersion !== AGINTI_SCHEMA_VERSION || !Array.isArray(response.artifacts) || response.artifacts.length > 32) {
1124
+ invalid("artifact list response is invalid");
1125
+ }
1126
+ return Object.freeze({ schemaVersion: AGINTI_SCHEMA_VERSION, artifacts: Object.freeze(response.artifacts.map(validateArtifact)) });
1127
+ }
1128
+ if (pathname === AGINTI_RPC_PATHS.artifactsGet) {
1129
+ const response = exact(value, ["schemaVersion", "artifact"], "artifact response");
1130
+ if (response.schemaVersion !== AGINTI_SCHEMA_VERSION) invalid("artifact response schemaVersion must be 1");
1131
+ return Object.freeze({ schemaVersion: AGINTI_SCHEMA_VERSION, artifact: validateArtifact(response.artifact) });
1132
+ }
1133
+ invalid("unknown AgInTi response path", "NOT_FOUND");
1134
+ }
1135
+
1136
+ export function failClosedCapabilities(value) {
1137
+ try {
1138
+ return validateAgentCapabilities(value);
1139
+ } catch {
1140
+ return FAIL_CLOSED_AGENT_CAPABILITIES;
1141
+ }
1142
+ }
1143
+
1144
+ export function initialEventCursor() {
1145
+ return Object.freeze({ seq: 0, hash: ZERO_HASH });
1146
+ }