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
10 changes: 6 additions & 4 deletions packages/pi-continuous-learning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ This installs the extension globally and makes the `pi-cl-analyze` CLI available
|---|---|
| [Pi](https://github.com/nicholasgasior/pi-coding-agent) | >= 0.62.0 |
| Node.js | >= 18 |
| LLM provider | configured in Pi (analyzer defaults to Haiku) |
| LLM provider | configured in Pi (analyzer defaults to Anthropic Haiku; provider/model are configurable) |

---

Expand All @@ -62,7 +62,7 @@ Every hook event in your session — tool calls, prompts, errors, corrections, m

### 2. Analysis

The background analyzer (`pi-cl-analyze`) processes new observations using Haiku. It applies three tiers of quality filtering:
The background analyzer (`pi-cl-analyze`) processes new observations using the configured provider/model (Anthropic Haiku by default). It applies three tiers of quality filtering:

| Tier | What it captures | How it's stored |
|---|---|---|
Expand Down Expand Up @@ -216,7 +216,7 @@ Ask Pi things like _"show me my instincts"_, _"merge these two"_, or _"remember

## Background analyzer

The analyzer is a standalone CLI that processes all your projects in a single pass and creates/updates instincts using Haiku. It runs outside of Pi sessions so it never causes lag or interference.
The analyzer is a standalone CLI that processes all your projects in a single pass and creates/updates instincts using the configured provider/model. It runs outside of Pi sessions so it never causes lag or interference.

### Running manually

Expand All @@ -233,7 +233,7 @@ pi-cl-analyze
5. Applies passive confidence decay to all instincts
6. Runs cleanup (expired/contradicted instincts)
7. Scores observation batches by signal strength — low-signal batches are skipped to save cost
8. Calls Haiku to analyse patterns and write instinct files
8. Calls the configured model to analyse patterns and write instinct files
9. Saves a cursor so only new observations are processed next time

**Safety features:**
Expand Down Expand Up @@ -378,6 +378,7 @@ All defaults work out of the box. Override at `~/.pi/continuous-learning/config.
"max_instincts": 20,
"max_injection_chars": 4000,
"model": "claude-haiku-4-5",
"provider": "anthropic",
"timeout_seconds": 120,
"active_hours_start": 8,
"active_hours_end": 23,
Expand All @@ -402,6 +403,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 |
| `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
14 changes: 7 additions & 7 deletions packages/pi-continuous-learning/docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ How pi-continuous-learning works under the hood. Covers the data flow, file layo
The system has two separate runtimes:

1. **Pi Extension** (runs inside Pi sessions): Observes events, records observations, injects instincts into prompts, registers LLM tools, and provides slash commands.
2. **Standalone Analyzer** (`src/cli/analyze.ts`): Runs outside Pi via cron/launchd. Iterates all projects, analyzes observations using Haiku + the Pi SDK, and writes instinct files.
2. **Standalone Analyzer** (`src/cli/analyze.ts`): Runs outside Pi via cron/launchd. Iterates all projects, analyzes observations using the configured provider/model via the Pi SDK, and writes instinct files.

---

Expand Down Expand Up @@ -79,6 +79,7 @@ Defined in `config.ts`. The extension reads `~/.pi/continuous-learning/config.js
min_confidence: 0.5, // Instincts below this are not injected
max_instincts: 20, // Cap on instincts injected per turn
model: "claude-haiku-4-5", // Model for the analyzer
provider: "anthropic", // Pi provider for the analyzer model
timeout_seconds: 120, // Per-project timeout for analyzer LLM session
active_hours_start: 8, // (legacy, unused by standalone analyzer)
active_hours_end: 23, // (legacy, unused by standalone analyzer)
Expand Down Expand Up @@ -166,17 +167,16 @@ instinct-cleanup.ts -- auto-cleanup: delete flagged/TTL/over-cap instincts
instinct-decay.ts -- apply passive confidence decay (-0.05/week) after cleanup
|
v
Create AgentSession (Pi SDK) with:
Resolve analyzer provider/model from config:
- provider: anthropic (configurable)
- model: claude-haiku-4-5 (configurable)
- customTools: instinct_list, instinct_read, instinct_write, instinct_delete
- systemPrompt: analyzer instructions (pattern detection, scoring rules, conservativeness)
- sessionManager: in-memory (no persistence)
- credentials: existing Pi auth for that provider
|
v
session.prompt(userPrompt) -- sends observations + project context to Haiku
runSingleShot(context, model, apiKey) -- sends observations + project context to the configured model
|
v
Haiku analyzes patterns, calls instinct_write/instinct_read tools
Model analyzes patterns and returns structured instinct changes
|
v
session.dispose(), update last_analyzed_at in project.json
Expand Down
5 changes: 3 additions & 2 deletions packages/pi-continuous-learning/docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Inspired by [everything-claude-code/continuous-learning-v2](https://github.com/n

1. **Observe** - capture tool calls, user prompts, and outcomes via Pi extension events
2. **Record** - write observations to project-scoped JSONL files
3. **Analyze** - run a background job every 5 minutes using Haiku to detect patterns
3. **Analyze** - run a background job every 5 minutes using the configured analyzer provider/model to detect patterns
4. **Learn** - create/update instinct files (YAML-frontmatter markdown) with confidence scoring
5. **Apply** - inject relevant instincts into Pi's system prompt via `before_agent_start`
6. **Validate** - closed-loop feedback: track whether injected instincts align with actual session behavior, adjusting confidence based on real outcomes rather than observation count alone
Expand Down Expand Up @@ -612,6 +612,7 @@ Stored at `~/.pi/continuous-learning/config.json`:
},
"analyzer": {
"model": "claude-haiku-4-5",
"provider": "anthropic",
"timeout_seconds": 120,
"max_observations_per_analysis": 500,
"max_turns": 10
Expand All @@ -623,7 +624,7 @@ Stored at `~/.pi/continuous-learning/config.json`:
}
```

Defaults are used when config file is absent. The extension reads config on `session_start` and caches it.
Defaults are used when config file is absent. The extension reads config on `session_start` and caches it. The analyzer defaults to Anthropic Haiku, but `provider` and `model` can be overridden to any Pi-registered provider/model pair.

---

Expand Down
82 changes: 82 additions & 0 deletions packages/pi-continuous-learning/src/cli/analyze-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_CONFIG } from "../config.js";
import type { Config } from "../types.js";
import { resolveAnalyzerModel } from "./analyze-model.js";

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

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

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

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");
});

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

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

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),
};

