Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/pi-continuous-learning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ All defaults work out of the box. Override at `~/.pi/continuous-learning/config.
| `max_instincts` | 20 | Maximum instincts injected per turn |
| `max_injection_chars` | 4000 | Character budget for the injection block (~1,000 tokens) |
| `model` | `claude-haiku-4-5` | Model for the background analyzer |
| `provider` | `anthropic` | Pi provider for the background analyzer model |
| `provider` | `anthropic` | Pi provider for the background analyzer model, including custom providers from `~/.pi/agent/models.json` |
| `timeout_seconds` | 120 | Per-project LLM session timeout |
| `active_hours_start` | 8 | Hour (0–23) at which the active observation window starts |
| `active_hours_end` | 23 | Hour (0–23) at which the active observation window ends |
Expand Down
5 changes: 3 additions & 2 deletions packages/pi-continuous-learning/docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,11 @@ instinct-decay.ts -- apply passive confidence decay (-0.05/week) after cleanup
Resolve analyzer provider/model from config:
- provider: anthropic (configurable)
- model: claude-haiku-4-5 (configurable)
- credentials: existing Pi auth for that provider
- registry: Pi's ModelRegistry, including custom providers from ~/.pi/agent/models.json
- credentials and request headers: existing Pi auth or models.json configuration for that provider
|
v
runSingleShot(context, model, apiKey) -- sends observations + project context to the configured model
runSingleShot(context, model, apiKey, signal, headers) -- sends observations + project context to the configured model
|
v
Model analyzes patterns and returns structured instinct changes
Expand Down
120 changes: 94 additions & 26 deletions packages/pi-continuous-learning/src/cli/analyze-model.test.ts
Original file line number Diff line number Diff line change
@@ -1,82 +1,150 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
AuthStorage,
ModelRegistry,
} from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_CONFIG } from "../config.js";
import type { Config } from "../types.js";
import { resolveAnalyzerModel } from "./analyze-model.js";

const temporaryDirectories: string[] = [];

function config(overrides: Partial<Config> = {}): Config {
return { ...DEFAULT_CONFIG, ...overrides };
}

function registry(apiKey: string | undefined) {
const models = ModelRegistry.inMemory(AuthStorage.inMemory()).getAll();
return {
find: vi.fn((provider: string, modelId: string) =>
models.find(
(model) => model.provider === provider && model.id === modelId,
),
),
getAll: vi.fn(() => models),
getApiKeyAndHeaders: vi.fn().mockResolvedValue({ ok: true, apiKey }),
};
}

afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});

describe("resolveAnalyzerModel", () => {
it("uses the configured provider and model", async () => {
const authStorage = {
getApiKey: vi.fn().mockResolvedValue("codex-token"),
};
const modelRegistry = registry("codex-token");

const result = await resolveAnalyzerModel(
config({ provider: "openai-codex", model: "gpt-5.4-mini" }),
authStorage,
modelRegistry,
);

expect(result.providerId).toBe("openai-codex");
expect(result.modelId).toBe("gpt-5.4-mini");
expect(result.model.provider).toBe("openai-codex");
expect(result.model.id).toBe("gpt-5.4-mini");
expect(result.apiKey).toBe("codex-token");
expect(authStorage.getApiKey).toHaveBeenCalledWith("openai-codex");
expect(modelRegistry.getApiKeyAndHeaders).toHaveBeenCalledWith(result.model);
});

it("keeps Anthropic Haiku as the backwards-compatible default", async () => {
const authStorage = {
getApiKey: vi.fn().mockResolvedValue("anthropic-token"),
};
const modelRegistry = registry("anthropic-token");

const result = await resolveAnalyzerModel(config(), authStorage);
const result = await resolveAnalyzerModel(config(), modelRegistry);

expect(result.providerId).toBe("anthropic");
expect(result.modelId).toBe("claude-haiku-4-5");
expect(result.model.provider).toBe("anthropic");
expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic");
});

it("throws a provider-specific error when credentials are missing", async () => {
const authStorage = {
getApiKey: vi.fn().mockResolvedValue(undefined),
};
it("loads custom provider models, credentials, and headers from models.json", async () => {
const directory = mkdtempSync(join(tmpdir(), "pi-cl-model-registry-"));
temporaryDirectories.push(directory);
const modelsPath = join(directory, "models.json");
writeFileSync(
modelsPath,
JSON.stringify({
providers: {
"my-custom-provider": {
baseUrl: "https://proxy.example.com/anthropic",
api: "anthropic-messages",
apiKey: "custom-token",
headers: { "X-Proxy-Tenant": "continuous-learning" },
models: [
{
id: "custom-model",
name: "Custom Model",
reasoning: true,
input: ["text"],
contextWindow: 100_000,
maxTokens: 8_192,
cost: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
},
},
],
},
},
}),
"utf8",
);
const modelRegistry = ModelRegistry.create(
AuthStorage.inMemory(),
modelsPath,
);

const result = await resolveAnalyzerModel(
config({ provider: "my-custom-provider", model: "custom-model" }),
modelRegistry,
);

expect(result.model.provider).toBe("my-custom-provider");
expect(result.model.id).toBe("custom-model");
expect(result.model.baseUrl).toBe("https://proxy.example.com/anthropic");
expect(result.apiKey).toBe("custom-token");
expect(result.headers).toEqual({
"X-Proxy-Tenant": "continuous-learning",
});
});

it("throws a provider-specific error when credentials are missing", async () => {
await expect(
resolveAnalyzerModel(
config({ provider: "openai-codex", model: "gpt-5.4-mini" }),
authStorage,
registry(undefined),
),
).rejects.toThrow("No API key configured for provider: openai-codex");
});

it("throws a provider/model-specific error for unknown model ids", async () => {
const authStorage = {
getApiKey: vi.fn().mockResolvedValue("token"),
};
const modelRegistry = registry("token");

await expect(
resolveAnalyzerModel(
config({ provider: "openai-codex", model: "not-a-real-model" }),
authStorage,
modelRegistry,
),
).rejects.toThrow("Unknown analyzer model: openai-codex/not-a-real-model");
expect(authStorage.getApiKey).not.toHaveBeenCalled();
expect(modelRegistry.getApiKeyAndHeaders).not.toHaveBeenCalled();
});

it("throws a provider-specific error for unknown provider strings", async () => {
const authStorage = {
getApiKey: vi.fn().mockResolvedValue("token"),
};
const modelRegistry = registry("token");

await expect(
resolveAnalyzerModel(
config({ provider: "not-a-real-provider", model: "claude-haiku-4-5" }),
authStorage,
modelRegistry,
),
).rejects.toThrow("Unknown analyzer provider: not-a-real-provider");
expect(authStorage.getApiKey).not.toHaveBeenCalled();
expect(modelRegistry.getApiKeyAndHeaders).not.toHaveBeenCalled();
});
});
49 changes: 28 additions & 21 deletions packages/pi-continuous-learning/src/cli/analyze-model.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,60 @@
import type { AuthStorage } from "@earendil-works/pi-coding-agent";
import {
getModel,
getProviders,
type Api,
type KnownProvider,
type Model,
} from "@earendil-works/pi-ai";
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
import type { Api, Model } from "@earendil-works/pi-ai";
import type { Config } from "../types.js";

type AnalyzerAuthStorage = Pick<AuthStorage, "getApiKey">;
type AnalyzerModelRegistry = Pick<
ModelRegistry,
"find" | "getAll" | "getApiKeyAndHeaders"
>;

export interface AnalyzerModelResolution {
readonly apiKey: string;
readonly model: Model<Api>;
readonly modelId: string;
readonly providerId: string;
}

function isKnownProvider(value: string): value is KnownProvider {
return (getProviders() as string[]).includes(value);
readonly headers?: Record<string, string>;
}

export async function resolveAnalyzerModel(
config: Config,
authStorage: AnalyzerAuthStorage,
modelRegistry: AnalyzerModelRegistry,
): Promise<AnalyzerModelResolution> {
const providerId = config.provider;
const modelId = config.model;

if (!isKnownProvider(providerId)) {
const providerModels = modelRegistry
.getAll()
.filter((candidate) => candidate.provider === providerId);
if (providerModels.length === 0) {
throw new Error(`Unknown analyzer provider: ${providerId}`);
}

// getModel returns undefined for unknown model IDs but its overload signature
// only accepts known model IDs — cast the result to include undefined so the
// runtime guard below is reachable for arbitrary config values.
const model = getModel(providerId, modelId as never) as Model<Api> | undefined;
const model = modelRegistry.find(providerId, modelId);

if (!model) {
throw new Error(`Unknown analyzer model: ${providerId}/${modelId}`);
}

const apiKey = await authStorage.getApiKey(providerId);
const auth = await modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok) {
throw new Error(
`Could not resolve analyzer credentials for provider ${providerId}: ${auth.error}`,
);
}

const apiKey = auth.apiKey;
if (!apiKey) {
throw new Error(
`No API key configured for provider: ${providerId}. ` +
"Set credentials via Pi auth.json, /login, or the provider's API key environment variable.",
);
}

return { apiKey, model, modelId, providerId };
return {
apiKey,
model,
modelId,
providerId,
...(auth.headers ? { headers: auth.headers } : {}),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,11 @@ export async function runSingleShot(
model: Parameters<typeof complete>[0],
apiKey: string,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SingleShotResult> {
const opts: Parameters<typeof complete>[2] = { apiKey };
if (signal !== undefined) opts.signal = signal;
if (headers !== undefined) opts.headers = headers;
const message = await complete(model, context, opts);

const textContent = message.content
Expand Down
26 changes: 16 additions & 10 deletions packages/pi-continuous-learning/src/cli/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
} from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { AuthStorage } from "@earendil-works/pi-coding-agent";
import {
AuthStorage,
ModelRegistry,
} from "@earendil-works/pi-coding-agent";

import { loadConfig, DEFAULT_CONFIG } from "../config.js";
import type { InstalledSkill, ProjectEntry } from "../types.js";
Expand Down Expand Up @@ -230,7 +233,7 @@ async function analyzeProject(
config: ReturnType<typeof loadConfig>,
baseDir: string,
logger: AnalyzeLogger,
authStorage: AuthStorage,
modelRegistry: ModelRegistry,
): Promise<AnalyzeResult> {
const meta = loadProjectMeta(project.id, baseDir);

Expand Down Expand Up @@ -401,9 +404,9 @@ async function analyzeProject(
},
);

const { apiKey, model, modelId } = await resolveAnalyzerModel(
const { apiKey, model, modelId, headers } = await resolveAnalyzerModel(
config,
authStorage,
modelRegistry,
);

const context = {
Expand Down Expand Up @@ -440,6 +443,7 @@ async function analyzeProject(
model,
apiKey,
abortController.signal,
headers,
);
singleShotMessage = result.message;

Expand Down Expand Up @@ -588,7 +592,7 @@ async function consolidateProject(
baseDir: string,
logger: AnalyzeLogger,
force: boolean,
authStorage: AuthStorage,
modelRegistry: ModelRegistry,
): Promise<AnalyzeResult> {
const obsPath = getObservationsPath(project.id, baseDir);
const sessionCount = countDistinctSessions(obsPath);
Expand Down Expand Up @@ -666,9 +670,9 @@ async function consolidateProject(
projectId: project.id,
});

const { apiKey, model, modelId } = await resolveAnalyzerModel(
const { apiKey, model, modelId, headers } = await resolveAnalyzerModel(
config,
authStorage,
modelRegistry,
);

const context = {
Expand Down Expand Up @@ -701,6 +705,7 @@ async function consolidateProject(
model,
apiKey,
abortController.signal,
headers,
);
singleShotMessage = result.message;

Expand Down Expand Up @@ -867,6 +872,7 @@ async function main(): Promise<void> {
let errored = 0;
const allProjectStats: ProjectRunStats[] = [];
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);

if (isConsolidateOnly) {
// --consolidate: manual trigger, consolidation only, skip gates
Expand All @@ -878,7 +884,7 @@ async function main(): Promise<void> {
baseDir,
logger,
true,
authStorage,
modelRegistry,
);
if (result.ran && result.stats) {
processed++;
Expand All @@ -902,7 +908,7 @@ async function main(): Promise<void> {
// Normal mode: analyze observations, then opportunistic consolidation
for (const project of projects) {
try {
const result = await analyzeProject(project, config, baseDir, logger, authStorage);
const result = await analyzeProject(project, config, baseDir, logger, modelRegistry);
if (result.ran && result.stats) {
processed++;
allProjectStats.push(result.stats);
Expand Down Expand Up @@ -932,7 +938,7 @@ async function main(): Promise<void> {
baseDir,
logger,
false,
authStorage,
modelRegistry,
);
if (result.ran && result.stats) {
processed++;
Expand Down
Loading