From 03b2891c5e4f50431a3b94030d079b95342d3bc7 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 13:00:25 +0800 Subject: [PATCH 01/14] feat: use pi-factory for pi launch wiring --- package-lock.json | 26 ++++++++ package.json | 3 + src/pi/app.ts | 125 ++++++++++++++++++++++++++++++++++++++ src/pi/config.ts | 152 ++-------------------------------------------- src/pi/launch.ts | 80 ++++-------------------- 5 files changed, 171 insertions(+), 215 deletions(-) create mode 100644 src/pi/app.ts diff --git a/package-lock.json b/package-lock.json index bc50c63..6fae13b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "localpi", "version": "0.3.0", "license": "MIT", + "dependencies": { + "pi-factory": "github:dutifuldev/pi-factory#feat/end-to-end-implementation" + }, "bin": { "localpi": "dist/src/cli/main.js" }, @@ -4625,6 +4628,17 @@ "node": ">= 14.16" } }, + "node_modules/pi-factory": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#e32a03a43ee988d51952d933c191f8f7fca8b43c", + "license": "MIT", + "dependencies": { + "smol-toml": "^1.4.2" + }, + "bin": { + "pi-factory": "dist/src/cli/main.js" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4990,6 +5004,18 @@ "node": ">=22.0.0" } }, + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", diff --git a/package.json b/package.json index bea2c34..4cde382 100644 --- a/package.json +++ b/package.json @@ -53,5 +53,8 @@ "typescript": "^5.0.0", "typescript-eslint": "^8.0.0", "vitest": "^3.0.0" + }, + "dependencies": { + "pi-factory": "github:dutifuldev/pi-factory#feat/end-to-end-implementation" } } diff --git a/src/pi/app.ts b/src/pi/app.ts new file mode 100644 index 0000000..3b9b137 --- /dev/null +++ b/src/pi/app.ts @@ -0,0 +1,125 @@ +import type { + PiAppDefinition, + PiExtensionDefinition, + PiModelDefinition, + PiProviderDefinition +} from "pi-factory"; + +import type { CatalogModel } from "../localpi/catalog.js"; +import type { LocalpiOptions } from "../localpi/options.js"; +import type { RuntimeConnection } from "../localpi/runtime.js"; +import type { ExtensionBundle } from "./extensions.js"; + +export function createLocalpiAppDefinition( + options: LocalpiOptions, + connection: RuntimeConnection, + extensions?: ExtensionBundle +): PiAppDefinition { + const providers = providersForConnection(options, connection); + return { + id: "localpi", + name: "localpi", + version: "0.3.0", + stateDir: options.stateDir, + sessionDir: options.sessionDir, + piCommand: options.piCommand, + providers, + defaultProvider: connection.providerId, + defaultModel: connection.model, + thinking: options.thinking, + ...(options.tools === undefined ? {} : { tools: options.tools }), + ...(extensions === undefined ? {} : extensionDefinition(extensions)), + forwardedArgs: options.forwardedArgs + }; +} + +function providersForConnection( + options: LocalpiOptions, + connection: RuntimeConnection +): readonly PiProviderDefinition[] { + const models = + connection.catalogModels.length === 0 ? fallbackCatalog(connection) : connection.catalogModels; + return groupedByProvider(models).map((group) => ({ + id: group.providerId, + baseUrl: group.baseUrl, + api: "openai-completions", + apiKey: "local", + compat: { + supportsDeveloperRole: false, + supportsReasoningEffort: false + }, + models: group.models.map((model) => modelDefinition(options, model)) + })); +} + +type ProviderGroup = { + readonly providerId: string; + readonly baseUrl: string; + readonly models: readonly CatalogModel[]; +}; + +function groupedByProvider(models: readonly CatalogModel[]): readonly ProviderGroup[] { + const groups = new Map(); + for (const model of models) { + const existing = groups.get(model.providerId); + groups.set( + model.providerId, + existing === undefined + ? { providerId: model.providerId, baseUrl: model.baseUrl, models: [model] } + : { ...existing, models: [...existing.models, model] } + ); + } + return [...groups.values()]; +} + +function modelDefinition(options: LocalpiOptions, model: CatalogModel): PiModelDefinition { + const contextWindow = modelContextWindow(options, model); + return { + id: model.modelId, + name: model.displayName, + reasoning: model.reasoning ?? false, + ...(model.thinkingFormat === undefined ? {} : { thinkingFormat: model.thinkingFormat }), + input: ["text"], + ...(contextWindow === undefined ? {} : { contextWindow }), + ...(model.maxTokens === undefined + ? { maxTokens: options.maxTokens } + : { maxTokens: model.maxTokens }) + }; +} + +function modelContextWindow(options: LocalpiOptions, model: CatalogModel): number | undefined { + if (options.contextWindow !== undefined) { + return options.contextWindow; + } + return model.contextWindow; +} + +function fallbackCatalog(connection: RuntimeConnection): readonly CatalogModel[] { + return [ + { + providerId: connection.providerId, + providerName: connection.providerName, + runtime: connection.runtime.startsWith("llama-server") + ? "managed-llama-server" + : "openai-compatible", + baseUrl: connection.baseUrl, + modelId: connection.model, + aliases: [], + displayName: `Local model (${connection.model})`, + reasoning: false, + capabilities: ["text"], + availability: "loaded", + ...(connection.contextWindow === undefined ? {} : { contextWindow: connection.contextWindow }) + } + ]; +} + +function extensionDefinition(extensions: ExtensionBundle): { + readonly extensions: readonly PiExtensionDefinition[]; + readonly appendSystemPrompts: readonly string[]; +} { + return { + extensions: extensions.paths.map((extensionPath) => ({ path: extensionPath })), + appendSystemPrompts: [extensions.systemPrompt] + }; +} diff --git a/src/pi/config.ts b/src/pi/config.ts index f8d8666..e882d35 100644 --- a/src/pi/config.ts +++ b/src/pi/config.ts @@ -1,157 +1,15 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; +import { writePiRuntimeConfig } from "pi-factory"; +import type { PiRuntimeConfig } from "pi-factory"; -import type { CatalogModel } from "../localpi/catalog.js"; import type { LocalpiOptions } from "../localpi/options.js"; import type { RuntimeConnection } from "../localpi/runtime.js"; +import { createLocalpiAppDefinition } from "./app.js"; -export type RuntimeConfig = { - readonly configDir: string; - readonly modelsPath: string; - readonly settingsPath: string; -}; +export type RuntimeConfig = PiRuntimeConfig; export async function writeRuntimeConfig( options: LocalpiOptions, connection: RuntimeConnection ): Promise { - const configDir = path.join(options.stateDir, "pi-config-runtime"); - await mkdir(configDir, { recursive: true }); - const modelsPath = path.join(configDir, "models.json"); - const settingsPath = path.join(configDir, "settings.json"); - await writeFile(modelsPath, `${JSON.stringify(modelsConfig(options, connection), null, 2)}\n`); - await writeFile( - settingsPath, - `${JSON.stringify(settingsConfig(options, connection), null, 2)}\n` - ); - return { configDir, modelsPath, settingsPath }; -} - -function modelsConfig(options: LocalpiOptions, connection: RuntimeConnection): unknown { - const models = - connection.catalogModels.length === 0 ? fallbackCatalog(connection) : connection.catalogModels; - return { - providers: Object.fromEntries( - groupedByProvider(models).map((entry) => [ - entry.providerId, - providerConfig(options, connection, entry) - ]) - ) - }; -} - -type ProviderGroup = { - readonly providerId: string; - readonly baseUrl: string; - readonly models: readonly CatalogModel[]; -}; - -function groupedByProvider(models: readonly CatalogModel[]): readonly ProviderGroup[] { - const groups = new Map(); - for (const model of models) { - const existing = groups.get(model.providerId); - groups.set( - model.providerId, - existing === undefined - ? { providerId: model.providerId, baseUrl: model.baseUrl, models: [model] } - : { ...existing, models: [...existing.models, model] } - ); - } - return [...groups.values()]; -} - -function providerConfig( - options: LocalpiOptions, - connection: RuntimeConnection, - group: ProviderGroup -): unknown { - return { - baseUrl: group.baseUrl, - api: "openai-completions", - apiKey: "local", - compat: { - supportsDeveloperRole: false, - supportsReasoningEffort: false - }, - models: group.models.map((model) => modelConfig(options, connection, model)) - }; -} - -function modelConfig( - options: LocalpiOptions, - connection: RuntimeConnection, - model: CatalogModel -): unknown { - return withoutUndefined({ - id: model.modelId, - name: model.displayName, - reasoning: model.reasoning ?? false, - compat: - model.thinkingFormat === undefined ? undefined : { thinkingFormat: model.thinkingFormat }, - input: ["text"], - contextWindow: modelContextWindow(options, model), - maxTokens: model.maxTokens ?? options.maxTokens, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0 - } - }); -} - -function modelContextWindow(options: LocalpiOptions, model: CatalogModel): number | undefined { - if (options.contextWindow !== undefined) { - return options.contextWindow; - } - return model.contextWindow; -} - -function fallbackCatalog(connection: RuntimeConnection): readonly CatalogModel[] { - return [ - { - providerId: connection.providerId, - providerName: connection.providerName, - runtime: connection.runtime.startsWith("llama-server") - ? "managed-llama-server" - : "openai-compatible", - baseUrl: connection.baseUrl, - modelId: connection.model, - aliases: [], - displayName: `Local model (${connection.model})`, - reasoning: false, - capabilities: ["text"], - availability: "loaded", - ...(connection.contextWindow === undefined ? {} : { contextWindow: connection.contextWindow }) - } - ]; -} - -function withoutUndefined(value: Record): Record { - return Object.fromEntries( - Object.entries(value).filter(([, entryValue]) => entryValue !== undefined) - ); -} - -function settingsConfig(options: LocalpiOptions, connection: RuntimeConnection): unknown { - const contextWindow = options.contextWindow ?? connection.contextWindow; - return { - defaultProvider: connection.providerId, - defaultModel: connection.model, - defaultThinkingLevel: options.thinking, - enableInstallTelemetry: false, - quietStartup: true, - compaction: compactionConfig(contextWindow) - }; -} - -function compactionConfig(contextWindow: number | undefined): unknown { - if (contextWindow === undefined) { - return { enabled: false }; - } - return { - enabled: true, - reserveTokens: Math.max(256, Math.min(16384, Math.floor(contextWindow / 4))), - keepRecentTokens: Math.max(512, Math.min(20000, Math.floor(contextWindow / 2))) - }; + return await writePiRuntimeConfig(createLocalpiAppDefinition(options, connection)); } diff --git a/src/pi/launch.ts b/src/pi/launch.ts index 5c97e8f..98ef552 100644 --- a/src/pi/launch.ts +++ b/src/pi/launch.ts @@ -1,16 +1,19 @@ -import { spawn } from "node:child_process"; -import type { StdioOptions } from "node:child_process"; import { mkdir } from "node:fs/promises"; +import { createPiLaunchPlan, execPiLaunchPlan } from "pi-factory"; +import type { PiLaunchPlan } from "pi-factory"; import type { LocalpiOptions } from "../localpi/options.js"; import type { RuntimeConnection } from "../localpi/runtime.js"; import type { ExtensionBundle } from "./extensions.js"; import type { RuntimeConfig } from "./config.js"; +import { createLocalpiAppDefinition } from "./app.js"; -export type LaunchPlan = { +export type LaunchPlan = PiLaunchPlan; +type ExecutableLaunchPlan = { readonly command: string; readonly args: readonly string[]; readonly env: Readonly>; + readonly cwd?: string; }; export async function createLaunchPlan( @@ -20,71 +23,12 @@ export async function createLaunchPlan( extensions: ExtensionBundle ): Promise { await mkdir(options.sessionDir, { recursive: true }); - return { - command: options.piCommand, - args: [ - "--provider", - connection.providerId, - "--model", - connection.model, - "--thinking", - options.thinking, - ...extensionArgs(extensions), - "--append-system-prompt", - extensions.systemPrompt, - ...withDefaultTools(options.forwardedArgs, options.tools) - ], - env: { - PI_CODING_AGENT_DIR: runtimeConfig.configDir, - PI_CODING_AGENT_SESSION_DIR: options.sessionDir, - PI_OFFLINE: process.env["PI_OFFLINE"] ?? "1", - PI_TELEMETRY: process.env["PI_TELEMETRY"] ?? "0", - PI_SKIP_VERSION_CHECK: process.env["PI_SKIP_VERSION_CHECK"] ?? "1" - } - }; -} - -export async function execLaunchPlan(plan: LaunchPlan): Promise { - const stdio: StdioOptions = "inherit"; - const child = spawn(shellCommand(plan.command, plan.args), { - shell: true, - stdio, - env: { ...process.env, ...plan.env } - }); - child.stdout?.resume(); - return await new Promise((resolve, reject) => { - child.on("error", reject); - child.on("exit", (code, signal) => { - if (signal !== null) { - process.kill(process.pid, signal); - return; - } - resolve(code ?? 0); - }); - }); -} - -function shellCommand(command: string, args: readonly string[]): string { - return [command, ...args.map(shellQuote)].join(" "); -} - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} - -function extensionArgs(extensions: ExtensionBundle): readonly string[] { - return extensions.paths.flatMap((extensionPath) => ["--extension", extensionPath]); -} - -function withDefaultTools(args: readonly string[], tools: string | undefined): readonly string[] { - if (tools === undefined || hasToolFlag(args)) { - return args; - } - return ["--tools", tools, ...args]; + return await createPiLaunchPlan( + createLocalpiAppDefinition(options, connection, extensions), + runtimeConfig + ); } -function hasToolFlag(args: readonly string[]): boolean { - return args.some( - (arg) => arg === "--tools" || arg === "-t" || arg === "--no-tools" || arg === "-nt" - ); +export async function execLaunchPlan(plan: ExecutableLaunchPlan): Promise { + return await execPiLaunchPlan(plan as PiLaunchPlan); } From c5cedee95bfac5d3a13f8f8d534d7df32e4d0c8e Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 13:10:05 +0800 Subject: [PATCH 02/14] chore: refresh pi-factory dependency --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 6fae13b..018071c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4630,7 +4630,7 @@ }, "node_modules/pi-factory": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#e32a03a43ee988d51952d933c191f8f7fca8b43c", + "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#b47bb213080778137d45404c4375cbda50aaa266", "license": "MIT", "dependencies": { "smol-toml": "^1.4.2" From bbad94dc97224b9eac2fbd50d9da01819f7f807b Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 13:17:05 +0800 Subject: [PATCH 03/14] chore: refresh pi-factory dependency --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 018071c..1a46a3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4630,7 +4630,7 @@ }, "node_modules/pi-factory": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#b47bb213080778137d45404c4375cbda50aaa266", + "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#4616d5810ac473bc092bd36011fe5b74cafc4361", "license": "MIT", "dependencies": { "smol-toml": "^1.4.2" From 37c7382848ae984f154d74503325dc25961cc94e Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 13:26:04 +0800 Subject: [PATCH 04/14] chore: refresh pi-factory dependency --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 1a46a3f..9061260 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4630,7 +4630,7 @@ }, "node_modules/pi-factory": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#4616d5810ac473bc092bd36011fe5b74cafc4361", + "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#2970928f128a65429e8f7bcbaea715d5bc8876ac", "license": "MIT", "dependencies": { "smol-toml": "^1.4.2" From e68e53bcdd32007341bb760b78336e2ba618105b Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 13:59:01 +0800 Subject: [PATCH 05/14] chore: refresh pi-factory dependency --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 9061260..da8e6ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4630,7 +4630,7 @@ }, "node_modules/pi-factory": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#2970928f128a65429e8f7bcbaea715d5bc8876ac", + "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#615499c9b9c5813da9e3eac08a8e42ccd988c94f", "license": "MIT", "dependencies": { "smol-toml": "^1.4.2" From ba3a8602ce5a22d1a2d03e46a2638d91c0b3e672 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 14:09:26 +0800 Subject: [PATCH 06/14] fix: preserve pi command shell semantics --- package-lock.json | 2 +- src/pi/launch.ts | 31 +++++++++++++++++++++++++++++-- tests/launch.test.ts | 7 +++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index da8e6ec..4877cb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4630,7 +4630,7 @@ }, "node_modules/pi-factory": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#615499c9b9c5813da9e3eac08a8e42ccd988c94f", + "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#47cf09cd314c1bb7c7c933ad4cb3316301bff94f", "license": "MIT", "dependencies": { "smol-toml": "^1.4.2" diff --git a/src/pi/launch.ts b/src/pi/launch.ts index 98ef552..dbd8b76 100644 --- a/src/pi/launch.ts +++ b/src/pi/launch.ts @@ -1,5 +1,7 @@ +import { spawn } from "node:child_process"; +import type { StdioOptions } from "node:child_process"; import { mkdir } from "node:fs/promises"; -import { createPiLaunchPlan, execPiLaunchPlan } from "pi-factory"; +import { createPiLaunchPlan } from "pi-factory"; import type { PiLaunchPlan } from "pi-factory"; import type { LocalpiOptions } from "../localpi/options.js"; @@ -30,5 +32,30 @@ export async function createLaunchPlan( } export async function execLaunchPlan(plan: ExecutableLaunchPlan): Promise { - return await execPiLaunchPlan(plan as PiLaunchPlan); + const stdio: StdioOptions = "inherit"; + const child = spawn(shellCommand(plan.command, plan.args), { + shell: true, + stdio, + cwd: plan.cwd, + env: { ...process.env, ...plan.env } + }); + child.stdout?.resume(); + return await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal !== null) { + process.kill(process.pid, signal); + return; + } + resolve(code ?? 0); + }); + }); +} + +function shellCommand(command: string, args: readonly string[]): string { + return [command, ...args.map(shellQuote)].join(" "); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; } diff --git a/tests/launch.test.ts b/tests/launch.test.ts index 7296868..c7f2684 100644 --- a/tests/launch.test.ts +++ b/tests/launch.test.ts @@ -57,6 +57,13 @@ describe("Pi launch plan", () => { await expect( execLaunchPlan({ command: "sh -c 'exit 7' --", args: [], env: { LOCALPI_TEST: "1" } }) ).resolves.toBe(7); + await expect( + execLaunchPlan({ + command: "LOCALPI_TEST=ok sh -c 'test \"$LOCALPI_TEST\" = ok' --", + args: [], + env: {} + }) + ).resolves.toBe(0); }); }); From 897add2371dfe2482dd3d19440429bfe039c7377 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 14:11:07 +0800 Subject: [PATCH 07/14] chore: refresh pi-factory dependency --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 4877cb1..288ca21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4630,7 +4630,7 @@ }, "node_modules/pi-factory": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#47cf09cd314c1bb7c7c933ad4cb3316301bff94f", + "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#68a29bda073da8cf71bd45480f42d36ac7d06e59", "license": "MIT", "dependencies": { "smol-toml": "^1.4.2" From 4c260112beb13f290f40eabe5389035df6b31545 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 15:26:52 +0800 Subject: [PATCH 08/14] chore: depend on pi-factory main --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 288ca21..2ee946a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.3.0", "license": "MIT", "dependencies": { - "pi-factory": "github:dutifuldev/pi-factory#feat/end-to-end-implementation" + "pi-factory": "github:dutifuldev/pi-factory#main" }, "bin": { "localpi": "dist/src/cli/main.js" @@ -4630,7 +4630,7 @@ }, "node_modules/pi-factory": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#68a29bda073da8cf71bd45480f42d36ac7d06e59", + "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#323f483d64767cefa535886981e7d180a28407d2", "license": "MIT", "dependencies": { "smol-toml": "^1.4.2" diff --git a/package.json b/package.json index 4cde382..6887847 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,6 @@ "vitest": "^3.0.0" }, "dependencies": { - "pi-factory": "github:dutifuldev/pi-factory#feat/end-to-end-implementation" + "pi-factory": "github:dutifuldev/pi-factory#main" } } From 726d5cc3bdf1fc145ce05ddc2605a85b2caebc88 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 20:58:09 +0800 Subject: [PATCH 09/14] chore: use published pi-factory package --- package-lock.json | 25 +++++++++++++------------ package.json | 2 +- src/pi/app.ts | 2 +- src/pi/config.ts | 4 ++-- src/pi/launch.ts | 4 ++-- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ee946a..fff4eb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.3.0", "license": "MIT", "dependencies": { - "pi-factory": "github:dutifuldev/pi-factory#main" + "@dutifuldev/pi-factory": "^0.1.0" }, "bin": { "localpi": "dist/src/cli/main.js" @@ -592,6 +592,18 @@ "node": ">=18" } }, + "node_modules/@dutifuldev/pi-factory": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@dutifuldev/pi-factory/-/pi-factory-0.1.0.tgz", + "integrity": "sha512-RzUiuE3Q5E+TlPAMBPiXwe+TvTu3AEnMAoj99emEmgHzWs976QYTqbU3EQ70vE6V+SgT1xAM7WUlpkDGQPmrNA==", + "license": "MIT", + "dependencies": { + "smol-toml": "^1.4.2" + }, + "bin": { + "pi-factory": "dist/src/cli/main.js" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -4628,17 +4640,6 @@ "node": ">= 14.16" } }, - "node_modules/pi-factory": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#323f483d64767cefa535886981e7d180a28407d2", - "license": "MIT", - "dependencies": { - "smol-toml": "^1.4.2" - }, - "bin": { - "pi-factory": "dist/src/cli/main.js" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 6887847..9a23a65 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,6 @@ "vitest": "^3.0.0" }, "dependencies": { - "pi-factory": "github:dutifuldev/pi-factory#main" + "@dutifuldev/pi-factory": "^0.1.0" } } diff --git a/src/pi/app.ts b/src/pi/app.ts index 3b9b137..38c3841 100644 --- a/src/pi/app.ts +++ b/src/pi/app.ts @@ -3,7 +3,7 @@ import type { PiExtensionDefinition, PiModelDefinition, PiProviderDefinition -} from "pi-factory"; +} from "@dutifuldev/pi-factory"; import type { CatalogModel } from "../localpi/catalog.js"; import type { LocalpiOptions } from "../localpi/options.js"; diff --git a/src/pi/config.ts b/src/pi/config.ts index e882d35..5bea7f3 100644 --- a/src/pi/config.ts +++ b/src/pi/config.ts @@ -1,5 +1,5 @@ -import { writePiRuntimeConfig } from "pi-factory"; -import type { PiRuntimeConfig } from "pi-factory"; +import { writePiRuntimeConfig } from "@dutifuldev/pi-factory"; +import type { PiRuntimeConfig } from "@dutifuldev/pi-factory"; import type { LocalpiOptions } from "../localpi/options.js"; import type { RuntimeConnection } from "../localpi/runtime.js"; diff --git a/src/pi/launch.ts b/src/pi/launch.ts index dbd8b76..086f6b2 100644 --- a/src/pi/launch.ts +++ b/src/pi/launch.ts @@ -1,8 +1,8 @@ import { spawn } from "node:child_process"; import type { StdioOptions } from "node:child_process"; import { mkdir } from "node:fs/promises"; -import { createPiLaunchPlan } from "pi-factory"; -import type { PiLaunchPlan } from "pi-factory"; +import { createPiLaunchPlan } from "@dutifuldev/pi-factory"; +import type { PiLaunchPlan } from "@dutifuldev/pi-factory"; import type { LocalpiOptions } from "../localpi/options.js"; import type { RuntimeConnection } from "../localpi/runtime.js"; From 5b342c88835009330e80cac99107420370018a54 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 21:28:48 +0800 Subject: [PATCH 10/14] refactor: tighten pi factory launch wiring --- src/cli/cli.ts | 15 +- src/localpi/settings-state.ts | 4 +- src/pi/app.ts | 64 ++- src/pi/config.ts | 13 +- src/pi/extension-sources/demo-mode.ts | 54 +++ .../startup-model-selector.ts | 80 ++++ src/pi/extension-sources/thinking-control.ts | 79 ++++ src/pi/extension-sources/token-status.ts | 133 ++++++ src/pi/extension-sources/tool-approval.ts | 45 ++ src/pi/extensions.ts | 403 +----------------- src/pi/launch.ts | 58 +-- src/version.ts | 35 ++ tests/extension-source.test.ts | 52 +++ tests/launch.test.ts | 56 ++- tests/pi-config.test.ts | 75 ++-- 15 files changed, 640 insertions(+), 526 deletions(-) create mode 100644 src/pi/extension-sources/demo-mode.ts create mode 100644 src/pi/extension-sources/startup-model-selector.ts create mode 100644 src/pi/extension-sources/thinking-control.ts create mode 100644 src/pi/extension-sources/token-status.ts create mode 100644 src/pi/extension-sources/tool-approval.ts create mode 100644 src/version.ts create mode 100644 tests/extension-source.test.ts diff --git a/src/cli/cli.ts b/src/cli/cli.ts index c660db1..29a3d2b 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -8,6 +8,7 @@ import { stopRuntime } from "../localpi/runtime.js"; import { applyRememberedSettings } from "../localpi/settings-state.js"; +import { createLocalpiAppDefinition } from "../pi/app.js"; import { writeRuntimeConfig } from "../pi/config.js"; import { writeDefaultExtensions } from "../pi/extensions.js"; import { createLaunchPlan, execLaunchPlan } from "../pi/launch.js"; @@ -29,13 +30,14 @@ export async function run(args: readonly string[]): Promise { }); const connection = await resolveRuntime(options); - const runtimeConfig = await writeRuntimeConfig(options, connection); const selectorOptions = startupModelSelectorOptions(options, connection); const extensions = await writeDefaultExtensions( options, selectorOptions === undefined ? {} : { startupModelSelector: selectorOptions } ); - return await launchResolvedRuntime(options, runtimeConfig, connection, extensions); + const app = createLocalpiAppDefinition(options, connection, extensions); + const runtimeConfig = await writeRuntimeConfig(app); + return await launchResolvedRuntime(app, runtimeConfig, connection); } catch (error) { return fail(`localpi: ${errorMessage(error)}`); } @@ -298,14 +300,11 @@ function isPiUnknownLongFlagNextValue(arg: string | undefined): boolean { } async function launchResolvedRuntime( - options: ParsedOptions, + app: ReturnType, runtimeConfig: Awaited>, - connection: Awaited>, - extensions: Awaited> + connection: Awaited> ): Promise { - const code = await execLaunchPlan( - await createLaunchPlan(options, runtimeConfig, connection, extensions) - ); + const code = await execLaunchPlan(await createLaunchPlan(app, runtimeConfig)); if (code !== 0) { return { code, stdout: "", stderr: "" }; } diff --git a/src/localpi/settings-state.ts b/src/localpi/settings-state.ts index b57b6f3..aafcb2e 100644 --- a/src/localpi/settings-state.ts +++ b/src/localpi/settings-state.ts @@ -8,7 +8,7 @@ export type LocalpiSettings = { readonly thinking?: LocalpiOptions["thinking"]; }; -export function settingsStatePath(options: Pick): string { +export function localpiSettingsPath(options: Pick): string { return path.join(options.stateDir, "settings.json"); } @@ -29,7 +29,7 @@ async function readLocalpiSettings( ): Promise { let raw: string; try { - raw = await readFile(settingsStatePath(options), "utf8"); + raw = await readFile(localpiSettingsPath(options), "utf8"); } catch (error) { if (isMissingFile(error)) { return {}; diff --git a/src/pi/app.ts b/src/pi/app.ts index 38c3841..0fc14a1 100644 --- a/src/pi/app.ts +++ b/src/pi/app.ts @@ -1,6 +1,5 @@ import type { PiAppDefinition, - PiExtensionDefinition, PiModelDefinition, PiProviderDefinition } from "@dutifuldev/pi-factory"; @@ -8,29 +7,64 @@ import type { import type { CatalogModel } from "../localpi/catalog.js"; import type { LocalpiOptions } from "../localpi/options.js"; import type { RuntimeConnection } from "../localpi/runtime.js"; +import { localpiVersion } from "../version.js"; import type { ExtensionBundle } from "./extensions.js"; +type LocalpiAppIdentity = Pick; +type LocalpiAppDirectories = Pick; +type LocalpiPiCommand = Pick; +type LocalpiRuntimeSelection = Pick< + PiAppDefinition, + "providers" | "defaultProvider" | "defaultModel" | "thinking" +> & + Partial>; +type LocalpiExtensionConfig = Partial>; + +const localpiAppIdentity: LocalpiAppIdentity = { + id: "localpi", + name: "localpi", + version: localpiVersion +}; + export function createLocalpiAppDefinition( options: LocalpiOptions, connection: RuntimeConnection, extensions?: ExtensionBundle ): PiAppDefinition { - const providers = providersForConnection(options, connection); return { - id: "localpi", - name: "localpi", - version: "0.3.0", + ...localpiAppIdentity, + ...appDirectories(options), + ...piCommand(options), + ...runtimeSelection(options, connection), + ...extensionConfig(extensions) + }; +} + +function appDirectories(options: LocalpiOptions): LocalpiAppDirectories { + return { stateDir: options.stateDir, - sessionDir: options.sessionDir, + sessionDir: options.sessionDir + }; +} + +function piCommand(options: LocalpiOptions): LocalpiPiCommand { + return { piCommand: options.piCommand, - providers, + forwardedArgs: options.forwardedArgs + }; +} + +function runtimeSelection( + options: LocalpiOptions, + connection: RuntimeConnection +): LocalpiRuntimeSelection { + const selection = { + providers: providersForConnection(options, connection), defaultProvider: connection.providerId, defaultModel: connection.model, - thinking: options.thinking, - ...(options.tools === undefined ? {} : { tools: options.tools }), - ...(extensions === undefined ? {} : extensionDefinition(extensions)), - forwardedArgs: options.forwardedArgs + thinking: options.thinking }; + return options.tools === undefined ? selection : { ...selection, tools: options.tools }; } function providersForConnection( @@ -114,10 +148,10 @@ function fallbackCatalog(connection: RuntimeConnection): readonly CatalogModel[] ]; } -function extensionDefinition(extensions: ExtensionBundle): { - readonly extensions: readonly PiExtensionDefinition[]; - readonly appendSystemPrompts: readonly string[]; -} { +function extensionConfig(extensions: ExtensionBundle | undefined): LocalpiExtensionConfig { + if (extensions === undefined) { + return {}; + } return { extensions: extensions.paths.map((extensionPath) => ({ path: extensionPath })), appendSystemPrompts: [extensions.systemPrompt] diff --git a/src/pi/config.ts b/src/pi/config.ts index 5bea7f3..0635e38 100644 --- a/src/pi/config.ts +++ b/src/pi/config.ts @@ -1,15 +1,8 @@ import { writePiRuntimeConfig } from "@dutifuldev/pi-factory"; -import type { PiRuntimeConfig } from "@dutifuldev/pi-factory"; - -import type { LocalpiOptions } from "../localpi/options.js"; -import type { RuntimeConnection } from "../localpi/runtime.js"; -import { createLocalpiAppDefinition } from "./app.js"; +import type { PiAppDefinition, PiRuntimeConfig } from "@dutifuldev/pi-factory"; export type RuntimeConfig = PiRuntimeConfig; -export async function writeRuntimeConfig( - options: LocalpiOptions, - connection: RuntimeConnection -): Promise { - return await writePiRuntimeConfig(createLocalpiAppDefinition(options, connection)); +export async function writeRuntimeConfig(app: PiAppDefinition): Promise { + return await writePiRuntimeConfig(app); } diff --git a/src/pi/extension-sources/demo-mode.ts b/src/pi/extension-sources/demo-mode.ts new file mode 100644 index 0000000..45f1d85 --- /dev/null +++ b/src/pi/extension-sources/demo-mode.ts @@ -0,0 +1,54 @@ +import type { DemoPrompts } from "../demo.js"; + +export function demoModeExtensionSource(prompts: DemoPrompts): string { + const initialPromptSource = JSON.stringify(prompts.initial); + const followupPromptSource = JSON.stringify(prompts.followup); + return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const initialPrompt = ${initialPromptSource}; +const followupPrompt = ${followupPromptSource}; + +export default function localpiDemoMode(pi: ExtensionAPI): void { + let started = false; + let stopped = false; + + pi.on("session_start", (event, ctx) => { + if (started || stopped || event.reason !== "startup" || ctx.mode !== "tui") { + return; + } + started = true; + queueMicrotask(() => { + if (!stopped) { + pi.sendUserMessage(initialPrompt); + } + }); + }); + + pi.on("turn_end", (event, ctx) => { + if (!started || stopped || ctx.mode !== "tui") { + return; + } + if (event.message.role !== "assistant") { + return; + } + switch (event.message.stopReason) { + case "aborted": + case "error": + stopped = true; + return; + case "toolUse": + return; + } + queueMicrotask(() => { + if (!stopped) { + pi.sendUserMessage(followupPrompt, { deliverAs: "followUp" }); + } + }); + }); + + pi.on("session_shutdown", () => { + stopped = true; + }); +} +`; +} diff --git a/src/pi/extension-sources/startup-model-selector.ts b/src/pi/extension-sources/startup-model-selector.ts new file mode 100644 index 0000000..b8312e4 --- /dev/null +++ b/src/pi/extension-sources/startup-model-selector.ts @@ -0,0 +1,80 @@ +import type { StartupModelSelectorOptions } from "../extensions.js"; + +export function startupModelSelectorExtensionSource(options: StartupModelSelectorOptions): string { + const startupModelsSource = JSON.stringify(options.models); + return `import type { ExtensionAPI, SettingsManager } from "@earendil-works/pi-coding-agent"; +import { ModelSelectorComponent } from "@earendil-works/pi-coding-agent"; + +type SelectedModel = Parameters[0]; +const startupModels = ${startupModelsSource} as const; +const startupModelKeys = new Set(startupModels.map((model) => modelKey(model))); + +export default function localpiStartupModelSelector(pi: ExtensionAPI): void { + let opened = false; + + pi.on("session_start", async (event, ctx) => { + if (opened || event.reason !== "startup" || ctx.mode !== "tui") { + return; + } + + const selectableModels = startupAvailableModels(ctx.modelRegistry); + if (selectableModels.length <= 1) { + return; + } + const scopedModels = selectableModels.map((model) => ({ model })); + + opened = true; + const selected = await ctx.ui.custom((tui, _theme, _keybindings, done) => { + const settings = { + setDefaultModelAndProvider: () => {} + } as unknown as SettingsManager; + return new ModelSelectorComponent( + tui, + ctx.model, + settings, + startupModelRegistry(ctx.modelRegistry) as typeof ctx.modelRegistry, + scopedModels, + (model) => done(model), + () => done(undefined) + ); + }); + + if (selected === undefined) { + return; + } + + const ok = await pi.setModel(selected); + if (!ok) { + ctx.ui.notify(\`No API key for \${selected.provider}/\${selected.id}\`, "error"); + } + }); +} + +function startupAvailableModels(registry: { + getAvailable(): SelectedModel[]; +}): SelectedModel[] { + return registry.getAvailable().filter((model) => startupModelKeys.has(modelKey(model))); +} + +function startupModelRegistry(registry: { + refresh(): void; + getError(): string | undefined; + getAvailable(): SelectedModel[]; + find(provider: string, modelId: string): SelectedModel | undefined; +}): typeof registry { + return { + refresh: () => registry.refresh(), + getError: () => registry.getError(), + getAvailable: () => startupAvailableModels(registry), + find: (provider, modelId) => { + const model = registry.find(provider, modelId); + return model !== undefined && startupModelKeys.has(modelKey(model)) ? model : undefined; + } + }; +} + +function modelKey(model: { readonly provider: string; readonly id: string }): string { + return \`\${model.provider}\\u0000\${model.id}\`; +} +`; +} diff --git a/src/pi/extension-sources/thinking-control.ts b/src/pi/extension-sources/thinking-control.ts new file mode 100644 index 0000000..3787f46 --- /dev/null +++ b/src/pi/extension-sources/thinking-control.ts @@ -0,0 +1,79 @@ +export function thinkingControlExtensionSource(settingsPath: string): string { + const settingsPathSource = JSON.stringify(settingsPath); + return `import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +const levels: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; +const settingsPath = ${settingsPathSource}; + +export default function localpiThinkingControl(pi: ExtensionAPI): void { + pi.registerCommand("thinking", { + description: "Set localpi thinking level", + getArgumentCompletions: (prefix) => { + const trimmed = prefix.trim().toLowerCase(); + const matches = levels.filter((level) => level.startsWith(trimmed)); + return matches.length === 0 ? null : matches.map((level) => ({ value: level, label: level })); + }, + handler: async (args, ctx) => { + const requested = parseThinkingLevel(args); + const level = requested ?? (await promptThinkingLevel(pi.getThinkingLevel(), ctx)); + if (level === undefined) { + return; + } + pi.setThinkingLevel(level); + const actual = pi.getThinkingLevel(); + await persistThinking(actual); + ctx.ui.notify( + actual === level ? \`thinking: \${actual}\` : \`thinking: \${actual} (clamped from \${level})\`, + actual === level ? "info" : "warning" + ); + } + }); + + pi.on("thinking_level_select", async (event, ctx) => { + await persistThinking(event.level); + ctx.ui.setStatus("localpi-thinking", \`thinking: \${event.level}\`); + }); + + pi.on("session_shutdown", async (_event, ctx) => { + await persistThinking(pi.getThinkingLevel()); + ctx.ui.setStatus("localpi-thinking", undefined); + }); +} + +async function persistThinking(level: ThinkingLevel): Promise { + const settings = await readSettings(); + settings.thinking = level; + await mkdir(dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, \`\${JSON.stringify(settings, null, 2)}\\n\`, "utf8"); +} + +async function readSettings(): Promise> { + try { + const value = JSON.parse(await readFile(settingsPath, "utf8")); + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + } catch { + return {}; + } +} + +async function promptThinkingLevel( + current: ThinkingLevel, + ctx: { readonly ui: { select(title: string, options: string[]): Promise } } +): Promise { + const selected = await ctx.ui.select( + "Thinking level", + levels.map((level) => (level === current ? \`\${level} (current)\` : level)) + ); + return selected === undefined ? undefined : parseThinkingLevel(selected); +} + +function parseThinkingLevel(value: string): ThinkingLevel | undefined { + const normalized = value.trim().split(/\\s+/u)[0]?.toLowerCase(); + return levels.find((level) => level === normalized); +} +`; +} diff --git a/src/pi/extension-sources/token-status.ts b/src/pi/extension-sources/token-status.ts new file mode 100644 index 0000000..d123400 --- /dev/null +++ b/src/pi/extension-sources/token-status.ts @@ -0,0 +1,133 @@ +export function tokenStatusExtensionSource(): string { + return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +type Usage = { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + totalTokens?: number; +}; + +type TurnState = { + startedAt: number; + outputText: string; + estimatedOutputTokens: number; + lastStatusAt: number; +}; + +export default function localpiTokenStatus(pi: ExtensionAPI): void { + let currentTurn: TurnState | undefined; + + pi.on("turn_start", () => { + currentTurn = { + startedAt: Date.now(), + outputText: "", + estimatedOutputTokens: 0, + lastStatusAt: 0 + }; + }); + + pi.on("message_update", (event, ctx) => { + const state = currentTurn; + if (!ctx.hasUI || state === undefined) { + return; + } + const update = textUpdateFromUnknown(event.assistantMessageEvent ?? event.message ?? event); + if (update.kind === "delta") { + state.outputText += update.text; + } else if (update.text.length > state.outputText.length) { + state.outputText = update.text; + } + state.estimatedOutputTokens = Math.ceil(state.outputText.length / 4); + if (Date.now() - state.lastStatusAt < 250) { + return; + } + state.lastStatusAt = Date.now(); + ctx.ui.setStatus("localpi-perf", ctx.ui.theme.fg("dim", statusText(state))); + }); + + pi.on("turn_end", (event, ctx) => { + const state = currentTurn ?? { + startedAt: Date.now(), + outputText: "", + estimatedOutputTokens: 0, + lastStatusAt: 0 + }; + currentTurn = undefined; + + if (!ctx.hasUI || event.message.role !== "assistant") { + return; + } + + const usage = event.message.usage as Usage | undefined; + const output = usage?.output ?? state.estimatedOutputTokens; + const input = usage?.input ?? 0; + const cacheRead = usage?.cacheRead ?? 0; + const cacheWrite = usage?.cacheWrite ?? 0; + const elapsedSeconds = elapsed(state); + const context = ctx.getContextUsage(); + const contextText = + context && context.percent !== null + ? \`ctx \${Math.round(context.percent)}%/\${Math.round(context.contextWindow / 1000)}k\` + : "ctx ?"; + + ctx.ui.setStatus( + "localpi-perf", + ctx.ui.theme.fg( + "dim", + [ + \`\${(output / elapsedSeconds).toFixed(1)} tok/s\`, + \`out \${output}\`, + \`in \${input}\`, + cacheRead > 0 ? \`cache \${cacheRead}\` : undefined, + cacheWrite > 0 ? \`cw \${cacheWrite}\` : undefined, + \`\${elapsedSeconds.toFixed(1)}s\`, + contextText + ] + .filter(Boolean) + .join(" | ") + ) + ); + }); + + pi.on("session_shutdown", (_event, ctx) => { + if (ctx.hasUI) { + ctx.ui.setStatus("localpi-perf", ""); + } + }); +} + +function statusText(state: TurnState): string { + const elapsedSeconds = elapsed(state); + return \`\${(state.estimatedOutputTokens / elapsedSeconds).toFixed(1)} tok/s | out ~\${state.estimatedOutputTokens} | \${elapsedSeconds.toFixed(1)}s\`; +} + +function elapsed(state: TurnState): number { + return Math.max((Date.now() - state.startedAt) / 1000, 0.001); +} + +type TextUpdate = { + kind: "delta" | "snapshot"; + text: string; +}; + +function textUpdateFromUnknown(value: unknown): TextUpdate { + if (typeof value === "string") { + return { kind: "snapshot", text: value }; + } + if (value && typeof value === "object") { + const object = value as Record; + const delta = object["delta"]; + const text = object["text"] ?? object["content"]; + if (typeof delta === "string") { + return { kind: "delta", text: delta }; + } + if (typeof text === "string") { + return { kind: "snapshot", text }; + } + } + return { kind: "snapshot", text: "" }; +} +`; +} diff --git a/src/pi/extension-sources/tool-approval.ts b/src/pi/extension-sources/tool-approval.ts new file mode 100644 index 0000000..bffd6d2 --- /dev/null +++ b/src/pi/extension-sources/tool-approval.ts @@ -0,0 +1,45 @@ +export function approvalExtensionSource(): string { + return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default function localpiToolApproval(pi: ExtensionAPI): void { + pi.on("before_agent_start", (event) => ({ + systemPrompt: + event.systemPrompt + + "\\n\\nTool approval rule: if any tool result says the tool was blocked, denied, or requires approval, the tool did not run. Do not claim blocked tools ran." + })); + + pi.on("tool_call", async (event, ctx) => { + const input = formatInput(event.input); + + if (!ctx.hasUI) { + return { + block: true, + reason: \`Tool call "\${event.toolName}" was blocked and did not run because interactive approval is required.\` + }; + } + + const ok = await ctx.ui.confirm(\`Allow tool call: \${event.toolName}?\`, input); + if (!ok) { + return { block: true, reason: "Tool call was blocked by the user and did not run." }; + } + + return undefined; + }); +} + +function formatInput(input: unknown): string { + let text: string; + try { + text = JSON.stringify(input, null, 2); + } catch { + text = String(input); + } + + const maxLength = 4000; + if (text.length <= maxLength) { + return text; + } + return \`\${text.slice(0, maxLength)}\\n... truncated ...\`; +} +`; +} diff --git a/src/pi/extensions.ts b/src/pi/extensions.ts index e421ab6..4a5333e 100644 --- a/src/pi/extensions.ts +++ b/src/pi/extensions.ts @@ -2,8 +2,13 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import type { LocalpiOptions } from "../localpi/options.js"; -import { settingsStatePath } from "../localpi/settings-state.js"; -import { resolveDemoPrompts, type DemoPrompts } from "./demo.js"; +import { localpiSettingsPath } from "../localpi/settings-state.js"; +import { resolveDemoPrompts } from "./demo.js"; +import { demoModeExtensionSource } from "./extension-sources/demo-mode.js"; +import { startupModelSelectorExtensionSource } from "./extension-sources/startup-model-selector.js"; +import { thinkingControlExtensionSource } from "./extension-sources/thinking-control.js"; +import { tokenStatusExtensionSource } from "./extension-sources/token-status.js"; +import { approvalExtensionSource } from "./extension-sources/tool-approval.js"; export type ExtensionBundle = { readonly paths: readonly string[]; @@ -52,7 +57,7 @@ export async function writeDefaultExtensions( await writeExtension( extensionDir, "thinking-control.ts", - thinkingControlExtensionSource(settingsStatePath(options)) + thinkingControlExtensionSource(localpiSettingsPath(options)) ) ); if (options.approval) { @@ -73,138 +78,6 @@ async function writeExtension(extensionDir: string, name: string, source: string return extensionPath; } -function demoModeExtensionSource(prompts: DemoPrompts): string { - const initialPromptSource = JSON.stringify(prompts.initial); - const followupPromptSource = JSON.stringify(prompts.followup); - return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -const initialPrompt = ${initialPromptSource}; -const followupPrompt = ${followupPromptSource}; - -export default function localpiDemoMode(pi: ExtensionAPI): void { - let started = false; - let stopped = false; - - pi.on("session_start", (event, ctx) => { - if (started || stopped || event.reason !== "startup" || ctx.mode !== "tui") { - return; - } - started = true; - queueMicrotask(() => { - if (!stopped) { - pi.sendUserMessage(initialPrompt); - } - }); - }); - - pi.on("turn_end", (event, ctx) => { - if (!started || stopped || ctx.mode !== "tui") { - return; - } - if (event.message.role !== "assistant") { - return; - } - switch (event.message.stopReason) { - case "aborted": - case "error": - stopped = true; - return; - case "toolUse": - return; - } - queueMicrotask(() => { - if (!stopped) { - pi.sendUserMessage(followupPrompt, { deliverAs: "followUp" }); - } - }); - }); - - pi.on("session_shutdown", () => { - stopped = true; - }); -} -`; -} - -function startupModelSelectorExtensionSource(options: StartupModelSelectorOptions): string { - const startupModelsSource = JSON.stringify(options.models); - return `import type { ExtensionAPI, SettingsManager } from "@earendil-works/pi-coding-agent"; -import { ModelSelectorComponent } from "@earendil-works/pi-coding-agent"; - -type SelectedModel = Parameters[0]; -const startupModels = ${startupModelsSource} as const; -const startupModelKeys = new Set(startupModels.map((model) => modelKey(model))); - -export default function localpiStartupModelSelector(pi: ExtensionAPI): void { - let opened = false; - - pi.on("session_start", async (event, ctx) => { - if (opened || event.reason !== "startup" || ctx.mode !== "tui") { - return; - } - - const selectableModels = startupAvailableModels(ctx.modelRegistry); - if (selectableModels.length <= 1) { - return; - } - const scopedModels = selectableModels.map((model) => ({ model })); - - opened = true; - const selected = await ctx.ui.custom((tui, _theme, _keybindings, done) => { - const settings = { - setDefaultModelAndProvider: () => {} - } as unknown as SettingsManager; - return new ModelSelectorComponent( - tui, - ctx.model, - settings, - startupModelRegistry(ctx.modelRegistry) as typeof ctx.modelRegistry, - scopedModels, - (model) => done(model), - () => done(undefined) - ); - }); - - if (selected === undefined) { - return; - } - - const ok = await pi.setModel(selected); - if (!ok) { - ctx.ui.notify(\`No API key for \${selected.provider}/\${selected.id}\`, "error"); - } - }); -} - -function startupAvailableModels(registry: { - getAvailable(): SelectedModel[]; -}): SelectedModel[] { - return registry.getAvailable().filter((model) => startupModelKeys.has(modelKey(model))); -} - -function startupModelRegistry(registry: { - refresh(): void; - getError(): string | undefined; - getAvailable(): SelectedModel[]; - find(provider: string, modelId: string): SelectedModel | undefined; -}): typeof registry { - return { - refresh: () => registry.refresh(), - getError: () => registry.getError(), - getAvailable: () => startupAvailableModels(registry), - find: (provider, modelId) => { - const model = registry.find(provider, modelId); - return model !== undefined && startupModelKeys.has(modelKey(model)) ? model : undefined; - } - }; -} - -function modelKey(model: { readonly provider: string; readonly id: string }): string { - return \`\${model.provider}\\u0000\${model.id}\`; -} -`; -} - function localpiSystemPrompt(approval: boolean): string { return [ "You are running through localpi, a local Pi launcher for local models.", @@ -215,263 +88,3 @@ function localpiSystemPrompt(approval: boolean): string { "Prefer answering directly when tools are not needed." ].join("\n"); } - -function approvalExtensionSource(): string { - return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -export default function localpiToolApproval(pi: ExtensionAPI): void { - pi.on("before_agent_start", (event) => ({ - systemPrompt: - event.systemPrompt + - "\\n\\nTool approval rule: if any tool result says the tool was blocked, denied, or requires approval, the tool did not run. Do not claim blocked tools ran." - })); - - pi.on("tool_call", async (event, ctx) => { - const input = formatInput(event.input); - - if (!ctx.hasUI) { - return { - block: true, - reason: \`Tool call "\${event.toolName}" was blocked and did not run because interactive approval is required.\` - }; - } - - const ok = await ctx.ui.confirm(\`Allow tool call: \${event.toolName}?\`, input); - if (!ok) { - return { block: true, reason: "Tool call was blocked by the user and did not run." }; - } - - return undefined; - }); -} - -function formatInput(input: unknown): string { - let text: string; - try { - text = JSON.stringify(input, null, 2); - } catch { - text = String(input); - } - - const maxLength = 4000; - if (text.length <= maxLength) { - return text; - } - return \`\${text.slice(0, maxLength)}\\n... truncated ...\`; -} -`; -} - -function tokenStatusExtensionSource(): string { - return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -type Usage = { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - totalTokens?: number; -}; - -type TurnState = { - startedAt: number; - outputText: string; - estimatedOutputTokens: number; - lastStatusAt: number; -}; - -export default function localpiTokenStatus(pi: ExtensionAPI): void { - let currentTurn: TurnState | undefined; - - pi.on("turn_start", () => { - currentTurn = { - startedAt: Date.now(), - outputText: "", - estimatedOutputTokens: 0, - lastStatusAt: 0 - }; - }); - - pi.on("message_update", (event, ctx) => { - const state = currentTurn; - if (!ctx.hasUI || state === undefined) { - return; - } - const update = textUpdateFromUnknown(event.assistantMessageEvent ?? event.message ?? event); - if (update.kind === "delta") { - state.outputText += update.text; - } else if (update.text.length > state.outputText.length) { - state.outputText = update.text; - } - state.estimatedOutputTokens = Math.ceil(state.outputText.length / 4); - if (Date.now() - state.lastStatusAt < 250) { - return; - } - state.lastStatusAt = Date.now(); - ctx.ui.setStatus("localpi-perf", ctx.ui.theme.fg("dim", statusText(state))); - }); - - pi.on("turn_end", (event, ctx) => { - const state = currentTurn ?? { - startedAt: Date.now(), - outputText: "", - estimatedOutputTokens: 0, - lastStatusAt: 0 - }; - currentTurn = undefined; - - if (!ctx.hasUI || event.message.role !== "assistant") { - return; - } - - const usage = event.message.usage as Usage | undefined; - const output = usage?.output ?? state.estimatedOutputTokens; - const input = usage?.input ?? 0; - const cacheRead = usage?.cacheRead ?? 0; - const cacheWrite = usage?.cacheWrite ?? 0; - const elapsedSeconds = elapsed(state); - const context = ctx.getContextUsage(); - const contextText = - context && context.percent !== null - ? \`ctx \${Math.round(context.percent)}%/\${Math.round(context.contextWindow / 1000)}k\` - : "ctx ?"; - - ctx.ui.setStatus( - "localpi-perf", - ctx.ui.theme.fg( - "dim", - [ - \`\${(output / elapsedSeconds).toFixed(1)} tok/s\`, - \`out \${output}\`, - \`in \${input}\`, - cacheRead > 0 ? \`cache \${cacheRead}\` : undefined, - cacheWrite > 0 ? \`cw \${cacheWrite}\` : undefined, - \`\${elapsedSeconds.toFixed(1)}s\`, - contextText - ] - .filter(Boolean) - .join(" | ") - ) - ); - }); - - pi.on("session_shutdown", (_event, ctx) => { - if (ctx.hasUI) { - ctx.ui.setStatus("localpi-perf", ""); - } - }); -} - -function statusText(state: TurnState): string { - const elapsedSeconds = elapsed(state); - return \`\${(state.estimatedOutputTokens / elapsedSeconds).toFixed(1)} tok/s | out ~\${state.estimatedOutputTokens} | \${elapsedSeconds.toFixed(1)}s\`; -} - -function elapsed(state: TurnState): number { - return Math.max((Date.now() - state.startedAt) / 1000, 0.001); -} - -type TextUpdate = { - kind: "delta" | "snapshot"; - text: string; -}; - -function textUpdateFromUnknown(value: unknown): TextUpdate { - if (typeof value === "string") { - return { kind: "snapshot", text: value }; - } - if (value && typeof value === "object") { - const object = value as Record; - const delta = object["delta"]; - const text = object["text"] ?? object["content"]; - if (typeof delta === "string") { - return { kind: "delta", text: delta }; - } - if (typeof text === "string") { - return { kind: "snapshot", text }; - } - } - return { kind: "snapshot", text: "" }; -} -`; -} - -function thinkingControlExtensionSource(settingsPath: string): string { - const settingsPathSource = JSON.stringify(settingsPath); - return `import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname } from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; - -const levels: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; -const settingsPath = ${settingsPathSource}; - -export default function localpiThinkingControl(pi: ExtensionAPI): void { - pi.registerCommand("thinking", { - description: "Set localpi thinking level", - getArgumentCompletions: (prefix) => { - const trimmed = prefix.trim().toLowerCase(); - const matches = levels.filter((level) => level.startsWith(trimmed)); - return matches.length === 0 ? null : matches.map((level) => ({ value: level, label: level })); - }, - handler: async (args, ctx) => { - const requested = parseThinkingLevel(args); - const level = requested ?? (await promptThinkingLevel(pi.getThinkingLevel(), ctx)); - if (level === undefined) { - return; - } - pi.setThinkingLevel(level); - const actual = pi.getThinkingLevel(); - await persistThinking(actual); - ctx.ui.notify( - actual === level ? \`thinking: \${actual}\` : \`thinking: \${actual} (clamped from \${level})\`, - actual === level ? "info" : "warning" - ); - } - }); - - pi.on("thinking_level_select", async (event, ctx) => { - await persistThinking(event.level); - ctx.ui.setStatus("localpi-thinking", \`thinking: \${event.level}\`); - }); - - pi.on("session_shutdown", async (_event, ctx) => { - await persistThinking(pi.getThinkingLevel()); - ctx.ui.setStatus("localpi-thinking", undefined); - }); -} - -async function persistThinking(level: ThinkingLevel): Promise { - const settings = await readSettings(); - settings.thinking = level; - await mkdir(dirname(settingsPath), { recursive: true }); - await writeFile(settingsPath, \`\${JSON.stringify(settings, null, 2)}\\n\`, "utf8"); -} - -async function readSettings(): Promise> { - try { - const value = JSON.parse(await readFile(settingsPath, "utf8")); - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; - } catch { - return {}; - } -} - -async function promptThinkingLevel( - current: ThinkingLevel, - ctx: { readonly ui: { select(title: string, options: string[]): Promise } } -): Promise { - const selected = await ctx.ui.select( - "Thinking level", - levels.map((level) => (level === current ? \`\${level} (current)\` : level)) - ); - return selected === undefined ? undefined : parseThinkingLevel(selected); -} - -function parseThinkingLevel(value: string): ThinkingLevel | undefined { - const normalized = value.trim().split(/\\s+/u)[0]?.toLowerCase(); - return levels.find((level) => level === normalized); -} -`; -} diff --git a/src/pi/launch.ts b/src/pi/launch.ts index 086f6b2..e659b49 100644 --- a/src/pi/launch.ts +++ b/src/pi/launch.ts @@ -1,61 +1,17 @@ -import { spawn } from "node:child_process"; -import type { StdioOptions } from "node:child_process"; import { mkdir } from "node:fs/promises"; -import { createPiLaunchPlan } from "@dutifuldev/pi-factory"; -import type { PiLaunchPlan } from "@dutifuldev/pi-factory"; +import { createPiLaunchPlan, execPiLaunchPlan } from "@dutifuldev/pi-factory"; +import type { PiAppDefinition, PiLaunchPlan } from "@dutifuldev/pi-factory"; -import type { LocalpiOptions } from "../localpi/options.js"; -import type { RuntimeConnection } from "../localpi/runtime.js"; -import type { ExtensionBundle } from "./extensions.js"; import type { RuntimeConfig } from "./config.js"; -import { createLocalpiAppDefinition } from "./app.js"; export type LaunchPlan = PiLaunchPlan; -type ExecutableLaunchPlan = { - readonly command: string; - readonly args: readonly string[]; - readonly env: Readonly>; - readonly cwd?: string; -}; export async function createLaunchPlan( - options: LocalpiOptions, - runtimeConfig: RuntimeConfig, - connection: RuntimeConnection, - extensions: ExtensionBundle + app: PiAppDefinition, + runtimeConfig: RuntimeConfig ): Promise { - await mkdir(options.sessionDir, { recursive: true }); - return await createPiLaunchPlan( - createLocalpiAppDefinition(options, connection, extensions), - runtimeConfig - ); + await mkdir(app.sessionDir, { recursive: true }); + return await createPiLaunchPlan(app, runtimeConfig); } -export async function execLaunchPlan(plan: ExecutableLaunchPlan): Promise { - const stdio: StdioOptions = "inherit"; - const child = spawn(shellCommand(plan.command, plan.args), { - shell: true, - stdio, - cwd: plan.cwd, - env: { ...process.env, ...plan.env } - }); - child.stdout?.resume(); - return await new Promise((resolve, reject) => { - child.on("error", reject); - child.on("exit", (code, signal) => { - if (signal !== null) { - process.kill(process.pid, signal); - return; - } - resolve(code ?? 0); - }); - }); -} - -function shellCommand(command: string, args: readonly string[]): string { - return [command, ...args.map(shellQuote)].join(" "); -} - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} +export const execLaunchPlan = execPiLaunchPlan; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..96b2fce --- /dev/null +++ b/src/version.ts @@ -0,0 +1,35 @@ +import { createRequire } from "node:module"; + +type PackageMetadata = { + readonly version?: unknown; +}; + +const require = createRequire(import.meta.url); +const packageMetadata = loadPackageMetadata(); + +if (typeof packageMetadata.version !== "string") { + throw new Error("localpi package metadata is missing a string version"); +} + +export const localpiVersion = packageMetadata.version; + +function loadPackageMetadata(): PackageMetadata { + for (const packagePath of ["../package.json", "../../package.json"]) { + try { + return require(packagePath) as PackageMetadata; + } catch (error) { + if (!isModuleNotFound(error)) { + throw error; + } + } + } + throw new Error("localpi package metadata could not be loaded"); +} + +function isModuleNotFound(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "MODULE_NOT_FOUND" + ); +} diff --git a/tests/extension-source.test.ts b/tests/extension-source.test.ts new file mode 100644 index 0000000..71a0b90 --- /dev/null +++ b/tests/extension-source.test.ts @@ -0,0 +1,52 @@ +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import { demoModeExtensionSource } from "../src/pi/extension-sources/demo-mode.js"; +import { startupModelSelectorExtensionSource } from "../src/pi/extension-sources/startup-model-selector.js"; +import { thinkingControlExtensionSource } from "../src/pi/extension-sources/thinking-control.js"; +import { tokenStatusExtensionSource } from "../src/pi/extension-sources/token-status.js"; +import { approvalExtensionSource } from "../src/pi/extension-sources/tool-approval.js"; + +describe("generated Pi extension sources", () => { + const sources = [ + { + fileName: "demo-mode.ts", + source: demoModeExtensionSource({ initial: "Begin.", followup: "Continue." }) + }, + { + fileName: "startup-model-selector.ts", + source: startupModelSelectorExtensionSource({ + models: [{ provider: "lmstudio", id: "gemma" }] + }) + }, + { + fileName: "thinking-control.ts", + source: thinkingControlExtensionSource("/tmp/localpi/settings.json") + }, + { fileName: "tool-approval.ts", source: approvalExtensionSource() }, + { fileName: "token-status.ts", source: tokenStatusExtensionSource() } + ] as const; + + for (const { fileName, source } of sources) { + it(`transpiles ${fileName}`, () => { + const result = ts.transpileModule(source, { + fileName, + reportDiagnostics: true, + compilerOptions: { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.ES2022, + strict: true + } + }); + expect(formatDiagnostics(result.diagnostics ?? [])).toBe(""); + }); + } +}); + +function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string { + return diagnostics + .filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")) + .join("\n"); +} diff --git a/tests/launch.test.ts b/tests/launch.test.ts index c7f2684..5945f45 100644 --- a/tests/launch.test.ts +++ b/tests/launch.test.ts @@ -4,19 +4,20 @@ import { describe, expect, it } from "vitest"; import type { LocalpiOptions } from "../src/localpi/options.js"; import type { RuntimeConnection } from "../src/localpi/runtime.js"; +import { createLocalpiAppDefinition } from "../src/pi/app.js"; import type { RuntimeConfig } from "../src/pi/config.js"; import { createLaunchPlan, execLaunchPlan } from "../src/pi/launch.js"; +import type { LaunchPlan } from "../src/pi/launch.js"; describe("Pi launch plan", () => { it("adds localpi extensions, system prompt, and default tools", async () => { + const stateDir = "/tmp/localpi-state"; const plan = await createLaunchPlan( - options("/tmp/localpi-state"), - runtimeConfig("/tmp/localpi-state"), - connection("gemma-4-e4b-it"), - { + createLocalpiAppDefinition(options(stateDir), connection("gemma-4-e4b-it"), { paths: ["/tmp/localpi-state/pi-extensions/tool-approval.ts"], systemPrompt: "localpi prompt" - } + }), + runtimeConfig(stateDir) ); expect(plan.args).toEqual([ @@ -38,11 +39,14 @@ describe("Pi launch plan", () => { }); it("does not add default tools when the user passes an explicit tool flag", async () => { + const stateDir = "/tmp/localpi-state"; const plan = await createLaunchPlan( - { ...options("/tmp/localpi-state"), forwardedArgs: ["--tools", "bash", "-p", "say ok"] }, - runtimeConfig("/tmp/localpi-state"), - connection("gemma-4-e4b-it"), - { paths: [], systemPrompt: "localpi prompt" } + createLocalpiAppDefinition( + { ...options(stateDir), forwardedArgs: ["--tools", "bash", "-p", "say ok"] }, + connection("gemma-4-e4b-it"), + { paths: [], systemPrompt: "localpi prompt" } + ), + runtimeConfig(stateDir) ); expect(plan.args).toContain("--tools"); @@ -50,23 +54,41 @@ describe("Pi launch plan", () => { expect(plan.args).toContain("bash"); }); - it("runs the plan through a shell and reports the exit code", async () => { + it("executes the pi-factory launch plan and reports the exit code", async () => { await expect( - execLaunchPlan({ command: "sh -c 'exit 0' --", args: ["quoted 'arg'"], env: {} }) + execLaunchPlan(executablePlan({ command: "sh", args: ["-c", "exit 0", "--"] })) ).resolves.toBe(0); await expect( - execLaunchPlan({ command: "sh -c 'exit 7' --", args: [], env: { LOCALPI_TEST: "1" } }) + execLaunchPlan(executablePlan({ command: "sh", args: ["-c", "exit 7", "--"] })) ).resolves.toBe(7); await expect( - execLaunchPlan({ - command: "LOCALPI_TEST=ok sh -c 'test \"$LOCALPI_TEST\" = ok' --", - args: [], - env: {} - }) + execLaunchPlan( + executablePlan({ + command: "sh", + args: ["-c", 'test "$LOCALPI_TEST" = ok', "--"], + env: { LOCALPI_TEST: "ok" } + }) + ) ).resolves.toBe(0); }); }); +function executablePlan(input: { + readonly command: string; + readonly args: readonly string[]; + readonly env?: Readonly>; +}): LaunchPlan { + return { + appId: "localpi", + appName: "localpi", + command: input.command, + args: input.args, + env: input.env ?? {}, + runtimeConfig: runtimeConfig("/tmp/localpi-state"), + warnings: [] + }; +} + function options(stateDir: string): LocalpiOptions { return { runtime: "lmstudio", diff --git a/tests/pi-config.test.ts b/tests/pi-config.test.ts index 65b069c..b6897e5 100644 --- a/tests/pi-config.test.ts +++ b/tests/pi-config.test.ts @@ -7,15 +7,28 @@ import { describe, expect, it } from "vitest"; import type { CatalogModel } from "../src/localpi/catalog.js"; import type { LocalpiOptions } from "../src/localpi/options.js"; import type { RuntimeConnection } from "../src/localpi/runtime.js"; +import { createLocalpiAppDefinition } from "../src/pi/app.js"; import { writeRuntimeConfig } from "../src/pi/config.js"; +import { localpiVersion } from "../src/version.js"; describe("Pi runtime config", () => { + it("uses package metadata for the Pi app version", () => { + const app = createLocalpiAppDefinition( + options("/tmp/localpi-test"), + connection("gemma-4-e4b-it", "http://127.0.0.1:1234/v1") + ); + + expect(app.version).toBe(localpiVersion); + }); + it("writes a local OpenAI-compatible provider config", async () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "localpi-test-")); try { const runtime = await writeRuntimeConfig( - options(stateDir), - connection("gemma-4-e4b-it", "http://127.0.0.1:1234/v1") + createLocalpiAppDefinition( + options(stateDir), + connection("gemma-4-e4b-it", "http://127.0.0.1:1234/v1") + ) ); const models = JSON.parse(await readFile(runtime.modelsPath, "utf8")) as { providers: Record; @@ -36,8 +49,10 @@ describe("Pi runtime config", () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "localpi-test-")); try { const runtime = await writeRuntimeConfig( - options(stateDir), - connection("gemma-4-e4b-it", "http://127.0.0.1:1234/v1", 120000) + createLocalpiAppDefinition( + options(stateDir), + connection("gemma-4-e4b-it", "http://127.0.0.1:1234/v1", 120000) + ) ); const models = JSON.parse(await readFile(runtime.modelsPath, "utf8")) as { providers: Record; @@ -60,8 +75,10 @@ describe("Pi runtime config", () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "localpi-test-")); try { const runtime = await writeRuntimeConfig( - { ...options(stateDir), contextWindow: 4096 }, - connection("gemma-4-e4b-it", "http://127.0.0.1:1234/v1") + createLocalpiAppDefinition( + { ...options(stateDir), contextWindow: 4096 }, + connection("gemma-4-e4b-it", "http://127.0.0.1:1234/v1") + ) ); const settings = JSON.parse(await readFile(runtime.settingsPath, "utf8")) as { compaction?: { enabled?: boolean; reserveTokens?: number; keepRecentTokens?: number }; @@ -80,28 +97,30 @@ describe("Pi runtime config", () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "localpi-test-")); try { const runtime = await writeRuntimeConfig( - { ...options(stateDir), contextWindow: 4096 }, - { - runtime: "vllm", - providerId: "vllm", - providerName: "vLLM", - baseUrl: "http://127.0.0.1:8000/v1", - model: "qwen", - availableModels: ["qwen"], - catalogModels: [ - catalogModel("lmstudio", "LM Studio", "http://127.0.0.1:1234/v1", "gemma", 120000), - catalogModel( - "vllm", - "vLLM", - "http://127.0.0.1:8000/v1", - "qwen", - 32768, - true, - "qwen-chat-template" - ) - ], - warnings: [] - } + createLocalpiAppDefinition( + { ...options(stateDir), contextWindow: 4096 }, + { + runtime: "vllm", + providerId: "vllm", + providerName: "vLLM", + baseUrl: "http://127.0.0.1:8000/v1", + model: "qwen", + availableModels: ["qwen"], + catalogModels: [ + catalogModel("lmstudio", "LM Studio", "http://127.0.0.1:1234/v1", "gemma", 120000), + catalogModel( + "vllm", + "vLLM", + "http://127.0.0.1:8000/v1", + "qwen", + 32768, + true, + "qwen-chat-template" + ) + ], + warnings: [] + } + ) ); const models = JSON.parse(await readFile(runtime.modelsPath, "utf8")) as { providers: Record< From 0a9a570afcfceda657bef438097cce9ba8cb5376 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 21:43:46 +0800 Subject: [PATCH 11/14] test: cover shell pi command launches --- package-lock.json | 8 ++++---- package.json | 2 +- tests/launch.test.ts | 11 +++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index fff4eb8..1118681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.3.0", "license": "MIT", "dependencies": { - "@dutifuldev/pi-factory": "^0.1.0" + "@dutifuldev/pi-factory": "github:dutifuldev/pi-factory#b301b8b678923b83b537f42ddd0ad47f2a0449fd" }, "bin": { "localpi": "dist/src/cli/main.js" @@ -593,9 +593,9 @@ } }, "node_modules/@dutifuldev/pi-factory": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@dutifuldev/pi-factory/-/pi-factory-0.1.0.tgz", - "integrity": "sha512-RzUiuE3Q5E+TlPAMBPiXwe+TvTu3AEnMAoj99emEmgHzWs976QYTqbU3EQ70vE6V+SgT1xAM7WUlpkDGQPmrNA==", + "version": "0.1.1", + "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#b301b8b678923b83b537f42ddd0ad47f2a0449fd", + "integrity": "sha512-VbwozCQPef3x54gOfk5YvL/jb82vqSrJjanb8wiKSp65xei8nnHK965hUAg8UZShpUU0Q8jK5KUvGot+qt63AQ==", "license": "MIT", "dependencies": { "smol-toml": "^1.4.2" diff --git a/package.json b/package.json index 9a23a65..ce22999 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,6 @@ "vitest": "^3.0.0" }, "dependencies": { - "@dutifuldev/pi-factory": "^0.1.0" + "@dutifuldev/pi-factory": "github:dutifuldev/pi-factory#b301b8b678923b83b537f42ddd0ad47f2a0449fd" } } diff --git a/tests/launch.test.ts b/tests/launch.test.ts index 5945f45..56f021b 100644 --- a/tests/launch.test.ts +++ b/tests/launch.test.ts @@ -71,6 +71,17 @@ describe("Pi launch plan", () => { ) ).resolves.toBe(0); }); + + it("preserves shell-style pi command values", async () => { + await expect( + execLaunchPlan( + executablePlan({ + command: "LOCALPI_TEST=ok sh -c 'test \"$LOCALPI_TEST\" = ok' --", + args: [] + }) + ) + ).resolves.toBe(0); + }); }); function executablePlan(input: { From 49b0744390d5c344c2ea180e99666642a4e25470 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 21:50:22 +0800 Subject: [PATCH 12/14] fix: keep version metadata inside pi boundary --- src/pi/app.ts | 2 +- src/{ => pi}/version.ts | 2 +- tests/pi-config.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/{ => pi}/version.ts (91%) diff --git a/src/pi/app.ts b/src/pi/app.ts index 0fc14a1..a15cbdf 100644 --- a/src/pi/app.ts +++ b/src/pi/app.ts @@ -7,8 +7,8 @@ import type { import type { CatalogModel } from "../localpi/catalog.js"; import type { LocalpiOptions } from "../localpi/options.js"; import type { RuntimeConnection } from "../localpi/runtime.js"; -import { localpiVersion } from "../version.js"; import type { ExtensionBundle } from "./extensions.js"; +import { localpiVersion } from "./version.js"; type LocalpiAppIdentity = Pick; type LocalpiAppDirectories = Pick; diff --git a/src/version.ts b/src/pi/version.ts similarity index 91% rename from src/version.ts rename to src/pi/version.ts index 96b2fce..2d13d48 100644 --- a/src/version.ts +++ b/src/pi/version.ts @@ -14,7 +14,7 @@ if (typeof packageMetadata.version !== "string") { export const localpiVersion = packageMetadata.version; function loadPackageMetadata(): PackageMetadata { - for (const packagePath of ["../package.json", "../../package.json"]) { + for (const packagePath of ["../../package.json", "../../../package.json"]) { try { return require(packagePath) as PackageMetadata; } catch (error) { diff --git a/tests/pi-config.test.ts b/tests/pi-config.test.ts index b6897e5..c8aba3a 100644 --- a/tests/pi-config.test.ts +++ b/tests/pi-config.test.ts @@ -9,7 +9,7 @@ import type { LocalpiOptions } from "../src/localpi/options.js"; import type { RuntimeConnection } from "../src/localpi/runtime.js"; import { createLocalpiAppDefinition } from "../src/pi/app.js"; import { writeRuntimeConfig } from "../src/pi/config.js"; -import { localpiVersion } from "../src/version.js"; +import { localpiVersion } from "../src/pi/version.js"; describe("Pi runtime config", () => { it("uses package metadata for the Pi app version", () => { From db18c2f3c212aa8418b31e8934757bc3ecfe26a7 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 22:25:13 +0800 Subject: [PATCH 13/14] chore: use published pi-factory package --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1118681..e603962 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.3.0", "license": "MIT", "dependencies": { - "@dutifuldev/pi-factory": "github:dutifuldev/pi-factory#b301b8b678923b83b537f42ddd0ad47f2a0449fd" + "@dutifuldev/pi-factory": "^0.1.1" }, "bin": { "localpi": "dist/src/cli/main.js" @@ -594,7 +594,7 @@ }, "node_modules/@dutifuldev/pi-factory": { "version": "0.1.1", - "resolved": "git+ssh://git@github.com/dutifuldev/pi-factory.git#b301b8b678923b83b537f42ddd0ad47f2a0449fd", + "resolved": "https://registry.npmjs.org/@dutifuldev/pi-factory/-/pi-factory-0.1.1.tgz", "integrity": "sha512-VbwozCQPef3x54gOfk5YvL/jb82vqSrJjanb8wiKSp65xei8nnHK965hUAg8UZShpUU0Q8jK5KUvGot+qt63AQ==", "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ce22999..628e3cc 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,6 @@ "vitest": "^3.0.0" }, "dependencies": { - "@dutifuldev/pi-factory": "github:dutifuldev/pi-factory#b301b8b678923b83b537f42ddd0ad47f2a0449fd" + "@dutifuldev/pi-factory": "^0.1.1" } } From e1db035ea08600d2ed13c654d36740b909a7c6fe Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 24 Jun 2026 23:18:10 +0800 Subject: [PATCH 14/14] refactor: call pi-factory launch directly --- src/cli/cli.ts | 10 ++++------ src/pi/config.ts | 8 -------- src/pi/launch.ts | 17 ----------------- tests/launch.test.ts | 21 ++++++++++----------- tests/pi-config.test.ts | 10 +++++----- 5 files changed, 19 insertions(+), 47 deletions(-) delete mode 100644 src/pi/config.ts delete mode 100644 src/pi/launch.ts diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 29a3d2b..714f7c7 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,3 +1,5 @@ +import { runPiApp } from "@dutifuldev/pi-factory"; + import { errorMessage, fail, ok, type CommandResult } from "../common/result.js"; import { parseLocalpiArgs, usage } from "../localpi/options.js"; import { @@ -9,9 +11,7 @@ import { } from "../localpi/runtime.js"; import { applyRememberedSettings } from "../localpi/settings-state.js"; import { createLocalpiAppDefinition } from "../pi/app.js"; -import { writeRuntimeConfig } from "../pi/config.js"; import { writeDefaultExtensions } from "../pi/extensions.js"; -import { createLaunchPlan, execLaunchPlan } from "../pi/launch.js"; export async function run(args: readonly string[]): Promise { try { @@ -36,8 +36,7 @@ export async function run(args: readonly string[]): Promise { selectorOptions === undefined ? {} : { startupModelSelector: selectorOptions } ); const app = createLocalpiAppDefinition(options, connection, extensions); - const runtimeConfig = await writeRuntimeConfig(app); - return await launchResolvedRuntime(app, runtimeConfig, connection); + return await launchResolvedRuntime(app, connection); } catch (error) { return fail(`localpi: ${errorMessage(error)}`); } @@ -301,10 +300,9 @@ function isPiUnknownLongFlagNextValue(arg: string | undefined): boolean { async function launchResolvedRuntime( app: ReturnType, - runtimeConfig: Awaited>, connection: Awaited> ): Promise { - const code = await execLaunchPlan(await createLaunchPlan(app, runtimeConfig)); + const code = await runPiApp(app); if (code !== 0) { return { code, stdout: "", stderr: "" }; } diff --git a/src/pi/config.ts b/src/pi/config.ts deleted file mode 100644 index 0635e38..0000000 --- a/src/pi/config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { writePiRuntimeConfig } from "@dutifuldev/pi-factory"; -import type { PiAppDefinition, PiRuntimeConfig } from "@dutifuldev/pi-factory"; - -export type RuntimeConfig = PiRuntimeConfig; - -export async function writeRuntimeConfig(app: PiAppDefinition): Promise { - return await writePiRuntimeConfig(app); -} diff --git a/src/pi/launch.ts b/src/pi/launch.ts deleted file mode 100644 index e659b49..0000000 --- a/src/pi/launch.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { mkdir } from "node:fs/promises"; -import { createPiLaunchPlan, execPiLaunchPlan } from "@dutifuldev/pi-factory"; -import type { PiAppDefinition, PiLaunchPlan } from "@dutifuldev/pi-factory"; - -import type { RuntimeConfig } from "./config.js"; - -export type LaunchPlan = PiLaunchPlan; - -export async function createLaunchPlan( - app: PiAppDefinition, - runtimeConfig: RuntimeConfig -): Promise { - await mkdir(app.sessionDir, { recursive: true }); - return await createPiLaunchPlan(app, runtimeConfig); -} - -export const execLaunchPlan = execPiLaunchPlan; diff --git a/tests/launch.test.ts b/tests/launch.test.ts index 56f021b..198e090 100644 --- a/tests/launch.test.ts +++ b/tests/launch.test.ts @@ -1,18 +1,17 @@ import path from "node:path"; +import { createPiLaunchPlan, execPiLaunchPlan } from "@dutifuldev/pi-factory"; +import type { PiLaunchPlan, PiRuntimeConfig } from "@dutifuldev/pi-factory"; import { describe, expect, it } from "vitest"; import type { LocalpiOptions } from "../src/localpi/options.js"; import type { RuntimeConnection } from "../src/localpi/runtime.js"; import { createLocalpiAppDefinition } from "../src/pi/app.js"; -import type { RuntimeConfig } from "../src/pi/config.js"; -import { createLaunchPlan, execLaunchPlan } from "../src/pi/launch.js"; -import type { LaunchPlan } from "../src/pi/launch.js"; describe("Pi launch plan", () => { it("adds localpi extensions, system prompt, and default tools", async () => { const stateDir = "/tmp/localpi-state"; - const plan = await createLaunchPlan( + const plan = await createPiLaunchPlan( createLocalpiAppDefinition(options(stateDir), connection("gemma-4-e4b-it"), { paths: ["/tmp/localpi-state/pi-extensions/tool-approval.ts"], systemPrompt: "localpi prompt" @@ -40,7 +39,7 @@ describe("Pi launch plan", () => { it("does not add default tools when the user passes an explicit tool flag", async () => { const stateDir = "/tmp/localpi-state"; - const plan = await createLaunchPlan( + const plan = await createPiLaunchPlan( createLocalpiAppDefinition( { ...options(stateDir), forwardedArgs: ["--tools", "bash", "-p", "say ok"] }, connection("gemma-4-e4b-it"), @@ -56,13 +55,13 @@ describe("Pi launch plan", () => { it("executes the pi-factory launch plan and reports the exit code", async () => { await expect( - execLaunchPlan(executablePlan({ command: "sh", args: ["-c", "exit 0", "--"] })) + execPiLaunchPlan(executablePlan({ command: "sh", args: ["-c", "exit 0", "--"] })) ).resolves.toBe(0); await expect( - execLaunchPlan(executablePlan({ command: "sh", args: ["-c", "exit 7", "--"] })) + execPiLaunchPlan(executablePlan({ command: "sh", args: ["-c", "exit 7", "--"] })) ).resolves.toBe(7); await expect( - execLaunchPlan( + execPiLaunchPlan( executablePlan({ command: "sh", args: ["-c", 'test "$LOCALPI_TEST" = ok', "--"], @@ -74,7 +73,7 @@ describe("Pi launch plan", () => { it("preserves shell-style pi command values", async () => { await expect( - execLaunchPlan( + execPiLaunchPlan( executablePlan({ command: "LOCALPI_TEST=ok sh -c 'test \"$LOCALPI_TEST\" = ok' --", args: [] @@ -88,7 +87,7 @@ function executablePlan(input: { readonly command: string; readonly args: readonly string[]; readonly env?: Readonly>; -}): LaunchPlan { +}): PiLaunchPlan { return { appId: "localpi", appName: "localpi", @@ -153,7 +152,7 @@ function connection(model: string): RuntimeConnection { }; } -function runtimeConfig(stateDir: string): RuntimeConfig { +function runtimeConfig(stateDir: string): PiRuntimeConfig { return { configDir: path.join(stateDir, "pi"), modelsPath: path.join(stateDir, "pi", "models.json"), diff --git a/tests/pi-config.test.ts b/tests/pi-config.test.ts index c8aba3a..fc4b2aa 100644 --- a/tests/pi-config.test.ts +++ b/tests/pi-config.test.ts @@ -2,13 +2,13 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { writePiRuntimeConfig } from "@dutifuldev/pi-factory"; import { describe, expect, it } from "vitest"; import type { CatalogModel } from "../src/localpi/catalog.js"; import type { LocalpiOptions } from "../src/localpi/options.js"; import type { RuntimeConnection } from "../src/localpi/runtime.js"; import { createLocalpiAppDefinition } from "../src/pi/app.js"; -import { writeRuntimeConfig } from "../src/pi/config.js"; import { localpiVersion } from "../src/pi/version.js"; describe("Pi runtime config", () => { @@ -24,7 +24,7 @@ describe("Pi runtime config", () => { it("writes a local OpenAI-compatible provider config", async () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "localpi-test-")); try { - const runtime = await writeRuntimeConfig( + const runtime = await writePiRuntimeConfig( createLocalpiAppDefinition( options(stateDir), connection("gemma-4-e4b-it", "http://127.0.0.1:1234/v1") @@ -48,7 +48,7 @@ describe("Pi runtime config", () => { it("writes context window only from an override or discovered metadata", async () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "localpi-test-")); try { - const runtime = await writeRuntimeConfig( + const runtime = await writePiRuntimeConfig( createLocalpiAppDefinition( options(stateDir), connection("gemma-4-e4b-it", "http://127.0.0.1:1234/v1", 120000) @@ -74,7 +74,7 @@ describe("Pi runtime config", () => { it("scales Pi compaction settings below small local context windows", async () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "localpi-test-")); try { - const runtime = await writeRuntimeConfig( + const runtime = await writePiRuntimeConfig( createLocalpiAppDefinition( { ...options(stateDir), contextWindow: 4096 }, connection("gemma-4-e4b-it", "http://127.0.0.1:1234/v1") @@ -96,7 +96,7 @@ describe("Pi runtime config", () => { it("writes every loaded catalog provider for Pi model switching", async () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "localpi-test-")); try { - const runtime = await writeRuntimeConfig( + const runtime = await writePiRuntimeConfig( createLocalpiAppDefinition( { ...options(stateDir), contextWindow: 4096 }, {