await expect(
resolveAnalyzerModel(
config({ provider: "openai-codex", model: "gpt-5.4-mini" }),
authStorage,
),
).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"),
};

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

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

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

type AnalyzerAuthStorage = Pick<AuthStorage, "getApiKey">;

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);
}

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

if (!isKnownProvider(providerId)) {
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;

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

const apiKey = await authStorage.getApiKey(providerId);
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 };
}
41 changes: 15 additions & 26 deletions packages/pi-continuous-learning/src/cli/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
import { createHash } from "node:crypto";
import { join } from "node:path";
import { AuthStorage } from "@mariozechner/pi-coding-agent";
import { getModel } from "@mariozechner/pi-ai";

import { loadConfig, DEFAULT_CONFIG } from "../config.js";
import type { InstalledSkill, ProjectEntry } from "../types.js";
Expand Down Expand Up @@ -74,6 +73,7 @@ import {
type ProjectRunStats,
type RunSummary,
} from "./analyze-logger.js";
import { resolveAnalyzerModel } from "./analyze-model.js";

// ---------------------------------------------------------------------------
// Lockfile guard - ensures only one instance runs at a time
Expand Down Expand Up @@ -230,6 +230,7 @@ async function analyzeProject(
config: ReturnType<typeof loadConfig>,
baseDir: string,
logger: AnalyzeLogger,
authStorage: AuthStorage,
): Promise<AnalyzeResult> {
const meta = loadProjectMeta(project.id, baseDir);

Expand Down Expand Up @@ -400,18 +401,10 @@ async function analyzeProject(
},
);

const authStorage = AuthStorage.create();
const modelId = (config.model || DEFAULT_CONFIG.model) as Parameters<
typeof getModel
>[1];
const model = getModel("anthropic", modelId);
const apiKey = await authStorage.getApiKey("anthropic");

if (!apiKey) {
throw new Error(
"No Anthropic API key configured. Set via auth.json or ANTHROPIC_API_KEY.",
);
}
const { apiKey, model, modelId } = await resolveAnalyzerModel(
config,
authStorage,
);

const context = {
systemPrompt: buildSingleShotSystemPrompt(),
Expand Down Expand Up @@ -595,6 +588,7 @@ async function consolidateProject(
baseDir: string,
logger: AnalyzeLogger,
force: boolean,
authStorage: AuthStorage,
): Promise<AnalyzeResult> {
const obsPath = getObservationsPath(project.id, baseDir);
const sessionCount = countDistinctSessions(obsPath);
Expand Down Expand Up @@ -672,18 +666,10 @@ async function consolidateProject(
projectId: project.id,
});

const authStorage = AuthStorage.create();
const modelId = (config.model || DEFAULT_CONFIG.model) as Parameters<
typeof getModel
>[1];
const model = getModel("anthropic", modelId);
const apiKey = await authStorage.getApiKey("anthropic");

if (!apiKey) {
throw new Error(
"No Anthropic API key configured. Set via auth.json or ANTHROPIC_API_KEY.",
);
}
const { apiKey, model, modelId } = await resolveAnalyzerModel(
config,
authStorage,
);

const context = {
systemPrompt,
Expand Down Expand Up @@ -880,6 +866,7 @@ async function main(): Promise<void> {
let skipped = 0;
let errored = 0;
const allProjectStats: ProjectRunStats[] = [];
const authStorage = AuthStorage.create();

if (isConsolidateOnly) {
// --consolidate: manual trigger, consolidation only, skip gates
Expand All @@ -891,6 +878,7 @@ async function main(): Promise<void> {
baseDir,
logger,
true,
authStorage,
);
if (result.ran && result.stats) {
processed++;
Expand All @@ -914,7 +902,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);
const result = await analyzeProject(project, config, baseDir, logger, authStorage);
if (result.ran && result.stats) {
processed++;
allProjectStats.push(result.stats);
Expand Down Expand Up @@ -944,6 +932,7 @@ async function main(): Promise<void> {
baseDir,
logger,
false,
authStorage,
);
if (result.ran && result.stats) {
processed++;
Expand Down
Loading
Loading