diff --git a/packages/app/package.json b/packages/app/package.json index a4936db6..449d6cc8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -64,7 +64,7 @@ "@types/ws": "^8.18.1", "@wdio/electron-service": "10.1.0", "@wdio/native-utils": "2.5.0", - "electron": "34.5.8", + "electron": "36.9.5", "electron-devtools-installer": "^4.0.0", "eslint": "^9.22.0", "eslint-config-prettier": "^10.1.1", @@ -90,6 +90,8 @@ "dependencies": { "@ai-sdk/openai": "^3.0.90", "@anthropic-ai/sandbox-runtime": "^0.0.67", + "@earendil-works/pi-agent-core": "0.83.0", + "@earendil-works/pi-ai": "0.83.0", "@earendil-works/pi-coding-agent": "^0.83.0", "@fontsource/inter": "^5.2.5", "@leeoniya/ufuzzy": "^1.0.18", diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts index f2b77645..1e12395f 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts @@ -903,6 +903,32 @@ describe("local AI IPC", () => { expect(runtime.updateMemorySettings).toHaveBeenCalledOnce(); }); + it.each(["openai-api", "fireworks-api"] as const)( + "accepts the registered %s provider as a memory curator", + async (subconsciousProvider) => { + const sender = new FakeWebContents(1); + const runtime = createRuntime(); + const { handlers, ipc } = createMainIPC(); + setupLocalAIIPC( + { + runtime, + getAllowedWebContents: () => sender as never, + }, + ipc as never, + ); + + await expect( + handlers.get(LOCAL_AI_CHANNELS.UPDATE_MEMORY_SETTINGS)?.( + createEvent(sender), + { subconsciousProvider }, + ), + ).resolves.toMatchObject({ success: true }); + expect(runtime.updateMemorySettings).toHaveBeenCalledWith({ + subconsciousProvider, + }); + }, + ); + it("validates read-only block changes before they reach memory storage", async () => { const sender = new FakeWebContents(1); const runtime = createRuntime(); diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.ts index 96cf893a..39ea430d 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.ts @@ -16,6 +16,7 @@ import type { LocalAITurnRuntimeStateRequest, } from "@/shared/types/local-ai"; import { isLocalAIMemoryProvider } from "@/shared/types/local-ai"; +import { isLocalAiProviderId } from "@/electron/ai/types"; import { createLocalAIAPI, LOCAL_AI_CHANNELS } from "./local-ai-api"; import { contextBridge, @@ -53,12 +54,6 @@ interface ActiveConversationLease { } const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; -const ALLOWED_PROVIDER_IDS = new Set([ - "claude-code", - "codex-cli", - "openai-api", - "fireworks-api", -]); const MAX_MESSAGE_CHARS = 200_000; const MAX_REQUEST_CHARS = 1_000_000; const MAX_INTERACTION_RESPONSE_CHARS = 20_000; @@ -214,7 +209,7 @@ function validateRequest(request: unknown): request is LocalAIChatRequest { !isValidIdentifier(request.conversationId) || !isValidIdentifier(request.turnId) || typeof request.providerId !== "string" || - !ALLOWED_PROVIDER_IDS.has(request.providerId) || + !isLocalAiProviderId(request.providerId) || !isRecord(request.operation) ) { return false; @@ -387,7 +382,7 @@ function validateResetRequest( isRecord(request) && isValidIdentifier(request.conversationId) && typeof request.providerId === "string" && - ALLOWED_PROVIDER_IDS.has(request.providerId) + isLocalAiProviderId(request.providerId) ); } @@ -411,9 +406,9 @@ function validateMemorySettingsUpdate( isLocalAIMemoryProvider(update.provider))) && (update.subconsciousProvider === undefined || update.subconsciousProvider === "off" || - update.subconsciousProvider === "codex-cli" || - update.subconsciousProvider === "claude-code" || - update.subconsciousProvider === "follow-active") && + update.subconsciousProvider === "follow-active" || + (typeof update.subconsciousProvider === "string" && + isLocalAiProviderId(update.subconsciousProvider))) && (update.schedule === undefined || update.schedule === "every-turn" || update.schedule === "batch" || @@ -622,10 +617,7 @@ export function setupLocalAIIPC( ); } if (!options.runtime) return failure(runtimeUnavailable()); - if ( - typeof providerId !== "string" || - !ALLOWED_PROVIDER_IDS.has(providerId) - ) { + if (typeof providerId !== "string" || !isLocalAiProviderId(providerId)) { return failure( createError("Invalid provider id", "LOCAL_AI_INVALID_REQUEST"), ); diff --git a/packages/app/src/electron/ai/__tests__/agent-host-tools.test.ts b/packages/app/src/electron/ai/__tests__/agent-host-tools.test.ts index 7c23f70f..ae4c6342 100644 --- a/packages/app/src/electron/ai/__tests__/agent-host-tools.test.ts +++ b/packages/app/src/electron/ai/__tests__/agent-host-tools.test.ts @@ -51,6 +51,32 @@ function input( } describe("Agent Host task tools", () => { + it("preserves lifecycle hooks while adding task tools", async () => { + const prepareDurableTurnHook = vi.fn(() => undefined); + const replayDurableTurnHook = vi.fn(() => undefined); + const onTurnCompleted = vi.fn(() => undefined); + const onTurnFailed = vi.fn(() => undefined); + const hooks = withAgentHostTools( + { + prepareDurableTurnHook, + replayDurableTurnHook, + onTurnCompleted, + onTurnFailed, + }, + () => undefined, + ); + + await hooks.prepareDurableTurnHook?.(undefined as never); + await hooks.replayDurableTurnHook?.(undefined as never); + await hooks.onTurnCompleted?.(undefined as never); + await hooks.onTurnFailed?.(undefined as never); + + expect(prepareDurableTurnHook).toHaveBeenCalledOnce(); + expect(replayDurableTurnHook).toHaveBeenCalledOnce(); + expect(onTurnCompleted).toHaveBeenCalledOnce(); + expect(onTurnFailed).toHaveBeenCalledOnce(); + }); + it("injects one task tool wherever the agent is standing", async () => { const host = { listTasks: vi.fn(async () => [task]), diff --git a/packages/app/src/electron/ai/__tests__/runtime.test.ts b/packages/app/src/electron/ai/__tests__/runtime.test.ts index a910d8b5..30bc3205 100644 --- a/packages/app/src/electron/ai/__tests__/runtime.test.ts +++ b/packages/app/src/electron/ai/__tests__/runtime.test.ts @@ -11,6 +11,7 @@ import { type LocalAiProviderAdapter, } from "../provider-adapter"; import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../provider-descriptors"; +import type { PiModel } from "../pi-agent-types"; import { describeSandboxMemory, fingerprintAgentContext, @@ -401,6 +402,129 @@ describe("LocalAiRuntime", () => { ).toBeUndefined(); }); + it("routes stateless Pi runs through Pi and replays bounded history", async () => { + const piModel: PiModel = { + id: "gpt-test", + name: "GPT Test", + api: "openai-responses", + provider: "openai", + baseUrl: "https://example.test/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 100_000, + maxTokens: 10_000, + }; + const adapter: LocalAiProviderAdapter = { + id: "openai-api", + enforcesSandbox: false, + resumesNativeSession: false, + getStatus: vi.fn(async () => ({ + ...LOCAL_AI_PROVIDER_DESCRIPTORS["openai-api"], + available: true, + authenticated: true, + checkedAt: new Date().toISOString(), + })), + prepareRun: vi.fn(async () => ({ + executionEngine: "pi-agent-core" as const, + model: piModel, + apiKey: "test-key", + getNativeSessionId: () => "pi-session", + })), + dispose: vi.fn(async () => undefined), + }; + const streamInvoker = vi.fn(() => { + throw new Error("AI SDK stream must not run for a Pi provider."); + }); + const piStreamFactory = vi.fn(() => ({ + toUIMessageStream: async function* () { + yield { type: "text-start" as const, id: "pi-text" }; + yield { + type: "text-delta" as const, + id: "pi-text", + delta: "Pi", + }; + yield { type: "text-end" as const, id: "pi-text" }; + yield { type: "finish" as const, finishReason: "stop" as const }; + }, + finishReason: Promise.resolve("stop" as const), + usage: Promise.resolve({ + inputTokens: 2, + outputTokens: 1, + totalTokens: 3, + }), + })); + const events: LocalAIStreamEvent[] = []; + const runtime = new LocalAiRuntime({ + adapters: [adapter], + streamInvoker, + piStreamFactory, + sessionRepository: new InMemorySessionStateRepository(), + }); + + await runtime.startChat( + request({ + providerId: "openai-api", + agent: { systemPrompt: "system" }, + }), + (event) => events.push(event), + ); + await runtime.startChat( + request({ + requestId: "request-2", + turnId: "turn-2", + providerId: "openai-api", + operation: { + kind: "append", + message: { role: "user", content: "follow up" }, + recoveryMessages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "Pi" }, + { role: "user", content: "follow up" }, + ], + }, + agent: { systemPrompt: "system" }, + }), + (event) => events.push(event), + ); + + expect(streamInvoker).not.toHaveBeenCalled(); + expect(piStreamFactory).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + requestId: "request-1", + run: expect.objectContaining({ + executionEngine: "pi-agent-core", + model: piModel, + }), + messages: [ + { role: "system", content: "system" }, + { role: "user", content: "hello" }, + ], + }), + ); + expect(piStreamFactory).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + requestId: "request-2", + messages: [ + { role: "system", content: "system" }, + { role: "user", content: "hello" }, + { role: "assistant", content: "Pi" }, + { role: "user", content: "follow up" }, + ], + }), + ); + expect( + vi.mocked(adapter.prepareRun).mock.calls[1]?.[2].session, + ).toMatchObject({ nativeSessionId: "pi-session" }); + expect(events.at(-1)).toMatchObject({ + type: "finish", + finishReason: "stop", + usage: { inputTokens: 2, outputTokens: 1, totalTokens: 3 }, + }); + }); + it("enforces text-only policy before provider tool preparation", async () => { const adapter = fakeAdapter("codex-cli"); const getToolGroups = vi.fn(async () => { diff --git a/packages/app/src/electron/ai/__tests__/workspace-tools.test.ts b/packages/app/src/electron/ai/__tests__/workspace-tools.test.ts index 22e3d479..d48aba77 100644 --- a/packages/app/src/electron/ai/__tests__/workspace-tools.test.ts +++ b/packages/app/src/electron/ai/__tests__/workspace-tools.test.ts @@ -279,6 +279,43 @@ function hookInput(memberId: string | undefined): LocalAiTurnHookInput { } describe("withWorkspacePerception", () => { + it("preserves bound lifecycle hooks from a class instance", async () => { + class HookOwner { + readonly calls: string[] = []; + + prepareDurableTurnHook() { + this.calls.push("prepare-durable"); + return undefined; + } + + replayDurableTurnHook() { + this.calls.push("replay-durable"); + } + + onTurnCompleted() { + this.calls.push("completed"); + } + + onTurnFailed() { + this.calls.push("failed"); + } + } + const owner = new HookOwner(); + const hooks = withWorkspacePerception(owner); + + await hooks.prepareDurableTurnHook?.(undefined as never); + await hooks.replayDurableTurnHook?.(undefined as never); + await hooks.onTurnCompleted?.(undefined as never); + await hooks.onTurnFailed?.(undefined as never); + + expect(owner.calls).toEqual([ + "prepare-durable", + "replay-durable", + "completed", + "failed", + ]); + }); + it("appends eyes to the tools an existing hook already injected", async () => { const existing: AgentTool = { name: "memory_status", diff --git a/packages/app/src/electron/ai/agent-host-tools.ts b/packages/app/src/electron/ai/agent-host-tools.ts index 51437ab3..a6c3f0ff 100644 --- a/packages/app/src/electron/ai/agent-host-tools.ts +++ b/packages/app/src/electron/ai/agent-host-tools.ts @@ -445,7 +445,18 @@ export function withAgentHostTools( getHost: () => AgentHost | undefined, ): LocalAiTurnHooks { return { - ...hooks, + prepareDurableTurnHook: hooks.prepareDurableTurnHook + ? (input) => hooks.prepareDurableTurnHook!(input) + : undefined, + replayDurableTurnHook: hooks.replayDurableTurnHook + ? (hook) => hooks.replayDurableTurnHook!(hook) + : undefined, + onTurnCompleted: hooks.onTurnCompleted + ? (input) => hooks.onTurnCompleted!(input) + : undefined, + onTurnFailed: hooks.onTurnFailed + ? (input) => hooks.onTurnFailed!(input) + : undefined, prepareTurnContext: async ( input, ): Promise => { diff --git a/packages/app/src/electron/ai/pi-agent-driver.test.ts b/packages/app/src/electron/ai/pi-agent-driver.test.ts new file mode 100644 index 00000000..25a9a5b4 --- /dev/null +++ b/packages/app/src/electron/ai/pi-agent-driver.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import type { AgentTool } from "./agent-tools"; +import { createPiAgentRuntimeStream } from "./pi-agent-driver"; +import type { + PiAgentContext, + PiAssistantMessage, + PiAssistantMessageEvent, + PiAssistantMessageEventStream, + PiModel, + PiStreamFn, + PiStreamOptions, +} from "./pi-agent-types"; +import type { LocalAiPiProviderRun } from "./provider-adapter"; + +const model: PiModel = { + id: "gpt-test", + name: "GPT Test", + api: "openai-responses", + provider: "openai", + baseUrl: "https://example.test/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 100_000, + maxTokens: 10_000, +}; + +function usage(input: number, output: number) { + return { + input, + output, + cacheRead: 0, + cacheWrite: 0, + totalTokens: input + output, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function assistant( + content: PiAssistantMessage["content"], + stopReason: PiAssistantMessage["stopReason"], + input = 1, + output = 1, +): PiAssistantMessage { + return { + role: "assistant", + content, + api: model.api, + provider: model.provider, + model: model.id, + usage: usage(input, output), + stopReason, + timestamp: Date.now(), + }; +} + +function eventStream( + events: PiAssistantMessageEvent[], + result: PiAssistantMessage, +): PiAssistantMessageEventStream { + return { + async *[Symbol.asyncIterator]() { + yield* events; + }, + async result() { + return result; + }, + }; +} + +function run(): LocalAiPiProviderRun { + return { + executionEngine: "pi-agent-core", + model, + apiKey: "test-key", + reasoning: "medium", + getNativeSessionId: () => "openai-api:request-1", + }; +} + +function tool(name: string, execute: AgentTool["execute"]): AgentTool { + return { + name, + qualifiedName: `workspace:${name}`, + description: `Run ${name}`, + inputSchema: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + additionalProperties: false, + }, + inputShape: { path: z.string() }, + inputValidator: z.object({ path: z.string() }), + execute, + }; +} + +async function collect(stream: ReturnType) { + const chunks = []; + for await (const chunk of stream.toUIMessageStream()) chunks.push(chunk); + return chunks; +} + +describe("Pi agent driver", () => { + it("runs the Pi tool loop and translates lifecycle events", async () => { + const execute = vi.fn(async () => ({ contents: "hello" })); + const contexts: PiAgentContext[] = []; + const streamOptions: Array = []; + let call = 0; + const streamFn: PiStreamFn = (_model, context, options) => { + contexts.push({ + systemPrompt: context.systemPrompt, + messages: structuredClone(context.messages), + tools: context.tools, + }); + streamOptions.push(options); + call += 1; + if (call === 1) { + const pending = assistant([], "pending", 0, 0); + const final = assistant( + [ + { + type: "toolCall", + id: "tool-1", + name: "read_file", + arguments: { path: "README.md" }, + }, + ], + "toolUse", + 3, + 1, + ); + return eventStream( + [ + { type: "start", partial: pending }, + { type: "toolcall_start", contentIndex: 0, partial: pending }, + { type: "toolcall_end", contentIndex: 0, partial: final }, + { type: "done", message: final }, + ], + final, + ); + } + const pending = assistant([], "pending", 0, 0); + const partial = assistant([{ type: "text", text: "done" }], "pending"); + const final = assistant([{ type: "text", text: "done" }], "stop", 5, 2); + return eventStream( + [ + { type: "start", partial: pending }, + { type: "text_start", contentIndex: 0, partial: pending }, + { type: "text_delta", contentIndex: 0, delta: "done", partial }, + { type: "text_end", contentIndex: 0, content: "done", partial }, + { type: "done", message: final }, + ], + final, + ); + }; + const stream = createPiAgentRuntimeStream({ + requestId: "request-1", + run: run(), + messages: [ + { role: "system", content: "Be concise." }, + { role: "user", content: "Read the file." }, + ], + tools: [tool("read_file", execute)], + abortSignal: new AbortController().signal, + maxOutputTokens: 2048, + temperature: 0.2, + streamFn, + }); + + const chunks = await collect(stream); + + expect(call).toBe(2); + expect(execute).toHaveBeenCalledWith({ path: "README.md" }); + expect(contexts[0]?.systemPrompt).toBe("Be concise."); + expect(contexts[1]?.messages.at(-1)).toMatchObject({ + role: "toolResult", + toolName: "read_file", + details: { contents: "hello" }, + }); + expect(streamOptions[0]).toMatchObject({ + apiKey: "test-key", + reasoning: "medium", + maxTokens: 2048, + temperature: 0.2, + }); + expect(chunks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "tool-input-available", + toolCallId: "tool-1", + toolName: "read_file", + }), + expect.objectContaining({ + type: "tool-output-available", + toolCallId: "tool-1", + output: { contents: "hello" }, + }), + expect.objectContaining({ type: "text-delta", delta: "done" }), + { type: "finish", finishReason: "stop" }, + ]), + ); + await expect(stream.finishReason).resolves.toBe("stop"); + await expect(stream.usage).resolves.toEqual({ + inputTokens: 8, + outputTokens: 3, + totalTokens: 11, + }); + }); + + it("stops after the workspace speech tool", async () => { + const execute = vi.fn(async () => ({ delivered: true })); + let calls = 0; + const streamFn: PiStreamFn = () => { + calls += 1; + const pending = assistant([], "pending", 0, 0); + const final = assistant( + [ + { + type: "toolCall", + id: "tool-speak", + name: "send_message", + arguments: { path: "room" }, + }, + ], + "toolUse", + ); + return eventStream( + [ + { type: "start", partial: pending }, + { type: "toolcall_start", contentIndex: 0, partial: pending }, + { type: "toolcall_end", contentIndex: 0, partial: final }, + { type: "done", message: final }, + ], + final, + ); + }; + const stream = createPiAgentRuntimeStream({ + requestId: "request-2", + run: run(), + messages: [{ role: "user", content: "Answer in the room." }], + tools: [tool("send_message", execute)], + abortSignal: new AbortController().signal, + streamFn, + }); + + const chunks = await collect(stream); + + expect(calls).toBe(1); + expect(execute).toHaveBeenCalledOnce(); + expect(chunks.at(-1)).toEqual({ + type: "finish", + finishReason: "tool-calls", + }); + await expect(stream.finishReason).resolves.toBe("tool-calls"); + }); +}); diff --git a/packages/app/src/electron/ai/pi-agent-driver.ts b/packages/app/src/electron/ai/pi-agent-driver.ts new file mode 100644 index 00000000..ffd8aa17 --- /dev/null +++ b/packages/app/src/electron/ai/pi-agent-driver.ts @@ -0,0 +1,412 @@ +import type { + LocalAIFinishReason, + LocalAIUsage, +} from "@/shared/types/local-ai"; +import { WORKSPACE_SEND_MESSAGE_TOOL } from "@/shared/types/workspace-perception"; +import type { + PiAgentCoreModule, + PiAgentEvent, + PiAgentTool, + PiAiCompatModule, + PiAssistantMessage, + PiImageContent, + PiMessage, + PiModel, + PiStreamFn, + PiTextContent, +} from "./pi-agent-types"; +import type { FinishReason, ModelMessage, UIMessageChunk } from "ai"; +import type { AgentTool } from "./agent-tools"; +import type { LocalAiPiProviderRun } from "./provider-adapter"; +import { toMcpToolResult } from "./tool-result"; + +const PI_AGENT_CORE_MODULE = "@earendil-works/pi-agent-core"; +const PI_AI_COMPAT_MODULE = "@earendil-works/pi-ai/compat"; +const piAgentCore = import(PI_AGENT_CORE_MODULE) as Promise; +const piAi = import(PI_AI_COMPAT_MODULE) as Promise; + +const MAX_AGENT_TURNS = 50; + +class AsyncQueue implements AsyncIterable { + private readonly values: T[] = []; + private readonly waiters: Array<(result: IteratorResult) => void> = []; + private closed = false; + + push(value: T): void { + if (this.closed) return; + const waiter = this.waiters.shift(); + if (waiter) { + waiter({ done: false, value }); + return; + } + this.values.push(value); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const waiter of this.waiters.splice(0)) { + waiter({ done: true, value: undefined }); + } + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + const value = this.values.shift(); + if (value !== undefined) { + return Promise.resolve({ done: false, value }); + } + if (this.closed) { + return Promise.resolve({ done: true, value: undefined }); + } + return new Promise((resolve) => this.waiters.push(resolve)); + }, + }; + } +} + +function deferred(): { + promise: Promise; + resolve(value: T): void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +function textOfContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) + return JSON.stringify(content) ?? String(content); + return content + .flatMap((part) => { + if (!part || typeof part !== "object") return []; + const record = part as Record; + if (record.type === "text" && typeof record.text === "string") { + return [record.text]; + } + return []; + }) + .join("\n"); +} + +function emptyUsage(): PiAssistantMessage["usage"] { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function toPiPrompt( + messages: ModelMessage[], + model: PiModel, +): { systemPrompt: string; messages: PiMessage[] } { + const system: string[] = []; + const prompt: PiMessage[] = []; + let timestamp = Date.now(); + + for (const message of messages) { + const content = textOfContent(message.content); + if (message.role === "system") { + if (content) system.push(content); + continue; + } + if (message.role === "user") { + prompt.push({ role: "user", content, timestamp: timestamp++ }); + continue; + } + if (message.role === "assistant") { + prompt.push({ + role: "assistant", + content: [{ type: "text", text: content }], + api: model.api, + provider: model.provider, + model: model.id, + usage: emptyUsage(), + stopReason: "stop", + timestamp: timestamp++, + }); + } + } + + return { systemPrompt: system.join("\n\n"), messages: prompt }; +} + +function toPiToolContent( + output: unknown, +): Array { + const result = toMcpToolResult(output); + const content = result.content.flatMap( + (part) => { + if (part.type === "text") { + return [{ type: "text", text: part.text }]; + } + if (part.type === "image") { + return [{ type: "image", data: part.data, mimeType: part.mimeType }]; + } + return [{ type: "text", text: JSON.stringify(part) }]; + }, + ); + if (result.isError) { + throw new Error( + content + .filter((part): part is PiTextContent => part.type === "text") + .map((part) => part.text) + .join("\n") || "Tool execution failed", + ); + } + return content.length > 0 ? content : [{ type: "text", text: "" }]; +} + +function isSpeechTool(name: string): boolean { + return ( + name === WORKSPACE_SEND_MESSAGE_TOOL || + name.endsWith(`__${WORKSPACE_SEND_MESSAGE_TOOL}`) || + name.endsWith(`.${WORKSPACE_SEND_MESSAGE_TOOL}`) + ); +} + +function toPiTools(tools: AgentTool[]): PiAgentTool[] { + return tools.map((tool) => ({ + name: tool.name, + label: tool.qualifiedName, + description: tool.description, + parameters: tool.inputSchema as PiAgentTool["parameters"], + execute: async (_toolCallId, params, signal) => { + signal?.throwIfAborted(); + const output = await tool.execute(params as Record); + return { + content: toPiToolContent(output), + details: output, + terminate: isSpeechTool(tool.name), + }; + }, + })); +} + +function finishReason( + reason: PiAssistantMessage["stopReason"], +): LocalAIFinishReason { + switch (reason) { + case "stop": + return "stop"; + case "length": + return "length"; + case "toolUse": + return "tool-calls"; + case "error": + case "aborted": + return "error"; + default: + return "unknown"; + } +} + +function uiFinishReason(reason: LocalAIFinishReason): FinishReason { + if (reason === "aborted") return "error"; + if (reason === "unknown") return "other"; + return reason; +} + +function usageOf(messages: PiMessage[]): LocalAIUsage { + const assistants = messages.filter( + (message): message is PiAssistantMessage => message.role === "assistant", + ); + return assistants.reduce( + (usage, message) => ({ + inputTokens: (usage.inputTokens ?? 0) + message.usage.input, + outputTokens: (usage.outputTokens ?? 0) + message.usage.output, + totalTokens: (usage.totalTokens ?? 0) + message.usage.totalTokens, + }), + { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + ); +} + +function resultOutput(result: unknown): unknown { + if (!result || typeof result !== "object") return result; + const record = result as Record; + if (record.details !== undefined) return record.details; + return record.content ?? result; +} + +function resultError(result: unknown): string { + const output = resultOutput(result); + if (typeof output === "string") return output; + return JSON.stringify(output) ?? "Tool execution failed"; +} + +export interface PiAgentRuntimeStreamOptions { + requestId: string; + run: LocalAiPiProviderRun; + messages: ModelMessage[]; + tools: AgentTool[]; + abortSignal: AbortSignal; + maxOutputTokens?: number; + temperature?: number; + streamFn?: PiStreamFn; +} + +export type PiAgentRuntimeStreamFactory = typeof createPiAgentRuntimeStream; + +export function createPiAgentRuntimeStream( + options: PiAgentRuntimeStreamOptions, +): { + toUIMessageStream(): AsyncIterable; + finishReason: Promise; + usage: Promise; +} { + const queue = new AsyncQueue(); + const completedReason = deferred(); + const completedUsage = deferred(); + const prompt = toPiPrompt(options.messages, options.run.model); + let turn = 0; + + queue.push({ type: "start", messageId: `pi-${options.requestId}` }); + + void (async () => { + try { + const [{ runAgentLoop }, pi] = await Promise.all([piAgentCore, piAi]); + const streamFn = options.streamFn ?? pi.streamSimple; + const messages = await runAgentLoop( + prompt.messages, + { + systemPrompt: prompt.systemPrompt, + messages: [], + tools: toPiTools(options.tools), + }, + { + model: options.run.model, + convertToLlm: (items) => items, + apiKey: options.run.apiKey, + fetch: options.run.fetch, + reasoning: options.run.reasoning, + maxTokens: options.maxOutputTokens, + temperature: options.temperature, + sessionId: options.run.getNativeSessionId(undefined), + onPayload: options.run.onPayload, + toolExecution: "parallel", + shouldStopAfterTurn: ({ toolResults }) => { + turn += 1; + return ( + turn >= MAX_AGENT_TURNS || + toolResults.some((result) => isSpeechTool(result.toolName)) + ); + }, + }, + async (event) => { + emitPiEvent(queue, options.requestId, event, turn); + }, + options.abortSignal, + streamFn, + ); + const lastAssistant = messages.findLast( + (message): message is PiAssistantMessage => + message.role === "assistant", + ); + const reason = lastAssistant + ? finishReason(lastAssistant.stopReason) + : "unknown"; + if (lastAssistant?.errorMessage) { + queue.push({ type: "error", errorText: lastAssistant.errorMessage }); + } + queue.push({ type: "finish", finishReason: uiFinishReason(reason) }); + completedReason.resolve(reason); + completedUsage.resolve(usageOf(messages)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + queue.push({ type: "error", errorText: message }); + queue.push({ type: "finish", finishReason: "error" }); + completedReason.resolve("error"); + completedUsage.resolve({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + }); + } finally { + queue.close(); + } + })(); + + return { + toUIMessageStream: () => queue, + finishReason: completedReason.promise, + usage: completedUsage.promise, + }; +} + +function emitPiEvent( + queue: AsyncQueue, + requestId: string, + event: PiAgentEvent, + turn: number, +): void { + switch (event.type) { + case "turn_start": + queue.push({ type: "start-step" }); + return; + case "turn_end": + queue.push({ type: "finish-step" }); + return; + case "message_update": { + const update = event.assistantMessageEvent; + const id = `pi-${requestId}-${turn}-${"contentIndex" in update ? update.contentIndex : 0}`; + switch (update.type) { + case "text_start": + queue.push({ type: "text-start", id }); + return; + case "text_delta": + queue.push({ type: "text-delta", id, delta: update.delta }); + return; + case "text_end": + queue.push({ type: "text-end", id }); + return; + case "thinking_start": + queue.push({ type: "reasoning-start", id }); + return; + case "thinking_delta": + queue.push({ type: "reasoning-delta", id, delta: update.delta }); + return; + case "thinking_end": + queue.push({ type: "reasoning-end", id }); + return; + default: + return; + } + } + case "tool_execution_start": + queue.push({ + type: "tool-input-available", + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.args, + dynamic: true, + }); + return; + case "tool_execution_end": + if (event.isError) { + queue.push({ + type: "tool-output-error", + toolCallId: event.toolCallId, + errorText: resultError(event.result), + dynamic: true, + }); + } else { + queue.push({ + type: "tool-output-available", + toolCallId: event.toolCallId, + output: resultOutput(event.result), + dynamic: true, + }); + } + return; + default: + return; + } +} diff --git a/packages/app/src/electron/ai/pi-agent-types.ts b/packages/app/src/electron/ai/pi-agent-types.ts new file mode 100644 index 00000000..a504a0a7 --- /dev/null +++ b/packages/app/src/electron/ai/pi-agent-types.ts @@ -0,0 +1,262 @@ +export type PiThinkingLevel = + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; + +export interface PiModel { + id: string; + name: string; + api: string; + provider: string; + baseUrl: string; + reasoning: boolean; + input: Array<"text" | "image">; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; + contextWindow: number; + maxTokens: number; + headers?: Record; + compat?: unknown; +} + +export interface PiUsage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; +} + +export interface PiTextContent { + type: "text"; + text: string; +} + +export interface PiImageContent { + type: "image"; + data: string; + mimeType: string; +} + +export interface PiThinkingContent { + type: "thinking"; + thinking: string; + thinkingSignature?: string; +} + +export interface PiToolCall { + type: "toolCall"; + id: string; + name: string; + arguments: Record; +} + +export interface PiUserMessage { + role: "user"; + content: string | Array; + timestamp: number; +} + +export interface PiAssistantMessage { + role: "assistant"; + content: Array; + api: string; + provider: string; + model: string; + usage: PiUsage; + stopReason: "pending" | "stop" | "length" | "toolUse" | "error" | "aborted"; + errorMessage?: string; + timestamp: number; +} + +export interface PiToolResultMessage { + role: "toolResult"; + toolCallId: string; + toolName: string; + content: Array; + details?: unknown; + isError: boolean; + timestamp: number; +} + +export type PiMessage = + | PiUserMessage + | PiAssistantMessage + | PiToolResultMessage; + +export interface PiToolResult { + content: Array; + details: unknown; + terminate?: boolean; +} + +export interface PiAgentTool { + name: string; + label: string; + description: string; + parameters: Record; + execute( + toolCallId: string, + params: Record, + signal?: AbortSignal, + ): Promise; +} + +export interface PiAgentContext { + systemPrompt?: string; + messages: PiMessage[]; + tools?: PiAgentTool[]; +} + +export interface PiStreamOptions { + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; + apiKey?: string; + fetch?: typeof globalThis.fetch; + reasoning?: PiThinkingLevel; + sessionId?: string; + onPayload?: ( + payload: unknown, + model: PiModel, + ) => unknown | undefined | Promise; +} + +export type PiStreamFn = ( + model: PiModel, + context: PiAgentContext, + options?: PiStreamOptions, +) => PiAssistantMessageEventStream | Promise; + +export type PiAssistantMessageEvent = + | { type: "start"; partial: PiAssistantMessage } + | { type: "text_start"; contentIndex: number; partial: PiAssistantMessage } + | { + type: "text_delta"; + contentIndex: number; + delta: string; + partial: PiAssistantMessage; + } + | { + type: "text_end"; + contentIndex: number; + content: string; + partial: PiAssistantMessage; + } + | { + type: "thinking_start"; + contentIndex: number; + partial: PiAssistantMessage; + } + | { + type: "thinking_delta"; + contentIndex: number; + delta: string; + partial: PiAssistantMessage; + } + | { + type: "thinking_end"; + contentIndex: number; + content: string; + partial: PiAssistantMessage; + } + | { + type: "toolcall_start"; + contentIndex: number; + partial: PiAssistantMessage; + } + | { + type: "toolcall_delta"; + contentIndex: number; + delta: string; + partial: PiAssistantMessage; + } + | { type: "toolcall_end"; contentIndex: number; partial: PiAssistantMessage } + | { type: "done"; message: PiAssistantMessage } + | { type: "error"; error: PiAssistantMessage }; + +export interface PiAssistantMessageEventStream + extends AsyncIterable { + result(): Promise; +} + +export type PiAgentEvent = + | { type: "agent_start" } + | { type: "agent_end"; messages: PiMessage[] } + | { type: "turn_start" } + | { + type: "turn_end"; + message: PiMessage; + toolResults: PiToolResultMessage[]; + } + | { type: "message_start"; message: PiMessage } + | { + type: "message_update"; + message: PiMessage; + assistantMessageEvent: PiAssistantMessageEvent; + } + | { type: "message_end"; message: PiMessage } + | { + type: "tool_execution_start"; + toolCallId: string; + toolName: string; + args: unknown; + } + | { + type: "tool_execution_update"; + toolCallId: string; + toolName: string; + args: unknown; + partialResult: unknown; + } + | { + type: "tool_execution_end"; + toolCallId: string; + toolName: string; + result: unknown; + isError: boolean; + }; + +export interface PiAgentLoopConfig extends PiStreamOptions { + model: PiModel; + convertToLlm(messages: PiMessage[]): PiMessage[] | Promise; + toolExecution?: "sequential" | "parallel"; + shouldStopAfterTurn?(context: { + message: PiAssistantMessage; + toolResults: PiToolResultMessage[]; + context: PiAgentContext; + newMessages: PiMessage[]; + }): boolean | Promise; +} + +export type PiRunAgentLoop = ( + prompts: PiMessage[], + context: PiAgentContext, + config: PiAgentLoopConfig, + emit: (event: PiAgentEvent) => Promise | void, + signal: AbortSignal | undefined, + streamFn: PiStreamFn, +) => Promise; + +export interface PiAgentCoreModule { + runAgentLoop: PiRunAgentLoop; +} + +export interface PiAiCompatModule { + getModels(provider: string): PiModel[]; + streamSimple: PiStreamFn; +} diff --git a/packages/app/src/electron/ai/provider-adapter.ts b/packages/app/src/electron/ai/provider-adapter.ts index a5ae9d78..feb85ff8 100644 --- a/packages/app/src/electron/ai/provider-adapter.ts +++ b/packages/app/src/electron/ai/provider-adapter.ts @@ -1,5 +1,10 @@ import type { AgentSandbox } from "@/shared/types/workspace"; import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import type { + PiModel, + PiStreamOptions, + PiThinkingLevel, +} from "./pi-agent-types"; import type { LanguageModel, ProviderMetadata, ToolSet } from "ai"; import type { AgentTool, @@ -17,7 +22,8 @@ export function resolveLocalModelId( return requested && requested !== "default" ? requested : defaultModelId; } -export interface LocalAiProviderRun { +export interface LocalAiAiSdkProviderRun { + executionEngine?: "ai-sdk"; model: LanguageModel; providerOptions?: Record>; /** @@ -29,6 +35,26 @@ export interface LocalAiProviderRun { getNativeSessionId(metadata: ProviderMetadata | undefined): string; } +export interface LocalAiPiProviderRun { + executionEngine: "pi-agent-core"; + model: PiModel; + apiKey: string; + fetch?: PiStreamOptions["fetch"]; + reasoning?: PiThinkingLevel; + onPayload?: PiStreamOptions["onPayload"]; + providerOptions?: undefined; + tools?: undefined; + getNativeSessionId(metadata: ProviderMetadata | undefined): string; +} + +export type LocalAiProviderRun = LocalAiAiSdkProviderRun | LocalAiPiProviderRun; + +export function isPiProviderRun( + run: LocalAiProviderRun, +): run is LocalAiPiProviderRun { + return run.executionEngine === "pi-agent-core"; +} + /** * Host-owned capability boundary for a provider turn. Unlike prompt * instructions, this policy is applied by the provider adapter before the @@ -58,6 +84,15 @@ export interface LocalAiProviderAdapter { * thing standing between the model and the rest of the disk. */ readonly enforcesSandbox: boolean; + /** + * Whether a persisted provider binding owns enough conversation history for + * the next append to send only its newest message. Stateless HTTP providers + * set this to false so the runtime replays the bounded recovery transcript. + * + * Optional so native-session adapters keep their existing behaviour; absent + * reads as true. + */ + readonly resumesNativeSession?: boolean; /** * True when the provider already gives the model file and shell access of its * own (both CLI adapters do). False means the runtime must supply the basic diff --git a/packages/app/src/electron/ai/providers/fireworks-api.test.ts b/packages/app/src/electron/ai/providers/fireworks-api.test.ts new file mode 100644 index 00000000..f208f647 --- /dev/null +++ b/packages/app/src/electron/ai/providers/fireworks-api.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { FireworksApiAdapter } from "./fireworks-api"; + +const previousKey = process.env.FIREWORKS_API_KEY; + +describe("FireworksApiAdapter", () => { + beforeEach(() => { + process.env.FIREWORKS_API_KEY = "test-fireworks-key"; + }); + + afterEach(() => { + if (previousKey === undefined) delete process.env.FIREWORKS_API_KEY; + else process.env.FIREWORKS_API_KEY = previousKey; + }); + + it("declares stateless history while preserving conversation affinity", async () => { + const adapter = new FireworksApiAdapter(); + const status = await adapter.getStatus(); + const run = await adapter.prepareRun( + { + requestId: "request-1", + conversationId: "conversation-1", + turnId: "turn-1", + providerId: "fireworks-api", + operation: { + kind: "append", + message: { role: "user", content: "hello" }, + }, + }, + status, + { + tools: [], + requestInteraction: async () => ({}), + }, + ); + + expect(adapter.resumesNativeSession).toBe(false); + expect(run.getNativeSessionId(undefined)).toBe( + "fireworks-api:conversation-1", + ); + }); +}); diff --git a/packages/app/src/electron/ai/providers/fireworks-api.ts b/packages/app/src/electron/ai/providers/fireworks-api.ts index 0ae9cd8d..fa807d89 100644 --- a/packages/app/src/electron/ai/providers/fireworks-api.ts +++ b/packages/app/src/electron/ai/providers/fireworks-api.ts @@ -19,6 +19,7 @@ export class FireworksApiAdapter implements LocalAiProviderAdapter { // No process boundary: resolveInSandbox inside the basic tools is the only // thing between the model and the rest of the disk. readonly enforcesSandbox = false; + readonly resumesNativeSession = false; readonly providesOwnTools = false; private provider?: ReturnType; @@ -80,7 +81,7 @@ export class FireworksApiAdapter implements LocalAiProviderAdapter { ); // Stateless as far as we are concerned: this runtime owns the transcript // and rebases it per turn, so there is no provider-native session to - // resume and the request id doubles as the session marker. + // resume. Keep a stable conversation marker for request/cache affinity. // // `store: false` — the Responses API keeps conversations server-side by // default under a different retention policy than chat completions, and a @@ -96,7 +97,7 @@ export class FireworksApiAdapter implements LocalAiProviderAdapter { model, tools: Object.keys(tools).length > 0 ? tools : undefined, providerOptions: { openai: { store: false, reasoningEffort: "max" } }, - getNativeSessionId: () => `fireworks-api:${request.requestId}`, + getNativeSessionId: () => `fireworks-api:${request.conversationId}`, }; } diff --git a/packages/app/src/electron/ai/providers/openai-api.test.ts b/packages/app/src/electron/ai/providers/openai-api.test.ts new file mode 100644 index 00000000..03e2c8f0 --- /dev/null +++ b/packages/app/src/electron/ai/providers/openai-api.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { OpenAIApiAdapter } from "./openai-api"; + +const previousKey = process.env.OPENAI_API_KEY; + +describe("OpenAIApiAdapter", () => { + beforeEach(() => { + process.env.OPENAI_API_KEY = "test-openai-key"; + }); + + afterEach(() => { + if (previousKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = previousKey; + }); + + it("prepares the locked OpenAI model for Pi agent-core", async () => { + const adapter = new OpenAIApiAdapter(); + const status = await adapter.getStatus(); + const run = await adapter.prepareRun( + { + requestId: "request-1", + conversationId: "conversation-1", + turnId: "turn-1", + providerId: "openai-api", + operation: { + kind: "append", + message: { role: "user", content: "hello" }, + }, + }, + status, + ); + + expect(run).toMatchObject({ + executionEngine: "pi-agent-core", + apiKey: "test-openai-key", + reasoning: "medium", + model: { + id: "gpt-5.6-luna", + provider: "openai", + api: "openai-responses", + }, + }); + if (run.executionEngine !== "pi-agent-core") { + throw new Error("Expected Pi agent-core execution"); + } + expect(adapter.resumesNativeSession).toBe(false); + expect(run.getNativeSessionId(undefined)).toBe("openai-api:conversation-1"); + expect( + await run.onPayload?.({ text: { format: "plain" } }, run.model), + ).toEqual({ + text: { format: "plain", verbosity: "low" }, + }); + }); +}); diff --git a/packages/app/src/electron/ai/providers/openai-api.ts b/packages/app/src/electron/ai/providers/openai-api.ts index d56ab6b2..2290dae1 100644 --- a/packages/app/src/electron/ai/providers/openai-api.ts +++ b/packages/app/src/electron/ai/providers/openai-api.ts @@ -1,13 +1,30 @@ import type { LocalAIChatRequest } from "@/shared/types/local-ai"; -import { createOpenAI } from "@ai-sdk/openai"; -import { tool as createTool, type ToolSet } from "ai"; import { resolveLocalModelId, type LocalAiProviderAdapter, type LocalAiProviderRun, } from "../provider-adapter"; import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../provider-descriptors"; +import type { PiAiCompatModule } from "../pi-agent-types"; import type { LocalAiProviderStatus } from "../types"; +import { ProxyAgent, fetch as undiciFetch } from "undici"; + +const PI_AI_COMPAT_MODULE = "@earendil-works/pi-ai/compat"; +const piAi = import(PI_AI_COMPAT_MODULE) as Promise; + +function withLowTextVerbosity(payload: unknown): unknown { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return payload; + } + const record = payload as Record; + const text = + record.text && + typeof record.text === "object" && + !Array.isArray(record.text) + ? (record.text as Record) + : {}; + return { ...record, text: { ...text, verbosity: "low" } }; +} /** * Direct OpenAI API access, keyed by OPENAI_API_KEY from the environment @@ -15,22 +32,47 @@ import type { LocalAiProviderStatus } from "../types"; * day-to-day use can run on the nano tier instead of subscription quota. * * Unlike the CLI adapters this is a bare HTTP endpoint: it ships no tools of - * its own, so the runtime hands it the basic read/write/list set and they are - * passed straight to `streamText`. + * its own, so the runtime hands Convera's basic read/write/list set to Pi's + * agent loop. The original Convera tool executors remain the security boundary. */ export class OpenAIApiAdapter implements LocalAiProviderAdapter { readonly id = "openai-api" as const; // No process boundary at all — resolveInSandbox inside the basic tools is the // only thing standing between the model and the rest of the disk. readonly enforcesSandbox = false; + readonly resumesNativeSession = false; readonly providesOwnTools = false; - - private provider?: ReturnType; + private proxyAgent?: ProxyAgent; + private proxyFetch?: typeof globalThis.fetch; private apiKey(): string | undefined { return process.env.OPENAI_API_KEY?.trim() || undefined; } + private fetch(): typeof globalThis.fetch | undefined { + const proxyUrl = ( + process.env.HTTPS_PROXY ?? + process.env.https_proxy ?? + process.env.ALL_PROXY ?? + process.env.all_proxy ?? + process.env.HTTP_PROXY ?? + process.env.http_proxy + )?.trim(); + if (!proxyUrl) return undefined; + if (!this.proxyFetch) { + this.proxyAgent = new ProxyAgent(proxyUrl); + this.proxyFetch = async (input, init) => + (await undiciFetch( + input as Parameters[0], + { + ...init, + dispatcher: this.proxyAgent, + } as Parameters[1], + )) as unknown as Response; + } + return this.proxyFetch; + } + async getStatus(): Promise { const descriptor = LOCAL_AI_PROVIDER_DESCRIPTORS[this.id]; const key = this.apiKey(); @@ -46,13 +88,11 @@ export class OpenAIApiAdapter implements LocalAiProviderAdapter { async prepareRun( request: LocalAIChatRequest, status: LocalAiProviderStatus, - context: Parameters[2], ): Promise { const key = this.apiKey(); if (!key) { throw new Error("OPENAI_API_KEY is not configured"); } - this.provider ??= createOpenAI({ apiKey: key }); // Conversations persist their model choice, so one started before the // catalog changed would keep requesting a model this provider no longer // offers. Fall back rather than sending a dead id to the API. @@ -60,38 +100,29 @@ export class OpenAIApiAdapter implements LocalAiProviderAdapter { const modelId = status.models.includes(requested) ? requested : status.defaultModel; - const model = this.provider(modelId); - const exposed = - context.executionPolicy === "text-only" ? [] : context.tools; - const tools: ToolSet = Object.fromEntries( - exposed.map((definition) => [ - definition.name, - createTool({ - description: definition.description, - inputSchema: definition.inputValidator, - execute: async (input) => - definition.execute(input as Record), - }), - ]), - ); - // Stateless HTTP API: no provider-native session to resume, so the - // request id doubles as the session marker. + const model = (await piAi) + .getModels("openai") + .find((candidate) => candidate.id === modelId); + if (!model) { + throw new Error(`OpenAI model is unavailable in Pi: ${modelId}`); + } + // Stateless HTTP API: the runtime replays the bounded transcript. Pi still + // receives a stable conversation marker for request/cache affinity, but it + // does not treat that marker as provider-owned conversation history. return { + executionEngine: "pi-agent-core", model, - tools: Object.keys(tools).length > 0 ? tools : undefined, - // Deciding whether to speak in a room, and then reaching for the right - // tool to do it, is a judgement: with reasoning switched off the cheapest - // path is to answer into the void and never call the tool at all. - // (There is no "auto" — the ladder is none/minimal/low/medium/high/xhigh/max, - // and "minimal" is a hard 400 on the 5.6 family.) - providerOptions: { - openai: { reasoningEffort: "medium", textVerbosity: "low" }, - }, - getNativeSessionId: () => `openai-api:${request.requestId}`, + apiKey: key, + fetch: this.fetch(), + reasoning: "medium", + onPayload: withLowTextVerbosity, + getNativeSessionId: () => `openai-api:${request.conversationId}`, }; } async dispose(): Promise { - this.provider = undefined; + await this.proxyAgent?.close(); + this.proxyAgent = undefined; + this.proxyFetch = undefined; } } diff --git a/packages/app/src/electron/ai/runtime.ts b/packages/app/src/electron/ai/runtime.ts index 0b691733..3ee92a10 100644 --- a/packages/app/src/electron/ai/runtime.ts +++ b/packages/app/src/electron/ai/runtime.ts @@ -43,10 +43,15 @@ import { } from "./agent-tools"; import { createBasicAgentTools } from "./basic-tools"; import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "./provider-descriptors"; -import type { - LocalAiProviderAdapter, - LocalAiProviderExecutionPolicy, +import { + isPiProviderRun, + type LocalAiProviderAdapter, + type LocalAiProviderExecutionPolicy, } from "./provider-adapter"; +import { + createPiAgentRuntimeStream, + type PiAgentRuntimeStreamFactory, +} from "./pi-agent-driver"; import { ClaudeCodeAdapter } from "./providers/claude-code"; import { CodexCliAdapter } from "./providers/codex-cli"; import { FireworksApiAdapter } from "./providers/fireworks-api"; @@ -67,6 +72,7 @@ import type { } from "./session/types"; import { LOCAL_AI_PROVIDER_IDS, + isLocalAiProviderId, type LocalAiProviderId, type LocalAiProviderStatus as ProbeStatus, } from "./types"; @@ -115,10 +121,6 @@ const defaultStreamInvoker: RuntimeStreamInvoker = (options) => options as Parameters[0], ) as unknown as RuntimeStreamResult; -function isProviderId(providerId: string): providerId is LocalAiProviderId { - return LOCAL_AI_PROVIDER_IDS.includes(providerId as LocalAiProviderId); -} - function availabilityFor(status: ProbeStatus): LocalAIProviderAvailability { if (!status.available) { return "missing"; @@ -457,6 +459,7 @@ export class LocalAiRuntime implements LocalAIRuntimeService { private readonly activeRequests = new Map(); private readonly inFlightChats = new Set>(); private readonly streamInvoker: RuntimeStreamInvoker; + private readonly piStreamFactory: PiAgentRuntimeStreamFactory; private readonly workingDirectory: string; private readonly sandbox: AgentSandbox; private readonly resolveSandbox: AgentSandboxResolver; @@ -481,6 +484,7 @@ export class LocalAiRuntime implements LocalAIRuntimeService { options: { adapters?: LocalAiProviderAdapter[]; streamInvoker?: RuntimeStreamInvoker; + piStreamFactory?: PiAgentRuntimeStreamFactory; workingDirectory?: string; sandbox?: AgentSandbox; resolveSandbox?: AgentSandboxResolver; @@ -500,6 +504,8 @@ export class LocalAiRuntime implements LocalAIRuntimeService { new FireworksApiAdapter(), ]; this.streamInvoker = options.streamInvoker ?? defaultStreamInvoker; + this.piStreamFactory = + options.piStreamFactory ?? createPiAgentRuntimeStream; this.workingDirectory = options.workingDirectory ?? process.cwd(); this.sandbox = options.sandbox ?? { root: this.workingDirectory, @@ -549,7 +555,7 @@ export class LocalAiRuntime implements LocalAIRuntimeService { } async getProviderStatus(providerId: string): Promise { - if (!isProviderId(providerId)) { + if (!isLocalAiProviderId(providerId)) { return missingProviderStatus(providerId); } @@ -625,7 +631,7 @@ export class LocalAiRuntime implements LocalAIRuntimeService { return; } - if (!isProviderId(request.providerId)) { + if (!isLocalAiProviderId(request.providerId)) { this.emitFailure( request.requestId, emit, @@ -850,40 +856,45 @@ export class LocalAiRuntime implements LocalAIRuntimeService { // after a process crash. await repository.markProviderStarted(request.turnId); providerMayHaveAdvanced = true; - const result = this.streamInvoker({ - model: run.model, - messages: toMessages( - request, - resumableBinding !== undefined, - turnContext?.systemContext, - // Only providers that receive the basic file tools can act on the - // notice; a CLI provider's tool surface has no write_file, and - // promising a notebook its tools refuse is worse than silence. - adapter.providesOwnTools === false - ? describeSandboxMemory(sandbox) - : undefined, - ), - abortSignal: controller.signal, - maxOutputTokens: request.options?.maxOutputTokens, - providerOptions: run.providerOptions, - tools: run.tools, - // Tools passed here are executed by the AI SDK, so the loop has to - // be stepped explicitly or the turn ends at the first tool call. - // - // Saying something ends the turn. Without that, a model that had - // already posted would keep its remaining steps and sometimes reach - // for the speech tool a second time — the room saw one colleague - // start typing, stop, and start again for a single message. Looking - // around first (`read_channel`, reasoning) still costs steps and is - // unaffected: `hasToolCall` only inspects the step just finished. - // The step count is a runaway backstop, not a work budget: a turn - // that reads a few rooms, checks its memory and then answers was - // hitting 12 and stopping mid-thought. Speaking still ends the turn, - // so the ceiling only matters to a model that never gets there. - stopWhen: run.tools - ? [hasToolCall(WORKSPACE_SEND_MESSAGE_TOOL), stepCountIs(50)] + const resumesNativeSession = + resumableBinding !== undefined && + adapter.resumesNativeSession !== false; + const messages = toMessages( + request, + resumesNativeSession, + turnContext?.systemContext, + // Only providers that receive the basic file tools can act on the + // notice; a CLI provider's tool surface has no write_file, and + // promising a notebook its tools refuse is worse than silence. + adapter.providesOwnTools === false + ? describeSandboxMemory(sandbox) : undefined, - }); + ); + const result = isPiProviderRun(run) + ? this.piStreamFactory({ + requestId: request.requestId, + run, + messages, + tools, + abortSignal: controller.signal, + maxOutputTokens: request.options?.maxOutputTokens, + temperature: request.options?.temperature, + }) + : this.streamInvoker({ + model: run.model, + messages, + abortSignal: controller.signal, + maxOutputTokens: request.options?.maxOutputTokens, + providerOptions: run.providerOptions, + tools: run.tools, + // Tools passed here are executed by the AI SDK, so the loop has + // to be stepped explicitly or the turn ends at the first tool + // call. Saying something ends the turn; the step count is only a + // runaway backstop. + stopWhen: run.tools + ? [hasToolCall(WORKSPACE_SEND_MESSAGE_TOOL), stepCountIs(50)] + : undefined, + }); const forwarded = await this.forwardStream( request.requestId, result, @@ -1239,7 +1250,7 @@ export class LocalAiRuntime implements LocalAIRuntimeService { async resetConversationProviderSession( request: LocalAIResetProviderSessionRequest, ): Promise { - if (!isProviderId(request.providerId)) { + if (!isLocalAiProviderId(request.providerId)) { throw Object.assign( new Error(`Unknown local AI provider: ${request.providerId}`), { code: "UNKNOWN_PROVIDER" }, diff --git a/packages/app/src/electron/ai/subscription-memory-curator.test.ts b/packages/app/src/electron/ai/subscription-memory-curator.test.ts index fdac277b..c8481b9a 100644 --- a/packages/app/src/electron/ai/subscription-memory-curator.test.ts +++ b/packages/app/src/electron/ai/subscription-memory-curator.test.ts @@ -162,6 +162,8 @@ describe("RestrictedMemoryCurator", () => { it.each([ ["codex-cli", "codex-cli"], ["claude-code", "claude-code"], + ["openai-api", "openai-api"], + ["fireworks-api", "fireworks-api"], ] satisfies Array< [LocalAISubconsciousProvider, LocalAISubconsciousProvider] >)("resolves the explicit %s provider", async (setting, expected) => { @@ -174,9 +176,9 @@ describe("RestrictedMemoryCurator", () => { await expect( resolveSubscriptionMemoryProvider( "follow-active", - input(["codex-cli", "claude-code"]), + input(["codex-cli", "claude-code", "openai-api", "fireworks-api"]), ), - ).resolves.toBe("claude-code"); + ).resolves.toBe("fireworks-api"); }); it("rejects off without invoking the subscription runtime", async () => { @@ -215,8 +217,8 @@ describe("RestrictedMemoryCurator", () => { providerId: "codex-cli", operation: { kind: "append" }, agent: { id: "restricted-memory-curator" }, - options: { temperature: 0 }, }); + expect(request.options).toBeUndefined(); expect(request.agent?.systemPrompt).toBe( RESTRICTED_MEMORY_CURATOR_SYSTEM_PROMPT, ); @@ -455,14 +457,14 @@ describe("RestrictedMemoryCurator", () => { }); it("uses the active-provider resolver when turns do not identify one", async () => { - const getActiveProviderId = vi.fn(async () => "claude-code" as const); + const getActiveProviderId = vi.fn(async () => "openai-api" as const); await expect( resolveSubscriptionMemoryProvider( "follow-active", input([]), getActiveProviderId, ), - ).resolves.toBe("claude-code"); + ).resolves.toBe("openai-api"); expect(getActiveProviderId).toHaveBeenCalledWith({ kind: "conversation", id: "conversation-1", diff --git a/packages/app/src/electron/ai/subscription-memory-curator.ts b/packages/app/src/electron/ai/subscription-memory-curator.ts index 2fbfded0..876e876f 100644 --- a/packages/app/src/electron/ai/subscription-memory-curator.ts +++ b/packages/app/src/electron/ai/subscription-memory-curator.ts @@ -19,12 +19,7 @@ import type { import { LocalAiRuntime } from "./runtime"; import type { LocalAiProviderExecutionPolicy } from "./provider-adapter"; import type { SessionStateRepository } from "./session/types"; -import type { LocalAiProviderId } from "./types"; - -const SUPPORTED_CURATOR_PROVIDERS = new Set([ - "codex-cli", - "claude-code", -]); +import { isLocalAiProviderId, type LocalAiProviderId } from "./types"; export function memoryCuratorConversationId( scope: MemoryScope, @@ -113,12 +108,16 @@ export async function resolveSubscriptionMemoryProvider( ): Promise { if (setting === "off") { throw curatorError( - "Subscription-native memory curation is disabled.", + "Background memory curation is disabled.", "LOCAL_AI_MEMORY_CURATOR_DISABLED", ); } if (setting !== "follow-active") { - return setting; + if (isLocalAiProviderId(setting)) return setting; + throw curatorError( + `Memory curator provider is not registered: ${setting}`, + "LOCAL_AI_MEMORY_CURATOR_PROVIDER_UNAVAILABLE", + ); } const turnProvider = input.turns @@ -126,13 +125,12 @@ export async function resolveSubscriptionMemoryProvider( .map((turn) => turn.providerId) .find( (providerId): providerId is LocalAiProviderId => - typeof providerId === "string" && - SUPPORTED_CURATOR_PROVIDERS.has(providerId as LocalAiProviderId), + typeof providerId === "string" && isLocalAiProviderId(providerId), ); const providerId = turnProvider ?? (await getActiveProviderId?.(input.scope)); - if (!providerId || !SUPPORTED_CURATOR_PROVIDERS.has(providerId)) { + if (!providerId || !isLocalAiProviderId(providerId)) { throw curatorError( - "follow-active could not resolve an authenticated Codex or Claude provider.", + "follow-active could not resolve a registered provider from the completed turns.", "LOCAL_AI_MEMORY_ACTIVE_PROVIDER_UNAVAILABLE", ); } @@ -223,8 +221,8 @@ function buildCuratorPrompt( } /** - * Runs subconscious curation through the user's existing Codex or Claude - * subscription without exposing the primary chat's native provider session. + * Runs background curation through an isolated registered provider session + * without exposing the primary chat's native provider session. */ export class RestrictedMemoryCurator implements RestrictedMemoryCuratorContract @@ -347,9 +345,6 @@ export class RestrictedMemoryCurator id: "restricted-memory-curator", systemPrompt: RESTRICTED_MEMORY_CURATOR_SYSTEM_PROMPT, }, - options: { - temperature: 0, - }, }; let output = ""; diff --git a/packages/app/src/electron/ai/types.ts b/packages/app/src/electron/ai/types.ts index e0950358..2fc1f00f 100644 --- a/packages/app/src/electron/ai/types.ts +++ b/packages/app/src/electron/ai/types.ts @@ -7,6 +7,10 @@ export const LOCAL_AI_PROVIDER_IDS = [ export type LocalAiProviderId = (typeof LOCAL_AI_PROVIDER_IDS)[number]; +export function isLocalAiProviderId(value: string): value is LocalAiProviderId { + return LOCAL_AI_PROVIDER_IDS.some((providerId) => providerId === value); +} + export interface LocalAiProviderDescriptor { id: LocalAiProviderId; label: string; diff --git a/packages/app/src/electron/ai/workspace-tools.ts b/packages/app/src/electron/ai/workspace-tools.ts index 52a05ba0..6a5b278f 100644 --- a/packages/app/src/electron/ai/workspace-tools.ts +++ b/packages/app/src/electron/ai/workspace-tools.ts @@ -212,7 +212,18 @@ export function withWorkspacePerception( hooks: LocalAiTurnHooks, ): LocalAiTurnHooks { return { - ...hooks, + prepareDurableTurnHook: hooks.prepareDurableTurnHook + ? (input) => hooks.prepareDurableTurnHook!(input) + : undefined, + replayDurableTurnHook: hooks.replayDurableTurnHook + ? (hook) => hooks.replayDurableTurnHook!(hook) + : undefined, + onTurnCompleted: hooks.onTurnCompleted + ? (input) => hooks.onTurnCompleted!(input) + : undefined, + onTurnFailed: hooks.onTurnFailed + ? (input) => hooks.onTurnFailed!(input) + : undefined, prepareTurnContext: async ( input, ): Promise => { diff --git a/packages/app/src/electron/memory/coordinator.test.ts b/packages/app/src/electron/memory/coordinator.test.ts index b8ea8aa9..a0563e55 100644 --- a/packages/app/src/electron/memory/coordinator.test.ts +++ b/packages/app/src/electron/memory/coordinator.test.ts @@ -316,6 +316,35 @@ describe("MemoryIntegrationCoordinator", () => { ).toEqual([]); }); + it("follows the registered provider from the latest completed turn", async () => { + const curate = vi.fn(async () => ({ + action: "noop" as const, + reason: "No durable change.", + })); + const create = vi.fn(async () => ({ curate })); + const { coordinator, settings } = setup({ + curatorFactory: { create }, + }); + await settings.update({ + provider: "local", + curator: "follow-active", + schedule: "every-turn", + }); + const prepared = await prepare(coordinator, "turn-openai"); + + await coordinator.completeTurn({ + token: prepared.contextToken!, + turnId: "turn-openai", + providerId: "openai-api", + userContent: "Remember that the curator follows this provider.", + assistantContent: "Okay.", + }); + await coordinator.flushSubconscious(); + + expect(create).toHaveBeenCalledWith("openai-api"); + expect(curate).toHaveBeenCalledOnce(); + }); + it("uses the durable terminal time when replaying completion curation", async () => { const { coordinator, jobs, settings } = setup(); await settings.update({ diff --git a/packages/app/src/electron/memory/coordinator.ts b/packages/app/src/electron/memory/coordinator.ts index f537ab27..b581b4be 100644 --- a/packages/app/src/electron/memory/coordinator.ts +++ b/packages/app/src/electron/memory/coordinator.ts @@ -21,7 +21,7 @@ import type { DurableTurnHookRecord, ProviderMemoryCursors, } from "../ai/session/types"; -import type { LocalAiProviderId } from "../ai/types"; +import { isLocalAiProviderId, type LocalAiProviderId } from "../ai/types"; import type { MemoryCandidateRepository } from "./candidate-sink"; import type { MemoryIndexRepository, @@ -133,7 +133,7 @@ const DEFAULT_CONTEXT_BUDGET = { }; function providerId(value: string): LocalAiProviderId | undefined { - return value === "codex-cli" || value === "claude-code" ? value : undefined; + return isLocalAiProviderId(value) ? value : undefined; } function publicSettings(settings: PublicMemorySettings): LocalAIMemorySettings { @@ -296,7 +296,7 @@ export class MemoryIntegrationCoordinator : providerId(settings.curator); if (!selected) { throw new Error( - "Subconscious memory curation is disabled or has no valid subscription provider.", + "Background memory curation is disabled or has no registered provider.", ); } const existing = this.curators.get(selected); diff --git a/packages/app/src/electron/memory/electron-integration.test.ts b/packages/app/src/electron/memory/electron-integration.test.ts index 0405fc50..7ea5701e 100644 --- a/packages/app/src/electron/memory/electron-integration.test.ts +++ b/packages/app/src/electron/memory/electron-integration.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { InMemorySessionStateRepository } from "../ai/session/repository"; +import { LOCAL_AI_PROVIDER_IDS } from "../ai/types"; +import { memoryCuratorConversationId } from "../ai/subscription-memory-curator"; import { createElectronMemoryIntegration, forgetMemoryCuratorSessions, @@ -46,23 +48,27 @@ describe("Electron memory integration", () => { }); }); - it("forgets both provider-native curator sessions for a memory scope", async () => { + it("forgets every registered provider's curator session for a memory scope", async () => { const sessions = new InMemorySessionStateRepository(); const scope = { kind: "conversation" as const, id: "conversation-1" }; - const codexId = "memory-curator:conversation:conversation-1:codex-cli"; - const claudeId = "memory-curator:conversation:conversation-1:claude-code"; - await sessions.setConversationMemoryState(codexId, { - memoryVersion: 3, - memoryEpoch: 1, - }); - await sessions.setConversationMemoryState(claudeId, { - memoryVersion: 4, - memoryEpoch: 2, - }); + const curatorIds = LOCAL_AI_PROVIDER_IDS.map((providerId) => + memoryCuratorConversationId(scope, providerId), + ); + await Promise.all( + curatorIds.map((conversationId, index) => + sessions.setConversationMemoryState(conversationId, { + memoryVersion: index + 1, + memoryEpoch: index, + }), + ), + ); await forgetMemoryCuratorSessions(sessions, scope); - expect(await sessions.getConversation(codexId)).toBeUndefined(); - expect(await sessions.getConversation(claudeId)).toBeUndefined(); + await Promise.all( + curatorIds.map(async (conversationId) => { + expect(await sessions.getConversation(conversationId)).toBeUndefined(); + }), + ); }); }); diff --git a/packages/app/src/electron/memory/electron-integration.ts b/packages/app/src/electron/memory/electron-integration.ts index 0ceb3274..98fca369 100644 --- a/packages/app/src/electron/memory/electron-integration.ts +++ b/packages/app/src/electron/memory/electron-integration.ts @@ -3,7 +3,7 @@ import { RestrictedMemoryCurator, } from "../ai/subscription-memory-curator"; import type { SessionStateRepository } from "../ai/session/types"; -import type { LocalAiProviderId } from "../ai/types"; +import { LOCAL_AI_PROVIDER_IDS } from "../ai/types"; import { createHash } from "node:crypto"; import { join, resolve } from "node:path"; import { MemoryIntegrationCoordinator } from "./coordinator"; @@ -26,17 +26,12 @@ function stableScopeId(namespace: string, value: string): string { return `${namespace}-${digest}`; } -const CURATOR_SESSION_PROVIDERS: LocalAiProviderId[] = [ - "codex-cli", - "claude-code", -]; - export async function forgetMemoryCuratorSessions( repository: SessionStateRepository, scope: Parameters[0], ): Promise { await Promise.all( - CURATOR_SESSION_PROVIDERS.map((providerId) => + LOCAL_AI_PROVIDER_IDS.map((providerId) => repository.deleteConversation( memoryCuratorConversationId(scope, providerId), ), @@ -63,12 +58,9 @@ export function createElectronMemoryIntegration( curatorFactory: { create: (provider) => new RestrictedMemoryCurator({ - // Memory curation runs on the CLI agents; a raw API provider has no - // curator persona, so its turns simply skip subconscious curation. - provider: - provider === "openai-api" || provider === "fireworks-api" - ? "off" - : provider, + // Curators are isolated text-only sessions. Provider adapters own + // authentication, model selection, and native session semantics. + provider, sessionRepository: options.sessionRepository, workingDirectory: options.workingDirectory, }), diff --git a/packages/app/src/electron/memory/settings-repository.test.ts b/packages/app/src/electron/memory/settings-repository.test.ts index b95fa3b3..c4c7a994 100644 --- a/packages/app/src/electron/memory/settings-repository.test.ts +++ b/packages/app/src/electron/memory/settings-repository.test.ts @@ -55,6 +55,19 @@ describe("MemorySettingsRepository", () => { expect(repository.getSourceId()).toBe(sourceId); }); + it.each(["openai-api", "fireworks-api"] as const)( + "persists the registered %s provider as a curator", + async (curator) => { + const persistence = new InMemoryMemorySettingsPersistence(); + const repository = new MemorySettingsRepository(persistence); + + await expect(repository.update({ curator })).resolves.toMatchObject({ + curator, + }); + await expect(persistence.read()).resolves.toMatchObject({ curator }); + }, + ); + it("ignores omitted IPC fields represented as explicit undefined", async () => { const repository = new MemorySettingsRepository( new InMemoryMemorySettingsPersistence(), diff --git a/packages/app/src/electron/memory/settings-repository.ts b/packages/app/src/electron/memory/settings-repository.ts index 013b0f59..08b324fb 100644 --- a/packages/app/src/electron/memory/settings-repository.ts +++ b/packages/app/src/electron/memory/settings-repository.ts @@ -1,13 +1,13 @@ import { z } from "zod"; +import { LOCAL_AI_PROVIDER_IDS } from "../ai/types"; import { AtomicJsonFile } from "./json-file"; import { SerialTaskQueue } from "./serial-queue"; export const MEMORY_PROVIDERS = ["off", "local"] as const; export const MEMORY_CURATORS = [ "off", - "codex-cli", - "claude-code", "follow-active", + ...LOCAL_AI_PROVIDER_IDS, ] as const; export const MEMORY_SCHEDULES = ["every-turn", "batch", "idle"] as const; diff --git a/packages/app/src/electron/memory/tools.test.ts b/packages/app/src/electron/memory/tools.test.ts index 28952ccc..13938806 100644 --- a/packages/app/src/electron/memory/tools.test.ts +++ b/packages/app/src/electron/memory/tools.test.ts @@ -142,8 +142,15 @@ describe("memory tools", () => { expect(learn).toMatchObject({ name: "memory_learn", qualifiedName: "memory:learn", + inputSchema: { + type: "object", + properties: { + storage: { enum: ["block", "archival"] }, + content: { type: "string" }, + }, + required: ["storage", "content"], + }, }); - expect(learn?.inputSchema).toMatchObject({ type: "object" }); await expect( learn?.execute({ storage: "block", diff --git a/packages/app/src/electron/memory/tools.ts b/packages/app/src/electron/memory/tools.ts index 783d8940..201f6dc9 100644 --- a/packages/app/src/electron/memory/tools.ts +++ b/packages/app/src/electron/memory/tools.ts @@ -1,6 +1,7 @@ import { tool } from "ai"; import type { AgentTool } from "../ai/agent-tools"; import { z, type ZodRawShape, type ZodTypeAny } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; import { errorMessage, MemoryError } from "./errors"; import { MEMORY_SCOPE_KINDS, @@ -501,6 +502,13 @@ const MEMORY_AGENT_TOOL_DEFINITIONS: MemoryAgentToolDefinition[] = [ }, ]; +function agentInputSchema(schema: ZodTypeAny): Record { + return zodToJsonSchema(schema, { + $refStrategy: "none", + target: "jsonSchema7", + }) as Record; +} + type ExecutableTool = { execute?: ( input: Record, @@ -527,11 +535,7 @@ export function createMemoryAgentTools( name: definition.name, qualifiedName: definition.qualifiedName, description: definition.description, - inputSchema: { - type: "object", - description: - "Validated by the provider-native Zod schema exposed on inputValidator.", - }, + inputSchema: agentInputSchema(definition.inputValidator), inputShape: definition.inputShape, inputValidator: definition.inputValidator, execute: async (input: Record) => diff --git a/packages/app/src/renderer/components/settings/pages/general-page.memory-provider.test.ts b/packages/app/src/renderer/components/settings/pages/general-page.memory-provider.test.ts new file mode 100644 index 00000000..55a7d5a3 --- /dev/null +++ b/packages/app/src/renderer/components/settings/pages/general-page.memory-provider.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { + MEMORY_CURATOR_OPTIONS, + createMemoryCuratorUpdate, +} from "./memory-curator-options"; + +describe("memory curator settings", () => { + it("offers every registered provider plus off and follow-active", () => { + expect(MEMORY_CURATOR_OPTIONS).toEqual([ + { value: "off", label: "Off" }, + { value: "claude-code", label: "Claude Code" }, + { value: "codex-cli", label: "Codex" }, + { value: "openai-api", label: "OpenAI API" }, + { value: "fireworks-api", label: "Fireworks" }, + { value: "follow-active", label: "Follow active provider" }, + ]); + }); + + it("rejects values outside the provider registry", () => { + expect(createMemoryCuratorUpdate("openai-api")).toEqual({ + subconsciousProvider: "openai-api", + }); + expect(createMemoryCuratorUpdate("hardcoded-provider")).toBeNull(); + }); +}); diff --git a/packages/app/src/renderer/components/settings/pages/general-page.tsx b/packages/app/src/renderer/components/settings/pages/general-page.tsx index 434e1ed3..894db211 100644 --- a/packages/app/src/renderer/components/settings/pages/general-page.tsx +++ b/packages/app/src/renderer/components/settings/pages/general-page.tsx @@ -33,6 +33,10 @@ import { Terminal, } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + MEMORY_CURATOR_OPTIONS, + createMemoryCuratorUpdate, +} from "./memory-curator-options"; export const MEMORY_PROVIDER_OPTIONS = LOCAL_AI_MEMORY_PROVIDERS.map( (value) => ({ @@ -521,8 +525,8 @@ export function GeneralSettingsPage() {

- Store memory locally. A separate Codex or Claude session can - curate completed turns without blocking the reply. + Store memory locally. An isolated provider session can curate + completed turns without blocking the reply.

@@ -560,28 +564,30 @@ export function GeneralSettingsPage() { diff --git a/packages/app/src/renderer/components/settings/pages/memory-curator-options.ts b/packages/app/src/renderer/components/settings/pages/memory-curator-options.ts new file mode 100644 index 00000000..4dc91f72 --- /dev/null +++ b/packages/app/src/renderer/components/settings/pages/memory-curator-options.ts @@ -0,0 +1,31 @@ +import { + LOCAL_AI_PROVIDER_NAMES, + isLocalAIProviderId, + type LocalAIProviderId, +} from "@/renderer/libs/local-ai"; +import type { + LocalAIMemorySettingsUpdate, + LocalAISubconsciousProvider, +} from "@/shared/types/local-ai"; + +export const MEMORY_CURATOR_OPTIONS: Array<{ + value: LocalAISubconsciousProvider; + label: string; +}> = [ + { value: "off", label: "Off" }, + ...( + Object.entries(LOCAL_AI_PROVIDER_NAMES) as Array< + [LocalAIProviderId, string] + > + ).map(([value, label]) => ({ value, label })), + { value: "follow-active", label: "Follow active provider" }, +]; + +export function createMemoryCuratorUpdate( + value: string, +): LocalAIMemorySettingsUpdate | null { + if (value === "off" || value === "follow-active") { + return { subconsciousProvider: value }; + } + return isLocalAIProviderId(value) ? { subconsciousProvider: value } : null; +} diff --git a/packages/app/src/shared/types/local-ai.ts b/packages/app/src/shared/types/local-ai.ts index 93cc341f..1f28d15d 100644 --- a/packages/app/src/shared/types/local-ai.ts +++ b/packages/app/src/shared/types/local-ai.ts @@ -146,9 +146,8 @@ export function isLocalAIMemoryProvider( } export type LocalAISubconsciousProvider = | "off" - | "codex-cli" - | "claude-code" - | "follow-active"; + | "follow-active" + | Exclude; export type LocalAIMemorySchedule = "every-turn" | "batch" | "idle"; export interface LocalAIMemorySettings { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d128586..4b46d9de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: '@anthropic-ai/sandbox-runtime': specifier: ^0.0.67 version: 0.0.67 + '@earendil-works/pi-agent-core': + specifier: 0.83.0 + version: 0.83.0(@modelcontextprotocol/sdk@1.13.0)(bufferutil@4.0.9)(utf-8-validate@6.0.5)(ws@8.18.2)(zod@3.25.76) + '@earendil-works/pi-ai': + specifier: 0.83.0 + version: 0.83.0(@modelcontextprotocol/sdk@1.13.0)(bufferutil@4.0.9)(utf-8-validate@6.0.5)(ws@8.18.2)(zod@3.25.76) '@earendil-works/pi-coding-agent': specifier: ^0.83.0 version: 0.83.0(@modelcontextprotocol/sdk@1.13.0)(bufferutil@4.0.9)(utf-8-validate@6.0.5)(ws@8.18.2)(zod@3.25.76) @@ -326,13 +332,13 @@ importers: version: 8.18.1 '@wdio/electron-service': specifier: 10.1.0 - version: 10.1.0(bufferutil@4.0.9)(electron@34.5.8)(expect-webdriverio@5.7.0)(tsx@4.23.1)(utf-8-validate@6.0.5)(webdriverio@9.27.1) + version: 10.1.0(bufferutil@4.0.9)(electron@36.9.5)(expect-webdriverio@5.7.0)(tsx@4.23.1)(utf-8-validate@6.0.5)(webdriverio@9.27.1) '@wdio/native-utils': specifier: 2.5.0 version: 2.5.0(tsx@4.23.1) electron: - specifier: 34.5.8 - version: 34.5.8 + specifier: 36.9.5 + version: 36.9.5 electron-devtools-installer: specifier: ^4.0.0 version: 4.0.0 @@ -8795,7 +8801,7 @@ packages: - utf-8-validate dev: true - /@wdio/electron-service@10.1.0(bufferutil@4.0.9)(electron@34.5.8)(expect-webdriverio@5.7.0)(tsx@4.23.1)(utf-8-validate@6.0.5)(webdriverio@9.27.1): + /@wdio/electron-service@10.1.0(bufferutil@4.0.9)(electron@36.9.5)(expect-webdriverio@5.7.0)(tsx@4.23.1)(utf-8-validate@6.0.5)(webdriverio@9.27.1): resolution: {integrity: sha512-OwPwVlYoNUEmGPDPBlkJGdv/pKOvruYEC5G3pyWSsAPnjKrW49/IVOBoM/a3UF8LKb2zsA+95ezJ517hTPk7dA==} engines: {node: '>=22.12.0'} peerDependencies: @@ -8817,7 +8823,7 @@ packages: compare-versions: 6.1.1 debug: 4.4.3 deepmerge-ts: 7.1.5 - electron: 34.5.8 + electron: 36.9.5 electron-to-chromium: 1.5.398 fast-copy: 4.0.4 find-versions: 6.0.0 @@ -11193,14 +11199,14 @@ packages: dev: true optional: true - /electron@34.5.8: - resolution: {integrity: sha512-vxLD65mabTzYmEVa9KceMHM0+zO+vqgrhcyNVlmTd0IGV5J7XZ8v/qElm0o4YQ4wPeq7olZkUjZkBQQEdr23/g==} + /electron@36.9.5: + resolution: {integrity: sha512-1UCss2IqxqujSzg/2jkRjuiT3G+EEXgd6UKB5kUekwQW1LJ6d4QCr8YItfC3Rr9VIGRDJ29eOERmnRNO1Eh+NA==} engines: {node: '>= 12.20.55'} hasBin: true requiresBuild: true dependencies: '@electron/get': 2.0.3 - '@types/node': 20.17.48 + '@types/node': 22.16.0 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color