@hachej/boring-agent 0.1.111 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +262 -51
- package/dist/agents.d.ts +75 -0
- package/dist/agents.js +91 -0
- package/dist/approval.d.ts +71 -0
- package/dist/approval.js +126 -0
- package/dist/artifacts.d.ts +53 -0
- package/dist/artifacts.js +129 -0
- package/dist/ask-user.d.ts +40 -0
- package/dist/ask-user.js +134 -0
- package/dist/attachment.d.ts +5 -0
- package/dist/attachment.js +4 -0
- package/dist/browser-task.d.ts +32 -0
- package/dist/browser-task.js +119 -0
- package/dist/chat-transport.d.ts +80 -0
- package/dist/chat-transport.js +306 -0
- package/dist/contracts.d.ts +40 -0
- package/dist/contracts.js +1 -0
- package/dist/conversations.d.ts +136 -0
- package/dist/conversations.js +366 -0
- package/dist/definitions.d.ts +37 -0
- package/dist/definitions.js +99 -0
- package/dist/delivery-input.d.ts +35 -0
- package/dist/delivery-input.js +56 -0
- package/dist/delivery.d.ts +33 -0
- package/dist/delivery.js +154 -0
- package/dist/durable-wait.d.ts +23 -0
- package/dist/durable-wait.js +79 -0
- package/dist/file-convert.d.ts +46 -0
- package/dist/file-convert.js +159 -0
- package/dist/file-guard.d.ts +50 -0
- package/dist/file-guard.js +177 -0
- package/dist/file-types.d.ts +17 -0
- package/dist/file-types.js +95 -0
- package/dist/gateway-provider.d.ts +24 -0
- package/dist/gateway-provider.js +42 -0
- package/dist/git.d.ts +34 -0
- package/dist/git.js +79 -0
- package/dist/harness-pool.d.ts +38 -0
- package/dist/harness-pool.js +134 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1 -0
- package/dist/json-schema-tool.d.ts +25 -0
- package/dist/json-schema-tool.js +19 -0
- package/dist/memory/optchat-core.d.ts +118 -0
- package/dist/memory/optchat-core.js +317 -0
- package/dist/memory/optchat.d.ts +156 -0
- package/dist/memory/optchat.js +432 -0
- package/dist/mentions.d.ts +56 -0
- package/dist/mentions.js +140 -0
- package/dist/metering.d.ts +183 -0
- package/dist/metering.js +443 -0
- package/dist/model-gateway.d.ts +86 -0
- package/dist/model-gateway.js +241 -0
- package/dist/native-version.d.ts +9 -0
- package/dist/native-version.js +7 -0
- package/dist/native.d.ts +4 -0
- package/dist/native.js +1 -0
- package/dist/presentation.d.ts +17 -0
- package/dist/presentation.js +39 -0
- package/dist/projection-format.d.ts +68 -0
- package/dist/projection-format.js +118 -0
- package/dist/projection-window.d.ts +9 -0
- package/dist/projection-window.js +93 -0
- package/dist/projection.d.ts +22 -0
- package/dist/projection.js +153 -0
- package/dist/question-response.d.ts +20 -0
- package/dist/question-response.js +75 -0
- package/dist/questions.d.ts +74 -0
- package/dist/questions.js +199 -0
- package/dist/self-evolving.d.ts +55 -0
- package/dist/self-evolving.js +239 -0
- package/dist/skills.d.ts +15 -0
- package/dist/skills.js +51 -0
- package/dist/tool-evidence.d.ts +14 -0
- package/dist/tool-evidence.js +54 -0
- package/dist/validation.d.ts +38 -0
- package/dist/validation.js +68 -0
- package/dist/workspaces.d.ts +107 -0
- package/dist/workspaces.js +212 -0
- package/package.json +145 -150
- package/dist/DebugDrawer-JNA43SVR.js +0 -8
- package/dist/admin-DORVQfLn.d.ts +0 -340
- package/dist/chunk-2BGLZZ7T.js +0 -10
- package/dist/chunk-42D6KAQV.js +0 -2882
- package/dist/chunk-4XOW57KJ.js +0 -343
- package/dist/chunk-72KT6VWV.js +0 -1338
- package/dist/chunk-7XAOVFSN.js +0 -28
- package/dist/chunk-KJIEMSQP.js +0 -198
- package/dist/chunk-KN72XWP7.js +0 -394
- package/dist/chunk-N6THTH7P.js +0 -110
- package/dist/chunk-V3H5A3BR.js +0 -22501
- package/dist/chunk-YGRQ4Q5P.js +0 -20
- package/dist/chunk-YTUZFKB3.js +0 -78
- package/dist/core/index.d.ts +0 -28
- package/dist/core/index.js +0 -617
- package/dist/createHarness-DOLOHA62.js +0 -18
- package/dist/eval/index.d.ts +0 -247
- package/dist/eval/index.js +0 -541
- package/dist/file-search-BUgMSKZE.d.ts +0 -289
- package/dist/front/artifacts.d.ts +0 -34
- package/dist/front/artifacts.js +0 -21
- package/dist/front/index.d.ts +0 -1082
- package/dist/front/index.js +0 -12912
- package/dist/front/styles.css +0 -5380
- package/dist/harness-qHlMJhZo.d.ts +0 -276
- package/dist/middleware-CaAEXpi5.d.ts +0 -115
- package/dist/piChatEvent-DpE0lIb9.d.ts +0 -450
- package/dist/piChatSessionService-CBct-hmz.d.ts +0 -86
- package/dist/sandbox-BMhut3kh.d.ts +0 -317
- package/dist/server/agent-host/testing/compositionRouteProof.d.ts +0 -8
- package/dist/server/agent-host/testing/compositionRouteProof.js +0 -78
- package/dist/server/agent-host/testing/gatewayConformance.d.ts +0 -43
- package/dist/server/agent-host/testing/gatewayConformance.js +0 -605
- package/dist/server/index.d.ts +0 -2278
- package/dist/server/index.js +0 -274
- package/dist/server/pi-session-readability.d.ts +0 -12
- package/dist/server/pi-session-readability.js +0 -23
- package/dist/server/worker/index.d.ts +0 -68
- package/dist/server/worker/index.js +0 -377
- package/dist/share-entry-CxS6UJ_L.d.ts +0 -330
- package/dist/shared/index.d.ts +0 -2111
- package/dist/shared/index.js +0 -262
- package/dist/types-DcWoQMn8.d.ts +0 -1377
- package/docs/ACCESSIBILITY.md +0 -52
- package/docs/AGENT_GATEWAY_V0.md +0 -212
- package/docs/AGENT_PACKAGES.md +0 -90
- package/docs/API.md +0 -123
- package/docs/AUTHORING.md +0 -68
- package/docs/CSP.md +0 -41
- package/docs/ERROR_CODES.md +0 -229
- package/docs/KNOWN_LIMITATIONS.md +0 -100
- package/docs/MIGRATION.md +0 -50
- package/docs/PERFORMANCE.md +0 -68
- package/docs/PLUGINS.md +0 -138
- package/docs/README.md +0 -98
- package/docs/RISKS-MULTI-TAB.md +0 -46
- package/docs/SESSION_IDENTITY.md +0 -91
- package/docs/STYLING.md +0 -73
- package/docs/UI-SHADCN.md +0 -56
- package/docs/VERCEL_COSTS.md +0 -103
- package/docs/plans/archive/AGENT_EVAL_FRAMEWORK.md +0 -466
- package/docs/plans/archive/agent-package-spec.md +0 -1777
- package/docs/plans/archive/harness-followup-capabilities.md +0 -440
- package/docs/plans/archive/harness-tool-ui-capabilities.md +0 -144
- package/docs/plans/archive/pi-followup-history-projection.md +0 -229
- package/docs/plans/archive/pi-tools-migration.md +0 -1061
- package/docs/plans/archive/reviews/pi-followup-history-codex-review.md +0 -11
- package/docs/plans/archive/reviews/pi-followup-history-gpt-review.md +0 -64
- package/docs/plans/archive/reviews/pi-followup-history-opus-review.md +0 -43
- package/docs/plans/archive/reviews/pi-followup-history-xai-review.md +0 -43
- package/docs/plans/archive/vercel-base-snapshot-template-plan.md +0 -527
- package/docs/plans/archive/vercel-persistent-sandbox-adapter.md +0 -553
- package/docs/runtime-provisioning.md +0 -157
- package/docs/runtime.md +0 -296
- package/docs/tools.md +0 -102
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** An upstream speaking OpenAI chat completions. The gateway gives it a rebuilt body; the upstream adds its own credential. */
|
|
2
|
+
export interface ModelUpstream {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
/** The upstream's name for the output ceiling (`max_completion_tokens` for OpenAI, `max_tokens` for most compatible APIs). */
|
|
5
|
+
readonly maxTokensField?: 'max_completion_tokens' | 'max_tokens';
|
|
6
|
+
readonly chatCompletions: (body: Readonly<Record<string, unknown>>, signal: AbortSignal) => Promise<Response>;
|
|
7
|
+
}
|
|
8
|
+
export type BudgetDecision = {
|
|
9
|
+
readonly kind: 'admitted';
|
|
10
|
+
readonly maxTokens: number;
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: 'refused';
|
|
13
|
+
readonly retryAfterSeconds: number;
|
|
14
|
+
readonly message: string;
|
|
15
|
+
};
|
|
16
|
+
/** Per-person spending policy. `admit` runs once per completion request, before the upstream is called. */
|
|
17
|
+
export interface ModelBudget {
|
|
18
|
+
readonly admit: (person: string, request: {
|
|
19
|
+
readonly model: string;
|
|
20
|
+
readonly maxTokens: number | undefined;
|
|
21
|
+
}) => BudgetDecision | Promise<BudgetDecision>;
|
|
22
|
+
}
|
|
23
|
+
export interface GatewayLogEntry {
|
|
24
|
+
readonly person: string | null;
|
|
25
|
+
readonly model: string | null;
|
|
26
|
+
readonly status: number;
|
|
27
|
+
readonly code?: string;
|
|
28
|
+
readonly latencyMs: number;
|
|
29
|
+
readonly inputTokens?: number;
|
|
30
|
+
readonly outputTokens?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface ModelGatewayOptions {
|
|
33
|
+
readonly upstream: ModelUpstream;
|
|
34
|
+
/** Allowed gateway model ids; a record maps each to its upstream model id. */
|
|
35
|
+
readonly allow: readonly string[] | Readonly<Record<string, string>>;
|
|
36
|
+
readonly budget: ModelBudget;
|
|
37
|
+
/** The app session: the person's stable id, or nothing (401). Only this callback ever sees the request headers. */
|
|
38
|
+
readonly authorize: (request: Request) => string | null | undefined | Promise<string | null | undefined>;
|
|
39
|
+
readonly log?: (entry: GatewayLogEntry) => void;
|
|
40
|
+
/** Where the gateway is mounted, for example `/api/llm`. Default: the root. */
|
|
41
|
+
readonly basePath?: string;
|
|
42
|
+
readonly maxBodyBytes?: number;
|
|
43
|
+
readonly timeoutMs?: number;
|
|
44
|
+
readonly now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
export declare const OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
47
|
+
/** Anthropic's OpenAI-compatible endpoint. */
|
|
48
|
+
export declare const ANTHROPIC_OPENAI_BASE_URL = "https://api.anthropic.com/v1";
|
|
49
|
+
export interface OpenAICompatibleUpstreamOptions {
|
|
50
|
+
readonly apiKey: string;
|
|
51
|
+
readonly baseUrl?: string;
|
|
52
|
+
readonly id?: string;
|
|
53
|
+
readonly maxTokensField?: 'max_completion_tokens' | 'max_tokens';
|
|
54
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
55
|
+
}
|
|
56
|
+
/** An OpenAI-compatible upstream at a fixed base URL with the server's key. */
|
|
57
|
+
export declare function openAICompatibleUpstream(options: OpenAICompatibleUpstreamOptions): ModelUpstream;
|
|
58
|
+
/** Anthropic through its OpenAI-compatible endpoint. */
|
|
59
|
+
export declare const anthropicUpstream: (options: Omit<OpenAICompatibleUpstreamOptions, "baseUrl" | "maxTokensField">) => ModelUpstream;
|
|
60
|
+
/** A scripted turn that calls tools instead of answering in text. */
|
|
61
|
+
export interface ScriptedToolCalls {
|
|
62
|
+
readonly toolCalls: readonly {
|
|
63
|
+
readonly name: string;
|
|
64
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
65
|
+
}[];
|
|
66
|
+
}
|
|
67
|
+
export interface ScriptedUpstreamOptions {
|
|
68
|
+
/** The deterministic reply to the conversation (OpenAI-shaped messages): text, or tool calls when the request declared tools. */
|
|
69
|
+
readonly reply: (messages: readonly Readonly<Record<string, unknown>>[], request: {
|
|
70
|
+
readonly tools: readonly string[];
|
|
71
|
+
}) => string | ScriptedToolCalls;
|
|
72
|
+
readonly id?: string;
|
|
73
|
+
}
|
|
74
|
+
/** A keyless upstream answering every request locally with `reply`, in the OpenAI wire format (JSON or SSE), tool calls included. */
|
|
75
|
+
export declare function scriptedUpstream(options: ScriptedUpstreamOptions): ModelUpstream;
|
|
76
|
+
export interface MemoryBudgetOptions {
|
|
77
|
+
readonly requestsPerMinute: number;
|
|
78
|
+
readonly maxTokensPerRequest: number;
|
|
79
|
+
readonly now?: () => number;
|
|
80
|
+
}
|
|
81
|
+
/** In-memory budget for one process: a sliding one-minute request window per person and a per-request output ceiling
|
|
82
|
+
* (a larger or missing request is clamped to it). A shared gateway swaps in a durable implementation of `ModelBudget`. */
|
|
83
|
+
export declare function memoryBudget(options: MemoryBudgetOptions): ModelBudget;
|
|
84
|
+
/** The gateway as a Fetch handler: `(request) => Promise<Response>`. */
|
|
85
|
+
export declare function createModelGateway(options: ModelGatewayOptions): (request: Request) => Promise<Response>;
|
|
86
|
+
//# sourceMappingURL=model-gateway.d.ts.map
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @hachej/boring-agent/model-gateway: the host's model gateway. Browser-side Pi agents (`@hachej/boring-agent/gateway-provider`) call models
|
|
2
|
+
// through it with the person's app session; the provider key never leaves the server. Server-only by role (it holds the key),
|
|
3
|
+
// Fetch-only by construction (no node or Pi imports, ARCHITECTURE.json pathRules), so it runs in Node, Workers or a shared
|
|
4
|
+
// "Boring model gateway" service with the same API: the browser knows only a base URL.
|
|
5
|
+
//
|
|
6
|
+
// API under `basePath`: `POST /v1/chat/completions` (OpenAI-compatible, JSON or SSE streaming, passed through as the upstream
|
|
7
|
+
// sends it) and `GET /v1/models` (the allowed ids). The host owns the policy: who may call (`authorize`, the app session), which
|
|
8
|
+
// models (`allow`, gateway id -> upstream id), how much (`budget`), which upstream and key (`upstream`). The browser cannot choose
|
|
9
|
+
// an upstream, a key or a header: the forwarded body is rebuilt from an allowlist of fields and no request header is forwarded.
|
|
10
|
+
// Errors are `{ error: { code, message } }`: 401 no session, 403 model not allowed, 429 budget, 502 upstream failure (never the
|
|
11
|
+
// upstream's body, which may quote the key), 400/404/405/413 for malformed calls. `log` receives metadata only (person, model,
|
|
12
|
+
// status, tokens, latency), never prompts or completions. The body goes through the shared request guard (JSON content type,
|
|
13
|
+
// byte cap), like every other handler of the packages.
|
|
14
|
+
import { RequestGuardError, readJsonBody } from '@hachej/boring-files/request-guard';
|
|
15
|
+
export const OPENAI_BASE_URL = 'https://api.openai.com/v1';
|
|
16
|
+
/** Anthropic's OpenAI-compatible endpoint. */
|
|
17
|
+
export const ANTHROPIC_OPENAI_BASE_URL = 'https://api.anthropic.com/v1';
|
|
18
|
+
/** Body fields a caller may set; everything else (store, user, metadata, n, provider routing...) is dropped. */
|
|
19
|
+
const FORWARDED = ['messages', 'temperature', 'top_p', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'presence_penalty', 'frequency_penalty'];
|
|
20
|
+
const failure = (status, code, message, headers = {}) => Response.json({ error: { code, message } }, { status, headers: { 'cache-control': 'no-store', ...headers } });
|
|
21
|
+
/** An OpenAI-compatible upstream at a fixed base URL with the server's key. */
|
|
22
|
+
export function openAICompatibleUpstream(options) {
|
|
23
|
+
if (!options.apiKey)
|
|
24
|
+
throw new TypeError('openAICompatibleUpstream needs the server-side apiKey');
|
|
25
|
+
const base = (options.baseUrl ?? OPENAI_BASE_URL).replace(/\/+$/, '');
|
|
26
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
27
|
+
const apiKey = options.apiKey;
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
id: options.id ?? 'openai', maxTokensField: options.maxTokensField ?? 'max_completion_tokens',
|
|
30
|
+
chatCompletions: (body, signal) => fetchImpl(`${base}/chat/completions`, {
|
|
31
|
+
method: 'POST', signal, body: JSON.stringify(body),
|
|
32
|
+
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', accept: body.stream === true ? 'text/event-stream' : 'application/json' },
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** Anthropic through its OpenAI-compatible endpoint. */
|
|
37
|
+
export const anthropicUpstream = (options) => openAICompatibleUpstream({ id: 'anthropic', ...options, baseUrl: ANTHROPIC_OPENAI_BASE_URL, maxTokensField: 'max_tokens' });
|
|
38
|
+
const tokensOf = (text) => Math.max(1, Math.ceil(text.length / 4));
|
|
39
|
+
/** The function names the request declared (`tools[].function.name`). */
|
|
40
|
+
const declaredTools = (tools) => Array.isArray(tools)
|
|
41
|
+
? tools.flatMap(tool => { const name = tool?.function?.name; return typeof name === 'string' ? [name] : []; }) : [];
|
|
42
|
+
/** A keyless upstream answering every request locally with `reply`, in the OpenAI wire format (JSON or SSE), tool calls included. */
|
|
43
|
+
export function scriptedUpstream(options) {
|
|
44
|
+
const encoder = new TextEncoder();
|
|
45
|
+
let counter = 0;
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
id: options.id ?? 'scripted', maxTokensField: 'max_completion_tokens',
|
|
48
|
+
chatCompletions: async (body) => {
|
|
49
|
+
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
50
|
+
const tools = declaredTools(body.tools);
|
|
51
|
+
const replied = options.reply(messages, { tools });
|
|
52
|
+
const calls = typeof replied === 'string' ? [] : replied.toolCalls.filter(call => tools.includes(call.name))
|
|
53
|
+
.map((call, index) => ({ index, id: `call_scripted_${counter + 1}_${index}`, type: 'function', function: { name: call.name, arguments: JSON.stringify(call.arguments) } }));
|
|
54
|
+
const text = typeof replied === 'string' ? replied : calls.length ? '' : '(scripted) no declared tool to call.';
|
|
55
|
+
const finish = calls.length ? 'tool_calls' : 'stop';
|
|
56
|
+
const id = `chatcmpl-scripted-${++counter}`, created = 0, model = String(body.model);
|
|
57
|
+
const usage = { prompt_tokens: tokensOf(JSON.stringify(messages)), completion_tokens: tokensOf(text + JSON.stringify(calls)), total_tokens: 0 };
|
|
58
|
+
const usageTotal = { ...usage, total_tokens: usage.prompt_tokens + usage.completion_tokens };
|
|
59
|
+
if (body.stream !== true) {
|
|
60
|
+
const message = calls.length ? { role: 'assistant', content: null, tool_calls: calls.map(({ index: _index, ...call }) => call) } : { role: 'assistant', content: text };
|
|
61
|
+
return Response.json({ id, object: 'chat.completion', created, model, choices: [{ index: 0, message, finish_reason: finish }], usage: usageTotal });
|
|
62
|
+
}
|
|
63
|
+
const chunk = (choices, extra = {}) => `data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created, model, choices, ...extra })}\n\n`;
|
|
64
|
+
const frames = [
|
|
65
|
+
chunk([{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }]),
|
|
66
|
+
...(text.match(/\S+\s*/g) ?? []).map(piece => chunk([{ index: 0, delta: { content: piece }, finish_reason: null }])),
|
|
67
|
+
...calls.map(call => chunk([{ index: 0, delta: { tool_calls: [call] }, finish_reason: null }])),
|
|
68
|
+
chunk([{ index: 0, delta: {}, finish_reason: finish }]),
|
|
69
|
+
chunk([], { usage: usageTotal }),
|
|
70
|
+
'data: [DONE]\n\n',
|
|
71
|
+
];
|
|
72
|
+
return new Response(new ReadableStream({ start(controller) { for (const frame of frames)
|
|
73
|
+
controller.enqueue(encoder.encode(frame)); controller.close(); } }), { headers: { 'content-type': 'text/event-stream; charset=utf-8' } });
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** In-memory budget for one process: a sliding one-minute request window per person and a per-request output ceiling
|
|
78
|
+
* (a larger or missing request is clamped to it). A shared gateway swaps in a durable implementation of `ModelBudget`. */
|
|
79
|
+
export function memoryBudget(options) {
|
|
80
|
+
const now = options.now ?? Date.now;
|
|
81
|
+
const windows = new Map();
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
admit: (person, request) => {
|
|
84
|
+
const at = now();
|
|
85
|
+
const recent = (windows.get(person) ?? []).filter(time => time > at - 60_000);
|
|
86
|
+
if (recent.length >= options.requestsPerMinute) {
|
|
87
|
+
windows.set(person, recent);
|
|
88
|
+
return { kind: 'refused', retryAfterSeconds: Math.max(1, Math.ceil(((recent[0] ?? at) + 60_000 - at) / 1000)), message: `At most ${options.requestsPerMinute} model requests a minute; try again shortly.` };
|
|
89
|
+
}
|
|
90
|
+
recent.push(at);
|
|
91
|
+
windows.set(person, recent);
|
|
92
|
+
const asked = request.maxTokens;
|
|
93
|
+
return { kind: 'admitted', maxTokens: asked !== undefined && asked > 0 ? Math.min(asked, options.maxTokensPerRequest) : options.maxTokensPerRequest };
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function usageOf(value, into) {
|
|
98
|
+
const usage = value?.usage;
|
|
99
|
+
if (typeof usage?.prompt_tokens === 'number')
|
|
100
|
+
into.inputTokens = usage.prompt_tokens;
|
|
101
|
+
if (typeof usage?.completion_tokens === 'number')
|
|
102
|
+
into.outputTokens = usage.completion_tokens;
|
|
103
|
+
}
|
|
104
|
+
/** The gateway as a Fetch handler: `(request) => Promise<Response>`. */
|
|
105
|
+
export function createModelGateway(options) {
|
|
106
|
+
if (!options.upstream || typeof options.upstream.chatCompletions !== 'function')
|
|
107
|
+
throw new TypeError('createModelGateway needs an upstream');
|
|
108
|
+
if (typeof options.authorize !== 'function')
|
|
109
|
+
throw new TypeError('createModelGateway needs authorize(request)');
|
|
110
|
+
if (!options.budget || typeof options.budget.admit !== 'function')
|
|
111
|
+
throw new TypeError('createModelGateway needs a budget');
|
|
112
|
+
const allowed = new Map(Array.isArray(options.allow) ? options.allow.map(id => [id, id]) : Object.entries(options.allow));
|
|
113
|
+
const basePath = (options.basePath ?? '').replace(/\/+$/, '');
|
|
114
|
+
const maxBodyBytes = options.maxBodyBytes ?? 2 * 1024 * 1024;
|
|
115
|
+
const timeoutMs = options.timeoutMs ?? 300_000;
|
|
116
|
+
const now = options.now ?? Date.now;
|
|
117
|
+
const upstream = options.upstream;
|
|
118
|
+
const report = (entry) => { try {
|
|
119
|
+
options.log?.(entry);
|
|
120
|
+
}
|
|
121
|
+
catch { /* logging never fails a request */ } };
|
|
122
|
+
async function completions(request, person, started) {
|
|
123
|
+
const done = (status, model, code, response) => { report({ person, model, status, code, latencyMs: now() - started }); return response; };
|
|
124
|
+
let body;
|
|
125
|
+
try {
|
|
126
|
+
const parsed = await readJsonBody(request, maxBodyBytes, request.signal);
|
|
127
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
128
|
+
throw new RequestGuardError(400, 'not an object');
|
|
129
|
+
body = parsed;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const status = error instanceof RequestGuardError ? error.status : 400;
|
|
133
|
+
return status === 413 ? done(413, null, 'too-large', failure(413, 'too-large', 'The request is too large.'))
|
|
134
|
+
: status === 415 ? done(415, null, 'unsupported-media-type', failure(415, 'unsupported-media-type', 'Send the request as application/json.'))
|
|
135
|
+
: done(400, null, 'invalid', failure(400, 'invalid', 'The body must be a JSON chat completion request.'));
|
|
136
|
+
}
|
|
137
|
+
const model = typeof body.model === 'string' ? body.model : null;
|
|
138
|
+
if (!model || !Array.isArray(body.messages) || body.messages.length === 0)
|
|
139
|
+
return done(400, model, 'invalid', failure(400, 'invalid', 'A model and at least one message are required.'));
|
|
140
|
+
const upstreamModel = allowed.get(model);
|
|
141
|
+
if (upstreamModel === undefined)
|
|
142
|
+
return done(403, model, 'model-not-allowed', failure(403, 'model-not-allowed', `The model "${model.slice(0, 80)}" is not available here.`));
|
|
143
|
+
const asked = [body.max_completion_tokens, body.max_tokens].find((value) => typeof value === 'number' && Number.isFinite(value));
|
|
144
|
+
const decision = await options.budget.admit(person, { model, maxTokens: asked });
|
|
145
|
+
if (decision.kind === 'refused')
|
|
146
|
+
return done(429, model, 'budget', failure(429, 'budget', decision.message, { 'retry-after': String(decision.retryAfterSeconds) }));
|
|
147
|
+
// Rebuilt, never forwarded: the model is the host's mapping, the ceiling the budget's, and only listed fields pass.
|
|
148
|
+
const stream = body.stream === true;
|
|
149
|
+
const forwarded = { model: upstreamModel, stream, [upstream.maxTokensField ?? 'max_completion_tokens']: decision.maxTokens };
|
|
150
|
+
for (const field of FORWARDED)
|
|
151
|
+
if (body[field] !== undefined)
|
|
152
|
+
forwarded[field] = body[field];
|
|
153
|
+
if (stream)
|
|
154
|
+
forwarded.stream_options = { include_usage: true };
|
|
155
|
+
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(timeoutMs)]);
|
|
156
|
+
let answer;
|
|
157
|
+
try {
|
|
158
|
+
answer = await upstream.chatCompletions(forwarded, signal);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return done(502, model, 'upstream-unreachable', failure(502, 'upstream-unreachable', 'The model provider could not be reached.'));
|
|
162
|
+
}
|
|
163
|
+
if (!answer.ok || !answer.body) {
|
|
164
|
+
await answer.body?.cancel().catch(() => undefined);
|
|
165
|
+
return done(502, model, 'upstream-failed', failure(502, 'upstream-failed', `The model provider refused the request (${answer.status}).`));
|
|
166
|
+
}
|
|
167
|
+
const usage = {};
|
|
168
|
+
const finish = (status, code) => report({ person, model, status, ...(code ? { code } : {}), latencyMs: now() - started, ...usage });
|
|
169
|
+
if (!stream) {
|
|
170
|
+
let json;
|
|
171
|
+
try {
|
|
172
|
+
json = await answer.json();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return done(502, model, 'upstream-failed', failure(502, 'upstream-failed', 'The model provider sent an unreadable answer.'));
|
|
176
|
+
}
|
|
177
|
+
usageOf(json, usage);
|
|
178
|
+
finish(200);
|
|
179
|
+
return Response.json(json, { headers: { 'cache-control': 'no-store' } });
|
|
180
|
+
}
|
|
181
|
+
// SSE passthrough, byte for byte; the `usage` frame is read on the way for the log, nothing else is looked at.
|
|
182
|
+
const reader = answer.body.getReader();
|
|
183
|
+
const decoder = new TextDecoder();
|
|
184
|
+
let pending = '';
|
|
185
|
+
const scan = (text) => {
|
|
186
|
+
pending += text;
|
|
187
|
+
const lines = pending.split('\n');
|
|
188
|
+
pending = lines.pop() ?? '';
|
|
189
|
+
for (const line of lines)
|
|
190
|
+
if (line.startsWith('data:') && line.includes('"usage"')) {
|
|
191
|
+
try {
|
|
192
|
+
usageOf(JSON.parse(line.slice(5)), usage);
|
|
193
|
+
}
|
|
194
|
+
catch { /* not JSON */ }
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
let ended = false;
|
|
198
|
+
const end = (status, code) => { if (!ended) {
|
|
199
|
+
ended = true;
|
|
200
|
+
finish(status, code);
|
|
201
|
+
} };
|
|
202
|
+
return new Response(new ReadableStream({
|
|
203
|
+
async pull(controller) {
|
|
204
|
+
try {
|
|
205
|
+
const { done: finished, value } = await reader.read();
|
|
206
|
+
if (finished) {
|
|
207
|
+
scan('\n');
|
|
208
|
+
end(200);
|
|
209
|
+
controller.close();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
scan(decoder.decode(value, { stream: true }));
|
|
213
|
+
controller.enqueue(value);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
end(502, 'upstream-interrupted');
|
|
217
|
+
controller.error(new Error('The model provider stream was interrupted.'));
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
async cancel() { end(499, 'client-closed'); await reader.cancel().catch(() => undefined); },
|
|
221
|
+
}), { headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no' } });
|
|
222
|
+
}
|
|
223
|
+
return async (request) => {
|
|
224
|
+
const started = now();
|
|
225
|
+
const path = new URL(request.url).pathname;
|
|
226
|
+
const route = path.startsWith(`${basePath}/`) ? path.slice(basePath.length) : null;
|
|
227
|
+
if (route !== '/v1/chat/completions' && route !== '/v1/models')
|
|
228
|
+
return failure(404, 'not-found', 'Unknown model gateway route.');
|
|
229
|
+
const method = route === '/v1/models' ? 'GET' : 'POST';
|
|
230
|
+
if (request.method !== method)
|
|
231
|
+
return failure(405, 'method-not-allowed', `Use ${method}.`, { allow: method });
|
|
232
|
+
const person = await options.authorize(request);
|
|
233
|
+
if (!person) {
|
|
234
|
+
report({ person: null, model: null, status: 401, code: 'unauthenticated', latencyMs: now() - started });
|
|
235
|
+
return failure(401, 'unauthenticated', 'Sign in to use models.');
|
|
236
|
+
}
|
|
237
|
+
if (route === '/v1/models')
|
|
238
|
+
return Response.json({ object: 'list', data: [...allowed.keys()].map(id => ({ id, object: 'model', owned_by: 'boring-gateway' })) }, { headers: { 'cache-control': 'no-store' } });
|
|
239
|
+
return completions(request, person, started);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Pi Durable release whose public exports Boring records were checked against. Records carry it so a reader can tell which
|
|
3
|
+
* native shapes produced them. New records take the current release; records written under an earlier reviewed release stay
|
|
4
|
+
* readable, because the native conversation and tool shapes they refer to did not change between them.
|
|
5
|
+
*/
|
|
6
|
+
export declare const NATIVE_VERSION = "pi-durable@1.1.0";
|
|
7
|
+
export type NativeVersion = 'pi-durable@1.0.1' | typeof NATIVE_VERSION;
|
|
8
|
+
export declare const isNativeVersion: (value: unknown) => value is NativeVersion;
|
|
9
|
+
//# sourceMappingURL=native-version.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Pi Durable release whose public exports Boring records were checked against. Records carry it so a reader can tell which
|
|
3
|
+
* native shapes produced them. New records take the current release; records written under an earlier reviewed release stay
|
|
4
|
+
* readable, because the native conversation and tool shapes they refer to did not change between them.
|
|
5
|
+
*/
|
|
6
|
+
export const NATIVE_VERSION = 'pi-durable@1.1.0';
|
|
7
|
+
export const isNativeVersion = (value) => value === NATIVE_VERSION || value === 'pi-durable@1.0.1';
|
package/dist/native.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Exact native interfaces; use upstream values/functions directly. */
|
|
2
|
+
export type { Harness, HarnessOptions, HarnessSettings, Registry, Extension, ToolRegistration, ToolExecutionApi, ToolExecutionResult, ToolControl, ToolHooks, GenerationHooks, CompactionHooks, EnvTarget, Agent, AgentChange, Conversation, ConversationView, ConversationWatch, WatchEnd, ConversationId, Submission, SubmissionDraft, SubmissionId, TaskId, TaskOutcome, Storage, } from '@earendil-works/pi-durable';
|
|
3
|
+
export type { Context } from '@earendil-works/chord';
|
|
4
|
+
//# sourceMappingURL=native.d.ts.map
|
package/dist/native.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PresentationCommand, PresentationResult, ViewerTarget } from '@hachej/boring-ui-kit/contracts';
|
|
2
|
+
import type { Context } from '@earendil-works/chord';
|
|
3
|
+
import type { Static, TSchema } from '@earendil-works/pi-ai';
|
|
4
|
+
import { type ToolExecutionApi, type ToolExecutionResult, type ToolRegistration } from '@earendil-works/pi-durable';
|
|
5
|
+
export interface PresentationToolOptions<P extends TSchema, Input, Output, Subject> {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly description: string;
|
|
8
|
+
readonly parameters: P;
|
|
9
|
+
readonly command: PresentationCommand<Input, Output, Subject>;
|
|
10
|
+
readonly target: ViewerTarget<Subject>;
|
|
11
|
+
readonly prepareInput: (args: Static<P>) => Input;
|
|
12
|
+
readonly authorize: (input: Input, target: ViewerTarget<Subject>, api: ToolExecutionApi, context: Context) => boolean | Promise<boolean>;
|
|
13
|
+
readonly formatResult: (result: PresentationResult<Output, Subject>) => ToolExecutionResult;
|
|
14
|
+
}
|
|
15
|
+
/** Bind one captured viewer target to a native, non-replayable tool. Target and parsed input must be structured-cloneable. */
|
|
16
|
+
export declare function createPresentationTool<P extends TSchema, Input, Output, Subject>(options: PresentationToolOptions<P, Input, Output, Subject>): ToolRegistration<P>;
|
|
17
|
+
//# sourceMappingURL=presentation.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineTool } from '@earendil-works/pi-durable';
|
|
2
|
+
/** Bind one captured viewer target to a native, non-replayable tool. Target and parsed input must be structured-cloneable. */
|
|
3
|
+
export function createPresentationTool(options) {
|
|
4
|
+
const target = structuredClone(options.target);
|
|
5
|
+
const { command, prepareInput, authorize, formatResult } = options;
|
|
6
|
+
const parse = command.input.parse;
|
|
7
|
+
const invoke = command.invoke;
|
|
8
|
+
function format(result) {
|
|
9
|
+
const refused = result.kind !== 'applied' && result.kind !== 'proposed';
|
|
10
|
+
const formatted = formatResult(result);
|
|
11
|
+
return refused ? { ...formatted, isError: true } : formatted;
|
|
12
|
+
}
|
|
13
|
+
return defineTool({
|
|
14
|
+
name: options.name,
|
|
15
|
+
description: options.description,
|
|
16
|
+
parameters: options.parameters,
|
|
17
|
+
replay: 'unsafe',
|
|
18
|
+
execute: async (args, api, context) => {
|
|
19
|
+
const signal = context.abortSignal;
|
|
20
|
+
signal?.throwIfAborted();
|
|
21
|
+
const prepared = prepareInput(args);
|
|
22
|
+
let input;
|
|
23
|
+
try {
|
|
24
|
+
input = parse(prepared);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return format({ kind: 'denied', reason: 'Invalid presentation command input' });
|
|
28
|
+
}
|
|
29
|
+
const retainedInput = structuredClone(input);
|
|
30
|
+
const allowed = await authorize(structuredClone(retainedInput), structuredClone(target), api, context);
|
|
31
|
+
signal?.throwIfAborted();
|
|
32
|
+
if (allowed !== true)
|
|
33
|
+
return format({ kind: 'denied', reason: 'Presentation command authorization denied' });
|
|
34
|
+
const result = await invoke(structuredClone(target), retainedInput, signal);
|
|
35
|
+
signal?.throwIfAborted();
|
|
36
|
+
return format(result);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ConversationId, EntryId, TaskId } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { NativeVersion } from './native-version.js';
|
|
3
|
+
export declare const CONVERSATION_PROJECTION_LIMITS: Readonly<{
|
|
4
|
+
maxMessages: 200;
|
|
5
|
+
maxMessageBytes: 32768;
|
|
6
|
+
maxWindowBytes: 196608;
|
|
7
|
+
maxFrameBytes: 262144;
|
|
8
|
+
}>;
|
|
9
|
+
export interface ProjectionIdentity {
|
|
10
|
+
readonly runtimeId: string;
|
|
11
|
+
readonly scopeId: string;
|
|
12
|
+
readonly principalId: string;
|
|
13
|
+
readonly conversationId: ConversationId;
|
|
14
|
+
}
|
|
15
|
+
export interface ConversationTextKey {
|
|
16
|
+
readonly entryId: EntryId;
|
|
17
|
+
readonly conversationId: ConversationId;
|
|
18
|
+
readonly messageIndex: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ConversationTextRow extends ConversationTextKey {
|
|
21
|
+
readonly byTaskId?: TaskId;
|
|
22
|
+
readonly role: 'user' | 'assistant';
|
|
23
|
+
readonly content: readonly {
|
|
24
|
+
readonly blockIndex: number;
|
|
25
|
+
readonly text: string;
|
|
26
|
+
}[];
|
|
27
|
+
readonly clipped: boolean;
|
|
28
|
+
}
|
|
29
|
+
interface ProjectionFrame {
|
|
30
|
+
readonly schema: 'boring.conversation-text';
|
|
31
|
+
readonly version: 2;
|
|
32
|
+
readonly nativeVersion: NativeVersion;
|
|
33
|
+
readonly source: ProjectionIdentity;
|
|
34
|
+
/** Ordering within this connection only, never a durable native cursor. */
|
|
35
|
+
readonly connection: {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly frame: number;
|
|
38
|
+
readonly observedAt: string;
|
|
39
|
+
};
|
|
40
|
+
/** Whether older permitted rows were omitted from this window. */
|
|
41
|
+
readonly window: {
|
|
42
|
+
readonly truncated: boolean;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export interface ConversationTextSnapshot extends ProjectionFrame {
|
|
46
|
+
readonly kind: 'snapshot';
|
|
47
|
+
readonly messages: readonly ConversationTextRow[];
|
|
48
|
+
}
|
|
49
|
+
export interface ConversationTextDelta extends ProjectionFrame {
|
|
50
|
+
readonly kind: 'delta';
|
|
51
|
+
readonly baseFrame: number;
|
|
52
|
+
readonly upsert: readonly ConversationTextRow[];
|
|
53
|
+
/** Complete window order; omitted keys are removed. */
|
|
54
|
+
readonly order: readonly ConversationTextKey[];
|
|
55
|
+
}
|
|
56
|
+
export type ConversationTextProjection = ConversationTextSnapshot | ConversationTextDelta;
|
|
57
|
+
export declare const projectionEncoder: TextEncoder;
|
|
58
|
+
export declare function projectionBytes(value: unknown): number;
|
|
59
|
+
export declare function projectionKey(key: ConversationTextKey): string;
|
|
60
|
+
export declare function projectionIdentity(value: unknown): value is ProjectionIdentity;
|
|
61
|
+
export interface ConversationTextReceiver {
|
|
62
|
+
/** Accept one complete JSON frame. A failure clears the cache and requires a fresh initial snapshot. */
|
|
63
|
+
readonly read: (line: string) => ConversationTextSnapshot;
|
|
64
|
+
readonly reset: () => void;
|
|
65
|
+
}
|
|
66
|
+
export declare function createConversationTextReceiver(expectedIdentity: ProjectionIdentity): ConversationTextReceiver;
|
|
67
|
+
export {};
|
|
68
|
+
//# sourceMappingURL=projection-format.d.ts.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { NATIVE_VERSION, isNativeVersion } from './native-version.js';
|
|
2
|
+
export const CONVERSATION_PROJECTION_LIMITS = Object.freeze({
|
|
3
|
+
maxMessages: 200, maxMessageBytes: 32768, maxWindowBytes: 196608, maxFrameBytes: 262144,
|
|
4
|
+
});
|
|
5
|
+
export const projectionEncoder = new TextEncoder();
|
|
6
|
+
export function projectionBytes(value) {
|
|
7
|
+
return projectionEncoder.encode(JSON.stringify(value)).byteLength;
|
|
8
|
+
}
|
|
9
|
+
export function projectionKey(key) {
|
|
10
|
+
return `${key.conversationId}/${key.entryId}/${key.messageIndex}`;
|
|
11
|
+
}
|
|
12
|
+
function record(value) {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function fields(value, required, optional = []) {
|
|
16
|
+
return required.every(key => Object.hasOwn(value, key)) && Object.keys(value).every(key => required.includes(key) || optional.includes(key));
|
|
17
|
+
}
|
|
18
|
+
function index(value) {
|
|
19
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
20
|
+
}
|
|
21
|
+
function id(value) { return index(value) && value > 0; }
|
|
22
|
+
export function projectionIdentity(value) {
|
|
23
|
+
return record(value) && fields(value, ['runtimeId', 'scopeId', 'principalId', 'conversationId']) && id(value.conversationId)
|
|
24
|
+
&& [value.runtimeId, value.scopeId, value.principalId].every(item => typeof item === 'string' && item.length > 0 && item.length <= 1024);
|
|
25
|
+
}
|
|
26
|
+
function sameSource(a, b) {
|
|
27
|
+
return a.runtimeId === b.runtimeId && a.scopeId === b.scopeId && a.principalId === b.principalId && a.conversationId === b.conversationId;
|
|
28
|
+
}
|
|
29
|
+
function rowKey(value) {
|
|
30
|
+
return record(value) && id(value.entryId) && id(value.conversationId) && index(value.messageIndex);
|
|
31
|
+
}
|
|
32
|
+
function row(value) {
|
|
33
|
+
if (!rowKey(value) || !record(value) || !fields(value, ['entryId', 'conversationId', 'messageIndex', 'role', 'content', 'clipped'], ['byTaskId'])
|
|
34
|
+
|| ('byTaskId' in value && !id(value.byTaskId)) || (value.role !== 'user' && value.role !== 'assistant') || typeof value.clipped !== 'boolean'
|
|
35
|
+
|| !Array.isArray(value.content) || value.content.length === 0 || projectionBytes(value) > CONVERSATION_PROJECTION_LIMITS.maxMessageBytes)
|
|
36
|
+
return false;
|
|
37
|
+
let previous = -1;
|
|
38
|
+
for (const block of value.content) {
|
|
39
|
+
if (!record(block) || !fields(block, ['blockIndex', 'text']) || !index(block.blockIndex) || block.blockIndex <= previous || typeof block.text !== 'string')
|
|
40
|
+
return false;
|
|
41
|
+
previous = block.blockIndex;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
function rows(value) {
|
|
46
|
+
return Array.isArray(value) && value.length <= CONVERSATION_PROJECTION_LIMITS.maxMessages && value.every(row)
|
|
47
|
+
&& new Set(value.map(projectionKey)).size === value.length && projectionBytes(value) <= CONVERSATION_PROJECTION_LIMITS.maxWindowBytes;
|
|
48
|
+
}
|
|
49
|
+
function frame(value) {
|
|
50
|
+
if (!record(value) || value.schema !== 'boring.conversation-text' || value.version !== 2 || !isNativeVersion(value.nativeVersion)
|
|
51
|
+
|| !projectionIdentity(value.source) || !record(value.connection) || !fields(value.connection, ['id', 'frame', 'observedAt'])
|
|
52
|
+
|| typeof value.connection.id !== 'string' || !value.connection.id.length || value.connection.id.length > 128 || !index(value.connection.frame)
|
|
53
|
+
|| typeof value.connection.observedAt !== 'string' || !Number.isFinite(Date.parse(value.connection.observedAt))
|
|
54
|
+
|| !record(value.window) || !fields(value.window, ['truncated']) || typeof value.window.truncated !== 'boolean')
|
|
55
|
+
return false;
|
|
56
|
+
const common = ['schema', 'version', 'nativeVersion', 'source', 'connection', 'window', 'kind'];
|
|
57
|
+
if (value.kind === 'snapshot')
|
|
58
|
+
return fields(value, [...common, 'messages']) && rows(value.messages);
|
|
59
|
+
return value.kind === 'delta' && fields(value, [...common, 'baseFrame', 'upsert', 'order']) && index(value.baseFrame) && rows(value.upsert)
|
|
60
|
+
&& Array.isArray(value.order) && value.order.length <= CONVERSATION_PROJECTION_LIMITS.maxMessages
|
|
61
|
+
&& value.order.every(key => rowKey(key) && record(key) && fields(key, ['entryId', 'conversationId', 'messageIndex']))
|
|
62
|
+
&& new Set(value.order.map(projectionKey)).size === value.order.length;
|
|
63
|
+
}
|
|
64
|
+
export function createConversationTextReceiver(expectedIdentity) {
|
|
65
|
+
if (!projectionIdentity(expectedIdentity))
|
|
66
|
+
throw new Error('Invalid projection identity');
|
|
67
|
+
const expected = { ...expectedIdentity };
|
|
68
|
+
let previous;
|
|
69
|
+
return {
|
|
70
|
+
reset: () => { previous = undefined; },
|
|
71
|
+
read: line => {
|
|
72
|
+
try {
|
|
73
|
+
if (typeof line !== 'string' || line.length > CONVERSATION_PROJECTION_LIMITS.maxFrameBytes
|
|
74
|
+
|| projectionEncoder.encode(line.endsWith('\n') ? line : line + '\n').byteLength > CONVERSATION_PROJECTION_LIMITS.maxFrameBytes)
|
|
75
|
+
throw new Error();
|
|
76
|
+
const value = JSON.parse(line);
|
|
77
|
+
if (!frame(value) || !sameSource(expected, value.source))
|
|
78
|
+
throw new Error();
|
|
79
|
+
if (previous) {
|
|
80
|
+
if (value.connection.id !== previous.connection.id || value.connection.frame !== previous.connection.frame + 1)
|
|
81
|
+
throw new Error();
|
|
82
|
+
}
|
|
83
|
+
else if (value.kind !== 'snapshot' || value.connection.frame !== 0)
|
|
84
|
+
throw new Error();
|
|
85
|
+
let next;
|
|
86
|
+
if (value.kind === 'snapshot')
|
|
87
|
+
next = value;
|
|
88
|
+
else {
|
|
89
|
+
if (!previous || value.baseFrame !== previous.connection.frame)
|
|
90
|
+
throw new Error();
|
|
91
|
+
const retained = new Map(previous.messages.map(message => [projectionKey(message), message]));
|
|
92
|
+
const ordered = new Set(value.order.map(projectionKey));
|
|
93
|
+
for (const message of value.upsert) {
|
|
94
|
+
if (!ordered.has(projectionKey(message)))
|
|
95
|
+
throw new Error();
|
|
96
|
+
retained.set(projectionKey(message), message);
|
|
97
|
+
}
|
|
98
|
+
const messages = value.order.map(key => {
|
|
99
|
+
const message = retained.get(projectionKey(key));
|
|
100
|
+
if (!message)
|
|
101
|
+
throw new Error();
|
|
102
|
+
return message;
|
|
103
|
+
});
|
|
104
|
+
if (!rows(messages))
|
|
105
|
+
throw new Error();
|
|
106
|
+
const { baseFrame: _base, upsert: _upsert, order: _order, ...common } = value;
|
|
107
|
+
next = { ...common, kind: 'snapshot', messages };
|
|
108
|
+
}
|
|
109
|
+
previous = next;
|
|
110
|
+
return structuredClone(next);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
previous = undefined;
|
|
114
|
+
throw new Error('Invalid conversation projection; reconnect required');
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EntryRecord } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { ConversationTextRow, ProjectionIdentity } from './projection-format.js';
|
|
3
|
+
export declare function projectConversationWindow(entries: readonly EntryRecord[], identity: ProjectionIdentity, allowEntry: (identity: ProjectionIdentity, entry: EntryRecord) => boolean): {
|
|
4
|
+
messages: readonly ConversationTextRow[];
|
|
5
|
+
window: {
|
|
6
|
+
truncated: boolean;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=projection-window.d.ts.map
|