theorum 0.1.11 → 0.1.13

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 (40) hide show
  1. package/README.md +25 -2
  2. package/docs/COMPACTION.md +227 -0
  3. package/docs/SECRETS.md +6 -1
  4. package/docs/STOP.md +85 -0
  5. package/esm/mod.d.ts +6 -2
  6. package/esm/mod.js +3 -1
  7. package/esm/src/cli/commands/bench.js +2 -4
  8. package/esm/src/cli/commands/fuzz-guardrails.js +195 -48
  9. package/esm/src/guardrails/injection.js +13 -8
  10. package/esm/src/guardrails/normalize.js +65 -38
  11. package/esm/src/guardrails/sensitive.js +1 -1
  12. package/esm/src/kernel/engine/compaction.d.ts +69 -0
  13. package/esm/src/kernel/engine/compaction.js +141 -0
  14. package/esm/src/kernel/engine/delta.js +30 -7
  15. package/esm/src/kernel/engine/history-tokens.d.ts +43 -0
  16. package/esm/src/kernel/engine/history-tokens.js +100 -0
  17. package/esm/src/kernel/engine/runner/mod.js +164 -62
  18. package/esm/src/kernel/engine/runner/state.d.ts +3 -1
  19. package/esm/src/kernel/engine/runner/steps.js +3 -0
  20. package/esm/src/kernel/mod.d.ts +4 -0
  21. package/esm/src/kernel/mod.js +2 -0
  22. package/esm/src/kernel/registry/profiles.js +37 -0
  23. package/esm/src/kernel/stop.d.ts +75 -0
  24. package/esm/src/kernel/stop.js +120 -0
  25. package/esm/src/kernel/types.d.ts +117 -1
  26. package/esm/src/providers/create-provider.d.ts +7 -0
  27. package/esm/src/providers/create-provider.js +24 -4
  28. package/esm/src/providers/expose-for-tests.js +5 -1
  29. package/esm/src/providers/local.d.ts +29 -0
  30. package/esm/src/providers/local.js +259 -0
  31. package/esm/src/providers/mod.d.ts +2 -0
  32. package/esm/src/providers/mod.js +1 -0
  33. package/esm/src/providers/openrouter.js +32 -13
  34. package/esm/src/providers/provider.js +1 -1
  35. package/esm/src/providers/speech.js +1 -1
  36. package/esm/src/streaming/mod.d.ts +3 -1
  37. package/esm/src/streaming/mod.js +2 -1
  38. package/package.json +6 -1
  39. package/docs/AGENT_PROFILE_CONTRACT.md +0 -189
  40. package/docs/CLI_SPEC.md +0 -183
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Local provider adapter for OpenAI-compatible endpoints (Ollama, llama.cpp,
3
+ * vLLM, LM Studio, etc.).
4
+ *
5
+ * Streams SSE from `/v1/chat/completions`, accumulates tool calls, and yields
6
+ * normalized `TurnEvent` objects. No external SDK dependency — raw fetch + SSE.
7
+ *
8
+ * Hosts pass `baseUrl` explicitly. THEORUM does not read `OLLAMA_HOST` or other
9
+ * environment variables (see docs/SECRETS.md).
10
+ *
11
+ * @module
12
+ */
13
+ import { isAbortError, toErrorEvent } from '../guardrails/error.js';
14
+ import { turnStopFromOpenRouter } from '../kernel/stop.js';
15
+ import { exposeForTests } from './expose-for-tests.js';
16
+ /** Default OpenAI-compat base when the host omits `baseUrl` (Ollama's default port). */
17
+ export const DEFAULT_LOCAL_BASE_URL = 'http://127.0.0.1:11434';
18
+ // ── request mapping ─────────────────────────────────
19
+ function normalizeBaseUrl(baseUrl) {
20
+ let end = baseUrl.length;
21
+ while (end > 0 && baseUrl.charCodeAt(end - 1) === 47)
22
+ end -= 1;
23
+ return baseUrl.slice(0, end);
24
+ }
25
+ function resolveBaseUrl(config) {
26
+ return normalizeBaseUrl(config?.baseUrl?.trim() || DEFAULT_LOCAL_BASE_URL);
27
+ }
28
+ function inputToContent(parts) {
29
+ if (parts.every((p) => p.type === 'text')) {
30
+ return parts.map((p) => ('text' in p ? p.text : '')).join('\n');
31
+ }
32
+ return parts.map((p) => {
33
+ if (p.type === 'text')
34
+ return { type: 'text', text: p.text };
35
+ return {
36
+ type: 'image_url',
37
+ image_url: { url: `data:${p.mimeType};base64,${p.data}` },
38
+ };
39
+ });
40
+ }
41
+ function historyMessageContent(msg) {
42
+ if (msg.parts && msg.parts.length > 0) {
43
+ return inputToContent(msg.parts);
44
+ }
45
+ if (msg.content != null)
46
+ return msg.content;
47
+ return undefined;
48
+ }
49
+ function historyToWire(req) {
50
+ const msgs = [];
51
+ if (req.system)
52
+ msgs.push({ role: 'system', content: req.system });
53
+ for (const msg of req.history ?? []) {
54
+ msgs.push(historyMessageToWire(msg));
55
+ }
56
+ if (req.input.length > 0) {
57
+ msgs.push({ role: 'user', content: inputToContent(req.input) });
58
+ }
59
+ return msgs;
60
+ }
61
+ function historyMessageToWire(msg) {
62
+ if (msg.role === 'tool') {
63
+ return {
64
+ role: 'tool',
65
+ tool_call_id: msg.tool_call_id ?? `call_${msg.name ?? 'tool'}`,
66
+ name: msg.name,
67
+ content: msg.content ?? '',
68
+ };
69
+ }
70
+ if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
71
+ return {
72
+ role: 'assistant',
73
+ content: historyMessageContent(msg) ?? undefined,
74
+ tool_calls: msg.tool_calls.map((tc) => ({
75
+ id: tc.id,
76
+ type: 'function',
77
+ function: { name: tc.function.name, arguments: tc.function.arguments },
78
+ })),
79
+ };
80
+ }
81
+ return { role: msg.role, content: historyMessageContent(msg) ?? '' };
82
+ }
83
+ function toolsToWire(dynamicTools) {
84
+ if (!dynamicTools || dynamicTools.length === 0)
85
+ return undefined;
86
+ return dynamicTools.map((t) => ({
87
+ type: 'function',
88
+ function: {
89
+ name: t.name,
90
+ description: t.description ?? '',
91
+ parameters: t.parameters ?? { type: 'object', properties: {} },
92
+ },
93
+ }));
94
+ }
95
+ function buildBody(req) {
96
+ const body = {
97
+ model: req.apiId,
98
+ messages: historyToWire(req),
99
+ stream: true,
100
+ stream_options: { include_usage: true },
101
+ temperature: req.temperature,
102
+ max_tokens: req.maxOutputTokens,
103
+ };
104
+ const tools = toolsToWire(req.dynamicTools);
105
+ if (tools)
106
+ body.tools = tools;
107
+ return body;
108
+ }
109
+ // ── SSE parsing ─────────────────────────────────────
110
+ async function* parseSse(body) {
111
+ const reader = body.getReader();
112
+ const decoder = new TextDecoder();
113
+ let buffer = '';
114
+ for (;;) {
115
+ const { done, value } = await reader.read();
116
+ if (done)
117
+ break;
118
+ buffer += decoder.decode(value, { stream: true });
119
+ const lines = buffer.split('\n');
120
+ buffer = lines.pop() ?? '';
121
+ for (const line of lines) {
122
+ const trimmed = line.trim();
123
+ if (!trimmed.startsWith('data: '))
124
+ continue;
125
+ const data = trimmed.slice(6);
126
+ if (data === '[DONE]')
127
+ return;
128
+ try {
129
+ yield JSON.parse(data);
130
+ }
131
+ catch {
132
+ // skip malformed
133
+ }
134
+ }
135
+ }
136
+ }
137
+ // ── stream → TurnEvent ──────────────────────────────
138
+ function flushPending(pending) {
139
+ const events = [];
140
+ for (const [, tc] of pending) {
141
+ let parsed = {};
142
+ try {
143
+ parsed = JSON.parse(tc.args);
144
+ }
145
+ catch {
146
+ // empty
147
+ }
148
+ events.push({
149
+ type: 'tool',
150
+ tool: { name: tc.name, arguments: parsed, id: tc.id },
151
+ });
152
+ }
153
+ pending.clear();
154
+ return events;
155
+ }
156
+ async function* streamComplete(baseUrl, req, fetchFn) {
157
+ const res = await fetchFn(`${baseUrl}/v1/chat/completions`, {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify(buildBody(req)),
161
+ signal: req.signal,
162
+ });
163
+ if (!res.ok) {
164
+ const text = await res.text();
165
+ yield toErrorEvent(`LLM HTTP ${res.status}: ${text.slice(0, 300)}`);
166
+ return;
167
+ }
168
+ if (!res.body) {
169
+ yield toErrorEvent('empty response body');
170
+ return;
171
+ }
172
+ yield* streamOpenAiBody(res.body);
173
+ }
174
+ async function* streamOpenAiBody(body) {
175
+ const pending = new Map();
176
+ let finishReason;
177
+ for await (const chunk of parseSse(body)) {
178
+ const usageEvent = tokensFromUsage(chunk.usage);
179
+ if (usageEvent)
180
+ yield usageEvent;
181
+ const choice = chunk.choices?.[0];
182
+ if (!choice)
183
+ continue;
184
+ yield* eventsFromChoiceDelta(choice.delta, pending);
185
+ if (choice.finish_reason != null) {
186
+ finishReason = choice.finish_reason;
187
+ for (const event of flushPending(pending))
188
+ yield event;
189
+ }
190
+ }
191
+ for (const event of flushPending(pending))
192
+ yield event;
193
+ yield { type: 'done', stop: turnStopFromOpenRouter(finishReason) };
194
+ }
195
+ function tokensFromUsage(usage) {
196
+ if (!usage)
197
+ return undefined;
198
+ return {
199
+ type: 'tokens',
200
+ tokens: {
201
+ input: usage.prompt_tokens ?? 0,
202
+ output: usage.completion_tokens ?? 0,
203
+ total: usage.total_tokens ?? 0,
204
+ },
205
+ };
206
+ }
207
+ function* eventsFromChoiceDelta(delta, pending) {
208
+ if (!delta)
209
+ return;
210
+ if (delta.content)
211
+ yield { type: 'text', text: delta.content };
212
+ accumulateToolCalls(delta.tool_calls, pending);
213
+ }
214
+ function accumulateToolCalls(toolCalls, pending) {
215
+ if (!toolCalls)
216
+ return;
217
+ for (const tc of toolCalls) {
218
+ const existing = pending.get(tc.index);
219
+ if (existing) {
220
+ existing.args += tc.function?.arguments ?? '';
221
+ continue;
222
+ }
223
+ pending.set(tc.index, {
224
+ id: tc.id ?? `call_${tc.index}`,
225
+ name: tc.function?.name ?? '',
226
+ args: tc.function?.arguments ?? '',
227
+ });
228
+ }
229
+ }
230
+ // ── public factory ──────────────────────────────────
231
+ /** Create a `ModelProvider` for a local OpenAI-compatible server (Ollama, llama.cpp, vLLM, LM Studio). */
232
+ function createLocalProvider(config) {
233
+ const baseUrl = resolveBaseUrl(config);
234
+ const fetchFn = config?.fetch ?? globalThis.fetch;
235
+ return {
236
+ async *complete(req) {
237
+ try {
238
+ yield* streamComplete(baseUrl, req, fetchFn);
239
+ }
240
+ catch (err) {
241
+ if (isAbortError(err))
242
+ throw err;
243
+ yield toErrorEvent(err);
244
+ }
245
+ },
246
+ };
247
+ }
248
+ export { createLocalProvider };
249
+ exposeForTests('local', {
250
+ inputToContent,
251
+ historyMessageContent,
252
+ historyToWire,
253
+ toolsToWire,
254
+ buildBody,
255
+ parseSse,
256
+ flushPending,
257
+ resolveBaseUrl,
258
+ DEFAULT_LOCAL_BASE_URL,
259
+ });
@@ -10,3 +10,5 @@ import "../../_dnt.polyfills.js";
10
10
  export type { CreateProviderOptions } from './create-provider.js';
