@desplega.ai/agent-swarm 1.76.2 → 1.76.3

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.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * General-purpose structured-output LLM wrapper.
3
+ *
4
+ * Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
5
+ * → Phase 0 § "complete-structured.ts"
6
+ *
7
+ * Context-agnostic: callable from both worker subprocesses and the API
8
+ * server. Resolves a credential per the precedence in `./credentials.ts`,
9
+ * then either:
10
+ * - Calls pi-ai's `complete()` with a single typebox-defined tool and
11
+ * extracts the tool-call payload (provider/anthropic/openai/openai-codex
12
+ * paths), OR
13
+ * - Shells out to `claude -p` (CLAUDE_CODE_OAUTH_TOKEN fallback path).
14
+ *
15
+ * Worker-safe: uses fetch() only, no bun:sqlite import.
16
+ */
17
+
18
+ import type { ToolCall } from "@mariozechner/pi-ai";
19
+ import { complete, getModel } from "@mariozechner/pi-ai";
20
+ import type { TSchema } from "typebox";
21
+ import { z } from "zod";
22
+ import { type ResolvedCredential, resolveCredential } from "./credentials.js";
23
+ import { parseModelStr } from "./models.js";
24
+
25
+ export interface CompleteStructuredOptions<TZod extends z.ZodTypeAny> {
26
+ /** Zod schema used for output validation (final source of truth). */
27
+ zodSchema: TZod;
28
+ /** Typebox schema used as pi-ai `Tool.parameters` (provider-side validation). */
29
+ toolSchema: TSchema;
30
+ toolName: string;
31
+ toolDescription: string;
32
+ systemPrompt: string;
33
+ userPrompt: string;
34
+ /**
35
+ * Optional context for codex-OAuth lookup. When omitted, only env vars are tried.
36
+ * - Workers: pass `config.apiUrl` / `config.apiKey`.
37
+ * - API server: pass `MCP_BASE_URL` / `API_KEY` (loopback).
38
+ * - Skip entirely to disable codex OAuth probing.
39
+ */
40
+ apiUrl?: string;
41
+ apiKey?: string;
42
+ /** Default: 3. */
43
+ retries?: number;
44
+ signal?: AbortSignal;
45
+ /** Optional diagnostic tag (e.g. `"session-summary:pi"`). */
46
+ callerTag?: string;
47
+ // Test injection points:
48
+ _resolveCredential?: typeof resolveCredential;
49
+ _complete?: typeof complete;
50
+ _spawnClaudeCli?: (
51
+ prompt: string,
52
+ model: string,
53
+ signal?: AbortSignal,
54
+ jsonSchema?: object,
55
+ ) => Promise<string>;
56
+ /**
57
+ * Bypass `resolveCredential` entirely — opencode auth path (and tests)
58
+ * pass an already-resolved credential.
59
+ */
60
+ _credentialOverride?: ResolvedCredential;
61
+ }
62
+
63
+ /**
64
+ * Default 30s timeout for the `claude -p` shellout — matches the existing
65
+ * pattern in `src/providers/pi-mono-extension.ts:328-351` (the call site
66
+ * being retired in Phase 1).
67
+ */
68
+ const CLAUDE_CLI_TIMEOUT_MS = 30_000;
69
+
70
+ /**
71
+ * Tolerant JSON extractor for `claude -p` `result` strings. Claude sometimes
72
+ * wraps JSON in ```json … ``` fences despite a "no code fences" prompt; this
73
+ * peels off the fence so `JSON.parse` can handle it.
74
+ */
75
+ function stripJsonFences(raw: string): string {
76
+ const trimmed = raw.trim();
77
+ const fenced = trimmed.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
78
+ return fenced?.[1] ? fenced[1].trim() : trimmed;
79
+ }
80
+
81
+ async function defaultSpawnClaudeCli(
82
+ prompt: string,
83
+ model: string,
84
+ signal?: AbortSignal,
85
+ jsonSchema?: object,
86
+ ): Promise<string> {
87
+ const cmd = [
88
+ process.env.CLAUDE_BINARY ?? "claude",
89
+ "-p",
90
+ "--model",
91
+ model,
92
+ "--output-format",
93
+ "json",
94
+ ];
95
+ if (jsonSchema) {
96
+ cmd.push("--json-schema", JSON.stringify(jsonSchema));
97
+ }
98
+ // The hook subprocess receives an empty CLAUDE_CODE_OAUTH_TOKEN (claude
99
+ // CLI strips it from hooks). Restore it from the mirror set by
100
+ // claude-adapter.ts so the inner `claude -p` invocation authenticates.
101
+ const env: Record<string, string> = { ...(process.env as Record<string, string>) };
102
+ if (!env.CLAUDE_CODE_OAUTH_TOKEN && env.AGENT_SWARM_CLAUDE_OAUTH_TOKEN) {
103
+ env.CLAUDE_CODE_OAUTH_TOKEN = env.AGENT_SWARM_CLAUDE_OAUTH_TOKEN;
104
+ }
105
+ const proc = Bun.spawn({
106
+ cmd,
107
+ env,
108
+ stdin: "pipe",
109
+ stdout: "pipe",
110
+ stderr: "pipe",
111
+ });
112
+ const timeout = setTimeout(() => {
113
+ try {
114
+ proc.kill();
115
+ } catch {
116
+ // ignore — process may have already exited
117
+ }
118
+ }, CLAUDE_CLI_TIMEOUT_MS);
119
+ const abortHandler = () => {
120
+ try {
121
+ proc.kill();
122
+ } catch {
123
+ // ignore
124
+ }
125
+ };
126
+ signal?.addEventListener("abort", abortHandler, { once: true });
127
+ try {
128
+ proc.stdin.write(prompt);
129
+ await proc.stdin.end();
130
+ const [stdout, stderr, exitCode] = await Promise.all([
131
+ new Response(proc.stdout).text(),
132
+ new Response(proc.stderr).text(),
133
+ proc.exited,
134
+ ]);
135
+ if (exitCode !== 0) {
136
+ console.error(`internal-ai: claude -p exited ${exitCode}; stderr=${stderr.slice(0, 500)}`);
137
+ }
138
+ // claude -p --output-format json envelope shape:
139
+ // { ..., result: "<text>", structured_output?: <validated-object> }
140
+ // When --json-schema is passed, prefer `structured_output` (validated
141
+ // by claude server-side). When it's absent, fall back to `result` — the
142
+ // caller has also embedded the schema in the prompt so `result` should
143
+ // be valid JSON; if it isn't, the caller's JSON.parse retry surfaces it.
144
+ try {
145
+ const envelope = JSON.parse(stdout) as { result?: string; structured_output?: unknown };
146
+ if (jsonSchema && envelope.structured_output !== undefined) {
147
+ return JSON.stringify(envelope.structured_output);
148
+ }
149
+ return envelope.result ?? stdout;
150
+ } catch {
151
+ return stdout;
152
+ }
153
+ } finally {
154
+ clearTimeout(timeout);
155
+ signal?.removeEventListener("abort", abortHandler);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Run a structured-output completion. Returns the parsed object on success,
161
+ * `null` on auth-missing or exhausted retries (errors logged, never thrown).
162
+ */
163
+ export async function completeStructured<TZod extends z.ZodTypeAny>(
164
+ opts: CompleteStructuredOptions<TZod>,
165
+ ): Promise<z.infer<TZod> | null> {
166
+ const retries = opts.retries ?? 3;
167
+ const callerTag = opts.callerTag ?? "<unset>";
168
+
169
+ // 1. Resolve credential (or use override).
170
+ let cred: ResolvedCredential | null;
171
+ if (opts._credentialOverride) {
172
+ cred = opts._credentialOverride;
173
+ } else {
174
+ const resolver = opts._resolveCredential ?? resolveCredential;
175
+ cred = await resolver({
176
+ env: process.env,
177
+ apiUrl: opts.apiUrl,
178
+ apiKey: opts.apiKey,
179
+ callerTag: opts.callerTag,
180
+ });
181
+ }
182
+ if (!cred) {
183
+ // No auth — graceful no-op. Don't log noisily; callers may invoke this
184
+ // routinely in environments without LLM credentials.
185
+ return null;
186
+ }
187
+
188
+ // Always-on debug log — relied on by Manual E2E Scenario 5 + Phase 4 QA.
189
+ console.log(`internal-ai: kind=${cred.kind} callerTag=${callerTag}`);
190
+
191
+ // 2. Claude-CLI fallback path.
192
+ if (cred.kind === "claude-cli") {
193
+ const spawn = opts._spawnClaudeCli ?? defaultSpawnClaudeCli;
194
+ // Belt-and-suspenders: pass the schema both as `--json-schema` (sets
195
+ // `envelope.structured_output` when the model complies) AND inline in
196
+ // the prompt (forces JSON-only `envelope.result` when it doesn't).
197
+ // The CLI flag alone is unreliable — claude sometimes asks "where's
198
+ // the schema?" if the prompt doesn't reference one.
199
+ const jsonSchema = z.toJSONSchema(opts.zodSchema) as object;
200
+ const schemaStr = JSON.stringify(jsonSchema);
201
+ const claudeUserPrompt = `${opts.userPrompt}\n\nRespond with ONLY a JSON object (no prose, no code fences) matching this schema:\n${schemaStr}`;
202
+ let lastErr: unknown = null;
203
+ for (let attempt = 0; attempt < retries; attempt++) {
204
+ try {
205
+ const raw = await spawn(
206
+ `${opts.systemPrompt}\n\n${claudeUserPrompt}`,
207
+ cred.modelDefault,
208
+ opts.signal,
209
+ jsonSchema,
210
+ );
211
+ let parsedJson: unknown;
212
+ try {
213
+ parsedJson = JSON.parse(stripJsonFences(raw));
214
+ } catch (err) {
215
+ lastErr = err;
216
+ continue;
217
+ }
218
+ const validated = opts.zodSchema.safeParse(parsedJson);
219
+ if (validated.success) {
220
+ return validated.data;
221
+ }
222
+ lastErr = validated.error;
223
+ } catch (err) {
224
+ lastErr = err;
225
+ }
226
+ }
227
+ console.error(
228
+ `internal-ai: structured output failed after ${retries} retries (callerTag=${callerTag} kind=${cred.kind})`,
229
+ lastErr,
230
+ );
231
+ return null;
232
+ }
233
+
234
+ // 3. pi-ai path (openrouter / anthropic / openai / openai-codex).
235
+ const [provider, modelId] = parseModelStr(cred.modelDefault);
236
+ let model: ReturnType<typeof getModel>;
237
+ try {
238
+ // The typed overload is too restrictive for our dynamic-string case; the
239
+ // runtime tolerates any registered (provider, id) pair.
240
+ model = getModel(provider as Parameters<typeof getModel>[0], modelId as never);
241
+ } catch (err) {
242
+ console.error(
243
+ `internal-ai: getModel(${provider}, ${modelId}) threw (callerTag=${callerTag})`,
244
+ err,
245
+ );
246
+ return null;
247
+ }
248
+
249
+ const completeFn = opts._complete ?? complete;
250
+ let userPrompt = opts.userPrompt;
251
+ let lastErr: unknown = null;
252
+ for (let attempt = 0; attempt < retries; attempt++) {
253
+ try {
254
+ const msg = await completeFn(
255
+ model,
256
+ {
257
+ systemPrompt: opts.systemPrompt,
258
+ messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }],
259
+ tools: [
260
+ {
261
+ name: opts.toolName,
262
+ description: opts.toolDescription,
263
+ parameters: opts.toolSchema,
264
+ },
265
+ ],
266
+ },
267
+ // pi-ai's ProviderStreamOptions type only allows known providers;
268
+ // we pass the validated apiKey through verbatim.
269
+ { apiKey: cred.apiKey, signal: opts.signal } as Parameters<typeof completeFn>[2],
270
+ );
271
+
272
+ const toolCall = msg.content.find(
273
+ (c): c is ToolCall => c.type === "toolCall" && c.name === opts.toolName,
274
+ );
275
+ if (!toolCall) {
276
+ userPrompt = `${userPrompt}\n\nYou did not call the ${opts.toolName} tool. You MUST call it with the requested arguments.`;
277
+ lastErr = new Error("no tool call in response");
278
+ continue;
279
+ }
280
+
281
+ const validated = opts.zodSchema.safeParse(toolCall.arguments);
282
+ if (validated.success) {
283
+ return validated.data;
284
+ }
285
+ userPrompt = `${userPrompt}\n\nThe ${opts.toolName} arguments did not validate: ${validated.error.message}. Please retry with correct arguments.`;
286
+ lastErr = validated.error;
287
+ } catch (err) {
288
+ lastErr = err;
289
+ }
290
+ }
291
+ console.error(
292
+ `internal-ai: structured output failed after ${retries} retries (callerTag=${callerTag} kind=${cred.kind})`,
293
+ lastErr,
294
+ );
295
+ return null;
296
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Credential resolver for the internal-ai abstraction.
3
+ *
4
+ * Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
5
+ * → Phase 0 § "credentials.ts"
6
+ *
7
+ * Context-agnostic: tries env vars first, then optionally probes codex OAuth
8
+ * via HTTP if `apiUrl + apiKey` are provided. Workers pass through
9
+ * `config.apiUrl` / `config.apiKey`; API-server callers pass `MCP_BASE_URL` /
10
+ * `API_KEY` (loopback). Callers that don't want codex-OAuth probing omit both.
11
+ *
12
+ * NO HARNESS CHECK — this resolver is harness-agnostic. The codex-OAuth probe
13
+ * costs one localhost HTTP call, which is fine to attempt on any worker.
14
+ *
15
+ * Worker-safe: uses fetch() only, no bun:sqlite import.
16
+ */
17
+
18
+ import type { OAuthCredentials } from "@mariozechner/pi-ai";
19
+ import { getEnvApiKey } from "@mariozechner/pi-ai";
20
+ import { getOAuthApiKey } from "@mariozechner/pi-ai/oauth";
21
+ import { getValidCodexOAuth, persistCodexOAuth } from "../../providers/codex-oauth/storage.js";
22
+ import { type CredentialKind, DEFAULT_MODEL, resolveModelString } from "./models.js";
23
+
24
+ export type ResolvedCredential =
25
+ | {
26
+ kind: "openrouter" | "anthropic" | "openai" | "openai-codex";
27
+ apiKey: string;
28
+ modelDefault: string;
29
+ }
30
+ | {
31
+ kind: "claude-cli";
32
+ modelDefault: string;
33
+ // No apiKey — the `claude` CLI uses CLAUDE_CODE_OAUTH_TOKEN from env directly.
34
+ };
35
+
36
+ export interface ResolveCredentialOptions {
37
+ /** Defaulted to `process.env`; injectable for tests. */
38
+ env?: NodeJS.ProcessEnv;
39
+ /** Optional: enables codex-OAuth lookup over HTTP. */
40
+ apiUrl?: string;
41
+ /** Optional: paired with apiUrl. */
42
+ apiKey?: string;
43
+ /** Optional log tag — purely for diagnostics, not load-bearing. */
44
+ callerTag?: string;
45
+ /** Test injection: override the codex OAuth lookup. */
46
+ _getValidCodexOAuth?: typeof getValidCodexOAuth;
47
+ /** Test injection: override pi-ai's OAuth-to-API-key resolution. */
48
+ _getOAuthApiKey?: typeof getOAuthApiKey;
49
+ /** Test injection: override pi-ai's env API key lookup. */
50
+ _getEnvApiKey?: typeof getEnvApiKey;
51
+ /** Test injection: override the persistCodexOAuth call. */
52
+ _persistCodexOAuth?: typeof persistCodexOAuth;
53
+ }
54
+
55
+ /**
56
+ * Resolve a credential according to the documented precedence. Returns `null`
57
+ * if no credential could be resolved — callers MUST treat null as a graceful
58
+ * no-op (do NOT throw; structured-output completion is a best-effort path).
59
+ *
60
+ * Precedence (top wins):
61
+ * 1. `env.OPENROUTER_API_KEY` (via pi-ai `getEnvApiKey("openrouter")`)
62
+ * 2. `env.ANTHROPIC_API_KEY` (via pi-ai `getEnvApiKey("anthropic")`)
63
+ * 3. `env.OPENAI_API_KEY` (via pi-ai `getEnvApiKey("openai")`)
64
+ * 4. codex OAuth (only when `apiUrl && apiKey` are provided)
65
+ * 5. `env.CLAUDE_CODE_OAUTH_TOKEN` → claude-cli fallback
66
+ * 6. null
67
+ */
68
+ export async function resolveCredential(
69
+ opts: ResolveCredentialOptions = {},
70
+ ): Promise<ResolvedCredential | null> {
71
+ const env = opts.env ?? process.env;
72
+ const getEnvKey = opts._getEnvApiKey ?? getEnvApiKey;
73
+ const getCodex = opts._getValidCodexOAuth ?? getValidCodexOAuth;
74
+ const getOAuth = opts._getOAuthApiKey ?? getOAuthApiKey;
75
+ const persistCodex = opts._persistCodexOAuth ?? persistCodexOAuth;
76
+
77
+ // 1. OpenRouter.
78
+ const openrouterKey = env.OPENROUTER_API_KEY ?? getEnvKey("openrouter");
79
+ if (openrouterKey) {
80
+ return {
81
+ kind: "openrouter",
82
+ apiKey: openrouterKey,
83
+ modelDefault: resolveModelString("openrouter"),
84
+ };
85
+ }
86
+
87
+ // 2. Anthropic.
88
+ const anthropicKey = env.ANTHROPIC_API_KEY ?? getEnvKey("anthropic");
89
+ if (anthropicKey) {
90
+ return {
91
+ kind: "anthropic",
92
+ apiKey: anthropicKey,
93
+ modelDefault: resolveModelString("anthropic"),
94
+ };
95
+ }
96
+
97
+ // 3. OpenAI.
98
+ const openaiKey = env.OPENAI_API_KEY ?? getEnvKey("openai");
99
+ if (openaiKey) {
100
+ return {
101
+ kind: "openai",
102
+ apiKey: openaiKey,
103
+ modelDefault: resolveModelString("openai"),
104
+ };
105
+ }
106
+
107
+ // 4. Codex OAuth — only if we have apiUrl + apiKey to probe the config store.
108
+ if (opts.apiUrl && opts.apiKey) {
109
+ try {
110
+ const codexCreds = await getCodex(opts.apiUrl, opts.apiKey);
111
+ if (codexCreds) {
112
+ // pi-ai expects a Record<providerID, OAuthCredentials>.
113
+ const credMap: Record<string, OAuthCredentials> = {
114
+ "openai-codex": {
115
+ access: codexCreds.access,
116
+ refresh: codexCreds.refresh,
117
+ expires: codexCreds.expires,
118
+ },
119
+ };
120
+ const oauthResult = await getOAuth("openai-codex", credMap);
121
+ if (oauthResult) {
122
+ // Persist any rotated refresh token — best-effort, must not block.
123
+ // Wrap defensively here even though the production helper already
124
+ // swallows; tests may inject a throwing hook, and persistence
125
+ // failure must never prevent the current call from succeeding.
126
+ if (oauthResult.newCredentials) {
127
+ const updated = oauthResult.newCredentials;
128
+ try {
129
+ await persistCodex(opts.apiUrl, opts.apiKey, {
130
+ access: String(updated.access),
131
+ refresh: String(updated.refresh),
132
+ expires: Number(updated.expires),
133
+ accountId: codexCreds.accountId,
134
+ });
135
+ } catch (err) {
136
+ console.error(
137
+ `internal-ai: persistCodexOAuth failed (callerTag=${opts.callerTag ?? "<unset>"}):`,
138
+ err,
139
+ );
140
+ }
141
+ }
142
+ return {
143
+ kind: "openai-codex",
144
+ apiKey: oauthResult.apiKey,
145
+ modelDefault: resolveModelString("openai-codex"),
146
+ };
147
+ }
148
+ }
149
+ } catch (err) {
150
+ console.error(
151
+ `internal-ai: codex OAuth probe failed (callerTag=${opts.callerTag ?? "<unset>"}):`,
152
+ err,
153
+ );
154
+ // Fall through to claude-cli fallback below.
155
+ }
156
+ }
157
+
158
+ // 5. CLAUDE_CODE_OAUTH_TOKEN → claude-cli fallback.
159
+ // `AGENT_SWARM_CLAUDE_OAUTH_TOKEN` is the mirror set by claude-adapter.ts
160
+ // before spawning `claude` — the CLI strips `CLAUDE_CODE_OAUTH_TOKEN` from
161
+ // hook subprocesses (security), so the hook reads the mirror instead.
162
+ if (env.AGENT_SWARM_CLAUDE_OAUTH_TOKEN || env.CLAUDE_CODE_OAUTH_TOKEN) {
163
+ return {
164
+ kind: "claude-cli",
165
+ modelDefault: resolveModelString("claude-cli"),
166
+ };
167
+ }
168
+
169
+ // 6. No creds.
170
+ return null;
171
+ }
172
+
173
+ // Re-export for convenience to keep imports flat.
174
+ export { DEFAULT_MODEL };
175
+ export type { CredentialKind };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Internal-AI: reusable structured-output LLM abstraction for both worker
3
+ * subprocesses and the API server.
4
+ *
5
+ * Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
6
+ *
7
+ * Worker-safe: uses fetch() only, no bun:sqlite import.
8
+ *
9
+ * Public surface:
10
+ * - `completeStructured<TZod>({...})` — context-agnostic lower layer.
11
+ * - `summarizeSession({...})` — worker-side session-end domain helper.
12
+ * - `resolveCredential({...})` — exposed for opencode-auth and tests.
13
+ */
14
+
15
+ export {
16
+ type CompleteStructuredOptions,
17
+ completeStructured,
18
+ } from "./complete-structured.js";
19
+ export {
20
+ type CredentialKind,
21
+ DEFAULT_MODEL,
22
+ type ResolveCredentialOptions,
23
+ type ResolvedCredential,
24
+ resolveCredential,
25
+ } from "./credentials.js";
26
+ export { parseModelStr, resolveModelString } from "./models.js";
27
+ export {
28
+ type SummarizeSessionOptions,
29
+ summarizeSession,
30
+ summaryToolSchema,
31
+ } from "./summarize-session.js";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Per-credential default model registry for the internal-ai abstraction.
3
+ *
4
+ * Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
5
+ * → Phase 0 § "models.ts"
6
+ *
7
+ * Model defaults are per credential kind, NOT per harness — every credential
8
+ * kind has exactly one default model. Override via `MEMORY_RATER_MODEL` env
9
+ * (kept for backwards-compat with the claude hook).
10
+ */
11
+
12
+ export type CredentialKind = "openrouter" | "anthropic" | "openai" | "openai-codex" | "claude-cli";
13
+
14
+ /**
15
+ * Per-credential default model strings. The "claude-cli" kind uses the
16
+ * shorthand "haiku" because the only consumer is the `claude -p --model haiku`
17
+ * shellout, not pi-ai's `getModel`.
18
+ */
19
+ export const DEFAULT_MODEL: Record<CredentialKind, string> = {
20
+ openrouter: "openrouter/google/gemini-3-flash-preview",
21
+ anthropic: "anthropic/claude-haiku-4-5",
22
+ openai: "openai/gpt-5.4-mini",
23
+ "openai-codex": "openai-codex/gpt-5.4-mini",
24
+ "claude-cli": "haiku",
25
+ };
26
+
27
+ /**
28
+ * Resolve the effective model string for a credential kind. Honours the
29
+ * `MEMORY_RATER_MODEL` env var so existing claude-hook users keep their
30
+ * override (it pre-dates the per-kind registry).
31
+ */
32
+ export function resolveModelString(kind: CredentialKind): string {
33
+ return process.env.MEMORY_RATER_MODEL ?? DEFAULT_MODEL[kind];
34
+ }
35
+
36
+ /**
37
+ * Split a `provider/model-id` string on the FIRST `/` so that OpenRouter
38
+ * compound IDs like `openrouter/google/gemini-3-flash-preview` parse as
39
+ * `("openrouter", "google/gemini-3-flash-preview")`. Mirrors the existing
40
+ * convention in `src/providers/pi-mono-adapter.ts:161-170`.
41
+ */
42
+ export function parseModelStr(modelStr: string): [provider: string, modelId: string] {
43
+ const idx = modelStr.indexOf("/");
44
+ if (idx < 0) throw new Error(`invalid model string (no '/'): ${modelStr}`);
45
+ return [modelStr.slice(0, idx), modelStr.slice(idx + 1)];
46
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Worker-side domain helper for session-end summarization.
3
+ *
4
+ * Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
5
+ * → Phase 0 § "summarize-session.ts"
6
+ *
7
+ * Composes `completeStructured` with `SummaryWithRatingsSchema` and the
8
+ * shared `BASE_SUMMARIZE_PROMPT` extracted from `src/hooks/hook.ts`. API-server
9
+ * callers wanting structured AI completion call `completeStructured` directly
10
+ * with their own schemas — this helper is for session-transcript consumers
11
+ * only.
12
+ *
13
+ * Worker-safe: uses fetch() only via underlying helpers; no bun:sqlite import.
14
+ */
15
+
16
+ import { Type } from "typebox";
17
+ import type { z } from "zod";
18
+ import {
19
+ BASE_SUMMARIZE_PROMPT,
20
+ buildSummaryWithRatingsPrompt,
21
+ type RetrievalRow,
22
+ SummaryWithRatingsSchema,
23
+ } from "../../be/memory/raters/llm.js";
24
+ import { completeStructured } from "./complete-structured.js";
25
+ import type { ResolvedCredential } from "./credentials.js";
26
+
27
+ export interface SummarizeSessionOptions {
28
+ /** Diagnostic tag only — propagated into `callerTag`, not into `completeStructured` directly. */
29
+ harness: "claude" | "pi" | "opencode" | "codex";
30
+ /** Pre-truncated transcript text. */
31
+ transcript: string;
32
+ /** Memory retrievals for the per-memory ratings block; [] when not requested. */
33
+ retrievals: RetrievalRow[];
34
+ taskContext: { sourceTaskId: string; agentId: string; prompt?: string };
35
+ /** Passed through to `completeStructured` for codex-OAuth probing. */
36
+ apiUrl: string;
37
+ apiKey: string;
38
+ signal?: AbortSignal;
39
+ /**
40
+ * Bypass `resolveCredential` entirely — opencode auth path (and tests) pass
41
+ * an already-resolved credential through to `completeStructured`. Phase 2
42
+ * amendment: clean injection point so harnesses with their own credential
43
+ * stores (opencode's `auth.json`) can skip the harness-agnostic resolver.
44
+ */
45
+ _credentialOverride?: ResolvedCredential;
46
+ /** Test injection. */
47
+ _completeStructured?: typeof completeStructured;
48
+ }
49
+
50
+ /**
51
+ * Typebox tool schema mirroring `SummaryWithRatingsSchema`.
52
+ *
53
+ * Kept in lockstep with the zod schema via `src/tests/internal-ai/schema-parity.test.ts`
54
+ * which fuzzes both validators with a fixture set.
55
+ */
56
+ export const summaryToolSchema = Type.Object({
57
+ summary: Type.String(),
58
+ ratings: Type.Array(
59
+ Type.Object({
60
+ id: Type.String({ minLength: 1 }),
61
+ score: Type.Number({ minimum: 0, maximum: 1 }),
62
+ reasoning: Type.String({ minLength: 1, maxLength: 500 }),
63
+ referencesSource: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
64
+ }),
65
+ ),
66
+ });
67
+
68
+ /**
69
+ * Returns the structured summary (with optional per-memory ratings), or
70
+ * `null` when:
71
+ * - the transcript is too short (≤ 100 chars), OR
72
+ * - `completeStructured` could not resolve a credential, OR
73
+ * - the LLM repeatedly failed to produce schema-valid output.
74
+ */
75
+ export async function summarizeSession(
76
+ opts: SummarizeSessionOptions,
77
+ ): Promise<z.infer<typeof SummaryWithRatingsSchema> | null> {
78
+ if (opts.transcript.length <= 100) return null;
79
+
80
+ const taskLine = opts.taskContext.prompt ? `\nTask: ${opts.taskContext.prompt}` : "";
81
+ const basePrompt = `${BASE_SUMMARIZE_PROMPT}${taskLine}\n\nTranscript:\n${opts.transcript}`;
82
+ const userPrompt = buildSummaryWithRatingsPrompt(basePrompt, opts.retrievals);
83
+
84
+ const runner = opts._completeStructured ?? completeStructured;
85
+ return await runner({
86
+ zodSchema: SummaryWithRatingsSchema,
87
+ toolSchema: summaryToolSchema,
88
+ toolName: "record_session_summary",
89
+ toolDescription:
90
+ "Record the high-value learnings extracted from this session, plus per-memory ratings of any retrievals.",
91
+ systemPrompt:
92
+ "You are an expert at extracting durable, generalizable learnings from agent sessions.",
93
+ userPrompt,
94
+ callerTag: `session-summary:${opts.harness}`,
95
+ apiUrl: opts.apiUrl,
96
+ apiKey: opts.apiKey,
97
+ signal: opts.signal,
98
+ retries: 3,
99
+ ...(opts._credentialOverride ? { _credentialOverride: opts._credentialOverride } : {}),
100
+ });
101
+ }