@mindees/ai 0.1.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.
Files changed (53) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +57 -0
  3. package/dist/contract.d.ts +113 -0
  4. package/dist/contract.d.ts.map +1 -0
  5. package/dist/contract.js +18 -0
  6. package/dist/contract.js.map +1 -0
  7. package/dist/devtools.d.ts +43 -0
  8. package/dist/devtools.d.ts.map +1 -0
  9. package/dist/devtools.js +77 -0
  10. package/dist/devtools.js.map +1 -0
  11. package/dist/errors.d.ts +21 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +18 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/index.d.ts +26 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +29 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/json.d.ts +76 -0
  20. package/dist/json.d.ts.map +1 -0
  21. package/dist/json.js +256 -0
  22. package/dist/json.js.map +1 -0
  23. package/dist/mappers.d.ts +52 -0
  24. package/dist/mappers.d.ts.map +1 -0
  25. package/dist/mappers.js +312 -0
  26. package/dist/mappers.js.map +1 -0
  27. package/dist/mock.d.ts +26 -0
  28. package/dist/mock.d.ts.map +1 -0
  29. package/dist/mock.js +69 -0
  30. package/dist/mock.js.map +1 -0
  31. package/dist/object.d.ts +78 -0
  32. package/dist/object.d.ts.map +1 -0
  33. package/dist/object.js +140 -0
  34. package/dist/object.js.map +1 -0
  35. package/dist/on-device.d.ts +13 -0
  36. package/dist/on-device.d.ts.map +1 -0
  37. package/dist/on-device.js +33 -0
  38. package/dist/on-device.js.map +1 -0
  39. package/dist/server.d.ts +42 -0
  40. package/dist/server.d.ts.map +1 -0
  41. package/dist/server.js +64 -0
  42. package/dist/server.js.map +1 -0
  43. package/dist/sse.d.ts +24 -0
  44. package/dist/sse.d.ts.map +1 -0
  45. package/dist/sse.js +81 -0
  46. package/dist/sse.js.map +1 -0
  47. package/dist/standard-schema.d.ts +89 -0
  48. package/dist/standard-schema.d.ts.map +1 -0
  49. package/dist/tools.d.ts +61 -0
  50. package/dist/tools.d.ts.map +1 -0
  51. package/dist/tools.js +195 -0
  52. package/dist/tools.js.map +1 -0
  53. package/package.json +40 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mappers.js","names":[],"sources":["../src/mappers.ts"],"sourcesContent":["/**\n * Per-provider wire mappers (OpenAI-compatible + Anthropic), isolating the HTTP shape so\n * the server backend never imports a vendor SDK and a provider change is a contained\n * edit. All response/stream parsing is defensive — model/server JSON is untrusted. Tool\n * mapping is added in 11C (with tools). See `docs/adr/0018-synapse-server-backend.md`.\n *\n * @module\n */\n\nimport {\n type AiChunk,\n type AiResult,\n type FinishReason,\n type GenerateRequest,\n type Message,\n messageText,\n type Part,\n type ToolCallPart,\n type ToolResultPart,\n type Usage,\n} from './contract'\n\n/**\n * Parse one SSE `data` JSON into zero or more {@link AiChunk}s. Returns an array (empty\n * to skip a non-content event) because a single event can legitimately carry more than\n * one logical chunk — e.g. an OpenAI-compatible server that puts a content delta AND the\n * terminal `finish_reason`/`usage` in the same event (a 1-chunk-per-event parser would\n * drop the finish/usage).\n */\nexport type StreamParser = (data: unknown) => readonly AiChunk[]\n\n/**\n * A provider wire mapper. A custom mapper whose `buildRequest` cannot express\n * `request.tools` MUST throw rather than silently drop them (the built-in openai/anthropic\n * mappers serialize them; the on-device backend throws).\n */\nexport interface ProviderMapper {\n /**\n * How `apiKey` is sent. `'anthropic'` → `x-api-key` + `anthropic-version`; `'bearer'`\n * (the default when omitted) → `Authorization: Bearer`. Declared on the mapper so auth\n * follows the chosen provider whether it is selected by name or by object — and so a\n * custom Anthropic-compatible mapper can opt into `x-api-key` auth.\n */\n readonly auth?: 'bearer' | 'anthropic'\n /** Build the HTTP path + JSON body for a request. */\n buildRequest(\n request: GenerateRequest,\n model: string,\n stream: boolean,\n ): { path: string; body: unknown }\n /** Parse a non-streaming JSON response into an {@link AiResult}. */\n parseResponse(json: unknown): AiResult\n /**\n * Create a {@link StreamParser} for one stream. Returned fresh per stream so it may hold\n * per-stream state (e.g. the last `finish_reason`) without leaking across concurrent\n * streams that share this mapper singleton.\n */\n createStreamParser(): StreamParser\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> | undefined {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : undefined\n}\nfunction asString(value: unknown): string | undefined {\n return typeof value === 'string' ? value : undefined\n}\nfunction asNumber(value: unknown): number | undefined {\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined\n}\n/** Build a {@link Usage} omitting undefined fields (exactOptionalPropertyTypes-safe). */\nfunction usageOf(inputTokens?: number, outputTokens?: number): Usage {\n const usage: { inputTokens?: number; outputTokens?: number } = {}\n if (inputTokens !== undefined) usage.inputTokens = inputTokens\n if (outputTokens !== undefined) usage.outputTokens = outputTokens\n return usage\n}\n\n/** Parse OpenAI's `function.arguments` (a JSON STRING) defensively into a value. */\nfunction parseJsonArgs(value: unknown): unknown {\n if (typeof value !== 'string') return value ?? {}\n try {\n return JSON.parse(value)\n } catch {\n return {} // a malformed arg string becomes empty args — the loop's validator rejects it\n }\n}\n\n/** Serialize a tool result to the string the wire APIs expect (never throws, cycle-safe-ish). */\nfunction toWireString(value: unknown): string {\n if (typeof value === 'string') return value\n try {\n return JSON.stringify(value) ?? String(value)\n } catch {\n return String(value)\n }\n}\n\n/** Split a message's parts (string content has only text). */\nfunction partsOf(content: string | readonly Part[]): {\n text: string\n calls: ToolCallPart[]\n results: ToolResultPart[]\n} {\n if (typeof content === 'string') return { text: content, calls: [], results: [] }\n const text = content\n .filter((p): p is Extract<Part, { type: 'text' }> => p.type === 'text')\n .map((p) => p.text)\n .join('')\n const calls = content.filter((p): p is ToolCallPart => p.type === 'tool-call')\n const results = content.filter((p): p is ToolResultPart => p.type === 'tool-result')\n return { text, calls, results }\n}\n\n/**\n * Serialize messages to the OpenAI chat shape, round-tripping tool turns: an assistant turn\n * with tool calls emits `tool_calls`; a `tool` message emits one `{ role:'tool', tool_call_id }`\n * per result. Without this the tool loop's 2nd turn would lose its tool context.\n */\nfunction openaiMessages(messages: readonly Message[]): unknown[] {\n const out: unknown[] = []\n for (const m of messages) {\n const { text, calls, results } = partsOf(m.content)\n if (results.length > 0) {\n for (const r of results)\n out.push({ role: 'tool', tool_call_id: r.id, content: toWireString(r.result) })\n } else if (calls.length > 0) {\n out.push({\n role: m.role,\n content: text || null,\n tool_calls: calls.map((c) => ({\n id: c.id,\n type: 'function',\n function: { name: c.name, arguments: JSON.stringify(c.args ?? {}) },\n })),\n })\n } else {\n out.push({ role: m.role, content: text })\n }\n }\n return out\n}\n\n/**\n * Serialize non-system messages to the Anthropic shape, round-tripping tool turns: assistant\n * tool calls become `tool_use` blocks; a `tool` message becomes a USER message of `tool_result`\n * blocks (Anthropic carries tool results in the user turn).\n */\nfunction anthropicMessages(messages: readonly Message[]): unknown[] {\n const out: unknown[] = []\n for (const m of messages) {\n if (m.role === 'system') continue // folded into the top-level `system` field\n const { text, calls, results } = partsOf(m.content)\n if (results.length > 0) {\n out.push({\n role: 'user',\n content: results.map((r) => ({\n type: 'tool_result',\n tool_use_id: r.id,\n content: toWireString(r.result),\n })),\n })\n } else if (calls.length > 0) {\n const content: unknown[] = []\n if (text) content.push({ type: 'text', text })\n for (const c of calls)\n content.push({ type: 'tool_use', id: c.id, name: c.name, input: c.args ?? {} })\n out.push({ role: 'assistant', content })\n } else {\n out.push({ role: m.role, content: text })\n }\n }\n return out\n}\n\n/** Add `toolCalls` to an {@link AiResult} only when present (exactOptionalPropertyTypes-safe). */\nfunction withToolCalls(result: AiResult, toolCalls: ToolCallPart[]): AiResult {\n return toolCalls.length > 0 ? { ...result, toolCalls } : result\n}\n\n// null-prototype maps so an untrusted finish_reason like 'toString'/'__proto__' reads as\n// undefined (→ the 'stop' fallback) rather than leaking an inherited member.\nconst OPENAI_FINISH: Record<string, FinishReason> = Object.assign(Object.create(null), {\n stop: 'stop',\n length: 'length',\n tool_calls: 'tool-calls',\n})\nconst ANTHROPIC_FINISH: Record<string, FinishReason> = Object.assign(Object.create(null), {\n end_turn: 'stop',\n max_tokens: 'length',\n tool_use: 'tool-calls',\n})\n\n/** OpenAI-compatible `/chat/completions` mapper (also fits many local/compatible servers). */\nexport const openaiMapper: ProviderMapper = {\n auth: 'bearer',\n buildRequest(request, model, stream) {\n const body: Record<string, unknown> = {\n model,\n stream,\n messages: openaiMessages(request.messages),\n }\n if (request.temperature !== undefined) body.temperature = request.temperature\n if (request.maxOutputTokens !== undefined) body.max_tokens = request.maxOutputTokens\n if (request.tools && request.tools.length > 0) {\n body.tools = request.tools.map((t) => ({\n type: 'function',\n function: {\n name: t.name,\n ...(t.description !== undefined ? { description: t.description } : {}),\n parameters: t.parameters ?? { type: 'object', properties: {} },\n },\n }))\n }\n if (stream) body.stream_options = { include_usage: true }\n return { path: '/chat/completions', body }\n },\n parseResponse(json) {\n const root = asRecord(json)\n const choice = asRecord((root?.choices as unknown[] | undefined)?.[0])\n const message = asRecord(choice?.message)\n const usage = asRecord(root?.usage)\n const toolCalls: ToolCallPart[] = []\n for (const raw of (message?.tool_calls as unknown[] | undefined) ?? []) {\n const tc = asRecord(raw)\n const fn = asRecord(tc?.function)\n const name = asString(fn?.name)\n if (!name) continue\n toolCalls.push({\n type: 'tool-call',\n id: asString(tc?.id) ?? name,\n name,\n args: parseJsonArgs(fn?.arguments),\n })\n }\n return withToolCalls(\n {\n text: asString(message?.content) ?? '',\n finishReason: OPENAI_FINISH[asString(choice?.finish_reason) ?? ''] ?? 'stop',\n usage: usageOf(asNumber(usage?.prompt_tokens), asNumber(usage?.completion_tokens)),\n },\n toolCalls,\n )\n },\n createStreamParser() {\n // With stream_options.include_usage, OpenAI sends usage on a trailing choices:[] chunk\n // that carries NO finish_reason. Remember the last real finish_reason so the\n // usage-bearing finish reports the true reason instead of a fabricated 'stop'.\n let lastReason: FinishReason = 'stop'\n return (data) => {\n const root = asRecord(data)\n const choice = asRecord((root?.choices as unknown[] | undefined)?.[0])\n const out: AiChunk[] = []\n const delta = asString(asRecord(choice?.delta)?.content)\n if (delta) out.push({ type: 'text-delta', delta })\n const finish = asString(choice?.finish_reason)\n if (finish) lastReason = OPENAI_FINISH[finish] ?? 'stop'\n const usage = asRecord(root?.usage)\n // Emit a finish for a finish_reason chunk OR the trailing usage-only chunk, so\n // streamed usage isn't lost — both carry the last-seen reason. Emitted IN ADDITION\n // to any content delta in the same event (servers that bundle them are common), so\n // the terminal finish/usage is never dropped.\n if (finish || usage) {\n out.push({\n type: 'finish',\n finishReason: lastReason,\n usage: usageOf(asNumber(usage?.prompt_tokens), asNumber(usage?.completion_tokens)),\n })\n }\n return out\n }\n },\n}\n\n/** Anthropic `/v1/messages` mapper. */\nexport const anthropicMapper: ProviderMapper = {\n auth: 'anthropic',\n buildRequest(request, model, stream) {\n const system = request.messages\n .filter((m) => m.role === 'system')\n .map((m) => messageText(m))\n .join('\\n')\n const messages = anthropicMessages(request.messages)\n const body: Record<string, unknown> = {\n model,\n stream,\n messages,\n max_tokens: request.maxOutputTokens ?? 1024, // required by Anthropic\n }\n if (system) body.system = system\n if (request.temperature !== undefined) body.temperature = request.temperature\n if (request.tools && request.tools.length > 0) {\n body.tools = request.tools.map((t) => ({\n name: t.name,\n ...(t.description !== undefined ? { description: t.description } : {}),\n input_schema: t.parameters ?? { type: 'object', properties: {} },\n }))\n }\n return { path: '/v1/messages', body }\n },\n parseResponse(json) {\n const root = asRecord(json)\n const content = (root?.content as unknown[] | undefined) ?? []\n let text = ''\n const toolCalls: ToolCallPart[] = []\n for (const part of content) {\n const block = asRecord(part)\n if (asString(block?.type) === 'tool_use') {\n const name = asString(block?.name)\n if (!name) continue\n toolCalls.push({\n type: 'tool-call',\n id: asString(block?.id) ?? name,\n name,\n args: block?.input ?? {},\n })\n } else {\n text += asString(block?.text) ?? ''\n }\n }\n const usage = asRecord(root?.usage)\n return withToolCalls(\n {\n text,\n finishReason: ANTHROPIC_FINISH[asString(root?.stop_reason) ?? ''] ?? 'stop',\n usage: usageOf(asNumber(usage?.input_tokens), asNumber(usage?.output_tokens)),\n },\n toolCalls,\n )\n },\n createStreamParser() {\n // Anthropic reports input_tokens once on `message_start` and the (cumulative)\n // output_tokens on `message_delta`. They arrive in SEPARATE events, so the parser\n // must remember input_tokens from the start event to report it on the finish chunk —\n // otherwise every streamed response loses its prompt-token count.\n let inputTokens: number | undefined\n return (data) => {\n const root = asRecord(data)\n const type = asString(root?.type)\n if (type === 'message_start') {\n inputTokens = asNumber(asRecord(asRecord(root?.message)?.usage)?.input_tokens)\n return []\n }\n if (type === 'content_block_delta') {\n const delta = asString(asRecord(root?.delta)?.text)\n return delta ? [{ type: 'text-delta', delta }] : []\n }\n if (type === 'message_delta') {\n const stop = asString(asRecord(root?.delta)?.stop_reason)\n const usage = asRecord(root?.usage)\n return [\n {\n type: 'finish',\n finishReason: ANTHROPIC_FINISH[stop ?? ''] ?? 'stop',\n usage: usageOf(inputTokens, asNumber(usage?.output_tokens)),\n },\n ]\n }\n return []\n }\n },\n}\n\n/** The built-in mappers by adapter name. */\nexport const MAPPERS = { openai: openaiMapper, anthropic: anthropicMapper } as const\n\n/** A built-in provider adapter name. */\nexport type AdapterName = keyof typeof MAPPERS\n"],"mappings":"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"}
package/dist/mock.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { AiBackend, AiResult, ToolCallPart, Usage } from "./contract.js";
2
+
3
+ //#region src/mock.d.ts
4
+ /** A scripted mock response: plain text, or a structured turn (e.g. emitting tool calls). */
5
+ interface MockResponse {
6
+ readonly text?: string;
7
+ readonly toolCalls?: readonly ToolCallPart[];
8
+ readonly finishReason?: AiResult['finishReason'];
9
+ readonly usage?: Usage;
10
+ }
11
+ /** One mock reply — a string (text only) or a {@link MockResponse}. */
12
+ type MockReply = string | MockResponse;
13
+ /** Options for {@link createMockBackend}. */
14
+ interface MockBackendOptions {
15
+ /** A fixed reply for every call. */
16
+ readonly reply?: MockReply;
17
+ /** Sequential replies (call N uses `script[N]`; the last repeats once exhausted). */
18
+ readonly script?: readonly MockReply[];
19
+ /** Characters per streamed `text-delta`. Default 8. */
20
+ readonly chunkSize?: number;
21
+ }
22
+ /** Create a deterministic mock backend. */
23
+ declare function createMockBackend(options?: MockBackendOptions): AiBackend;
24
+ //#endregion
25
+ export { MockBackendOptions, MockReply, MockResponse, createMockBackend };
26
+ //# sourceMappingURL=mock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mock.d.ts","names":[],"sources":["../src/mock.ts"],"mappings":";;;;UAYiB,YAAA;EAAA,SACN,IAAA;EAAA,SACA,SAAA,YAAqB,YAAA;EAAA,SACrB,YAAA,GAAe,QAAA;EAAA,SACf,KAAA,GAAQ,KAAA;AAAA;;KAIP,SAAA,YAAqB,YAAY;;UAG5B,kBAAA;EAPO;EAAA,SASb,KAAA,GAAQ,SAAA;EALE;EAAA,SAOV,MAAA,YAAkB,SAAS;EAPO;EAAA,SASlC,SAAA;AAAA;;iBAIK,iBAAA,CAAkB,OAAA,GAAS,kBAAA,GAA0B,SAAS"}
package/dist/mock.js ADDED
@@ -0,0 +1,69 @@
1
+ import { AiError } from "./errors.js";
2
+ //#region src/mock.ts
3
+ /** Create a deterministic mock backend. */
4
+ function createMockBackend(options = {}) {
5
+ const chunkSize = Math.max(1, options.chunkSize ?? 8);
6
+ let call = 0;
7
+ const replyFor = () => {
8
+ let raw;
9
+ if (options.script && options.script.length > 0) raw = options.script[Math.min(call, options.script.length - 1)];
10
+ else raw = options.reply;
11
+ if (raw === void 0) return { text: "" };
12
+ return typeof raw === "string" ? { text: raw } : raw;
13
+ };
14
+ const resultOf = (response) => {
15
+ const text = response.text ?? "";
16
+ const toolCalls = response.toolCalls;
17
+ const finishReason = response.finishReason ?? (toolCalls && toolCalls.length > 0 ? "tool-calls" : "stop");
18
+ const usage = response.usage ?? { outputTokens: text.length };
19
+ return toolCalls && toolCalls.length > 0 ? {
20
+ text,
21
+ toolCalls,
22
+ finishReason,
23
+ usage
24
+ } : {
25
+ text,
26
+ finishReason,
27
+ usage
28
+ };
29
+ };
30
+ const checkAborted = (request) => {
31
+ if (request.signal?.aborted) throw new AiError("ABORTED", "request aborted");
32
+ };
33
+ return {
34
+ async generate(request) {
35
+ checkAborted(request);
36
+ const response = replyFor();
37
+ call += 1;
38
+ return resultOf(response);
39
+ },
40
+ async *stream(request) {
41
+ checkAborted(request);
42
+ const response = replyFor();
43
+ call += 1;
44
+ const text = response.text ?? "";
45
+ for (let i = 0; i < text.length; i += chunkSize) {
46
+ checkAborted(request);
47
+ yield {
48
+ type: "text-delta",
49
+ delta: text.slice(i, i + chunkSize)
50
+ };
51
+ }
52
+ for (const tc of response.toolCalls ?? []) yield {
53
+ type: "tool-call",
54
+ id: tc.id,
55
+ name: tc.name,
56
+ args: tc.args
57
+ };
58
+ yield {
59
+ type: "finish",
60
+ finishReason: response.finishReason ?? (response.toolCalls && response.toolCalls.length > 0 ? "tool-calls" : "stop"),
61
+ usage: response.usage ?? { outputTokens: text.length }
62
+ };
63
+ }
64
+ };
65
+ }
66
+ //#endregion
67
+ export { createMockBackend };
68
+
69
+ //# sourceMappingURL=mock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mock.js","names":[],"sources":["../src/mock.ts"],"sourcesContent":["/**\n * A deterministic mock {@link AiBackend} — no network, no keys. The analog of\n * Continuum's in-memory hub: it powers all unit tests and lets apps run fully offline,\n * and is the working fallback that keeps the on-device research track honest.\n *\n * @module\n */\n\nimport type { AiBackend, AiChunk, AiResult, GenerateRequest, ToolCallPart, Usage } from './contract'\nimport { AiError } from './errors'\n\n/** A scripted mock response: plain text, or a structured turn (e.g. emitting tool calls). */\nexport interface MockResponse {\n readonly text?: string\n readonly toolCalls?: readonly ToolCallPart[]\n readonly finishReason?: AiResult['finishReason']\n readonly usage?: Usage\n}\n\n/** One mock reply — a string (text only) or a {@link MockResponse}. */\nexport type MockReply = string | MockResponse\n\n/** Options for {@link createMockBackend}. */\nexport interface MockBackendOptions {\n /** A fixed reply for every call. */\n readonly reply?: MockReply\n /** Sequential replies (call N uses `script[N]`; the last repeats once exhausted). */\n readonly script?: readonly MockReply[]\n /** Characters per streamed `text-delta`. Default 8. */\n readonly chunkSize?: number\n}\n\n/** Create a deterministic mock backend. */\nexport function createMockBackend(options: MockBackendOptions = {}): AiBackend {\n const chunkSize = Math.max(1, options.chunkSize ?? 8)\n let call = 0\n\n const replyFor = (): MockResponse => {\n let raw: MockReply | undefined\n if (options.script && options.script.length > 0) {\n raw = options.script[Math.min(call, options.script.length - 1)]\n } else {\n raw = options.reply\n }\n if (raw === undefined) return { text: '' }\n return typeof raw === 'string' ? { text: raw } : raw\n }\n const resultOf = (response: MockResponse): AiResult => {\n const text = response.text ?? ''\n const toolCalls = response.toolCalls\n const finishReason =\n response.finishReason ?? (toolCalls && toolCalls.length > 0 ? 'tool-calls' : 'stop')\n const usage = response.usage ?? { outputTokens: text.length }\n return toolCalls && toolCalls.length > 0\n ? { text, toolCalls, finishReason, usage }\n : { text, finishReason, usage }\n }\n const checkAborted = (request: GenerateRequest): void => {\n if (request.signal?.aborted) throw new AiError('ABORTED', 'request aborted')\n }\n\n return {\n async generate(request): Promise<AiResult> {\n checkAborted(request)\n const response = replyFor()\n call += 1\n return resultOf(response)\n },\n\n async *stream(request): AsyncIterable<AiChunk> {\n checkAborted(request)\n const response = replyFor()\n call += 1\n const text = response.text ?? ''\n for (let i = 0; i < text.length; i += chunkSize) {\n checkAborted(request) // honor cancellation between chunks\n yield { type: 'text-delta', delta: text.slice(i, i + chunkSize) }\n }\n // The mock emits tool-call chunks to cover the full AiChunk contract; the real server\n // streaming mappers do NOT yet parse tool-call deltas (runTools uses generate, not stream).\n for (const tc of response.toolCalls ?? []) {\n yield { type: 'tool-call', id: tc.id, name: tc.name, args: tc.args }\n }\n const finishReason =\n response.finishReason ??\n (response.toolCalls && response.toolCalls.length > 0 ? 'tool-calls' : 'stop')\n yield { type: 'finish', finishReason, usage: response.usage ?? { outputTokens: text.length } }\n },\n }\n}\n"],"mappings":";;;AAiCA,SAAgB,kBAAkB,UAA8B,CAAC,GAAc;CAC7E,MAAM,YAAY,KAAK,IAAI,GAAG,QAAQ,aAAa,CAAC;CACpD,IAAI,OAAO;CAEX,MAAM,iBAA+B;EACnC,IAAI;EACJ,IAAI,QAAQ,UAAU,QAAQ,OAAO,SAAS,GAC5C,MAAM,QAAQ,OAAO,KAAK,IAAI,MAAM,QAAQ,OAAO,SAAS,CAAC;OAE7D,MAAM,QAAQ;EAEhB,IAAI,QAAQ,KAAA,GAAW,OAAO,EAAE,MAAM,GAAG;EACzC,OAAO,OAAO,QAAQ,WAAW,EAAE,MAAM,IAAI,IAAI;CACnD;CACA,MAAM,YAAY,aAAqC;EACrD,MAAM,OAAO,SAAS,QAAQ;EAC9B,MAAM,YAAY,SAAS;EAC3B,MAAM,eACJ,SAAS,iBAAiB,aAAa,UAAU,SAAS,IAAI,eAAe;EAC/E,MAAM,QAAQ,SAAS,SAAS,EAAE,cAAc,KAAK,OAAO;EAC5D,OAAO,aAAa,UAAU,SAAS,IACnC;GAAE;GAAM;GAAW;GAAc;EAAM,IACvC;GAAE;GAAM;GAAc;EAAM;CAClC;CACA,MAAM,gBAAgB,YAAmC;EACvD,IAAI,QAAQ,QAAQ,SAAS,MAAM,IAAI,QAAQ,WAAW,iBAAiB;CAC7E;CAEA,OAAO;EACL,MAAM,SAAS,SAA4B;GACzC,aAAa,OAAO;GACpB,MAAM,WAAW,SAAS;GAC1B,QAAQ;GACR,OAAO,SAAS,QAAQ;EAC1B;EAEA,OAAO,OAAO,SAAiC;GAC7C,aAAa,OAAO;GACpB,MAAM,WAAW,SAAS;GAC1B,QAAQ;GACR,MAAM,OAAO,SAAS,QAAQ;GAC9B,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK,WAAW;IAC/C,aAAa,OAAO;IACpB,MAAM;KAAE,MAAM;KAAc,OAAO,KAAK,MAAM,GAAG,IAAI,SAAS;IAAE;GAClE;GAGA,KAAK,MAAM,MAAM,SAAS,aAAa,CAAC,GACtC,MAAM;IAAE,MAAM;IAAa,IAAI,GAAG;IAAI,MAAM,GAAG;IAAM,MAAM,GAAG;GAAK;GAKrE,MAAM;IAAE,MAAM;IAAU,cAFtB,SAAS,iBACR,SAAS,aAAa,SAAS,UAAU,SAAS,IAAI,eAAe;IAClC,OAAO,SAAS,SAAS,EAAE,cAAc,KAAK,OAAO;GAAE;EAC/F;CACF;AACF"}
@@ -0,0 +1,78 @@
1
+ import { AiBackend, GenerateRequest, Usage } from "./contract.js";
2
+ import { StandardSchemaV1 } from "./standard-schema.js";
3
+ import { SanitizeLimits } from "./json.js";
4
+
5
+ //#region src/object.d.ts
6
+ /** The subset of a backend `generateObject` needs (one-shot generation). */
7
+ type GeneratingBackend = Pick<AiBackend, 'generate'>;
8
+ /** The subset of a backend `streamObject` needs (streamed generation). */
9
+ type StreamingBackend = Pick<AiBackend, 'stream'>;
10
+ /** Options for {@link generateObject}. */
11
+ interface GenerateObjectOptions {
12
+ /**
13
+ * Maximum number of REPAIR re-asks after the first attempt (default `2`). Total model
14
+ * calls = `1 + maxRepairs`.
15
+ */
16
+ readonly maxRepairs?: number;
17
+ /** Sanitization limits for the parsed value (defaults are generous-but-bounded). */
18
+ readonly limits?: Partial<SanitizeLimits>;
19
+ /** Max characters of model text to parse per attempt (default ~8M). */
20
+ readonly maxInputChars?: number;
21
+ }
22
+ /** The result of {@link generateObject}. */
23
+ interface GenerateObjectResult<T> {
24
+ /** The validated, typed object. */
25
+ readonly object: T;
26
+ /** Accumulated token usage across every attempt, when the backend reports it. */
27
+ readonly usage?: Usage;
28
+ /** How many model calls were made (`1` on first-try success). */
29
+ readonly attempts: number;
30
+ }
31
+ /**
32
+ * Generate a value validated against `schema`, repairing up to `maxRepairs` times.
33
+ *
34
+ * @throws AiError `INVALID_OBJECT` if no valid object is produced within the bound (carrying
35
+ * the last validation `issues`), or `ABORTED` if the signal is set between attempts.
36
+ *
37
+ * @example
38
+ * const { object } = await generateObject(backend, { messages }, z.object({ title: z.string() }))
39
+ */
40
+ declare function generateObject<S extends StandardSchemaV1>(backend: GeneratingBackend, request: GenerateRequest, schema: S, options?: GenerateObjectOptions): Promise<GenerateObjectResult<StandardSchemaV1.InferOutput<S>>>;
41
+ /** A chunk emitted by {@link streamObject}. */
42
+ type StreamObjectChunk<T> = /** A raw text delta from the underlying stream. */{
43
+ readonly type: 'text-delta';
44
+ readonly delta: string;
45
+ } /** A best-effort, UNVALIDATED, UNSANITIZED preview parsed from the partial text. */ | {
46
+ readonly type: 'partial-object';
47
+ readonly object: unknown;
48
+ readonly validated: false;
49
+ } /** The final, sanitized + validated object (emitted once, at end of stream). */ | {
50
+ readonly type: 'object';
51
+ readonly object: T;
52
+ readonly validated: true;
53
+ };
54
+ /** Options for {@link streamObject}. */
55
+ interface StreamObjectOptions {
56
+ /**
57
+ * Emit best-effort `partial-object` previews as text streams in (default `false`). Previews
58
+ * are UNVALIDATED and UNSANITIZED — treat them as untyped UI hints only; the single final
59
+ * `object` chunk is the validated value.
60
+ */
61
+ readonly partial?: boolean;
62
+ /** Sanitization limits for the final value. */
63
+ readonly limits?: Partial<SanitizeLimits>;
64
+ /** Max characters to accumulate before failing with `INVALID_OBJECT` (default ~8M). */
65
+ readonly maxInputChars?: number;
66
+ }
67
+ /**
68
+ * Stream a structured object: passes raw `text-delta`s through, optionally emits unvalidated
69
+ * `partial-object` previews, and validates the fully-assembled value EXACTLY ONCE at the end
70
+ * (no mid-stream repair — a stream can't be un-sent).
71
+ *
72
+ * @throws AiError `INVALID_OBJECT` if the final value fails extraction/validation, or
73
+ * `ABORTED` if the signal is set during streaming.
74
+ */
75
+ declare function streamObject<S extends StandardSchemaV1>(backend: StreamingBackend, request: GenerateRequest, schema: S, options?: StreamObjectOptions): AsyncIterable<StreamObjectChunk<StandardSchemaV1.InferOutput<S>>>;
76
+ //#endregion
77
+ export { GenerateObjectOptions, GenerateObjectResult, GeneratingBackend, StreamObjectChunk, StreamObjectOptions, StreamingBackend, generateObject, streamObject };
78
+ //# sourceMappingURL=object.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"object.d.ts","names":[],"sources":["../src/object.ts"],"mappings":";;;;;;KA+BY,iBAAA,GAAoB,IAAI,CAAC,SAAA;;KAEzB,gBAAA,GAAmB,IAAI,CAAC,SAAA;;UAGnB,qBAAA;EASN;;AAAa;AAIxB;EAJW,SAJA,UAAA;EAQ0B;EAAA,SAN1B,MAAA,GAAS,OAAO,CAAC,cAAA;EAMU;EAAA,SAJ3B,aAAA;AAAA;;UAIM,oBAAA;EAMN;EAAA,SAJA,MAAA,EAAQ,CAAA;EAIA;EAAA,SAFR,KAAA,GAAQ,KAAK;EAwDY;EAAA,SAtDzB,QAAA;AAAA;;;;;;;;;;iBAsDW,cAAA,WAAyB,gBAAA,EAC7C,OAAA,EAAS,iBAAA,EACT,OAAA,EAAS,eAAA,EACT,MAAA,EAAQ,CAAA,EACR,OAAA,GAAS,qBAAA,GACR,OAAA,CAAQ,oBAAA,CAAqB,gBAAA,CAAiB,WAAA,CAAY,CAAA;;KA2CjD,iBAAA;WAEG,IAAA;EAAA,SAA6B,KAAA;AAAA;WAE7B,IAAA;EAAA,SAAiC,MAAA;EAAA,SAA0B,SAAA;AAAA;WAE3D,IAAA;EAAA,SAAyB,MAAA,EAAQ,CAAC;EAAA,SAAW,SAAA;AAAA;;UAG3C,mBAAA;EATa;;;;;EAAA,SAenB,OAAA;EATI;EAAA,SAWJ,MAAA,GAAS,OAAO,CAAC,cAAA;EAXoB;EAAA,SAarC,aAAA;AAAA;AAb0D;AAGrE;;;;;;;AAHqE,iBAwB9C,YAAA,WAAuB,gBAAA,EAC5C,OAAA,EAAS,gBAAA,EACT,OAAA,EAAS,eAAA,EACT,MAAA,EAAQ,CAAA,EACR,OAAA,GAAS,mBAAA,GACR,aAAA,CAAc,iBAAA,CAAkB,gBAAA,CAAiB,WAAA,CAAY,CAAA"}
package/dist/object.js ADDED
@@ -0,0 +1,140 @@
1
+ import { AiError } from "./errors.js";
2
+ import { DEFAULT_SANITIZE_LIMITS, containsForbiddenKey, extractJson, formatIssues, lenientParseJson, sanitizeJson, validateStandard } from "./json.js";
3
+ //#region src/object.ts
4
+ const JSON_INSTRUCTION = "Respond with ONLY a single valid JSON value that matches the required schema. Do not include any prose, explanation, or markdown code fences.";
5
+ function withJsonInstruction(request) {
6
+ const instruction = {
7
+ role: "system",
8
+ content: JSON_INSTRUCTION
9
+ };
10
+ return {
11
+ ...request,
12
+ messages: [...request.messages, instruction]
13
+ };
14
+ }
15
+ function repairRequest(base, failedText, problem) {
16
+ const assistant = {
17
+ role: "assistant",
18
+ content: failedText
19
+ };
20
+ const correction = {
21
+ role: "user",
22
+ content: `Your previous reply could not be used: ${problem}. Reply again with ONLY the corrected JSON — no prose, no code fences.`
23
+ };
24
+ return {
25
+ ...base,
26
+ messages: [
27
+ ...base.messages,
28
+ assistant,
29
+ correction
30
+ ]
31
+ };
32
+ }
33
+ function mergeLimits(limits) {
34
+ if (!limits) return void 0;
35
+ return {
36
+ ...DEFAULT_SANITIZE_LIMITS,
37
+ ...limits
38
+ };
39
+ }
40
+ function addUsage(a, b) {
41
+ if (!a) return b;
42
+ if (!b) return a;
43
+ const sum = {};
44
+ const input = (a.inputTokens ?? 0) + (b.inputTokens ?? 0);
45
+ const output = (a.outputTokens ?? 0) + (b.outputTokens ?? 0);
46
+ if (a.inputTokens !== void 0 || b.inputTokens !== void 0) sum.inputTokens = input;
47
+ if (a.outputTokens !== void 0 || b.outputTokens !== void 0) sum.outputTokens = output;
48
+ return sum;
49
+ }
50
+ /**
51
+ * Generate a value validated against `schema`, repairing up to `maxRepairs` times.
52
+ *
53
+ * @throws AiError `INVALID_OBJECT` if no valid object is produced within the bound (carrying
54
+ * the last validation `issues`), or `ABORTED` if the signal is set between attempts.
55
+ *
56
+ * @example
57
+ * const { object } = await generateObject(backend, { messages }, z.object({ title: z.string() }))
58
+ */
59
+ async function generateObject(backend, request, schema, options = {}) {
60
+ const maxRepairs = options.maxRepairs ?? 2;
61
+ const limits = mergeLimits(options.limits);
62
+ const maxInputChars = options.maxInputChars ?? 8388608;
63
+ const base = withJsonInstruction(request);
64
+ let usage;
65
+ let attempt = 0;
66
+ let problem = "";
67
+ let failedText = "";
68
+ while (attempt <= maxRepairs) {
69
+ if (request.signal?.aborted) throw new AiError("ABORTED", "request aborted");
70
+ const req = attempt === 0 ? base : repairRequest(base, failedText, problem);
71
+ const result = await backend.generate(req);
72
+ usage = addUsage(usage, result.usage);
73
+ attempt++;
74
+ failedText = result.text;
75
+ const extracted = extractJson(result.text, maxInputChars);
76
+ if (!extracted.ok) {
77
+ problem = extracted.reason;
78
+ continue;
79
+ }
80
+ const validation = await validateStandard(schema, sanitizeJson(extracted.value, limits));
81
+ if (validation.ok) return usage === void 0 ? {
82
+ object: validation.value,
83
+ attempts: attempt
84
+ } : {
85
+ object: validation.value,
86
+ usage,
87
+ attempts: attempt
88
+ };
89
+ problem = `schema validation failed: ${formatIssues(validation.issues)}`;
90
+ if (attempt > maxRepairs) throw new AiError("INVALID_OBJECT", problem, { issues: validation.issues });
91
+ }
92
+ throw new AiError("INVALID_OBJECT", problem || "no valid object produced");
93
+ }
94
+ /**
95
+ * Stream a structured object: passes raw `text-delta`s through, optionally emits unvalidated
96
+ * `partial-object` previews, and validates the fully-assembled value EXACTLY ONCE at the end
97
+ * (no mid-stream repair — a stream can't be un-sent).
98
+ *
99
+ * @throws AiError `INVALID_OBJECT` if the final value fails extraction/validation, or
100
+ * `ABORTED` if the signal is set during streaming.
101
+ */
102
+ async function* streamObject(backend, request, schema, options = {}) {
103
+ const limits = mergeLimits(options.limits);
104
+ const maxInputChars = options.maxInputChars ?? 8388608;
105
+ const base = withJsonInstruction(request);
106
+ let text = "";
107
+ for await (const chunk of backend.stream(base)) {
108
+ if (request.signal?.aborted) throw new AiError("ABORTED", "request aborted");
109
+ if (chunk.type === "text-delta") {
110
+ text += chunk.delta;
111
+ if (text.length > maxInputChars) throw new AiError("INVALID_OBJECT", `stream exceeds ${maxInputChars} characters`);
112
+ yield {
113
+ type: "text-delta",
114
+ delta: chunk.delta
115
+ };
116
+ if (options.partial && (chunk.delta.includes("}") || chunk.delta.includes("]"))) {
117
+ const preview = lenientParseJson(text, maxInputChars);
118
+ if (preview !== void 0 && !containsForbiddenKey(preview)) yield {
119
+ type: "partial-object",
120
+ object: preview,
121
+ validated: false
122
+ };
123
+ }
124
+ }
125
+ }
126
+ if (request.signal?.aborted) throw new AiError("ABORTED", "request aborted");
127
+ const extracted = extractJson(text, maxInputChars);
128
+ if (!extracted.ok) throw new AiError("INVALID_OBJECT", extracted.reason);
129
+ const validation = await validateStandard(schema, sanitizeJson(extracted.value, limits));
130
+ if (!validation.ok) throw new AiError("INVALID_OBJECT", `schema validation failed: ${formatIssues(validation.issues)}`, { issues: validation.issues });
131
+ yield {
132
+ type: "object",
133
+ object: validation.value,
134
+ validated: true
135
+ };
136
+ }
137
+ //#endregion
138
+ export { generateObject, streamObject };
139
+
140
+ //# sourceMappingURL=object.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"object.js","names":[],"sources":["../src/object.ts"],"sourcesContent":["/**\n * Structured output for Synapse: `generateObject` / `streamObject` produce a value validated\n * by any Standard Schema (Zod, Valibot, ArkType, …), built **purely on top of**\n * `AiBackend.generate` / `stream` — so the deterministic mock backend exercises the whole\n * path offline, and every backend (mock, server, on-device) gets structured output for free.\n *\n * Pipeline per attempt: prompt the model for JSON-only → extract a JSON value from the\n * (possibly decorated) text → sanitize against prototype-pollution/DoS → validate. On a\n * validation/extraction miss, re-ask with the concrete issues, up to a hard bound. No `eval`,\n * no vendor SDK, no JSON-Schema generation (Standard Schema has no introspection — describe\n * the desired shape in your own prompt). See `docs/adr/0019-synapse-structured-output.md`.\n *\n * @module\n */\n\nimport type { AiBackend, GenerateRequest, Message, Usage } from './contract'\nimport { AiError } from './errors'\nimport {\n containsForbiddenKey,\n DEFAULT_MAX_INPUT_CHARS,\n DEFAULT_SANITIZE_LIMITS,\n extractJson,\n formatIssues,\n lenientParseJson,\n type SanitizeLimits,\n sanitizeJson,\n validateStandard,\n} from './json'\nimport type { StandardSchemaV1 } from './standard-schema'\n\n/** The subset of a backend `generateObject` needs (one-shot generation). */\nexport type GeneratingBackend = Pick<AiBackend, 'generate'>\n/** The subset of a backend `streamObject` needs (streamed generation). */\nexport type StreamingBackend = Pick<AiBackend, 'stream'>\n\n/** Options for {@link generateObject}. */\nexport interface GenerateObjectOptions {\n /**\n * Maximum number of REPAIR re-asks after the first attempt (default `2`). Total model\n * calls = `1 + maxRepairs`.\n */\n readonly maxRepairs?: number\n /** Sanitization limits for the parsed value (defaults are generous-but-bounded). */\n readonly limits?: Partial<SanitizeLimits>\n /** Max characters of model text to parse per attempt (default ~8M). */\n readonly maxInputChars?: number\n}\n\n/** The result of {@link generateObject}. */\nexport interface GenerateObjectResult<T> {\n /** The validated, typed object. */\n readonly object: T\n /** Accumulated token usage across every attempt, when the backend reports it. */\n readonly usage?: Usage\n /** How many model calls were made (`1` on first-try success). */\n readonly attempts: number\n}\n\nconst JSON_INSTRUCTION =\n 'Respond with ONLY a single valid JSON value that matches the required schema. ' +\n 'Do not include any prose, explanation, or markdown code fences.'\n\nfunction withJsonInstruction(request: GenerateRequest): GenerateRequest {\n const instruction: Message = { role: 'system', content: JSON_INSTRUCTION }\n return { ...request, messages: [...request.messages, instruction] }\n}\n\nfunction repairRequest(\n base: GenerateRequest,\n failedText: string,\n problem: string,\n): GenerateRequest {\n // Bounded history: always rebuild from `base` (which already carries the JSON instruction),\n // appending only the single last failure + a correction — never an ever-growing transcript.\n const assistant: Message = { role: 'assistant', content: failedText }\n const correction: Message = {\n role: 'user',\n content:\n `Your previous reply could not be used: ${problem}. ` +\n 'Reply again with ONLY the corrected JSON — no prose, no code fences.',\n }\n return { ...base, messages: [...base.messages, assistant, correction] }\n}\n\nfunction mergeLimits(limits: Partial<SanitizeLimits> | undefined): SanitizeLimits | undefined {\n if (!limits) return undefined\n return { ...DEFAULT_SANITIZE_LIMITS, ...limits }\n}\n\nfunction addUsage(a: Usage | undefined, b: Usage | undefined): Usage | undefined {\n if (!a) return b\n if (!b) return a\n const sum: { inputTokens?: number; outputTokens?: number } = {}\n const input = (a.inputTokens ?? 0) + (b.inputTokens ?? 0)\n const output = (a.outputTokens ?? 0) + (b.outputTokens ?? 0)\n if (a.inputTokens !== undefined || b.inputTokens !== undefined) sum.inputTokens = input\n if (a.outputTokens !== undefined || b.outputTokens !== undefined) sum.outputTokens = output\n return sum\n}\n\n/**\n * Generate a value validated against `schema`, repairing up to `maxRepairs` times.\n *\n * @throws AiError `INVALID_OBJECT` if no valid object is produced within the bound (carrying\n * the last validation `issues`), or `ABORTED` if the signal is set between attempts.\n *\n * @example\n * const { object } = await generateObject(backend, { messages }, z.object({ title: z.string() }))\n */\nexport async function generateObject<S extends StandardSchemaV1>(\n backend: GeneratingBackend,\n request: GenerateRequest,\n schema: S,\n options: GenerateObjectOptions = {},\n): Promise<GenerateObjectResult<StandardSchemaV1.InferOutput<S>>> {\n const maxRepairs = options.maxRepairs ?? 2\n const limits = mergeLimits(options.limits)\n const maxInputChars = options.maxInputChars ?? DEFAULT_MAX_INPUT_CHARS\n const base = withJsonInstruction(request)\n\n let usage: Usage | undefined\n let attempt = 0\n let problem = ''\n let failedText = ''\n\n while (attempt <= maxRepairs) {\n if (request.signal?.aborted) throw new AiError('ABORTED', 'request aborted')\n const req = attempt === 0 ? base : repairRequest(base, failedText, problem)\n const result = await backend.generate(req)\n usage = addUsage(usage, result.usage)\n attempt++\n\n failedText = result.text\n const extracted = extractJson(result.text, maxInputChars)\n if (!extracted.ok) {\n problem = extracted.reason\n continue\n }\n // Sanitize BEFORE validate — throws INVALID_OBJECT on pollution/limits (not repaired).\n const clean = sanitizeJson(extracted.value, limits)\n const validation = await validateStandard(schema, clean)\n if (validation.ok) {\n return usage === undefined\n ? { object: validation.value, attempts: attempt }\n : { object: validation.value, usage, attempts: attempt }\n }\n problem = `schema validation failed: ${formatIssues(validation.issues)}`\n // Remember the issues for the final throw if this was the last attempt.\n if (attempt > maxRepairs) {\n throw new AiError('INVALID_OBJECT', problem, { issues: validation.issues })\n }\n }\n\n throw new AiError('INVALID_OBJECT', problem || 'no valid object produced')\n}\n\n/** A chunk emitted by {@link streamObject}. */\nexport type StreamObjectChunk<T> =\n /** A raw text delta from the underlying stream. */\n | { readonly type: 'text-delta'; readonly delta: string }\n /** A best-effort, UNVALIDATED, UNSANITIZED preview parsed from the partial text. */\n | { readonly type: 'partial-object'; readonly object: unknown; readonly validated: false }\n /** The final, sanitized + validated object (emitted once, at end of stream). */\n | { readonly type: 'object'; readonly object: T; readonly validated: true }\n\n/** Options for {@link streamObject}. */\nexport interface StreamObjectOptions {\n /**\n * Emit best-effort `partial-object` previews as text streams in (default `false`). Previews\n * are UNVALIDATED and UNSANITIZED — treat them as untyped UI hints only; the single final\n * `object` chunk is the validated value.\n */\n readonly partial?: boolean\n /** Sanitization limits for the final value. */\n readonly limits?: Partial<SanitizeLimits>\n /** Max characters to accumulate before failing with `INVALID_OBJECT` (default ~8M). */\n readonly maxInputChars?: number\n}\n\n/**\n * Stream a structured object: passes raw `text-delta`s through, optionally emits unvalidated\n * `partial-object` previews, and validates the fully-assembled value EXACTLY ONCE at the end\n * (no mid-stream repair — a stream can't be un-sent).\n *\n * @throws AiError `INVALID_OBJECT` if the final value fails extraction/validation, or\n * `ABORTED` if the signal is set during streaming.\n */\nexport async function* streamObject<S extends StandardSchemaV1>(\n backend: StreamingBackend,\n request: GenerateRequest,\n schema: S,\n options: StreamObjectOptions = {},\n): AsyncIterable<StreamObjectChunk<StandardSchemaV1.InferOutput<S>>> {\n const limits = mergeLimits(options.limits)\n const maxInputChars = options.maxInputChars ?? DEFAULT_MAX_INPUT_CHARS\n const base = withJsonInstruction(request)\n let text = ''\n\n for await (const chunk of backend.stream(base)) {\n if (request.signal?.aborted) throw new AiError('ABORTED', 'request aborted')\n if (chunk.type === 'text-delta') {\n text += chunk.delta\n if (text.length > maxInputChars) {\n throw new AiError('INVALID_OBJECT', `stream exceeds ${maxInputChars} characters`)\n }\n yield { type: 'text-delta', delta: chunk.delta }\n // Throttle previews to structural closes (when a value/structure may have completed) to\n // avoid re-parsing the whole buffer on every token, and skip a preview carrying a poison\n // key so a naive consumer merge can't be weaponized.\n if (options.partial && (chunk.delta.includes('}') || chunk.delta.includes(']'))) {\n const preview = lenientParseJson(text, maxInputChars)\n if (preview !== undefined && !containsForbiddenKey(preview)) {\n yield { type: 'partial-object', object: preview, validated: false }\n }\n }\n }\n }\n\n if (request.signal?.aborted) throw new AiError('ABORTED', 'request aborted')\n const extracted = extractJson(text, maxInputChars)\n if (!extracted.ok) throw new AiError('INVALID_OBJECT', extracted.reason)\n const clean = sanitizeJson(extracted.value, limits)\n const validation = await validateStandard(schema, clean)\n if (!validation.ok) {\n throw new AiError(\n 'INVALID_OBJECT',\n `schema validation failed: ${formatIssues(validation.issues)}`,\n {\n issues: validation.issues,\n },\n )\n }\n yield { type: 'object', object: validation.value, validated: true }\n}\n"],"mappings":";;;AA0DA,MAAM,mBACJ;AAGF,SAAS,oBAAoB,SAA2C;CACtE,MAAM,cAAuB;EAAE,MAAM;EAAU,SAAS;CAAiB;CACzE,OAAO;EAAE,GAAG;EAAS,UAAU,CAAC,GAAG,QAAQ,UAAU,WAAW;CAAE;AACpE;AAEA,SAAS,cACP,MACA,YACA,SACiB;CAGjB,MAAM,YAAqB;EAAE,MAAM;EAAa,SAAS;CAAW;CACpE,MAAM,aAAsB;EAC1B,MAAM;EACN,SACE,0CAA0C,QAAQ;CAEtD;CACA,OAAO;EAAE,GAAG;EAAM,UAAU;GAAC,GAAG,KAAK;GAAU;GAAW;EAAU;CAAE;AACxE;AAEA,SAAS,YAAY,QAAyE;CAC5F,IAAI,CAAC,QAAQ,OAAO,KAAA;CACpB,OAAO;EAAE,GAAG;EAAyB,GAAG;CAAO;AACjD;AAEA,SAAS,SAAS,GAAsB,GAAyC;CAC/E,IAAI,CAAC,GAAG,OAAO;CACf,IAAI,CAAC,GAAG,OAAO;CACf,MAAM,MAAuD,CAAC;CAC9D,MAAM,SAAS,EAAE,eAAe,MAAM,EAAE,eAAe;CACvD,MAAM,UAAU,EAAE,gBAAgB,MAAM,EAAE,gBAAgB;CAC1D,IAAI,EAAE,gBAAgB,KAAA,KAAa,EAAE,gBAAgB,KAAA,GAAW,IAAI,cAAc;CAClF,IAAI,EAAE,iBAAiB,KAAA,KAAa,EAAE,iBAAiB,KAAA,GAAW,IAAI,eAAe;CACrF,OAAO;AACT;;;;;;;;;;AAWA,eAAsB,eACpB,SACA,SACA,QACA,UAAiC,CAAC,GAC8B;CAChE,MAAM,aAAa,QAAQ,cAAc;CACzC,MAAM,SAAS,YAAY,QAAQ,MAAM;CACzC,MAAM,gBAAgB,QAAQ,iBAAA;CAC9B,MAAM,OAAO,oBAAoB,OAAO;CAExC,IAAI;CACJ,IAAI,UAAU;CACd,IAAI,UAAU;CACd,IAAI,aAAa;CAEjB,OAAO,WAAW,YAAY;EAC5B,IAAI,QAAQ,QAAQ,SAAS,MAAM,IAAI,QAAQ,WAAW,iBAAiB;EAC3E,MAAM,MAAM,YAAY,IAAI,OAAO,cAAc,MAAM,YAAY,OAAO;EAC1E,MAAM,SAAS,MAAM,QAAQ,SAAS,GAAG;EACzC,QAAQ,SAAS,OAAO,OAAO,KAAK;EACpC;EAEA,aAAa,OAAO;EACpB,MAAM,YAAY,YAAY,OAAO,MAAM,aAAa;EACxD,IAAI,CAAC,UAAU,IAAI;GACjB,UAAU,UAAU;GACpB;EACF;EAGA,MAAM,aAAa,MAAM,iBAAiB,QAD5B,aAAa,UAAU,OAAO,MACU,CAAC;EACvD,IAAI,WAAW,IACb,OAAO,UAAU,KAAA,IACb;GAAE,QAAQ,WAAW;GAAO,UAAU;EAAQ,IAC9C;GAAE,QAAQ,WAAW;GAAO;GAAO,UAAU;EAAQ;EAE3D,UAAU,6BAA6B,aAAa,WAAW,MAAM;EAErE,IAAI,UAAU,YACZ,MAAM,IAAI,QAAQ,kBAAkB,SAAS,EAAE,QAAQ,WAAW,OAAO,CAAC;CAE9E;CAEA,MAAM,IAAI,QAAQ,kBAAkB,WAAW,0BAA0B;AAC3E;;;;;;;;;AAiCA,gBAAuB,aACrB,SACA,SACA,QACA,UAA+B,CAAC,GACmC;CACnE,MAAM,SAAS,YAAY,QAAQ,MAAM;CACzC,MAAM,gBAAgB,QAAQ,iBAAA;CAC9B,MAAM,OAAO,oBAAoB,OAAO;CACxC,IAAI,OAAO;CAEX,WAAW,MAAM,SAAS,QAAQ,OAAO,IAAI,GAAG;EAC9C,IAAI,QAAQ,QAAQ,SAAS,MAAM,IAAI,QAAQ,WAAW,iBAAiB;EAC3E,IAAI,MAAM,SAAS,cAAc;GAC/B,QAAQ,MAAM;GACd,IAAI,KAAK,SAAS,eAChB,MAAM,IAAI,QAAQ,kBAAkB,kBAAkB,cAAc,YAAY;GAElF,MAAM;IAAE,MAAM;IAAc,OAAO,MAAM;GAAM;GAI/C,IAAI,QAAQ,YAAY,MAAM,MAAM,SAAS,GAAG,KAAK,MAAM,MAAM,SAAS,GAAG,IAAI;IAC/E,MAAM,UAAU,iBAAiB,MAAM,aAAa;IACpD,IAAI,YAAY,KAAA,KAAa,CAAC,qBAAqB,OAAO,GACxD,MAAM;KAAE,MAAM;KAAkB,QAAQ;KAAS,WAAW;IAAM;GAEtE;EACF;CACF;CAEA,IAAI,QAAQ,QAAQ,SAAS,MAAM,IAAI,QAAQ,WAAW,iBAAiB;CAC3E,MAAM,YAAY,YAAY,MAAM,aAAa;CACjD,IAAI,CAAC,UAAU,IAAI,MAAM,IAAI,QAAQ,kBAAkB,UAAU,MAAM;CAEvE,MAAM,aAAa,MAAM,iBAAiB,QAD5B,aAAa,UAAU,OAAO,MACU,CAAC;CACvD,IAAI,CAAC,WAAW,IACd,MAAM,IAAI,QACR,kBACA,6BAA6B,aAAa,WAAW,MAAM,KAC3D,EACE,QAAQ,WAAW,OACrB,CACF;CAEF,MAAM;EAAE,MAAM;EAAU,QAAQ,WAAW;EAAO,WAAW;CAAK;AACpE"}
@@ -0,0 +1,13 @@
1
+ import { AiBackend } from "./contract.js";
2
+
3
+ //#region src/on-device.d.ts
4
+ /**
5
+ * 🔬 Research track — not implemented. Returns an {@link AiBackend} whose `generate`
6
+ * and `stream` throw {@link NotImplementedError}. Use a mock or server backend instead.
7
+ *
8
+ * @experimental
9
+ */
10
+ declare function createOnDeviceBackend(): AiBackend;
11
+ //#endregion
12
+ export { createOnDeviceBackend };
13
+ //# sourceMappingURL=on-device.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"on-device.d.ts","names":[],"sources":["../src/on-device.ts"],"mappings":";;;;;;;;;iBAqBgB,qBAAA,IAAyB,SAAS"}
@@ -0,0 +1,33 @@
1
+ import { notImplemented } from "@mindees/core";
2
+ //#region src/on-device.ts
3
+ /**
4
+ * 🔬 Research track — the on-device AI backend seam. Every on-device LLM runtime is
5
+ * inherently native (Apple Foundation Models, Android AICore/Gemini Nano, ExecuTorch,
6
+ * llama.rn) or web-only (WebGPU/WASM), so none runs on the pure-TS Hermes/RN device
7
+ * path. This backend implements the **same** {@link AiBackend} interface but throws
8
+ * {@link NotImplementedError}, so a native runtime drops in later non-breakingly. The
9
+ * working path today is the mock / server backends. See
10
+ * `docs/adr/0017-synapse-ai-contract.md`.
11
+ *
12
+ * @module
13
+ */
14
+ /**
15
+ * 🔬 Research track — not implemented. Returns an {@link AiBackend} whose `generate`
16
+ * and `stream` throw {@link NotImplementedError}. Use a mock or server backend instead.
17
+ *
18
+ * @experimental
19
+ */
20
+ function createOnDeviceBackend() {
21
+ return {
22
+ generate() {
23
+ return notImplemented("ai.onDevice.generate (native on-device LLM runtime)");
24
+ },
25
+ stream() {
26
+ return notImplemented("ai.onDevice.stream (native on-device LLM runtime)");
27
+ }
28
+ };
29
+ }
30
+ //#endregion
31
+ export { createOnDeviceBackend };
32
+
33
+ //# sourceMappingURL=on-device.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"on-device.js","names":[],"sources":["../src/on-device.ts"],"sourcesContent":["/**\n * 🔬 Research track — the on-device AI backend seam. Every on-device LLM runtime is\n * inherently native (Apple Foundation Models, Android AICore/Gemini Nano, ExecuTorch,\n * llama.rn) or web-only (WebGPU/WASM), so none runs on the pure-TS Hermes/RN device\n * path. This backend implements the **same** {@link AiBackend} interface but throws\n * {@link NotImplementedError}, so a native runtime drops in later non-breakingly. The\n * working path today is the mock / server backends. See\n * `docs/adr/0017-synapse-ai-contract.md`.\n *\n * @module\n */\n\nimport { notImplemented } from '@mindees/core'\nimport type { AiBackend } from './contract'\n\n/**\n * 🔬 Research track — not implemented. Returns an {@link AiBackend} whose `generate`\n * and `stream` throw {@link NotImplementedError}. Use a mock or server backend instead.\n *\n * @experimental\n */\nexport function createOnDeviceBackend(): AiBackend {\n return {\n generate() {\n return notImplemented('ai.onDevice.generate (native on-device LLM runtime)')\n },\n stream() {\n return notImplemented('ai.onDevice.stream (native on-device LLM runtime)')\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,wBAAmC;CACjD,OAAO;EACL,WAAW;GACT,OAAO,eAAe,qDAAqD;EAC7E;EACA,SAAS;GACP,OAAO,eAAe,mDAAmD;EAC3E;CACF;AACF"}
@@ -0,0 +1,42 @@
1
+ import { AiBackend } from "./contract.js";
2
+ import { AdapterName, ProviderMapper, StreamParser, anthropicMapper, openaiMapper } from "./mappers.js";
3
+ import { SseMessage, decodeChunks, parseSse } from "./sse.js";
4
+
5
+ //#region src/server.d.ts
6
+ /** A minimal `Response` shape (no DOM lib). A real `Response` is structurally compatible. */
7
+ interface ResponseLike {
8
+ readonly ok: boolean;
9
+ readonly status: number;
10
+ json(): Promise<unknown>;
11
+ text(): Promise<string>;
12
+ readonly body?: AsyncIterable<Uint8Array> | null;
13
+ }
14
+ /** A minimal request init (no DOM lib). */
15
+ interface RequestInitLike {
16
+ readonly method: string;
17
+ readonly headers: Record<string, string>;
18
+ readonly body: string;
19
+ readonly signal?: unknown;
20
+ }
21
+ /** A minimal `fetch` (no DOM lib). The global `fetch` is structurally compatible. */
22
+ type FetchLike = (url: string, init: RequestInitLike) => Promise<ResponseLike>;
23
+ /** Options for {@link createServerBackend}. */
24
+ interface ServerBackendOptions {
25
+ /** Injected transport (the global `fetch`, or a fake in tests). */
26
+ readonly fetch: FetchLike;
27
+ /** Base URL of the model API (no trailing slash). */
28
+ readonly baseUrl: string;
29
+ /** Model id to request. */
30
+ readonly model: string;
31
+ /** API key — sent as `Authorization: Bearer` (or Anthropic's `x-api-key`). */
32
+ readonly apiKey?: string;
33
+ /** Provider adapter name or a custom {@link ProviderMapper}. Default `'openai'`. */
34
+ readonly adapter?: AdapterName | ProviderMapper;
35
+ /** Extra headers merged over the defaults. */
36
+ readonly headers?: Record<string, string>;
37
+ }
38
+ /** Create a server/HTTP {@link AiBackend}. */
39
+ declare function createServerBackend(options: ServerBackendOptions): AiBackend;
40
+ //#endregion
41
+ export { type AdapterName, FetchLike, type ProviderMapper, RequestInitLike, ResponseLike, ServerBackendOptions, type SseMessage, type StreamParser, anthropicMapper, createServerBackend, decodeChunks, openaiMapper, parseSse };
42
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","names":[],"sources":["../src/server.ts"],"mappings":";;;;;;UA0BiB,YAAA;EAAA,SACN,EAAA;EAAA,SACA,MAAA;EACT,IAAA,IAAQ,OAAA;EACR,IAAA,IAAQ,OAAA;EAAA,SACC,IAAA,GAAO,aAAA,CAAc,UAAA;AAAA;;UAIf,eAAA;EAAA,SACN,MAAA;EAAA,SACA,OAAA,EAAS,MAAM;EAAA,SACf,IAAA;EAAA,SACA,MAAA;AAAA;;KAIC,SAAA,IAAa,GAAA,UAAa,IAAA,EAAM,eAAA,KAAoB,OAAA,CAAQ,YAAA;;UAGvD,oBAAA;EAPN;EAAA,SASA,KAAA,EAAO,SAAA;EATD;EAAA,SAWN,OAAA;EAPU;EAAA,SASV,KAAA;EATiC;EAAA,SAWjC,MAAA;EAXqD;EAAA,SAarD,OAAA,GAAU,WAAA,GAAc,cAAA;EAboC;EAAA,SAe5D,OAAA,GAAU,MAAA;AAAA;;iBAIL,mBAAA,CAAoB,OAAA,EAAS,oBAAA,GAAuB,SAAS"}
package/dist/server.js ADDED
@@ -0,0 +1,64 @@
1
+ import { AiError } from "./errors.js";
2
+ import { MAPPERS, anthropicMapper, openaiMapper } from "./mappers.js";
3
+ import { decodeChunks, parseSse } from "./sse.js";
4
+ //#region src/server.ts
5
+ /** Create a server/HTTP {@link AiBackend}. */
6
+ function createServerBackend(options) {
7
+ const { baseUrl, model } = options;
8
+ const doFetch = options.fetch;
9
+ if (typeof doFetch !== "function") throw new AiError("NO_TRANSPORT", "createServerBackend requires a `fetch`");
10
+ const mapper = typeof options.adapter === "object" ? options.adapter : MAPPERS[options.adapter ?? "openai"];
11
+ const buildHeaders = () => {
12
+ const headers = {
13
+ "content-type": "application/json",
14
+ ...options.headers
15
+ };
16
+ if (options.apiKey) if (mapper.auth === "anthropic") {
17
+ headers["x-api-key"] = options.apiKey;
18
+ headers["anthropic-version"] = "2023-06-01";
19
+ } else headers.authorization = `Bearer ${options.apiKey}`;
20
+ return headers;
21
+ };
22
+ const send = async (request, stream) => {
23
+ if (request.signal?.aborted) throw new AiError("ABORTED", "request aborted");
24
+ const { path, body } = mapper.buildRequest(request, model, stream);
25
+ const res = await doFetch(`${baseUrl}${path}`, {
26
+ method: "POST",
27
+ headers: buildHeaders(),
28
+ body: JSON.stringify(body),
29
+ signal: request.signal
30
+ });
31
+ if (!res.ok) {
32
+ const detail = await res.text().catch(() => "");
33
+ throw new AiError("HTTP_STATUS", `model API returned ${res.status}: ${detail.slice(0, 200)}`);
34
+ }
35
+ return res;
36
+ };
37
+ return {
38
+ async generate(request) {
39
+ const json = await (await send(request, false)).json();
40
+ if (request.signal?.aborted) throw new AiError("ABORTED", "request aborted");
41
+ return mapper.parseResponse(json);
42
+ },
43
+ async *stream(request) {
44
+ const res = await send(request, true);
45
+ if (!res.body) throw new AiError("STREAM_PARSE", "streaming response has no body");
46
+ const parseChunk = mapper.createStreamParser();
47
+ for await (const message of parseSse(decodeChunks(res.body))) {
48
+ if (message.data.trim() === "[DONE]") return;
49
+ if (request.signal?.aborted) throw new AiError("ABORTED", "request aborted");
50
+ let parsed;
51
+ try {
52
+ parsed = JSON.parse(message.data);
53
+ } catch {
54
+ throw new AiError("STREAM_PARSE", `malformed SSE data: ${message.data.slice(0, 80)}`);
55
+ }
56
+ for (const chunk of parseChunk(parsed)) yield chunk;
57
+ }
58
+ }
59
+ };
60
+ }
61
+ //#endregion
62
+ export { anthropicMapper, createServerBackend, decodeChunks, openaiMapper, parseSse };
63
+
64
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","names":[],"sources":["../src/server.ts"],"sourcesContent":["/**\n * The Synapse server/HTTP backend — a real {@link AiBackend} that talks to a hosted\n * model API over an **injected `fetch`** (capability injection, like Pulse's\n * `fetchManifest`). Provider-agnostic via a {@link ProviderMapper}; streaming via the\n * pure-TS {@link parseSse} parser → `AsyncIterable<AiChunk>` (no Web/Node streams in the\n * public surface). Exported from the `@mindees/ai/server` subpath. See\n * `docs/adr/0018-synapse-server-backend.md`.\n *\n * @module\n */\n\nimport type { AiBackend, AiChunk, AiResult, GenerateRequest } from './contract'\nimport { AiError } from './errors'\nimport { type AdapterName, MAPPERS, type ProviderMapper } from './mappers'\nimport { decodeChunks, parseSse } from './sse'\n\nexport {\n type AdapterName,\n anthropicMapper,\n openaiMapper,\n type ProviderMapper,\n type StreamParser,\n} from './mappers'\nexport { decodeChunks, parseSse, type SseMessage } from './sse'\n\n/** A minimal `Response` shape (no DOM lib). A real `Response` is structurally compatible. */\nexport interface ResponseLike {\n readonly ok: boolean\n readonly status: number\n json(): Promise<unknown>\n text(): Promise<string>\n readonly body?: AsyncIterable<Uint8Array> | null\n}\n\n/** A minimal request init (no DOM lib). */\nexport interface RequestInitLike {\n readonly method: string\n readonly headers: Record<string, string>\n readonly body: string\n readonly signal?: unknown\n}\n\n/** A minimal `fetch` (no DOM lib). The global `fetch` is structurally compatible. */\nexport type FetchLike = (url: string, init: RequestInitLike) => Promise<ResponseLike>\n\n/** Options for {@link createServerBackend}. */\nexport interface ServerBackendOptions {\n /** Injected transport (the global `fetch`, or a fake in tests). */\n readonly fetch: FetchLike\n /** Base URL of the model API (no trailing slash). */\n readonly baseUrl: string\n /** Model id to request. */\n readonly model: string\n /** API key — sent as `Authorization: Bearer` (or Anthropic's `x-api-key`). */\n readonly apiKey?: string\n /** Provider adapter name or a custom {@link ProviderMapper}. Default `'openai'`. */\n readonly adapter?: AdapterName | ProviderMapper\n /** Extra headers merged over the defaults. */\n readonly headers?: Record<string, string>\n}\n\n/** Create a server/HTTP {@link AiBackend}. */\nexport function createServerBackend(options: ServerBackendOptions): AiBackend {\n const { baseUrl, model } = options\n const doFetch = options.fetch\n if (typeof doFetch !== 'function') {\n throw new AiError('NO_TRANSPORT', 'createServerBackend requires a `fetch`')\n }\n const mapper: ProviderMapper =\n typeof options.adapter === 'object' ? options.adapter : MAPPERS[options.adapter ?? 'openai']\n\n const buildHeaders = (): Record<string, string> => {\n const headers: Record<string, string> = {\n 'content-type': 'application/json',\n ...options.headers,\n }\n if (options.apiKey) {\n // Auth scheme follows the MAPPER (not the string-vs-object form the adapter was\n // supplied in), so `adapter: anthropicMapper` authenticates like `adapter: 'anthropic'`.\n if (mapper.auth === 'anthropic') {\n headers['x-api-key'] = options.apiKey\n headers['anthropic-version'] = '2023-06-01'\n } else {\n headers.authorization = `Bearer ${options.apiKey}`\n }\n }\n return headers\n }\n\n const send = async (request: GenerateRequest, stream: boolean): Promise<ResponseLike> => {\n if (request.signal?.aborted) throw new AiError('ABORTED', 'request aborted')\n const { path, body } = mapper.buildRequest(request, model, stream)\n const res = await doFetch(`${baseUrl}${path}`, {\n method: 'POST',\n headers: buildHeaders(),\n body: JSON.stringify(body),\n signal: request.signal,\n })\n if (!res.ok) {\n const detail = await res.text().catch(() => '')\n throw new AiError('HTTP_STATUS', `model API returned ${res.status}: ${detail.slice(0, 200)}`)\n }\n return res\n }\n\n return {\n async generate(request): Promise<AiResult> {\n const res = await send(request, false)\n const json = await res.json()\n // Re-check after the round-trip: an abort during the fetch/parse must surface,\n // matching stream()'s and runTools' polling model (an injected fetch may ignore\n // the forwarded signal).\n if (request.signal?.aborted) throw new AiError('ABORTED', 'request aborted')\n return mapper.parseResponse(json)\n },\n\n async *stream(request): AsyncIterable<AiChunk> {\n const res = await send(request, true)\n if (!res.body) throw new AiError('STREAM_PARSE', 'streaming response has no body')\n const parseChunk = mapper.createStreamParser()\n for await (const message of parseSse(decodeChunks(res.body))) {\n // Terminal sentinel first: a completed stream must resolve normally even if the\n // signal flips on this exact iteration (no spurious ABORTED on a done stream).\n if (message.data.trim() === '[DONE]') return\n if (request.signal?.aborted) throw new AiError('ABORTED', 'request aborted')\n let parsed: unknown\n try {\n parsed = JSON.parse(message.data)\n } catch {\n throw new AiError('STREAM_PARSE', `malformed SSE data: ${message.data.slice(0, 80)}`)\n }\n for (const chunk of parseChunk(parsed)) yield chunk\n }\n },\n }\n}\n"],"mappings":";;;;;AA8DA,SAAgB,oBAAoB,SAA0C;CAC5E,MAAM,EAAE,SAAS,UAAU;CAC3B,MAAM,UAAU,QAAQ;CACxB,IAAI,OAAO,YAAY,YACrB,MAAM,IAAI,QAAQ,gBAAgB,wCAAwC;CAE5E,MAAM,SACJ,OAAO,QAAQ,YAAY,WAAW,QAAQ,UAAU,QAAQ,QAAQ,WAAW;CAErF,MAAM,qBAA6C;EACjD,MAAM,UAAkC;GACtC,gBAAgB;GAChB,GAAG,QAAQ;EACb;EACA,IAAI,QAAQ,QAGV,IAAI,OAAO,SAAS,aAAa;GAC/B,QAAQ,eAAe,QAAQ;GAC/B,QAAQ,uBAAuB;EACjC,OACE,QAAQ,gBAAgB,UAAU,QAAQ;EAG9C,OAAO;CACT;CAEA,MAAM,OAAO,OAAO,SAA0B,WAA2C;EACvF,IAAI,QAAQ,QAAQ,SAAS,MAAM,IAAI,QAAQ,WAAW,iBAAiB;EAC3E,MAAM,EAAE,MAAM,SAAS,OAAO,aAAa,SAAS,OAAO,MAAM;EACjE,MAAM,MAAM,MAAM,QAAQ,GAAG,UAAU,QAAQ;GAC7C,QAAQ;GACR,SAAS,aAAa;GACtB,MAAM,KAAK,UAAU,IAAI;GACzB,QAAQ,QAAQ;EAClB,CAAC;EACD,IAAI,CAAC,IAAI,IAAI;GACX,MAAM,SAAS,MAAM,IAAI,KAAK,EAAE,YAAY,EAAE;GAC9C,MAAM,IAAI,QAAQ,eAAe,sBAAsB,IAAI,OAAO,IAAI,OAAO,MAAM,GAAG,GAAG,GAAG;EAC9F;EACA,OAAO;CACT;CAEA,OAAO;EACL,MAAM,SAAS,SAA4B;GAEzC,MAAM,OAAO,OAAM,MADD,KAAK,SAAS,KAAK,GACd,KAAK;GAI5B,IAAI,QAAQ,QAAQ,SAAS,MAAM,IAAI,QAAQ,WAAW,iBAAiB;GAC3E,OAAO,OAAO,cAAc,IAAI;EAClC;EAEA,OAAO,OAAO,SAAiC;GAC7C,MAAM,MAAM,MAAM,KAAK,SAAS,IAAI;GACpC,IAAI,CAAC,IAAI,MAAM,MAAM,IAAI,QAAQ,gBAAgB,gCAAgC;GACjF,MAAM,aAAa,OAAO,mBAAmB;GAC7C,WAAW,MAAM,WAAW,SAAS,aAAa,IAAI,IAAI,CAAC,GAAG;IAG5D,IAAI,QAAQ,KAAK,KAAK,MAAM,UAAU;IACtC,IAAI,QAAQ,QAAQ,SAAS,MAAM,IAAI,QAAQ,WAAW,iBAAiB;IAC3E,IAAI;IACJ,IAAI;KACF,SAAS,KAAK,MAAM,QAAQ,IAAI;IAClC,QAAQ;KACN,MAAM,IAAI,QAAQ,gBAAgB,uBAAuB,QAAQ,KAAK,MAAM,GAAG,EAAE,GAAG;IACtF;IACA,KAAK,MAAM,SAAS,WAAW,MAAM,GAAG,MAAM;GAChD;EACF;CACF;AACF"}
package/dist/sse.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ //#region src/sse.d.ts
2
+ /**
3
+ * A tiny, hand-rolled Server-Sent Events parser (no `eventsource` dep) for the server
4
+ * backend's streaming responses. Pure-TS: buffers across chunk boundaries, joins
5
+ * multi-line `data:` fields, skips `:` comments / keep-alives, and is driven by an
6
+ * `AsyncIterable<string>` so it's golden-fixture-testable with zero network. See
7
+ * `docs/adr/0018-synapse-server-backend.md`.
8
+ *
9
+ * @module
10
+ */
11
+ /** One dispatched SSE event. */
12
+ interface SseMessage {
13
+ /** The joined `data:` payload. */
14
+ readonly data: string;
15
+ /** The `event:` type, if any. */
16
+ readonly event: string | undefined;
17
+ }
18
+ /** Parse an SSE byte/string stream into dispatched {@link SseMessage}s. */
19
+ declare function parseSse(chunks: AsyncIterable<string>): AsyncIterable<SseMessage>;
20
+ /** Adapt a byte stream (e.g. `response.body`) to the string chunks {@link parseSse} expects. */
21
+ declare function decodeChunks(bytes: AsyncIterable<Uint8Array>): AsyncIterable<string>;
22
+ //#endregion
23
+ export { SseMessage, decodeChunks, parseSse };
24
+ //# sourceMappingURL=sse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse.d.ts","names":[],"sources":["../src/sse.ts"],"mappings":";;AAsBA;;;;AAIgB;AAIhB;;;;UARiB,UAAA;EAQ+C;EAAA,SANrD,IAAA;EAMkE;EAAA,SAJlE,KAAK;AAAA;;iBAIO,QAAA,CAAS,MAAA,EAAQ,aAAA,WAAwB,aAAA,CAAc,UAAA;;iBAoEvD,YAAA,CAAa,KAAA,EAAO,aAAA,CAAc,UAAA,IAAc,aAAA"}