11
11
  export { createProvider } from './create-provider.js';
12
12
  export type { GeminiTransport, GeminiVault } from './keys.js';
13
+ export type { LocalProviderConfig } from './local.js';
14
+ export { createLocalProvider, DEFAULT_LOCAL_BASE_URL } from './local.js';
@@ -8,3 +8,4 @@
8
8
  */
9
9
  import "../../_dnt.polyfills.js";
10
10
  export { createProvider } from './create-provider.js';
11
+ export { createLocalProvider, DEFAULT_LOCAL_BASE_URL } from './local.js';
@@ -12,8 +12,9 @@ import { jsonSchema, streamText, tool, } from 'ai';
12
12
  import { isAbortError, toErrorEvent } from '../guardrails/error.js';
13
13
  import { tryStructured } from '../kernel/engine/delta.js';
14
14
  import { getStructured } from '../kernel/registry/schemas.js';
15
- import { resolveOpenRouterModel, resolveOpenRouterPlugins, toOpenRouterPayload, } from './openrouter-payload.js';
15
+ import { turnStopFromOpenRouter } from '../kernel/stop.js';
16
16
  import { exposeForTests } from './expose-for-tests.js';
17
+ import { resolveOpenRouterModel, resolveOpenRouterPlugins, toOpenRouterPayload, } from './openrouter-payload.js';
17
18
  function trimApiKey(explicitKey) {
18
19
  if (explicitKey?.trim()) {
19
20
  return explicitKey.trim();
@@ -301,6 +302,18 @@ function rawEvents(raw, acc) {
301
302
  if (messageEvidence) {
302
303
  events.push(messageEvidence);
303
304
  }
305
+ const choices = Array.isArray(record.choices) ? record.choices : [];
306
+ for (const choice of choices) {
307
+ const row = rawRecord(choice);
308
+ if (!row)
309
+ continue;
310
+ if (typeof row.finish_reason === 'string' || row.finish_reason === null) {
311
+ acc.finishReason = row.finish_reason;
312
+ }
313
+ if (typeof row.native_finish_reason === 'string' || row.native_finish_reason === null) {
314
+ acc.nativeFinishReason = row.native_finish_reason;
315
+ }
316
+ }
304
317
  return events;
305
318
  }
306
319
  function toolCallEvent(part) {
@@ -373,6 +386,9 @@ function primaryEventFromPart(part, acc) {
373
386
  }
374
387
  }
375
388
  function finishEvent(part, acc) {
389
+ if ('finishReason' in part && part.finishReason != null) {
390
+ acc.finishReason = String(part.finishReason);
391
+ }
376
392
  if (acc.emittedTokens) {
377
393
  return undefined;
378
394
  }
@@ -382,6 +398,21 @@ function finishEvent(part, acc) {
382
398
  }
383
399
  return event;
384
400
  }
401
+ function* finalEvents(req, acc) {
402
+ if (acc.errored) {
403
+ return;
404
+ }
405
+ if (req.structured && acc.text) {
406
+ const structured = tryStructured(acc.text);
407
+ if (structured) {
408
+ yield structured;
409
+ }
410
+ }
411
+ yield {
412
+ type: 'done',
413
+ stop: turnStopFromOpenRouter(acc.finishReason, acc.nativeFinishReason),
414
+ };
415
+ }
385
416
  function createStreamContext(req, config, apiKey) {
386
417
  const openrouter = createOpenRouter({
387
418
  apiKey,
@@ -450,18 +481,6 @@ async function* streamOpenRouter(req, config) {
450
481
  yield toErrorEvent(err);
451
482
  }
452
483
  }
453
- function* finalEvents(req, acc) {
454
- if (acc.errored) {
455
- return;
456
- }
457
- if (req.structured && acc.text) {
458
- const structured = tryStructured(acc.text);
459
- if (structured) {
460
- yield structured;
461
- }
462
- }
463
- yield { type: 'done' };
464
- }
465
484
  function responseFormatFor(req) {
466
485
  if (!req.structured)
467
486
  return undefined;
@@ -10,12 +10,12 @@
10
10
  */
11
11
  import { isAbortError, TheorumError, toErrorEvent } from '../guardrails/error.js';
12
12
  import { eventsFromComplete, eventsFromDelta, extractTokenEvent, groundingFromEvent, tryStructured, } from '../kernel/engine/delta.js';
13
+ import { exposeForTests } from './expose-for-tests.js';
13
14
  import { tapFetch } from './google-tap.js';
14
15
  import { toInteractionsBody } from './interactions.js';
15
16
  import { fetchGemini } from './keys.js';
16
17
  import { wrapPcmAsWav } from './pcm.js';
17
18
  import { INTERACTIONS_URL, takeSsePayloads } from './sse.js';
18
- import { exposeForTests } from './expose-for-tests.js';
19
19
  const HTTP_OK = 200;
20
20
  function base64ToBytes(data) {
21
21
  const bin = atob(data);
@@ -7,8 +7,8 @@
7
7
  * @module
8
8
  */
9
9
  import { toErrorEvent } from '../guardrails/error.js';
10
- import { wrapPcmAsWav } from './pcm.js';
11
10
  import { exposeForTests } from './expose-for-tests.js';
11
+ import { wrapPcmAsWav } from './pcm.js';
12
12
  const HTTP_OK = 200;
13
13
  function bytesToBase64(bytes) {
14
14
  let bin = '';
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Structured-output streaming helpers (incomplete JSON text buffers).
2
+ * Structured-output streaming helpers and turn-stop classification.
3
3
  *
4
4
  * @module
5
5
  */
6
6
  import "../../_dnt.polyfills.js";
7
+ export type { ProfileResumeSpec, TurnContinueFrom, TurnStop, TurnStopKind, } from '../kernel/stop.js';
8
+ export { AUTO_CONTINUE_DELAY_MS, CONTINUE_INSTRUCTION, DEFAULT_AUTO_CONTINUE, GenerationStopError, isGenerationStopError, isResumeableStop, isUserCancelledStop, shouldAutoContinue, turnStopFromClientStreamEnd, turnStopFromInteractionStatus, turnStopFromOpenRouter, } from '../kernel/stop.js';
7
9
  export { readStreamingJsonStringField } from './readStreamingJsonStringField.js';
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Structured-output streaming helpers (incomplete JSON text buffers).
2
+ * Structured-output streaming helpers and turn-stop classification.
3
3
  *
4
4
  * @module
5
5
  */
6
6
  import "../../_dnt.polyfills.js";
7
+ export { AUTO_CONTINUE_DELAY_MS, CONTINUE_INSTRUCTION, DEFAULT_AUTO_CONTINUE, GenerationStopError, isGenerationStopError, isResumeableStop, isUserCancelledStop, shouldAutoContinue, turnStopFromClientStreamEnd, turnStopFromInteractionStatus, turnStopFromOpenRouter, } from '../kernel/stop.js';
7
8
  export { readStreamingJsonStringField } from './readStreamingJsonStringField.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "theorum",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "A flat TypeScript agent kernel for typed profiles, deterministic turn execution, dynamic tools, provider adapters, guardrails, and host-injected traces.",
5
5
  "keywords": [
6
6
  "agent",
@@ -13,6 +13,10 @@
13
13
  "openrouter",
14
14
  "gemini"
15
15
  ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/masudl-hub/theorum"
19
+ },
16
20
  "license": "MIT",
17
21
  "module": "./esm/mod.js",
18
22
  "exports": {
@@ -59,6 +63,7 @@
59
63
  "dependencies": {
60
64
  "@openrouter/ai-sdk-provider": "^3.0.0",
61
65
  "ai": "^7.0.0",
66
+ "gpt-tokenizer": "^4.0.0",
62
67
  "@deno/shim-deno": "~0.18.0"
63
68
  },
64
69
  "devDependencies": {
@@ -1,189 +0,0 @@
1
- # Theorum Profile Specification
2
-
3
- A **Profile** is the deterministic, typed security and behavioral contract for an agent role in Theorum.
4
-
5
- Every profile strictly namespaces its capabilities across 6 functional domains:
6
- 1. `identity` — Persona, display handle, and static base system prompts.
7
- 2. `model` — Protocol, provider backend, model whitelist, thinking level, and controls.
8
- 3. `tools` — Tool access ceiling.
9
- 4. `inputs` — Strict ingress constraints (text, attachments, voice), file limits, and routing slots.
10
- 5. `outputs` — Structured schemas, image, speech, streaming, and validation/auto-repair.
11
- 6. `guardrails` — Rate limits, canary leak detection, content safety, and outbound disclosure policies.
12
-
13
- **Vocabulary:** Ingress audio is `inputs.voice`. Generated audio is `outputs.speech` (TTS voice id is `outputs.speech.voice`). Wire container formats for speech (`pcm` / `mp3`) may live in the kernel; vendor voice catalogs and image aspect/size vocabularies live in presets/apps.
14
-
15
- ---
16
-
17
- ## 1. Authoring Shape
18
-
19
- Host apps should author profile definitions, not hand-build normalized runtime
20
- profiles. The minimum useful profile is:
21
-
22
- ```typescript
23
- defineProfile({
24
- id: 'host.agent',
25
- model: {
26
- allow: ['your-model-id'],
27
- config: {
28
- 'your-model-id': {
29
- apiId: 'provider-native-model-id',
30
- thinking: { on: 'high', off: 'minimal' },
31
- thinkingLevels: ['minimal', 'low', 'medium', 'high'],
32
- summaries: { on: 'auto', off: 'none' },
33
- maxOutputTokens: 8192,
34
- temperature: 1,
35
- keyBuiltins: [],
36
- },
37
- },
38
- },
39
- });
40
- ```
41
-
42
- Everything except `id`, `model.allow`, and `model.config` is optional at authoring time:
43
-
44
- ```typescript
45
- export type ProfileDefinition = {
46
- id: ProfileId;
47
- identity?: Partial<Profile['identity']>;
48
- model: Partial<Profile['model']> & Pick<Profile['model'], 'allow' | 'config'>;
49
- tools?: Partial<Profile['tools']>;
50
- inputs?: Partial<Profile['inputs']>;
51
- outputs?: Partial<Profile['outputs']>;
52
- guardrails?: Partial<Profile['guardrails']>;
53
- };
54
- ```
55
-
56
- `defineProfile()` and `registerProfile()` normalize this shape into a complete
57
- runtime `Profile`.
58
-
59
- ## 2. Runtime Type Definition
60
-
61
- ```typescript
62
- export interface Profile {
63
- /** Unique host-defined profile identifier. */
64
- id: ProfileId;
65
-
66
- /** 1. Identity & Persona */
67
- identity: {
68
- handle: string;
69
- chat?: boolean;
70
- system?: string;
71
- systemByRole?: Record<string, string>;
72
- };
73
-
74
- /** 2. Model & Execution Bounds */
75
- model: {
76
- protocol: 'geminiInteractions' | 'openAi';
77
- provider: 'google' | 'openrouter';
78
- allow: ModelId[];
79
- /** Host-owned wire config for every id in `allow`. */
80
- config: Record<ModelId, ModelSpec>;
81
- select?: Record<string, ModelId>;
82
- thinking?: ThinkingLevel | Record<string, ThinkingLevel>;
83
- controls?: ControlId[];
84
- maxSteps?: number;
85
- key?: GeminiFreeBucket;
86
- };
87
-
88
- /** 3. Tools Envelope */
89
- tools: {
90
- allow: ToolId[];
91
- };
92
-
93
- /** 4. Ingress (Input constraints & slots) */
94
- inputs: {
95
- text?: boolean;
96
- attachments?: { accept: string[] };
97
- voice?: { accept: string[] };
98
- maxFiles?: number;
99
- maxBytes?: number;
100
- maxTurnBytes?: number;
101
- limitsByMime?: Record<string, number>;
102
- slots?: Record<string, string[]>;
103
- };
104
-
105
- /** 5. Outputs (Structured output, image, speech, validation, streaming) */
106
- outputs: {
107
- structured?: StructuredSchemaId | StructuredBySlot | null;
108
- /** Pins for an image-role profile. Model id lives on `model`. */
109
- image?: ProfileImageSpec;
110
- /** Pins for a speech-role profile. Model id lives on `model`. */
111
- speech?: ProfileSpeechSpec;
112
- validation?: ProfileValidationSpec;
113
- streaming?: ProfileStreamingSpec;
114
- };
115
-
116
- /** 6. Guardrails (Policies & safety boundaries) */
117
- guardrails: {
118
- quota?: { perDay: number };
119
- canary?: boolean;
120
- sanitizeInput?: boolean;
121
- redactSensitive?: boolean;
122
- egress?: ProfileEgressSpec;
123
- };
124
- }
125
- ```
126
-
127
- ---
128
-
129
- ## 3. Field Reference
130
-
131
- ### `identity`
132
- - `identity.handle`: Public-facing display handle for the persona.
133
- - `identity.chat`: Flag marking whether this profile participates in interactive chat.
134
- - `identity.system`: Base system prompt block. Fenced and bound by Theorum guardrails.
135
- - `identity.systemByRole`: Role-specialized system prompts (e.g., `{ reviewer: '...', drafter: '...' }`).
136
-
137
- ### `model`
138
- - `model.protocol`: Wire framing protocol (`'geminiInteractions'` for Google Interactions API, `'openAi'` for OpenAI/OpenRouter compatible chat completions API).
139
- - `model.provider`: Provider execution backend (`'google'` or `'openrouter'`).
140
- - `model.allow`: Whitelist of host-defined `ModelId`s for this profile. THEORUM does not ship model names.
141
- - `model.config`: Host-owned `ModelSpec` map keyed by the same ids as `allow` / `select` (`apiId`, thinking levels, tokens, `keyBuiltins`, optional per-model `key`, etc.).
142
- - `model.select`: Named model mappings (e.g. `{ fast: 'flash', deep: 'pro' }` — ids are host-defined).
143
- - `model.thinking`: Pinned thinking level (`'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'`) when not user-controllable. Each model’s accepted subset is declared on `ModelSpec.thinkingLevels`.
144
- - `model.controls`: User-togglable controls on this profile (e.g. `['thinking']`).
145
- - `model.maxSteps`: Step limit on autonomous tool loops (1 = one-shot; >1 = autonomous tool loop).
146
- - `model.key`: Default Gemini vault slot for this profile (`'freeA' | 'freeB' | 'freeC'`). Overflow uses `'paid'`.
147
-
148
- ### `ModelSpec` (host-owned, per id in `config`)
149
- - `apiId` / optional `openRouterId`: Provider-native wire ids.
150
- - `keyBuiltins`: Builtins that may use `profile.model.key`. Any other enabled builtin selects the overflow vault slot. Host policy — THEORUM does not infer tool pricing.
151
- - `key`: Optional vault slot override for this model (e.g. pin an image model to `'paid'`). When set, wins over profile key and builtin routing.
152
-
153
- ### `tools`
154
- - `tools.allow`: Whitelist of tool IDs permitted to run under this profile. Calls to unlisted tools are blocked at the kernel boundary.
155
- - Harness tools (`askUser`) ship with THEORUM and are always in the catalog.
156
- - Provider builtins (e.g. Google search/maps/urlContext) are registered by optional presets such as `theorum/presets/google` via `registerGooglePreset()`.
157
-
158
- ### `inputs`
159
- - `inputs.text`: Boolean flag accepting user text input.
160
- - `inputs.attachments`: Allowed mime types for uploaded file attachments.
161
- - `inputs.voice`: Allowed mime types for recorded audio clips (ingress only).
162
- - `inputs.maxFiles`: Maximum number of files permitted per message.
163
- - `inputs.maxBytes`: Maximum byte size permitted per single file.
164
- - `inputs.maxTurnBytes`: Maximum total byte size permitted across all files in one turn.
165
- - `inputs.limitsByMime`: Granular per-MIME byte limits (e.g. `{ 'application/pdf': 50 * 1024 * 1024, 'video/*': 100 * 1024 * 1024 }`).
166
- - `inputs.slots`: Allowed values for dynamic routing slots. Image-role overrides use `slots.aspectRatio` / `slots.size`.
167
-
168
- ### `outputs`
169
- - `outputs.structured`: Structured JSON schema specification (or slot-based schema routing).
170
- - `outputs.image`: Pins for an image-role profile (`aspectRatio`, `size`, `mimeType`, optional `allowsGrounding`, `maxInputImages`). The image model itself is selected via `model.allow` / `model.config`. Slot overrides use `slots.aspectRatio` / `slots.size` when the profile lists allowlists under `inputs.slots`. Adapters map `size` to provider wire keys (e.g. Google Interactions `imageSize`).
171
- - `outputs.speech`: Pins for a speech-role profile (`voice`, optional `format: 'pcm' | 'mp3'`). The speech model itself is selected via `model.allow` / `model.config`. Bind with `createProvider(profile, …)` — same door as chat/image. `geminiInteractions` uses Interactions (`response_format: audio` + `speech_config`); `openAi`/`openrouter` speech roles use `/audio/speech` with the same `openRouter` credentials. `format: 'pcm'` (default) yields WAV media on both. `format: 'mp3'` is only valid on `openAi` speech — Interactions rejects it at resolve.
172
- - `outputs.validation`: Schema-driven in-harness auto-correction. Required vs optional comes only from the structured JSON Schema. Host `fields` validators (dotted paths such as `diagram.mermaid`) run for required paths and for optional paths that are present. Omitted optional paths are skipped. Setting `validation` without a structured `jsonSchema` is an error.
173
- - `outputs.streaming`: SSE streaming behaviors (`streamThoughts`, `gateMedia`). `gateMedia` holds stream `media` events until validation/egress. With validation only, thought and text still stream live (structured is held until accepted). With egress enforcement, user-visible thought/text stay buffered until the egress gate passes.
174
-
175
- ### `guardrails`
176
- - `guardrails.quota.perDay`: Optional daily turn quota enforced per client IP. If omitted, quota enforcement is explicitly `not_configured`.
177
- - `guardrails.canary`: Enable unique token canary leak interception (default `true`).
178
- - `guardrails.sanitizeInput`: Run prompt injection / jailbreak redaction on ingress text (default `true`).
179
- - `guardrails.redactSensitive`: Redact SSN, credit cards, IP addresses, API keys from inputs (default `true`).
180
- - `guardrails.egress`: Generic outbound disclosure control engine (`enforce`, `onBlock: 'reject_to_agent' | 'refuse_to_user'`, `maxRetries`, `repairGuidance`). Runs deterministic auto-repair loops for chat or immediate in-character refusal when blocking egress on voice-input turns.
181
-
182
- ### Per-turn Interactions state
183
- - `TurnRequest.input`: Optional turn input object. If omitted, Theorum normalizes it to an empty input and still runs the profile/provider turn.
184
- - `TurnRequest.signal`: Optional `AbortSignal`. When aborted, THEORUM stops the turn and cancels in-flight provider HTTP (Gemini fetch, OpenRouter `abortSignal`, speech fetch). Traces mark `cancelled: true`.
185
- - `TurnRequest.previousInteractionId`: Optional Google Interactions server-side conversation pointer. Theorum passes it through as `previous_interaction_id` for profiles using `geminiInteractions`.
186
- - `TurnRequest.store`: Optional Google Interactions storage override. If omitted, Theorum does not send `store`; provider/project policy remains the authority. If supplied, Theorum serializes the explicit boolean.
187
-
188
- ### Grounding events
189
- - `TurnEvent.type: 'grounding'`: Provider evidence passthrough for Google Search / Maps grounding. The event carries raw `groundingMetadata`, raw `groundingChunks`, optional search widget HTML, and lightweight `sources` for maps/web URIs. Host apps own domain-specific interpretation, such as store cards or citation display.