@theoremjs/web 0.0.0-stage → 0.3.4-next.375.ga2d1878
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 +21 -0
- package/README.md +493 -2
- package/client/audio-level.d.ts +5 -0
- package/client/audio-level.js +24 -0
- package/client/chat.d.ts +144 -0
- package/client/chat.js +760 -0
- package/client/composer-drawer.d.ts +19 -0
- package/client/composer-drawer.js +15 -0
- package/client/composer-hints.d.ts +25 -0
- package/client/composer-hints.js +20 -0
- package/client/composer-primary.d.ts +18 -0
- package/client/composer-primary.js +29 -0
- package/client/decision-transport.d.ts +70 -0
- package/client/decision-transport.js +175 -0
- package/client/decode-composer-draft.d.ts +11 -0
- package/client/decode-composer-draft.js +12 -0
- package/client/embedding-input.d.ts +84 -0
- package/client/embedding-input.js +194 -0
- package/client/embedding-space.d.ts +82 -0
- package/client/embedding-space.js +389 -0
- package/client/embedding-transport.d.ts +40 -0
- package/client/embedding-transport.js +71 -0
- package/client/encode-composer-draft.d.ts +9 -0
- package/client/encode-composer-draft.js +34 -0
- package/client/encode-files.d.ts +18 -0
- package/client/encode-files.js +54 -0
- package/client/failure.d.ts +29 -0
- package/client/failure.js +34 -0
- package/client/generation-selection.d.ts +15 -0
- package/client/generation-selection.js +24 -0
- package/client/host-transport.d.ts +70 -0
- package/client/host-transport.js +75 -0
- package/client/index.d.ts +37 -0
- package/client/index.js +31 -0
- package/client/live/live-mic-forward.d.ts +30 -0
- package/client/live/live-mic-forward.js +46 -0
- package/client/live/live-page-tool.d.ts +39 -0
- package/client/live/live-page-tool.js +26 -0
- package/client/live/live-state.d.ts +13 -0
- package/client/live/live-state.js +22 -0
- package/client/live/live-tool.d.ts +11 -0
- package/client/live/live-tool.js +1 -0
- package/client/live-client.d.ts +189 -0
- package/client/live-client.js +829 -0
- package/client/live-errors.d.ts +2 -0
- package/client/live-errors.js +9 -0
- package/client/live-messages.d.ts +148 -0
- package/client/live-messages.js +100 -0
- package/client/mic-capture.d.ts +4 -0
- package/client/mic-capture.js +6 -0
- package/client/oauth-popup.d.ts +26 -0
- package/client/oauth-popup.js +30 -0
- package/client/page-dispatch.d.ts +15 -0
- package/client/page-dispatch.js +63 -0
- package/client/pcm-downsample.d.ts +4 -0
- package/client/pcm-downsample.js +45 -0
- package/client/run-commit.d.ts +34 -0
- package/client/run-commit.js +49 -0
- package/client/run-session.d.ts +79 -0
- package/client/run-session.js +207 -0
- package/client/schema-fields.d.ts +63 -0
- package/client/schema-fields.js +199 -0
- package/client/shaped-data.d.ts +118 -0
- package/client/shaped-data.js +343 -0
- package/client/source-citations.d.ts +13 -0
- package/client/source-citations.js +56 -0
- package/client/space-geometry.d.ts +39 -0
- package/client/space-geometry.js +183 -0
- package/client/tool-result.d.ts +78 -0
- package/client/tool-result.js +293 -0
- package/client/tool-resume.d.ts +50 -0
- package/client/tool-resume.js +14 -0
- package/client/trace-feed.d.ts +13 -0
- package/client/trace-feed.js +18 -0
- package/client/transcript-schema.d.ts +10 -0
- package/client/transcript-schema.js +120 -0
- package/client/transport.d.ts +264 -0
- package/client/transport.js +330 -0
- package/client/turn-client.d.ts +44 -0
- package/client/turn-client.js +88 -0
- package/client/wire-line.d.ts +56 -0
- package/client/wire-line.js +90 -0
- package/client/worklets/mic-capture.js +16 -0
- package/package.json +28 -4
- package/server/checkpoint-ownership.d.ts +6 -0
- package/server/checkpoint-ownership.js +42 -0
- package/server/conversation-store.d.ts +92 -0
- package/server/conversation-store.js +131 -0
- package/server/conversation.d.ts +193 -0
- package/server/conversation.js +552 -0
- package/server/credential-store.d.ts +24 -0
- package/server/credential-store.js +14 -0
- package/server/decision-handler.d.ts +42 -0
- package/server/decision-handler.js +46 -0
- package/server/embedding-handler.d.ts +33 -0
- package/server/embedding-handler.js +45 -0
- package/server/handler.d.ts +158 -0
- package/server/handler.js +775 -0
- package/server/json-response.d.ts +2 -0
- package/server/json-response.js +7 -0
- package/server/mcp-handler.d.ts +46 -0
- package/server/mcp-handler.js +312 -0
- package/server/mod.d.ts +27 -0
- package/server/mod.js +25 -0
- package/server/page-dispatch.d.ts +22 -0
- package/server/page-dispatch.js +127 -0
- package/server/request-body.d.ts +2 -0
- package/server/request-body.js +42 -0
- package/server/request-check.d.ts +21 -0
- package/server/request-check.js +49 -0
- package/server/session-authority.d.ts +8 -0
- package/server/session-authority.js +78 -0
- package/server/session-boundary.d.ts +26 -0
- package/server/session-boundary.js +64 -0
- package/server/session-identity.d.ts +16 -0
- package/server/session-identity.js +41 -0
- package/server/session-limits.d.ts +12 -0
- package/server/session-limits.js +59 -0
- package/server/session-store.d.ts +123 -0
- package/server/session-store.js +264 -0
- package/server/single-action.d.ts +21 -0
- package/server/single-action.js +30 -0
- package/server/steer-inbox.d.ts +35 -0
- package/server/steer-inbox.js +52 -0
- package/server/tool-operations.d.ts +6 -0
- package/server/tool-operations.js +132 -0
- package/server/turn-input.d.ts +10 -0
- package/server/turn-input.js +29 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { labelText, TheoremError } from '@theoremjs/agents';
|
|
2
|
+
import { foldTurnEvents, prepareUserTurn, streamThoughtsEnabled, } from '@theoremjs/agents/interface';
|
|
3
|
+
import { filesToPending } from "./encode-files.js";
|
|
4
|
+
import { defaultModel } from "./generation-selection.js";
|
|
5
|
+
export function issuedOperationId(call) {
|
|
6
|
+
if (!call.operationId)
|
|
7
|
+
throw new TheoremError('request', labelText('session.gate_expired'));
|
|
8
|
+
return call.operationId;
|
|
9
|
+
}
|
|
10
|
+
function resolveModelId(iface, session) {
|
|
11
|
+
return session.selectedModel ?? defaultModel(iface);
|
|
12
|
+
}
|
|
13
|
+
function resolveEffort(iface, session, modelId) {
|
|
14
|
+
if (!modelId || !Object.hasOwn(iface.models, modelId))
|
|
15
|
+
return undefined;
|
|
16
|
+
const binding = iface.models[modelId];
|
|
17
|
+
if (!binding.allowEffortSelect)
|
|
18
|
+
return undefined;
|
|
19
|
+
return session.selectedEffort ?? binding.defaultEffort;
|
|
20
|
+
}
|
|
21
|
+
/** Model / effort selection the host should honour for this session. */
|
|
22
|
+
function generationFields(iface, session) {
|
|
23
|
+
const modelId = resolveModelId(iface, session);
|
|
24
|
+
const model = iface.allowModelSelect ? modelId : undefined;
|
|
25
|
+
const effort = resolveEffort(iface, session, modelId);
|
|
26
|
+
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) };
|
|
27
|
+
}
|
|
28
|
+
function gateReplay(iface, session, call) {
|
|
29
|
+
return {
|
|
30
|
+
name: call.name,
|
|
31
|
+
input: call.input,
|
|
32
|
+
...(call.gateKind ? { gateKind: call.gateKind } : {}),
|
|
33
|
+
sessionPermissions: call.sessionPermissions ?? session.sessionPermissions,
|
|
34
|
+
...(session.toolSnapshot ? { snapshot: session.toolSnapshot } : {}),
|
|
35
|
+
...(session.promotedToolIds.length ? { promoted: [...session.promotedToolIds] } : {}),
|
|
36
|
+
...generationFields(iface, session),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function scopeFields(scope) {
|
|
40
|
+
return {
|
|
41
|
+
...(scope.conversation ? { conversation: scope.conversation } : {}),
|
|
42
|
+
...(scope.turnId ? { turnId: scope.turnId } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function buildTurnRequest(iface, session, input, options = {}) {
|
|
46
|
+
const abandon = (options.walkAway?.calls ?? []).map((call) => ({
|
|
47
|
+
callId: call.callId,
|
|
48
|
+
operationId: issuedOperationId(call),
|
|
49
|
+
}));
|
|
50
|
+
return {
|
|
51
|
+
...scopeFields(options),
|
|
52
|
+
...generationFields(iface, session),
|
|
53
|
+
input,
|
|
54
|
+
...(abandon.length ? { abandon } : {}),
|
|
55
|
+
replay: { sessionPermissions: session.sessionPermissions },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function buildInvokeRequest(iface, session, args, scope = {}) {
|
|
59
|
+
return {
|
|
60
|
+
...scopeFields(scope),
|
|
61
|
+
gateId: args.gateId,
|
|
62
|
+
operationId: args.operationId,
|
|
63
|
+
decision: args.decision,
|
|
64
|
+
...(args.secret === undefined ? {} : { secret: args.secret }),
|
|
65
|
+
...(args.page === undefined ? {} : { page: args.page }),
|
|
66
|
+
replay: gateReplay(iface, session, {
|
|
67
|
+
...args,
|
|
68
|
+
gateKind: args.gateKind ??
|
|
69
|
+
(session.gatedTool?.callId === args.gateId ? session.gatedTool.gateKind : undefined),
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function projectUserTurn(iface, draft) {
|
|
74
|
+
return prepareUserTurn(iface.inputs, draft, iface.guardrails);
|
|
75
|
+
}
|
|
76
|
+
export function prepareComposerTurn(iface, text, pendingFiles, pendingVoice = []) {
|
|
77
|
+
return projectUserTurn(iface, {
|
|
78
|
+
...(text.trim() ? { text } : {}),
|
|
79
|
+
...(pendingFiles.length ? { attachments: filesToPending(pendingFiles) } : {}),
|
|
80
|
+
...(pendingVoice.length ? { voice: filesToPending(pendingVoice) } : {}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export function foldAssistantTurn(iface, events) {
|
|
84
|
+
// why: Its `turn-done` blocks stay: hidden in the view, they carry the reply's worked time.
|
|
85
|
+
return foldTurnEvents(events, {
|
|
86
|
+
showThoughts: streamThoughtsEnabled(iface.outputs),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every value that reaches the browser over a wire (NDJSON lines, live
|
|
3
|
+
* envelopes) is checked against its schema here. A line or event of a kind the
|
|
4
|
+
* client does not know is reported as `unsupported`, and one that fails its
|
|
5
|
+
* check (not JSON, no kind, or a known kind that fails its schema) as
|
|
6
|
+
* `malformed`; either way the reply goes on. A whole reply that fails its check
|
|
7
|
+
* (the profile description) is `bad_response`.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import { TheoremError, z } from '@theoremjs/agents';
|
|
12
|
+
/**
|
|
13
|
+
* A line or envelope of a kind the client does not know. The server never
|
|
14
|
+
* sends it; the client's parser makes it so the host sees what arrived. Theorem
|
|
15
|
+
* shows nothing for it.
|
|
16
|
+
*/
|
|
17
|
+
export type UnsupportedEvent = {
|
|
18
|
+
type: 'unsupported';
|
|
19
|
+
received: string;
|
|
20
|
+
raw: unknown;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* A line or event that failed its wire check. The server never sends it; the
|
|
24
|
+
* client's reader makes it, leaves the line out, and the reply goes on. `error`
|
|
25
|
+
* is `bad_response`: its message names what broke, never the value, and the
|
|
26
|
+
* user reads `session.part_skipped`.
|
|
27
|
+
*/
|
|
28
|
+
export type MalformedEvent = {
|
|
29
|
+
type: 'malformed';
|
|
30
|
+
error: TheoremError;
|
|
31
|
+
};
|
|
32
|
+
/** Every kind a wire carries, and the schema a value of that kind must pass. */
|
|
33
|
+
export type WireLines<Line extends {
|
|
34
|
+
type: string;
|
|
35
|
+
}> = {
|
|
36
|
+
readonly [K in Line['type']]: z.ZodType<Extract<Line, {
|
|
37
|
+
type: K;
|
|
38
|
+
}>>;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Issue paths and codes only: a failure names what broke, never the value. A
|
|
42
|
+
* union that matched no branch lists each branch's issues.
|
|
43
|
+
*/
|
|
44
|
+
export declare function issueSummary(error: z.ZodError): string;
|
|
45
|
+
/** `raw` as `schema` reads it; one that fails is `bad_response`, naming `what`. */
|
|
46
|
+
export declare function checkWire<T>(schema: z.ZodType<T>, raw: unknown, what: string): T;
|
|
47
|
+
/** One wire value: a known kind that passed its schema, `unsupported`, or `malformed`. */
|
|
48
|
+
export declare function readWireValue<Line extends {
|
|
49
|
+
type: string;
|
|
50
|
+
}>(lines: WireLines<Line>, raw: unknown): Line | UnsupportedEvent | MalformedEvent;
|
|
51
|
+
/** One wire line's text: {@link readWireValue} of its JSON, or `malformed` when it is not JSON. */
|
|
52
|
+
export declare function readWireLine<Line extends {
|
|
53
|
+
type: string;
|
|
54
|
+
}>(lines: WireLines<Line>, text: string): Line | UnsupportedEvent | MalformedEvent;
|
|
55
|
+
/** A whole reply's JSON text as a value; text that is not JSON is `bad_response`. */
|
|
56
|
+
export declare function parseWireJson(text: string): unknown;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every value that reaches the browser over a wire (NDJSON lines, live
|
|
3
|
+
* envelopes) is checked against its schema here. A line or event of a kind the
|
|
4
|
+
* client does not know is reported as `unsupported`, and one that fails its
|
|
5
|
+
* check (not JSON, no kind, or a known kind that fails its schema) as
|
|
6
|
+
* `malformed`; either way the reply goes on. A whole reply that fails its check
|
|
7
|
+
* (the profile description) is `bad_response`.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import { TheoremError, z } from '@theoremjs/agents';
|
|
12
|
+
const wireKind = z.object({ type: z.string() });
|
|
13
|
+
function isKnown(lines, type) {
|
|
14
|
+
return Object.hasOwn(lines, type);
|
|
15
|
+
}
|
|
16
|
+
function issuesSummary(issues, at) {
|
|
17
|
+
return issues
|
|
18
|
+
.map((issue) => {
|
|
19
|
+
const path = [...at, ...issue.path];
|
|
20
|
+
const head = `${path.map(String).join('.') || '(root)'} ${issue.code}`;
|
|
21
|
+
if (issue.code !== 'invalid_union' || issue.errors.length === 0)
|
|
22
|
+
return head;
|
|
23
|
+
return `${head} [${issue.errors.map((branch) => issuesSummary(branch, path)).join(' | ')}]`;
|
|
24
|
+
})
|
|
25
|
+
.join('; ');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Issue paths and codes only: a failure names what broke, never the value. A
|
|
29
|
+
* union that matched no branch lists each branch's issues.
|
|
30
|
+
*/
|
|
31
|
+
export function issueSummary(error) {
|
|
32
|
+
return issuesSummary(error.issues, []);
|
|
33
|
+
}
|
|
34
|
+
function badResponse(what, error) {
|
|
35
|
+
// labels-exempt: internal diagnostic; the user reads error.bad_response
|
|
36
|
+
return new TheoremError('bad_response', `${what} failed its wire check: ${issueSummary(error)}`);
|
|
37
|
+
}
|
|
38
|
+
/** `raw` as `schema` reads it; one that fails is `bad_response`, naming `what`. */
|
|
39
|
+
export function checkWire(schema, raw, what) {
|
|
40
|
+
const parsed = schema.safeParse(raw);
|
|
41
|
+
if (!parsed.success)
|
|
42
|
+
throw badResponse(what, parsed.error);
|
|
43
|
+
return parsed.data;
|
|
44
|
+
}
|
|
45
|
+
function malformed(message) {
|
|
46
|
+
return {
|
|
47
|
+
type: 'malformed',
|
|
48
|
+
error: new TheoremError('bad_response', message, { copy: { key: 'session.part_skipped' } }),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** One wire value: a known kind that passed its schema, `unsupported`, or `malformed`. */
|
|
52
|
+
export function readWireValue(lines, raw) {
|
|
53
|
+
const kind = wireKind.safeParse(raw);
|
|
54
|
+
// labels-exempt: internal diagnostic; the user reads session.part_skipped
|
|
55
|
+
if (!kind.success)
|
|
56
|
+
return malformed(`a line without a kind failed its wire check: ${issueSummary(kind.error)}`);
|
|
57
|
+
const { type } = kind.data;
|
|
58
|
+
if (!isKnown(lines, type))
|
|
59
|
+
return { type: 'unsupported', received: type, raw };
|
|
60
|
+
const line = lines[type].safeParse(raw);
|
|
61
|
+
if (line.success)
|
|
62
|
+
return line.data;
|
|
63
|
+
// labels-exempt: internal diagnostic; the user reads session.part_skipped
|
|
64
|
+
return malformed(`a '${type}' line failed its wire check: ${issueSummary(line.error)}`);
|
|
65
|
+
}
|
|
66
|
+
/** JSON text as a value, or undefined when it is not JSON. The parser's own error quotes the text, so it is not kept. */
|
|
67
|
+
function jsonValue(text) {
|
|
68
|
+
try {
|
|
69
|
+
return { value: JSON.parse(text) };
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (err instanceof SyntaxError)
|
|
73
|
+
return undefined;
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** One wire line's text: {@link readWireValue} of its JSON, or `malformed` when it is not JSON. */
|
|
78
|
+
export function readWireLine(lines, text) {
|
|
79
|
+
const json = jsonValue(text);
|
|
80
|
+
// labels-exempt: internal diagnostic; the user reads session.part_skipped
|
|
81
|
+
return json ? readWireValue(lines, json.value) : malformed('a line is not JSON');
|
|
82
|
+
}
|
|
83
|
+
/** A whole reply's JSON text as a value; text that is not JSON is `bad_response`. */
|
|
84
|
+
export function parseWireJson(text) {
|
|
85
|
+
const json = jsonValue(text);
|
|
86
|
+
// labels-exempt: internal diagnostic; the user reads error.bad_response
|
|
87
|
+
if (!json)
|
|
88
|
+
throw new TheoremError('bad_response', 'a line is not JSON');
|
|
89
|
+
return json.value;
|
|
90
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioWorklet processor that forwards mono mic frames to the main thread.
|
|
3
|
+
* Plain JS: it runs in the AudioWorkletGlobalScope as its own module, loaded
|
|
4
|
+
* by URL (see `../mic-capture.ts`), so it imports nothing and ships as is.
|
|
5
|
+
*/
|
|
6
|
+
registerProcessor(
|
|
7
|
+
'mic-capture-processor',
|
|
8
|
+
class MicCaptureProcessor extends AudioWorkletProcessor {
|
|
9
|
+
process(inputs) {
|
|
10
|
+
const channel = inputs[0][0];
|
|
11
|
+
const copy = new Float32Array(channel);
|
|
12
|
+
this.port.postMessage(copy, [copy.buffer]);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theoremjs/web",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.4-next.375.ga2d1878",
|
|
4
|
+
"description": "Theorem on the web platform, with no UI framework: Web-standard server handlers that serve a profile, and the browser client that talks to them.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/masudl-hub/theoremai.git",
|
|
13
|
+
"directory": "web"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
"./server": {
|
|
18
|
+
"types": "./server/mod.d.ts",
|
|
19
|
+
"default": "./server/mod.js"
|
|
20
|
+
},
|
|
21
|
+
"./client": {
|
|
22
|
+
"types": "./client/index.d.ts",
|
|
23
|
+
"default": "./client/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"zod": "^4.6.5",
|
|
28
|
+
"@theoremjs/agents": "^0.3.4-next.375.ga2d1878"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ProviderCheckpoint, type TurnHistoryMessage } from '@theoremjs/agents';
|
|
2
|
+
import type { TheoremSessionState } from './session-store.ts';
|
|
3
|
+
export declare function checkpointIdentity(checkpoint: ProviderCheckpoint): Promise<string>;
|
|
4
|
+
export declare function checkpointOwnershipKey(identity: string, operations: TheoremSessionState['operations']): Promise<string>;
|
|
5
|
+
export declare function requireOwnedCheckpoint(identities: readonly string[], checkpoint: ProviderCheckpoint | undefined, operations?: TheoremSessionState['operations'], portableHistory?: readonly TurnHistoryMessage[]): Promise<ProviderCheckpoint | undefined>;
|
|
6
|
+
export declare function retainedCheckpointIdentities(existing: readonly string[], added: readonly string[], limit: number): string[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { labelText, providerCheckpointSchema, TheoremError, toolInvocationHash, } from '@theoremjs/agents';
|
|
2
|
+
export function checkpointIdentity(checkpoint) {
|
|
3
|
+
return toolInvocationHash(providerCheckpointSchema.parse(checkpoint));
|
|
4
|
+
}
|
|
5
|
+
export async function checkpointOwnershipKey(identity, operations) {
|
|
6
|
+
const rows = Object.entries(operations)
|
|
7
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
8
|
+
.map(([id, operation]) => operation.status === 'ready'
|
|
9
|
+
? [id, operation.status, operation.profileId, operation.readyAt]
|
|
10
|
+
: [id, operation.status, operation.profileId, operation.toolId, operation.invocationHash]);
|
|
11
|
+
const epoch = await toolInvocationHash(rows);
|
|
12
|
+
return `${identity}:${epoch}`;
|
|
13
|
+
}
|
|
14
|
+
export async function requireOwnedCheckpoint(identities, checkpoint, operations = {}, portableHistory) {
|
|
15
|
+
if (checkpoint === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
const parsed = providerCheckpointSchema.parse(checkpoint);
|
|
18
|
+
const identity = await checkpointIdentity(parsed);
|
|
19
|
+
const key = await checkpointOwnershipKey(identity, operations);
|
|
20
|
+
const records = Object.values(operations);
|
|
21
|
+
const portableSettlements = portableHistory !== undefined &&
|
|
22
|
+
records.length > 0 &&
|
|
23
|
+
identities.some((saved) => saved.startsWith(`${identity}:`)) &&
|
|
24
|
+
records.every((operation) => operation.status === 'settled' &&
|
|
25
|
+
operation.outcome.effects !== 'unknown' &&
|
|
26
|
+
operation.outcome.event.tool.readBack !== undefined &&
|
|
27
|
+
portableHistory.some((message) => message.role === 'tool' &&
|
|
28
|
+
message.tool_call_id === operation.callId &&
|
|
29
|
+
message.name === operation.toolId &&
|
|
30
|
+
message.content === operation.outcome.event.tool.readBack));
|
|
31
|
+
if (!identities.includes(key) &&
|
|
32
|
+
!portableSettlements &&
|
|
33
|
+
!(records.length === 0 && identities.includes(identity))) {
|
|
34
|
+
throw new TheoremError('request', labelText('session.provider_state_unowned'), {
|
|
35
|
+
copy: { key: 'session.provider_state_unowned' },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
export function retainedCheckpointIdentities(existing, added, limit) {
|
|
41
|
+
return [...new Set([...existing, ...added].reverse())].slice(0, limit).reverse();
|
|
42
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a chat's conversation lives for `createTheoremHandler`: the transcript
|
|
3
|
+
* the person reads and the history the model reads next. The host owns it; the
|
|
4
|
+
* browser holds only an opaque ref the store issued, and never sends history.
|
|
5
|
+
*
|
|
6
|
+
* A database store keys the conversation by its id. The sealed store keeps
|
|
7
|
+
* nothing: its ref is the conversation itself, encrypted and signed with the
|
|
8
|
+
* host's key, so the browser carries what it cannot read or change.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
import { type ProviderCheckpoint, TheoremError, type TurnEvent, type TurnHistoryMessage, z } from '@theoremjs/agents';
|
|
13
|
+
import type { TranscriptBlock } from '@theoremjs/agents/interface';
|
|
14
|
+
import { type MemorySessionStoreOptions } from './session-store.ts';
|
|
15
|
+
/** A reply paused on gates: its events so far, and the history it was given. */
|
|
16
|
+
export type PausedReply = {
|
|
17
|
+
events: TurnEvent[];
|
|
18
|
+
/** The history the reply was given; its events append to this when it commits. */
|
|
19
|
+
base: TurnHistoryMessage[];
|
|
20
|
+
/** How many of `events` `base` already holds: a compaction mid-reply moves the base. */
|
|
21
|
+
baseEvents: number;
|
|
22
|
+
/** How long the reply worked before it paused (ms), approval waits excluded. */
|
|
23
|
+
workedMs: number;
|
|
24
|
+
/** When the reply last paused (epoch ms). */
|
|
25
|
+
endedAt: number;
|
|
26
|
+
/** The model and effort the reply ran with, for the run that goes on after its gates. */
|
|
27
|
+
model?: string;
|
|
28
|
+
effort?: string;
|
|
29
|
+
};
|
|
30
|
+
/** One conversation as the host stores it. */
|
|
31
|
+
export type StoredConversation = {
|
|
32
|
+
version: 1;
|
|
33
|
+
/** What the person reads, without the paused reply. */
|
|
34
|
+
transcript: TranscriptBlock[];
|
|
35
|
+
/** What the model reads on the next turn, compacted when the profile compacts. */
|
|
36
|
+
history: TurnHistoryMessage[];
|
|
37
|
+
providerState?: ProviderCheckpoint;
|
|
38
|
+
/** The last turn's input tokens, for the compaction meter. */
|
|
39
|
+
inputTokens?: number;
|
|
40
|
+
/** The history's size in tokens as the last compaction measured it. */
|
|
41
|
+
historyTokens?: number;
|
|
42
|
+
paused?: PausedReply;
|
|
43
|
+
};
|
|
44
|
+
/** A stored conversation as a host's own store reads it back. */
|
|
45
|
+
export declare const storedConversationSchema: z.ZodType<StoredConversation>;
|
|
46
|
+
/** An empty conversation: what a chat with no ref starts from. */
|
|
47
|
+
export declare function emptyConversation(): StoredConversation;
|
|
48
|
+
export interface ConversationSnapshot {
|
|
49
|
+
revision: string;
|
|
50
|
+
conversation: StoredConversation;
|
|
51
|
+
}
|
|
52
|
+
export type ConversationWriteResult = {
|
|
53
|
+
written: true;
|
|
54
|
+
ref: string;
|
|
55
|
+
revision: string;
|
|
56
|
+
} | {
|
|
57
|
+
written: false;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Where conversations live. `ref` is the store's own: the handler hands it to
|
|
61
|
+
* the browser and back, and never reads it. Every read and write names the
|
|
62
|
+
* session, so a ref from another session opens nothing.
|
|
63
|
+
*/
|
|
64
|
+
export interface TheoremConversationStore {
|
|
65
|
+
/** The conversation, or undefined when this session has none under `ref`. */
|
|
66
|
+
read(sessionId: string, ref: string): Promise<ConversationSnapshot | undefined>;
|
|
67
|
+
/**
|
|
68
|
+
* Compare and replace. A null `ref` creates a conversation; otherwise the
|
|
69
|
+
* write lands only on a conversation this session can read, while its
|
|
70
|
+
* stored revision is still `expectedRevision`.
|
|
71
|
+
*/
|
|
72
|
+
write(sessionId: string, ref: string | null, expectedRevision: string | null, next: StoredConversation): Promise<ConversationWriteResult>;
|
|
73
|
+
}
|
|
74
|
+
/** Refuses a ref the store does not know or cannot open: the browser reads `session.conversation_unknown`. */
|
|
75
|
+
export declare function unknownConversation(cause?: unknown): TheoremError;
|
|
76
|
+
/** Conversations in process memory: forgotten when idle, oldest evicted first. For one instance. */
|
|
77
|
+
export declare function createMemoryConversationStore(options?: Omit<MemorySessionStoreOptions, 'sessionLimits'>): TheoremConversationStore;
|
|
78
|
+
export type SealedConversationStoreOptions = {
|
|
79
|
+
/** Every sealing key still in use, by version: each at least 32 bytes, kept off the browser. */
|
|
80
|
+
keys: Readonly<Record<number, string>>;
|
|
81
|
+
/** The key that seals new writes. */
|
|
82
|
+
keyVersion: number;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* A store for a host that keeps nothing. The ref is the conversation sealed
|
|
86
|
+
* with the host's key and bound to the session: the browser carries it and
|
|
87
|
+
* cannot read or change it, and it opens in no other session.
|
|
88
|
+
*
|
|
89
|
+
* It cannot tell an older seal from the newest, so an older seal opens: like a
|
|
90
|
+
* rewind, it returns a conversation the host wrote. Its revision is the seal.
|
|
91
|
+
*/
|
|
92
|
+
export declare function createSealedConversationStore(options: SealedConversationStoreOptions): TheoremConversationStore;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a chat's conversation lives for `createTheoremHandler`: the transcript
|
|
3
|
+
* the person reads and the history the model reads next. The host owns it; the
|
|
4
|
+
* browser holds only an opaque ref the store issued, and never sends history.
|
|
5
|
+
*
|
|
6
|
+
* A database store keys the conversation by its id. The sealed store keeps
|
|
7
|
+
* nothing: its ref is the conversation itself, encrypted and signed with the
|
|
8
|
+
* host's key, so the browser carries what it cannot read or change.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
import { labelText, providerCheckpointSchema, TheoremError, turnEventSchema, turnHistoryMessageSchema, z, } from '@theoremjs/agents';
|
|
13
|
+
import { openSecret, sealSecret } from '@theoremjs/agents/kernel';
|
|
14
|
+
import { transcriptBlockSchema } from "../client/transcript-schema.js";
|
|
15
|
+
import { memoryStoreLimits } from "./session-store.js";
|
|
16
|
+
const pausedReply = z.object({
|
|
17
|
+
events: z.array(turnEventSchema),
|
|
18
|
+
base: z.array(turnHistoryMessageSchema),
|
|
19
|
+
baseEvents: z.number().int().nonnegative(),
|
|
20
|
+
workedMs: z.number().nonnegative(),
|
|
21
|
+
endedAt: z.number(),
|
|
22
|
+
model: z.string().optional(),
|
|
23
|
+
effort: z.string().optional(),
|
|
24
|
+
});
|
|
25
|
+
true;
|
|
26
|
+
const storedConversation = z.object({
|
|
27
|
+
version: z.literal(1),
|
|
28
|
+
transcript: z.array(transcriptBlockSchema),
|
|
29
|
+
history: z.array(turnHistoryMessageSchema),
|
|
30
|
+
providerState: providerCheckpointSchema.optional(),
|
|
31
|
+
inputTokens: z.number().optional(),
|
|
32
|
+
historyTokens: z.number().optional(),
|
|
33
|
+
paused: pausedReply.optional(),
|
|
34
|
+
});
|
|
35
|
+
true;
|
|
36
|
+
/** A stored conversation as a host's own store reads it back. */
|
|
37
|
+
export const storedConversationSchema = storedConversation;
|
|
38
|
+
/** An empty conversation: what a chat with no ref starts from. */
|
|
39
|
+
export function emptyConversation() {
|
|
40
|
+
return { version: 1, transcript: [], history: [] };
|
|
41
|
+
}
|
|
42
|
+
/** Refuses a ref the store does not know or cannot open: the browser reads `session.conversation_unknown`. */
|
|
43
|
+
export function unknownConversation(cause) {
|
|
44
|
+
return new TheoremError('request', labelText('session.conversation_unknown'), {
|
|
45
|
+
copy: { key: 'session.conversation_unknown' },
|
|
46
|
+
...(cause === undefined ? {} : { cause }),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Conversations in process memory: forgotten when idle, oldest evicted first. For one instance. */
|
|
50
|
+
export function createMemoryConversationStore(options = {}) {
|
|
51
|
+
const { ttlMs, maxSessions } = memoryStoreLimits(options);
|
|
52
|
+
const conversations = new Map();
|
|
53
|
+
const current = (sessionId, ref) => {
|
|
54
|
+
const entry = conversations.get(ref);
|
|
55
|
+
if (!entry || entry.sessionId !== sessionId)
|
|
56
|
+
return undefined;
|
|
57
|
+
if (Date.now() - entry.touched >= ttlMs) {
|
|
58
|
+
conversations.delete(ref);
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return entry.snapshot;
|
|
62
|
+
};
|
|
63
|
+
const written = (...[sessionId, ref, expectedRevision, next]) => {
|
|
64
|
+
const snapshot = ref === null ? undefined : current(sessionId, ref);
|
|
65
|
+
// invariant: A ref this session cannot read takes no write, so no session overwrites another's conversation.
|
|
66
|
+
if (ref !== null && snapshot === undefined)
|
|
67
|
+
return { written: false };
|
|
68
|
+
if ((snapshot?.revision ?? null) !== expectedRevision)
|
|
69
|
+
return { written: false };
|
|
70
|
+
const conversation = storedConversation.parse(next);
|
|
71
|
+
const key = ref ?? crypto.randomUUID();
|
|
72
|
+
const revision = crypto.randomUUID();
|
|
73
|
+
conversations.delete(key);
|
|
74
|
+
conversations.set(key, {
|
|
75
|
+
sessionId,
|
|
76
|
+
snapshot: { revision, conversation: structuredClone(conversation) },
|
|
77
|
+
touched: Date.now(),
|
|
78
|
+
});
|
|
79
|
+
while (conversations.size > maxSessions) {
|
|
80
|
+
const oldest = conversations.keys().next().value;
|
|
81
|
+
if (oldest === undefined)
|
|
82
|
+
break;
|
|
83
|
+
conversations.delete(oldest);
|
|
84
|
+
}
|
|
85
|
+
return { written: true, ref: key, revision };
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
read: (sessionId, ref) => Promise.resolve(structuredClone(current(sessionId, ref))),
|
|
89
|
+
// why: A write that fails its check rejects, as any store's does, rather than throwing at the call.
|
|
90
|
+
write: (...args) => new Promise((resolve) => resolve(written(...args))),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A store for a host that keeps nothing. The ref is the conversation sealed
|
|
95
|
+
* with the host's key and bound to the session: the browser carries it and
|
|
96
|
+
* cannot read or change it, and it opens in no other session.
|
|
97
|
+
*
|
|
98
|
+
* It cannot tell an older seal from the newest, so an older seal opens: like a
|
|
99
|
+
* rewind, it returns a conversation the host wrote. Its revision is the seal.
|
|
100
|
+
*/
|
|
101
|
+
export function createSealedConversationStore(options) {
|
|
102
|
+
const sealKey = options.keys[options.keyVersion];
|
|
103
|
+
if (sealKey === undefined) {
|
|
104
|
+
throw new TheoremError('config', labelText('error.config'), {
|
|
105
|
+
cause: { code: 'conversation_seal_key_missing', keyVersion: options.keyVersion },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
async read(sessionId, ref) {
|
|
110
|
+
const plaintext = await openSecret({ sealed: ref, keys: options.keys, binding: [sessionId] })
|
|
111
|
+
// why: A seal that does not open is a ref this session does not own; the caller refuses it.
|
|
112
|
+
.catch(() => undefined);
|
|
113
|
+
if (plaintext === undefined)
|
|
114
|
+
return undefined;
|
|
115
|
+
const conversation = storedConversation.parse(JSON.parse(plaintext));
|
|
116
|
+
return { revision: ref, conversation };
|
|
117
|
+
},
|
|
118
|
+
async write(sessionId, ref, expectedRevision, next) {
|
|
119
|
+
if (ref !== expectedRevision)
|
|
120
|
+
return { written: false };
|
|
121
|
+
const conversation = storedConversation.parse(next);
|
|
122
|
+
const sealed = await sealSecret({
|
|
123
|
+
plaintext: JSON.stringify(conversation),
|
|
124
|
+
key: sealKey,
|
|
125
|
+
binding: [sessionId],
|
|
126
|
+
keyVersion: options.keyVersion,
|
|
127
|
+
});
|
|
128
|
+
return { written: true, ref: sealed, revision: sealed };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|