mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
Merge sdk-272: Add SDK for programmatic usage
- createAgentSession() factory with full control - SessionManager/SettingsManager static factories - Project-specific settings support - 12 examples and comprehensive docs Fixes #272
This commit is contained in:
commit
207c2cd566
39 changed files with 3011 additions and 1072 deletions
|
|
@ -2,6 +2,16 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **SDK for programmatic usage**: New `createAgentSession()` factory with full control over model, tools, hooks, skills, session persistence, and settings. Philosophy: "omit to discover, provide to override". Includes 12 examples and comprehensive documentation. ([#272](https://github.com/badlogic/pi-mono/issues/272))
|
||||
|
||||
- **Project-specific settings**: Settings now load from both `~/.pi/agent/settings.json` (global) and `<cwd>/.pi/settings.json` (project). Project settings override global with deep merge for nested objects. Project settings are read-only (for version control). ([#276](https://github.com/badlogic/pi-mono/pull/276))
|
||||
|
||||
- **SettingsManager static factories**: `SettingsManager.create(cwd?, agentDir?)` for file-based settings, `SettingsManager.inMemory(settings?)` for testing. Added `applyOverrides()` for programmatic overrides.
|
||||
|
||||
- **SessionManager static factories**: `SessionManager.create()`, `SessionManager.open()`, `SessionManager.continueRecent()`, `SessionManager.inMemory()`, `SessionManager.list()` for flexible session management.
|
||||
|
||||
## [0.25.4] - 2025-12-22
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows-
|
|||
- [CLI Reference](#cli-reference)
|
||||
- [Tools](#tools)
|
||||
- [Programmatic Usage](#programmatic-usage)
|
||||
- [SDK](#sdk)
|
||||
- [RPC Mode](#rpc-mode)
|
||||
- [HTML Export](#html-export)
|
||||
- [Philosophy](#philosophy)
|
||||
- [Development](#development)
|
||||
- [License](#license)
|
||||
|
|
@ -649,7 +652,14 @@ export default factory;
|
|||
|
||||
### Settings File
|
||||
|
||||
`~/.pi/agent/settings.json` stores persistent preferences:
|
||||
Settings are loaded from two locations and merged:
|
||||
|
||||
1. **Global:** `~/.pi/agent/settings.json` - user preferences
|
||||
2. **Project:** `<cwd>/.pi/settings.json` - project-specific overrides (version control friendly)
|
||||
|
||||
Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only.
|
||||
|
||||
Global `~/.pi/agent/settings.json` stores persistent preferences:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -818,9 +828,43 @@ For adding new tools, see [Custom Tools](#custom-tools) in the Configuration sec
|
|||
|
||||
## Programmatic Usage
|
||||
|
||||
### SDK
|
||||
|
||||
For embedding pi in Node.js/TypeScript applications, use the SDK:
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt("What files are in the current directory?");
|
||||
```
|
||||
|
||||
The SDK provides full control over:
|
||||
- Model selection and thinking level
|
||||
- System prompt (replace or modify)
|
||||
- Tools (built-in subsets, custom tools)
|
||||
- Hooks (inline or discovered)
|
||||
- Skills, context files, slash commands
|
||||
- Session persistence (`SessionManager`)
|
||||
- Settings (`SettingsManager`)
|
||||
- API key resolution and OAuth
|
||||
|
||||
**Philosophy:** "Omit to discover, provide to override." Omit an option and pi discovers from standard locations. Provide an option and your value is used.
|
||||
|
||||
> See [SDK Documentation](docs/sdk.md) for the full API reference. See [examples/sdk/](examples/sdk/) for working examples from minimal to full control.
|
||||
|
||||
### RPC Mode
|
||||
|
||||
For embedding pi in other applications:
|
||||
For embedding pi from other languages or with process isolation:
|
||||
|
||||
```bash
|
||||
pi --mode rpc --no-session
|
||||
|
|
@ -832,9 +876,7 @@ Send JSON commands on stdin:
|
|||
{"type":"abort"}
|
||||
```
|
||||
|
||||
See [RPC documentation](docs/rpc.md) for full protocol.
|
||||
|
||||
**Node.js/TypeScript:** Consider using `AgentSession` directly from `@mariozechner/pi-coding-agent` instead of subprocess. See [`src/core/agent-session.ts`](src/core/agent-session.ts) and [`src/modes/rpc/rpc-client.ts`](src/modes/rpc/rpc-client.ts).
|
||||
> See [RPC Documentation](docs/rpc.md) for the full protocol.
|
||||
|
||||
### HTML Export
|
||||
|
||||
|
|
|
|||
819
packages/coding-agent/docs/sdk.md
Normal file
819
packages/coding-agent/docs/sdk.md
Normal file
|
|
@ -0,0 +1,819 @@
|
|||
# SDK
|
||||
|
||||
The SDK provides programmatic access to pi's agent capabilities. Use it to embed pi in other applications, build custom interfaces, or integrate with automated workflows.
|
||||
|
||||
**Example use cases:**
|
||||
- Build a custom UI (web, desktop, mobile)
|
||||
- Integrate agent capabilities into existing applications
|
||||
- Create automated pipelines with agent reasoning
|
||||
- Build custom tools that spawn sub-agents
|
||||
- Test agent behavior programmatically
|
||||
|
||||
See [examples/sdk/](../examples/sdk/) for working examples from minimal to full control.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt("What files are in the current directory?");
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @mariozechner/pi-coding-agent
|
||||
```
|
||||
|
||||
The SDK is included in the main package. No separate installation needed.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### createAgentSession()
|
||||
|
||||
The main factory function. Creates an `AgentSession` with configurable options.
|
||||
|
||||
**Philosophy:** "Omit to discover, provide to override."
|
||||
- Omit an option → pi discovers/loads from standard locations
|
||||
- Provide an option → your value is used, discovery skipped for that option
|
||||
|
||||
```typescript
|
||||
import { createAgentSession } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Minimal: all defaults (discovers everything from cwd and ~/.pi/agent)
|
||||
const { session } = await createAgentSession();
|
||||
|
||||
// Custom: override specific options
|
||||
const { session } = await createAgentSession({
|
||||
model: myModel,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [readTool, bashTool],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
```
|
||||
|
||||
### AgentSession
|
||||
|
||||
The session manages the agent lifecycle, message history, and event streaming.
|
||||
|
||||
```typescript
|
||||
interface AgentSession {
|
||||
// Send a prompt and wait for completion
|
||||
prompt(text: string, options?: PromptOptions): Promise<void>;
|
||||
|
||||
// Subscribe to events (returns unsubscribe function)
|
||||
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
|
||||
|
||||
// Session info
|
||||
sessionFile: string | null;
|
||||
sessionId: string;
|
||||
|
||||
// Model control
|
||||
setModel(model: Model): Promise<void>;
|
||||
setThinkingLevel(level: ThinkingLevel): void;
|
||||
cycleModel(): Promise<ModelCycleResult | null>;
|
||||
cycleThinkingLevel(): ThinkingLevel | null;
|
||||
|
||||
// State access
|
||||
agent: Agent;
|
||||
model: Model | null;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
messages: AppMessage[];
|
||||
isStreaming: boolean;
|
||||
|
||||
// Session management
|
||||
reset(): Promise<void>;
|
||||
branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }>;
|
||||
switchSession(sessionPath: string): Promise<void>;
|
||||
|
||||
// Compaction
|
||||
compact(customInstructions?: string): Promise<CompactionResult>;
|
||||
abortCompaction(): void;
|
||||
|
||||
// Abort current operation
|
||||
abort(): Promise<void>;
|
||||
|
||||
// Cleanup
|
||||
dispose(): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Agent and AgentState
|
||||
|
||||
The `Agent` class (from `@mariozechner/pi-agent-core`) handles the core LLM interaction. Access it via `session.agent`.
|
||||
|
||||
```typescript
|
||||
// Access current state
|
||||
const state = session.agent.state;
|
||||
|
||||
// state.messages: AppMessage[] - conversation history
|
||||
// state.model: Model - current model
|
||||
// state.thinkingLevel: ThinkingLevel - current thinking level
|
||||
// state.systemPrompt: string - system prompt
|
||||
// state.tools: Tool[] - available tools
|
||||
|
||||
// Replace messages (useful for branching, restoration)
|
||||
session.agent.replaceMessages(messages);
|
||||
|
||||
// Wait for agent to finish processing
|
||||
await session.agent.waitForIdle();
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
Subscribe to events to receive streaming output and lifecycle notifications.
|
||||
|
||||
```typescript
|
||||
session.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
// Streaming text from assistant
|
||||
case "message_update":
|
||||
if (event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
if (event.assistantMessageEvent.type === "thinking_delta") {
|
||||
// Thinking output (if thinking enabled)
|
||||
}
|
||||
break;
|
||||
|
||||
// Tool execution
|
||||
case "tool_execution_start":
|
||||
console.log(`Tool: ${event.toolName}`);
|
||||
break;
|
||||
case "tool_execution_update":
|
||||
// Streaming tool output
|
||||
break;
|
||||
case "tool_execution_end":
|
||||
console.log(`Result: ${event.isError ? "error" : "success"}`);
|
||||
break;
|
||||
|
||||
// Message lifecycle
|
||||
case "message_start":
|
||||
// New message starting
|
||||
break;
|
||||
case "message_end":
|
||||
// Message complete
|
||||
break;
|
||||
|
||||
// Agent lifecycle
|
||||
case "agent_start":
|
||||
// Agent started processing prompt
|
||||
break;
|
||||
case "agent_end":
|
||||
// Agent finished (event.messages contains new messages)
|
||||
break;
|
||||
|
||||
// Turn lifecycle (one LLM response + tool calls)
|
||||
case "turn_start":
|
||||
break;
|
||||
case "turn_end":
|
||||
// event.message: assistant response
|
||||
// event.toolResults: tool results from this turn
|
||||
break;
|
||||
|
||||
// Session events (auto-compaction, retry)
|
||||
case "auto_compaction_start":
|
||||
case "auto_compaction_end":
|
||||
case "auto_retry_start":
|
||||
case "auto_retry_end":
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Options Reference
|
||||
|
||||
### Directories
|
||||
|
||||
```typescript
|
||||
const { session } = await createAgentSession({
|
||||
// Working directory for project-local discovery
|
||||
cwd: process.cwd(), // default
|
||||
|
||||
// Global config directory
|
||||
agentDir: "~/.pi/agent", // default (expands ~)
|
||||
});
|
||||
```
|
||||
|
||||
`cwd` is used for:
|
||||
- Project hooks (`.pi/hooks/`)
|
||||
- Project tools (`.pi/tools/`)
|
||||
- Project skills (`.pi/skills/`)
|
||||
- Project commands (`.pi/commands/`)
|
||||
- Context files (`AGENTS.md` walking up from cwd)
|
||||
- Session directory naming
|
||||
|
||||
`agentDir` is used for:
|
||||
- Global hooks (`hooks/`)
|
||||
- Global tools (`tools/`)
|
||||
- Global skills (`skills/`)
|
||||
- Global commands (`commands/`)
|
||||
- Global context file (`AGENTS.md`)
|
||||
- Settings (`settings.json`)
|
||||
- Models (`models.json`)
|
||||
- OAuth tokens (`oauth.json`)
|
||||
- Sessions (`sessions/`)
|
||||
|
||||
### Model
|
||||
|
||||
```typescript
|
||||
import { findModel, discoverAvailableModels } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Find specific model (returns { model, error })
|
||||
const { model, error } = findModel("anthropic", "claude-sonnet-4-20250514");
|
||||
if (error) throw new Error(error);
|
||||
if (!model) throw new Error("Model not found");
|
||||
|
||||
// Or get all models with valid API keys
|
||||
const available = await discoverAvailableModels();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
model: model,
|
||||
thinkingLevel: "medium", // off, minimal, low, medium, high, xhigh
|
||||
|
||||
// Models for cycling (Ctrl+P in interactive mode)
|
||||
scopedModels: [
|
||||
{ model: sonnet, thinkingLevel: "high" },
|
||||
{ model: haiku, thinkingLevel: "off" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
If no model is provided:
|
||||
1. Tries to restore from session (if continuing)
|
||||
2. Uses default from settings
|
||||
3. Falls back to first available model
|
||||
|
||||
> See [examples/sdk/02-custom-model.ts](../examples/sdk/02-custom-model.ts)
|
||||
|
||||
### API Keys
|
||||
|
||||
```typescript
|
||||
import { defaultGetApiKey, configureOAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Default: checks models.json, OAuth, environment variables
|
||||
const { session } = await createAgentSession();
|
||||
|
||||
// Custom resolver
|
||||
const { session } = await createAgentSession({
|
||||
getApiKey: async (model) => {
|
||||
// Custom logic (secrets manager, database, etc.)
|
||||
if (model.provider === "anthropic") {
|
||||
return process.env.MY_ANTHROPIC_KEY;
|
||||
}
|
||||
// Fall back to default
|
||||
return defaultGetApiKey()(model);
|
||||
},
|
||||
});
|
||||
|
||||
// Use OAuth from ~/.pi/agent with custom agentDir for everything else
|
||||
configureOAuthStorage(); // Must call before createAgentSession
|
||||
const { session } = await createAgentSession({
|
||||
agentDir: "/custom/config",
|
||||
// OAuth tokens still come from ~/.pi/agent/oauth.json
|
||||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/09-api-keys-and-oauth.ts](../examples/sdk/09-api-keys-and-oauth.ts)
|
||||
|
||||
### System Prompt
|
||||
|
||||
```typescript
|
||||
const { session } = await createAgentSession({
|
||||
// Replace entirely
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
|
||||
// Or modify default (receives default, returns modified)
|
||||
systemPrompt: (defaultPrompt) => {
|
||||
return `${defaultPrompt}\n\n## Additional Rules\n- Be concise`;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/03-custom-prompt.ts](../examples/sdk/03-custom-prompt.ts)
|
||||
|
||||
### Tools
|
||||
|
||||
```typescript
|
||||
import {
|
||||
codingTools, // read, bash, edit, write (default)
|
||||
readOnlyTools, // read, bash
|
||||
readTool, bashTool, editTool, writeTool,
|
||||
grepTool, findTool, lsTool,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Use built-in tool set
|
||||
const { session } = await createAgentSession({
|
||||
tools: readOnlyTools,
|
||||
});
|
||||
|
||||
// Pick specific tools
|
||||
const { session } = await createAgentSession({
|
||||
tools: [readTool, bashTool, grepTool],
|
||||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
|
||||
|
||||
### Custom Tools
|
||||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { createAgentSession, discoverCustomTools, type CustomAgentTool } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Inline custom tool
|
||||
const myTool: CustomAgentTool = {
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "Does something useful",
|
||||
parameters: Type.Object({
|
||||
input: Type.String({ description: "Input value" }),
|
||||
}),
|
||||
execute: async (toolCallId, params) => ({
|
||||
content: [{ type: "text", text: `Result: ${params.input}` }],
|
||||
details: {},
|
||||
}),
|
||||
};
|
||||
|
||||
// Replace discovery with inline tools
|
||||
const { session } = await createAgentSession({
|
||||
customTools: [{ tool: myTool }],
|
||||
});
|
||||
|
||||
// Merge with discovered tools
|
||||
const discovered = await discoverCustomTools();
|
||||
const { session } = await createAgentSession({
|
||||
customTools: [...discovered, { tool: myTool }],
|
||||
});
|
||||
|
||||
// Add paths without replacing discovery
|
||||
const { session } = await createAgentSession({
|
||||
additionalCustomToolPaths: ["/extra/tools"],
|
||||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
|
||||
|
||||
### Hooks
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, discoverHooks, type HookFactory } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Inline hook
|
||||
const loggingHook: HookFactory = (api) => {
|
||||
api.on("tool_call", async (event) => {
|
||||
console.log(`Tool: ${event.toolName}`);
|
||||
return undefined; // Don't block
|
||||
});
|
||||
|
||||
api.on("tool_call", async (event) => {
|
||||
// Block dangerous commands
|
||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||
return { block: true, reason: "Dangerous command" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
// Replace discovery
|
||||
const { session } = await createAgentSession({
|
||||
hooks: [{ factory: loggingHook }],
|
||||
});
|
||||
|
||||
// Disable all hooks
|
||||
const { session } = await createAgentSession({
|
||||
hooks: [],
|
||||
});
|
||||
|
||||
// Merge with discovered
|
||||
const discovered = await discoverHooks();
|
||||
const { session } = await createAgentSession({
|
||||
hooks: [...discovered, { factory: loggingHook }],
|
||||
});
|
||||
|
||||
// Add paths without replacing
|
||||
const { session } = await createAgentSession({
|
||||
additionalHookPaths: ["/extra/hooks"],
|
||||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/06-hooks.ts](../examples/sdk/06-hooks.ts)
|
||||
|
||||
### Skills
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, discoverSkills, type Skill } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Discover and filter
|
||||
const allSkills = discoverSkills();
|
||||
const filtered = allSkills.filter(s => s.name.includes("search"));
|
||||
|
||||
// Custom skill
|
||||
const mySkill: Skill = {
|
||||
name: "my-skill",
|
||||
description: "Custom instructions",
|
||||
filePath: "/path/to/SKILL.md",
|
||||
baseDir: "/path/to",
|
||||
source: "custom",
|
||||
};
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
skills: [...filtered, mySkill],
|
||||
});
|
||||
|
||||
// Disable skills
|
||||
const { session } = await createAgentSession({
|
||||
skills: [],
|
||||
});
|
||||
|
||||
// Discovery with settings filter
|
||||
const skills = discoverSkills(process.cwd(), undefined, {
|
||||
ignoredSkills: ["browser-*"], // glob patterns to exclude
|
||||
includeSkills: ["search-*"], // glob patterns to include (empty = all)
|
||||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts)
|
||||
|
||||
### Context Files
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, discoverContextFiles } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Discover AGENTS.md files
|
||||
const discovered = discoverContextFiles();
|
||||
|
||||
// Add custom context
|
||||
const { session } = await createAgentSession({
|
||||
contextFiles: [
|
||||
...discovered,
|
||||
{
|
||||
path: "/virtual/AGENTS.md",
|
||||
content: "# Guidelines\n\n- Be concise\n- Use TypeScript",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Disable context files
|
||||
const { session } = await createAgentSession({
|
||||
contextFiles: [],
|
||||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/07-context-files.ts](../examples/sdk/07-context-files.ts)
|
||||
|
||||
### Slash Commands
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, discoverSlashCommands, type FileSlashCommand } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const discovered = discoverSlashCommands();
|
||||
|
||||
const customCommand: FileSlashCommand = {
|
||||
name: "deploy",
|
||||
description: "Deploy the application",
|
||||
source: "(custom)",
|
||||
content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy",
|
||||
};
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
slashCommands: [...discovered, customCommand],
|
||||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/08-slash-commands.ts](../examples/sdk/08-slash-commands.ts)
|
||||
|
||||
### Session Management
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// In-memory (no persistence)
|
||||
const { session } = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
// New persistent session
|
||||
const { session } = await createAgentSession({
|
||||
sessionManager: SessionManager.create(process.cwd()),
|
||||
});
|
||||
|
||||
// Continue most recent
|
||||
const { session, modelFallbackMessage } = await createAgentSession({
|
||||
sessionManager: SessionManager.continueRecent(process.cwd()),
|
||||
});
|
||||
if (modelFallbackMessage) {
|
||||
console.log("Note:", modelFallbackMessage);
|
||||
}
|
||||
|
||||
// Open specific file
|
||||
const { session } = await createAgentSession({
|
||||
sessionManager: SessionManager.open("/path/to/session.jsonl"),
|
||||
});
|
||||
|
||||
// List available sessions
|
||||
const sessions = SessionManager.list(process.cwd());
|
||||
for (const info of sessions) {
|
||||
console.log(`${info.id}: ${info.firstMessage} (${info.messageCount} messages)`);
|
||||
}
|
||||
|
||||
// Custom agentDir for sessions
|
||||
const { session } = await createAgentSession({
|
||||
agentDir: "/custom/agent",
|
||||
sessionManager: SessionManager.create(process.cwd(), "/custom/agent"),
|
||||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts)
|
||||
|
||||
### Settings Management
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, SettingsManager, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Default: loads from files (global + project merged)
|
||||
const { session } = await createAgentSession({
|
||||
settingsManager: SettingsManager.create(),
|
||||
});
|
||||
|
||||
// With overrides
|
||||
const settingsManager = SettingsManager.create();
|
||||
settingsManager.applyOverrides({
|
||||
compaction: { enabled: false },
|
||||
retry: { enabled: true, maxRetries: 5 },
|
||||
});
|
||||
const { session } = await createAgentSession({ settingsManager });
|
||||
|
||||
// In-memory (no file I/O, for testing)
|
||||
const { session } = await createAgentSession({
|
||||
settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
// Custom directories
|
||||
const { session } = await createAgentSession({
|
||||
settingsManager: SettingsManager.create("/custom/cwd", "/custom/agent"),
|
||||
});
|
||||
```
|
||||
|
||||
**Static factories:**
|
||||
- `SettingsManager.create(cwd?, agentDir?)` - Load from files
|
||||
- `SettingsManager.inMemory(settings?)` - No file I/O
|
||||
|
||||
**Project-specific settings:**
|
||||
|
||||
Settings load from two locations and merge:
|
||||
1. Global: `~/.pi/agent/settings.json`
|
||||
2. Project: `<cwd>/.pi/settings.json`
|
||||
|
||||
Project overrides global. Nested objects merge keys. Setters only modify global (project is read-only for version control).
|
||||
|
||||
> See [examples/sdk/10-settings.ts](../examples/sdk/10-settings.ts)
|
||||
|
||||
## Discovery Functions
|
||||
|
||||
All discovery functions accept optional `cwd` and `agentDir` parameters.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
discoverModels,
|
||||
discoverAvailableModels,
|
||||
findModel,
|
||||
discoverSkills,
|
||||
discoverHooks,
|
||||
discoverCustomTools,
|
||||
discoverContextFiles,
|
||||
discoverSlashCommands,
|
||||
loadSettings,
|
||||
buildSystemPrompt,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Models
|
||||
const allModels = discoverModels();
|
||||
const available = await discoverAvailableModels();
|
||||
const { model, error } = findModel("anthropic", "claude-sonnet-4-20250514");
|
||||
|
||||
// Skills
|
||||
const skills = discoverSkills(cwd, agentDir, skillsSettings);
|
||||
|
||||
// Hooks (async - loads TypeScript)
|
||||
const hooks = await discoverHooks(cwd, agentDir);
|
||||
|
||||
// Custom tools (async - loads TypeScript)
|
||||
const tools = await discoverCustomTools(cwd, agentDir);
|
||||
|
||||
// Context files
|
||||
const contextFiles = discoverContextFiles(cwd, agentDir);
|
||||
|
||||
// Slash commands
|
||||
const commands = discoverSlashCommands(cwd, agentDir);
|
||||
|
||||
// Settings (global + project merged)
|
||||
const settings = loadSettings(cwd, agentDir);
|
||||
|
||||
// Build system prompt manually
|
||||
const prompt = buildSystemPrompt({
|
||||
skills,
|
||||
contextFiles,
|
||||
appendPrompt: "Additional instructions",
|
||||
cwd,
|
||||
});
|
||||
```
|
||||
|
||||
## Return Value
|
||||
|
||||
`createAgentSession()` returns:
|
||||
|
||||
```typescript
|
||||
interface CreateAgentSessionResult {
|
||||
// The session
|
||||
session: AgentSession;
|
||||
|
||||
// Custom tools (for UI setup)
|
||||
customToolsResult: {
|
||||
tools: LoadedCustomTool[];
|
||||
setUIContext: (ctx, hasUI) => void;
|
||||
};
|
||||
|
||||
// Warning if session model couldn't be restored
|
||||
modelFallbackMessage?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
createAgentSession,
|
||||
configureOAuthStorage,
|
||||
defaultGetApiKey,
|
||||
findModel,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
readTool,
|
||||
bashTool,
|
||||
type HookFactory,
|
||||
type CustomAgentTool,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { getAgentDir } from "@mariozechner/pi-coding-agent/config";
|
||||
|
||||
// Use OAuth from default location
|
||||
configureOAuthStorage(getAgentDir());
|
||||
|
||||
// Custom API key with fallback
|
||||
const getApiKey = async (model: { provider: string }) => {
|
||||
if (model.provider === "anthropic" && process.env.MY_KEY) {
|
||||
return process.env.MY_KEY;
|
||||
}
|
||||
return defaultGetApiKey()(model as any);
|
||||
};
|
||||
|
||||
// Inline hook
|
||||
const auditHook: HookFactory = (api) => {
|
||||
api.on("tool_call", async (event) => {
|
||||
console.log(`[Audit] ${event.toolName}`);
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
// Inline tool
|
||||
const statusTool: CustomAgentTool = {
|
||||
name: "status",
|
||||
label: "Status",
|
||||
description: "Get system status",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: `Uptime: ${process.uptime()}s` }],
|
||||
details: {},
|
||||
}),
|
||||
};
|
||||
|
||||
const { model, error } = findModel("anthropic", "claude-sonnet-4-20250514");
|
||||
if (error) throw new Error(error);
|
||||
if (!model) throw new Error("Model not found");
|
||||
|
||||
// In-memory settings with overrides
|
||||
const settingsManager = SettingsManager.inMemory({
|
||||
compaction: { enabled: false },
|
||||
retry: { enabled: true, maxRetries: 2 },
|
||||
});
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: process.cwd(),
|
||||
agentDir: "/custom/agent",
|
||||
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
getApiKey,
|
||||
|
||||
systemPrompt: "You are a minimal assistant. Be concise.",
|
||||
|
||||
tools: [readTool, bashTool],
|
||||
customTools: [{ tool: statusTool }],
|
||||
hooks: [{ factory: auditHook }],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
slashCommands: [],
|
||||
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager,
|
||||
});
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt("Get status and list files.");
|
||||
```
|
||||
|
||||
## RPC Mode Alternative
|
||||
|
||||
For subprocess-based integration, use RPC mode instead of the SDK:
|
||||
|
||||
```bash
|
||||
pi --mode rpc --no-session
|
||||
```
|
||||
|
||||
See [RPC documentation](rpc.md) for the JSON protocol.
|
||||
|
||||
The SDK is preferred when:
|
||||
- You want type safety
|
||||
- You're in the same Node.js process
|
||||
- You need direct access to agent state
|
||||
- You want to customize tools/hooks programmatically
|
||||
|
||||
RPC mode is preferred when:
|
||||
- You're integrating from another language
|
||||
- You want process isolation
|
||||
- You're building a language-agnostic client
|
||||
|
||||
## Exports
|
||||
|
||||
The main entry point exports:
|
||||
|
||||
```typescript
|
||||
// Factory
|
||||
createAgentSession
|
||||
configureOAuthStorage
|
||||
|
||||
// Discovery
|
||||
discoverModels
|
||||
discoverAvailableModels
|
||||
findModel
|
||||
discoverSkills
|
||||
discoverHooks
|
||||
discoverCustomTools
|
||||
discoverContextFiles
|
||||
discoverSlashCommands
|
||||
|
||||
// Helpers
|
||||
defaultGetApiKey
|
||||
loadSettings
|
||||
buildSystemPrompt
|
||||
|
||||
// Session management
|
||||
SessionManager
|
||||
SettingsManager
|
||||
|
||||
// Built-in tools
|
||||
codingTools
|
||||
readOnlyTools
|
||||
readTool, bashTool, editTool, writeTool
|
||||
grepTool, findTool, lsTool
|
||||
|
||||
// Types
|
||||
type CreateAgentSessionOptions
|
||||
type CreateAgentSessionResult
|
||||
type CustomAgentTool
|
||||
type HookFactory
|
||||
type Skill
|
||||
type FileSlashCommand
|
||||
type Settings
|
||||
type SkillsSettings
|
||||
type Tool
|
||||
```
|
||||
|
||||
For hook types, import from the hooks subpath:
|
||||
|
||||
```typescript
|
||||
import type { HookAPI, HookEvent, ToolCallEvent } from "@mariozechner/pi-coding-agent/hooks";
|
||||
```
|
||||
|
||||
For config utilities:
|
||||
|
||||
```typescript
|
||||
import { getAgentDir } from "@mariozechner/pi-coding-agent/config";
|
||||
```
|
||||
29
packages/coding-agent/examples/README.md
Normal file
29
packages/coding-agent/examples/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Examples
|
||||
|
||||
Example code for pi-coding-agent.
|
||||
|
||||
## Directories
|
||||
|
||||
### [sdk/](sdk/)
|
||||
Programmatic usage via `createAgentSession()`. Shows how to customize models, prompts, tools, hooks, and session management.
|
||||
|
||||
### [hooks/](hooks/)
|
||||
Example hooks for intercepting tool calls, adding safety gates, and integrating with external systems.
|
||||
|
||||
### [custom-tools/](custom-tools/)
|
||||
Example custom tools that extend the agent's capabilities.
|
||||
|
||||
## Running Examples
|
||||
|
||||
```bash
|
||||
cd packages/coding-agent
|
||||
npx tsx examples/sdk/01-minimal.ts
|
||||
npx tsx examples/hooks/permission-gate.ts
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [SDK Reference](sdk/README.md)
|
||||
- [Hooks Documentation](../docs/hooks.md)
|
||||
- [Custom Tools Documentation](../docs/custom-tools.md)
|
||||
- [Skills Documentation](../docs/skills.md)
|
||||
22
packages/coding-agent/examples/sdk/01-minimal.ts
Normal file
22
packages/coding-agent/examples/sdk/01-minimal.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Minimal SDK Usage
|
||||
*
|
||||
* Uses all defaults: discovers skills, hooks, tools, context files
|
||||
* from cwd and ~/.pi/agent. Model chosen from settings or first available.
|
||||
*/
|
||||
|
||||
import { createAgentSession } from "../../src/index.js";
|
||||
|
||||
const { session } = await createAgentSession();
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt("What files are in the current directory?");
|
||||
session.state.messages.forEach((msg) => {
|
||||
console.log(msg);
|
||||
});
|
||||
console.log();
|
||||
36
packages/coding-agent/examples/sdk/02-custom-model.ts
Normal file
36
packages/coding-agent/examples/sdk/02-custom-model.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Custom Model Selection
|
||||
*
|
||||
* Shows how to select a specific model and thinking level.
|
||||
*/
|
||||
|
||||
import { createAgentSession, findModel, discoverAvailableModels } from "../../src/index.js";
|
||||
|
||||
// Option 1: Find a specific model by provider/id
|
||||
const { model: sonnet } = findModel("anthropic", "claude-sonnet-4-20250514");
|
||||
if (sonnet) {
|
||||
console.log(`Found model: ${sonnet.provider}/${sonnet.id}`);
|
||||
}
|
||||
|
||||
// Option 2: Pick from available models (have valid API keys)
|
||||
const available = await discoverAvailableModels();
|
||||
console.log(
|
||||
"Available models:",
|
||||
available.map((m) => `${m.provider}/${m.id}`),
|
||||
);
|
||||
|
||||
if (available.length > 0) {
|
||||
const { session } = await createAgentSession({
|
||||
model: available[0],
|
||||
thinkingLevel: "medium", // off, low, medium, high
|
||||
});
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt("Say hello in one sentence.");
|
||||
console.log();
|
||||
}
|
||||
44
packages/coding-agent/examples/sdk/03-custom-prompt.ts
Normal file
44
packages/coding-agent/examples/sdk/03-custom-prompt.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Custom System Prompt
|
||||
*
|
||||
* Shows how to replace or modify the default system prompt.
|
||||
*/
|
||||
|
||||
import { createAgentSession, SessionManager } from "../../src/index.js";
|
||||
|
||||
// Option 1: Replace prompt entirely
|
||||
const { session: session1 } = await createAgentSession({
|
||||
systemPrompt: `You are a helpful assistant that speaks like a pirate.
|
||||
Always end responses with "Arrr!"`,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
session1.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("=== Replace prompt ===");
|
||||
await session1.prompt("What is 2 + 2?");
|
||||
console.log("\n");
|
||||
|
||||
// Option 2: Modify default prompt (receives default, returns modified)
|
||||
const { session: session2 } = await createAgentSession({
|
||||
systemPrompt: (defaultPrompt) => `${defaultPrompt}
|
||||
|
||||
## Additional Instructions
|
||||
- Always be concise
|
||||
- Use bullet points when listing things`,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
session2.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("=== Modify prompt ===");
|
||||
await session2.prompt("List 3 benefits of TypeScript.");
|
||||
console.log();
|
||||
44
packages/coding-agent/examples/sdk/04-skills.ts
Normal file
44
packages/coding-agent/examples/sdk/04-skills.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Skills Configuration
|
||||
*
|
||||
* Skills provide specialized instructions loaded into the system prompt.
|
||||
* Discover, filter, merge, or replace them.
|
||||
*/
|
||||
|
||||
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "../../src/index.js";
|
||||
|
||||
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
|
||||
const allSkills = discoverSkills();
|
||||
console.log(
|
||||
"Discovered skills:",
|
||||
allSkills.map((s) => s.name),
|
||||
);
|
||||
|
||||
// Filter to specific skills
|
||||
const filteredSkills = allSkills.filter((s) => s.name.includes("browser") || s.name.includes("search"));
|
||||
|
||||
// Or define custom skills inline
|
||||
const customSkill: Skill = {
|
||||
name: "my-skill",
|
||||
description: "Custom project instructions",
|
||||
filePath: "/virtual/SKILL.md",
|
||||
baseDir: "/virtual",
|
||||
source: "custom",
|
||||
};
|
||||
|
||||
// Use filtered + custom skills
|
||||
const { session } = await createAgentSession({
|
||||
skills: [...filteredSkills, customSkill],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
console.log(`Session created with ${filteredSkills.length + 1} skills`);
|
||||
|
||||
// To disable all skills:
|
||||
// skills: []
|
||||
|
||||
// To use discovery with filtering via settings:
|
||||
// discoverSkills(process.cwd(), undefined, {
|
||||
// ignoredSkills: ["browser-tools"], // glob patterns to exclude
|
||||
// includeSkills: ["brave-*"], // glob patterns to include (empty = all)
|
||||
// })
|
||||
67
packages/coding-agent/examples/sdk/05-tools.ts
Normal file
67
packages/coding-agent/examples/sdk/05-tools.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Tools Configuration
|
||||
*
|
||||
* Use built-in tool sets, individual tools, or add custom tools.
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
createAgentSession,
|
||||
discoverCustomTools,
|
||||
SessionManager,
|
||||
codingTools, // read, bash, edit, write (default)
|
||||
readOnlyTools, // read, bash
|
||||
readTool,
|
||||
bashTool,
|
||||
grepTool,
|
||||
type CustomAgentTool,
|
||||
} from "../../src/index.js";
|
||||
|
||||
// Read-only mode (no edit/write)
|
||||
const { session: readOnly } = await createAgentSession({
|
||||
tools: readOnlyTools,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
console.log("Read-only session created");
|
||||
|
||||
// Custom tool selection
|
||||
const { session: custom } = await createAgentSession({
|
||||
tools: [readTool, bashTool, grepTool],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
console.log("Custom tools session created");
|
||||
|
||||
// Inline custom tool (needs TypeBox schema)
|
||||
const weatherTool: CustomAgentTool = {
|
||||
name: "get_weather",
|
||||
label: "Get Weather",
|
||||
description: "Get current weather for a city",
|
||||
parameters: Type.Object({
|
||||
city: Type.String({ description: "City name" }),
|
||||
}),
|
||||
execute: async (_toolCallId, params) => ({
|
||||
content: [{ type: "text", text: `Weather in ${(params as { city: string }).city}: 22°C, sunny` }],
|
||||
details: {},
|
||||
}),
|
||||
};
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
customTools: [{ tool: weatherTool }],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt("What's the weather in Tokyo?");
|
||||
console.log();
|
||||
|
||||
// Merge with discovered tools from cwd/.pi/tools and ~/.pi/agent/tools:
|
||||
// const discovered = await discoverCustomTools();
|
||||
// customTools: [...discovered, { tool: myTool }]
|
||||
|
||||
// Or add paths without replacing discovery:
|
||||
// additionalCustomToolPaths: ["/extra/tools"]
|
||||
61
packages/coding-agent/examples/sdk/06-hooks.ts
Normal file
61
packages/coding-agent/examples/sdk/06-hooks.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Hooks Configuration
|
||||
*
|
||||
* Hooks intercept agent events for logging, blocking, or modification.
|
||||
*/
|
||||
|
||||
import { createAgentSession, discoverHooks, SessionManager, type HookFactory } from "../../src/index.js";
|
||||
|
||||
// Logging hook
|
||||
const loggingHook: HookFactory = (api) => {
|
||||
api.on("agent_start", async () => {
|
||||
console.log("[Hook] Agent starting");
|
||||
});
|
||||
|
||||
api.on("tool_call", async (event) => {
|
||||
console.log(`[Hook] Tool: ${event.toolName}`);
|
||||
return undefined; // Don't block
|
||||
});
|
||||
|
||||
api.on("agent_end", async (event) => {
|
||||
console.log(`[Hook] Done, ${event.messages.length} messages`);
|
||||
});
|
||||
};
|
||||
|
||||
// Blocking hook (returns { block: true, reason: "..." })
|
||||
const safetyHook: HookFactory = (api) => {
|
||||
api.on("tool_call", async (event) => {
|
||||
if (event.toolName === "bash") {
|
||||
const cmd = (event.input as { command?: string }).command ?? "";
|
||||
if (cmd.includes("rm -rf")) {
|
||||
return { block: true, reason: "Dangerous command blocked" };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
// Use inline hooks
|
||||
const { session } = await createAgentSession({
|
||||
hooks: [{ factory: loggingHook }, { factory: safetyHook }],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt("List files in the current directory.");
|
||||
console.log();
|
||||
|
||||
// Disable all hooks:
|
||||
// hooks: []
|
||||
|
||||
// Merge with discovered hooks:
|
||||
// const discovered = await discoverHooks();
|
||||
// hooks: [...discovered, { factory: myHook }]
|
||||
|
||||
// Add paths without replacing discovery:
|
||||
// additionalHookPaths: ["/extra/hooks"]
|
||||
36
packages/coding-agent/examples/sdk/07-context-files.ts
Normal file
36
packages/coding-agent/examples/sdk/07-context-files.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Context Files (AGENTS.md)
|
||||
*
|
||||
* Context files provide project-specific instructions loaded into the system prompt.
|
||||
*/
|
||||
|
||||
import { createAgentSession, discoverContextFiles, SessionManager } from "../../src/index.js";
|
||||
|
||||
// Discover AGENTS.md files walking up from cwd
|
||||
const discovered = discoverContextFiles();
|
||||
console.log("Discovered context files:");
|
||||
for (const file of discovered) {
|
||||
console.log(` - ${file.path} (${file.content.length} chars)`);
|
||||
}
|
||||
|
||||
// Use custom context files
|
||||
const { session } = await createAgentSession({
|
||||
contextFiles: [
|
||||
...discovered,
|
||||
{
|
||||
path: "/virtual/AGENTS.md",
|
||||
content: `# Project Guidelines
|
||||
|
||||
## Code Style
|
||||
- Use TypeScript strict mode
|
||||
- No any types
|
||||
- Prefer const over let`,
|
||||
},
|
||||
],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
console.log(`Session created with ${discovered.length + 1} context files`);
|
||||
|
||||
// Disable context files:
|
||||
// contextFiles: []
|
||||
37
packages/coding-agent/examples/sdk/08-slash-commands.ts
Normal file
37
packages/coding-agent/examples/sdk/08-slash-commands.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Slash Commands
|
||||
*
|
||||
* File-based commands that inject content when invoked with /commandname.
|
||||
*/
|
||||
|
||||
import { createAgentSession, discoverSlashCommands, SessionManager, type FileSlashCommand } from "../../src/index.js";
|
||||
|
||||
// Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/
|
||||
const discovered = discoverSlashCommands();
|
||||
console.log("Discovered slash commands:");
|
||||
for (const cmd of discovered) {
|
||||
console.log(` /${cmd.name}: ${cmd.description}`);
|
||||
}
|
||||
|
||||
// Define custom commands
|
||||
const deployCommand: FileSlashCommand = {
|
||||
name: "deploy",
|
||||
description: "Deploy the application",
|
||||
source: "(custom)",
|
||||
content: `# Deploy Instructions
|
||||
|
||||
1. Build: npm run build
|
||||
2. Test: npm test
|
||||
3. Deploy: npm run deploy`,
|
||||
};
|
||||
|
||||
// Use discovered + custom commands
|
||||
const { session } = await createAgentSession({
|
||||
slashCommands: [...discovered, deployCommand],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
console.log(`Session created with ${discovered.length + 1} slash commands`);
|
||||
|
||||
// Disable slash commands:
|
||||
// slashCommands: []
|
||||
45
packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts
Normal file
45
packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* API Keys and OAuth
|
||||
*
|
||||
* Configure API key resolution. Default checks: models.json, OAuth, env vars.
|
||||
*/
|
||||
|
||||
import {
|
||||
createAgentSession,
|
||||
configureOAuthStorage,
|
||||
defaultGetApiKey,
|
||||
SessionManager,
|
||||
} from "../../src/index.js";
|
||||
import { getAgentDir } from "../../src/config.js";
|
||||
|
||||
// Default: uses env vars (ANTHROPIC_API_KEY, etc.), OAuth, and models.json
|
||||
const { session: defaultSession } = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
console.log("Session with default API key resolution");
|
||||
|
||||
// Custom resolver
|
||||
const { session: customSession } = await createAgentSession({
|
||||
getApiKey: async (model) => {
|
||||
// Custom logic (secrets manager, database, etc.)
|
||||
if (model.provider === "anthropic") {
|
||||
return process.env.MY_ANTHROPIC_KEY;
|
||||
}
|
||||
// Fall back to default
|
||||
return defaultGetApiKey()(model);
|
||||
},
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
console.log("Session with custom API key resolver");
|
||||
|
||||
// Use OAuth from ~/.pi/agent while customizing everything else
|
||||
configureOAuthStorage(getAgentDir()); // Must call before createAgentSession
|
||||
|
||||
const { session: hybridSession } = await createAgentSession({
|
||||
agentDir: "/tmp/custom-config", // Custom config location
|
||||
// But OAuth tokens still come from ~/.pi/agent/oauth.json
|
||||
systemPrompt: "You are helpful.",
|
||||
skills: [],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
console.log("Session with OAuth from default location, custom config elsewhere");
|
||||
38
packages/coding-agent/examples/sdk/10-settings.ts
Normal file
38
packages/coding-agent/examples/sdk/10-settings.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Settings Configuration
|
||||
*
|
||||
* Override settings using SettingsManager.
|
||||
*/
|
||||
|
||||
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "../../src/index.js";
|
||||
|
||||
// Load current settings (merged global + project)
|
||||
const settings = loadSettings();
|
||||
console.log("Current settings:", JSON.stringify(settings, null, 2));
|
||||
|
||||
// Override specific settings
|
||||
const settingsManager = SettingsManager.create();
|
||||
settingsManager.applyOverrides({
|
||||
compaction: { enabled: false },
|
||||
retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 },
|
||||
});
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
settingsManager,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
console.log("Session created with custom settings");
|
||||
|
||||
// For testing without file I/O:
|
||||
const inMemorySettings = SettingsManager.inMemory({
|
||||
compaction: { enabled: false },
|
||||
retry: { enabled: false },
|
||||
});
|
||||
|
||||
const { session: testSession } = await createAgentSession({
|
||||
settingsManager: inMemorySettings,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
console.log("Test session created with in-memory settings");
|
||||
46
packages/coding-agent/examples/sdk/11-sessions.ts
Normal file
46
packages/coding-agent/examples/sdk/11-sessions.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Session Management
|
||||
*
|
||||
* Control session persistence: in-memory, new file, continue, or open specific.
|
||||
*/
|
||||
|
||||
import { createAgentSession, SessionManager } from "../../src/index.js";
|
||||
|
||||
// In-memory (no persistence)
|
||||
const { session: inMemory } = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
console.log("In-memory session:", inMemory.sessionFile ?? "(none)");
|
||||
|
||||
// New persistent session
|
||||
const { session: newSession } = await createAgentSession({
|
||||
sessionManager: SessionManager.create(process.cwd()),
|
||||
});
|
||||
console.log("New session file:", newSession.sessionFile);
|
||||
|
||||
// Continue most recent session (or create new if none)
|
||||
const { session: continued, modelFallbackMessage } = await createAgentSession({
|
||||
sessionManager: SessionManager.continueRecent(process.cwd()),
|
||||
});
|
||||
if (modelFallbackMessage) console.log("Note:", modelFallbackMessage);
|
||||
console.log("Continued session:", continued.sessionFile);
|
||||
|
||||
// List and open specific session
|
||||
const sessions = SessionManager.list(process.cwd());
|
||||
console.log(`\nFound ${sessions.length} sessions:`);
|
||||
for (const info of sessions.slice(0, 3)) {
|
||||
console.log(` ${info.id.slice(0, 8)}... - "${info.firstMessage.slice(0, 30)}..."`);
|
||||
}
|
||||
|
||||
if (sessions.length > 0) {
|
||||
const { session: opened } = await createAgentSession({
|
||||
sessionManager: SessionManager.open(sessions[0].path),
|
||||
});
|
||||
console.log(`\nOpened: ${opened.sessionId}`);
|
||||
}
|
||||
|
||||
// Custom session directory
|
||||
// const { session } = await createAgentSession({
|
||||
// agentDir: "/custom/agent",
|
||||
// sessionManager: SessionManager.create(process.cwd(), "/custom/agent"),
|
||||
// });
|
||||
91
packages/coding-agent/examples/sdk/12-full-control.ts
Normal file
91
packages/coding-agent/examples/sdk/12-full-control.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Full Control
|
||||
*
|
||||
* Replace everything - no discovery, explicit configuration.
|
||||
* Still uses OAuth from ~/.pi/agent for convenience.
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
createAgentSession,
|
||||
configureOAuthStorage,
|
||||
defaultGetApiKey,
|
||||
findModel,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
readTool,
|
||||
bashTool,
|
||||
type HookFactory,
|
||||
type CustomAgentTool,
|
||||
} from "../../src/index.js";
|
||||
import { getAgentDir } from "../../src/config.js";
|
||||
|
||||
// Use OAuth from default location
|
||||
configureOAuthStorage(getAgentDir());
|
||||
|
||||
// Custom API key with fallback
|
||||
const getApiKey = async (model: { provider: string }) => {
|
||||
if (model.provider === "anthropic" && process.env.MY_ANTHROPIC_KEY) {
|
||||
return process.env.MY_ANTHROPIC_KEY;
|
||||
}
|
||||
return defaultGetApiKey()(model as any);
|
||||
};
|
||||
|
||||
// Inline hook
|
||||
const auditHook: HookFactory = (api) => {
|
||||
api.on("tool_call", async (event) => {
|
||||
console.log(`[Audit] ${event.toolName}`);
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
// Inline custom tool
|
||||
const statusTool: CustomAgentTool = {
|
||||
name: "status",
|
||||
label: "Status",
|
||||
description: "Get system status",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: `Uptime: ${process.uptime()}s, Node: ${process.version}` }],
|
||||
details: {},
|
||||
}),
|
||||
};
|
||||
|
||||
const { model } = findModel("anthropic", "claude-sonnet-4-20250514");
|
||||
if (!model) throw new Error("Model not found");
|
||||
|
||||
// In-memory settings with overrides
|
||||
const settingsManager = SettingsManager.inMemory({
|
||||
compaction: { enabled: false },
|
||||
retry: { enabled: true, maxRetries: 2 },
|
||||
});
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: process.cwd(),
|
||||
agentDir: "/tmp/my-agent",
|
||||
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
getApiKey,
|
||||
|
||||
systemPrompt: `You are a minimal assistant.
|
||||
Available: read, bash, status. Be concise.`,
|
||||
|
||||
tools: [readTool, bashTool],
|
||||
customTools: [{ tool: statusTool }],
|
||||
hooks: [{ factory: auditHook }],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
slashCommands: [],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager,
|
||||
});
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt("Get status and list files.");
|
||||
console.log();
|
||||
138
packages/coding-agent/examples/sdk/README.md
Normal file
138
packages/coding-agent/examples/sdk/README.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# SDK Examples
|
||||
|
||||
Programmatic usage of pi-coding-agent via `createAgentSession()`.
|
||||
|
||||
## Examples
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `01-minimal.ts` | Simplest usage with all defaults |
|
||||
| `02-custom-model.ts` | Select model and thinking level |
|
||||
| `03-custom-prompt.ts` | Replace or modify system prompt |
|
||||
| `04-skills.ts` | Discover, filter, or replace skills |
|
||||
| `05-tools.ts` | Built-in tools, custom tools |
|
||||
| `06-hooks.ts` | Logging, blocking, result modification |
|
||||
| `07-context-files.ts` | AGENTS.md context files |
|
||||
| `08-slash-commands.ts` | File-based slash commands |
|
||||
| `09-api-keys-and-oauth.ts` | API key resolution, OAuth config |
|
||||
| `10-settings.ts` | Override compaction, retry, terminal settings |
|
||||
| `11-sessions.ts` | In-memory, persistent, continue, list sessions |
|
||||
| `12-full-control.ts` | Replace everything, no discovery |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd packages/coding-agent
|
||||
npx tsx examples/sdk/01-minimal.ts
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createAgentSession,
|
||||
configureOAuthStorage,
|
||||
discoverSkills,
|
||||
discoverHooks,
|
||||
discoverCustomTools,
|
||||
discoverContextFiles,
|
||||
discoverSlashCommands,
|
||||
discoverAvailableModels,
|
||||
findModel,
|
||||
defaultGetApiKey,
|
||||
loadSettings,
|
||||
buildSystemPrompt,
|
||||
SessionManager,
|
||||
codingTools,
|
||||
readOnlyTools,
|
||||
readTool, bashTool, editTool, writeTool,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Minimal
|
||||
const { session } = await createAgentSession();
|
||||
|
||||
// Custom model
|
||||
const { model } = findModel("anthropic", "claude-sonnet-4-20250514");
|
||||
const { session } = await createAgentSession({ model, thinkingLevel: "high" });
|
||||
|
||||
// Modify prompt
|
||||
const { session } = await createAgentSession({
|
||||
systemPrompt: (defaultPrompt) => defaultPrompt + "\n\nBe concise.",
|
||||
});
|
||||
|
||||
// Read-only
|
||||
const { session } = await createAgentSession({ tools: readOnlyTools });
|
||||
|
||||
// In-memory
|
||||
const { session } = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
// Full control
|
||||
configureOAuthStorage(); // Use OAuth from ~/.pi/agent
|
||||
const { session } = await createAgentSession({
|
||||
model,
|
||||
getApiKey: async (m) => process.env.MY_KEY,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [readTool, bashTool],
|
||||
customTools: [{ tool: myTool }],
|
||||
hooks: [{ factory: myHook }],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
slashCommands: [],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settings: { compaction: { enabled: false } },
|
||||
});
|
||||
|
||||
// Run prompts
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
await session.prompt("Hello");
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `cwd` | `process.cwd()` | Working directory |
|
||||
| `agentDir` | `~/.pi/agent` | Config directory |
|
||||
| `model` | From settings/first available | Model to use |
|
||||
| `thinkingLevel` | From settings/"off" | off, low, medium, high |
|
||||
| `getApiKey` | Built-in resolver | API key function |
|
||||
| `systemPrompt` | Discovered | String or `(default) => modified` |
|
||||
| `tools` | `codingTools` | Built-in tools |
|
||||
| `customTools` | Discovered | Replaces discovery |
|
||||
| `additionalCustomToolPaths` | `[]` | Merge with discovery |
|
||||
| `hooks` | Discovered | Replaces discovery |
|
||||
| `additionalHookPaths` | `[]` | Merge with discovery |
|
||||
| `skills` | Discovered | Skills for prompt |
|
||||
| `contextFiles` | Discovered | AGENTS.md files |
|
||||
| `slashCommands` | Discovered | File commands |
|
||||
| `sessionManager` | `SessionManager.create(cwd)` | Persistence |
|
||||
| `settings` | From agentDir | Overrides |
|
||||
|
||||
## Events
|
||||
|
||||
```typescript
|
||||
session.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "message_update":
|
||||
if (event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
break;
|
||||
case "tool_execution_start":
|
||||
console.log(`Tool: ${event.toolName}`);
|
||||
break;
|
||||
case "tool_execution_end":
|
||||
console.log(`Result: ${event.result}`);
|
||||
break;
|
||||
case "agent_end":
|
||||
console.log("Done");
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
|
@ -3,17 +3,17 @@
|
|||
*/
|
||||
|
||||
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
||||
import type { SessionManager } from "../core/session-manager.js";
|
||||
import type { SessionInfo } from "../core/session-manager.js";
|
||||
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";
|
||||
|
||||
/** Show TUI session selector and return selected session path or null if cancelled */
|
||||
export async function selectSession(sessionManager: SessionManager): Promise<string | null> {
|
||||
export async function selectSession(sessions: SessionInfo[]): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = new TUI(new ProcessTerminal());
|
||||
let resolved = false;
|
||||
|
||||
const selector = new SessionSelectorComponent(
|
||||
sessionManager,
|
||||
sessions,
|
||||
(path: string) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
|
|
|
|||
|
|
@ -202,11 +202,6 @@ export class AgentSession {
|
|||
if (event.type === "message_end") {
|
||||
this.sessionManager.saveMessage(event.message);
|
||||
|
||||
// Initialize session after first user+assistant exchange
|
||||
if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {
|
||||
this.sessionManager.startSession(this.agent.state);
|
||||
}
|
||||
|
||||
// Track assistant message for auto-compaction (checked on agent_end)
|
||||
if (event.message.role === "assistant") {
|
||||
this._lastAssistantMessage = event.message;
|
||||
|
|
@ -389,7 +384,7 @@ export class AgentSession {
|
|||
|
||||
/** Current session file path, or null if sessions are disabled */
|
||||
get sessionFile(): string | null {
|
||||
return this.sessionManager.isEnabled() ? this.sessionManager.getSessionFile() : null;
|
||||
return this.sessionManager.isPersisted() ? this.sessionManager.getSessionFile() : null;
|
||||
}
|
||||
|
||||
/** Current session ID */
|
||||
|
|
@ -1096,11 +1091,6 @@ export class AgentSession {
|
|||
|
||||
// Save to session
|
||||
this.sessionManager.saveMessage(bashMessage);
|
||||
|
||||
// Initialize session if needed
|
||||
if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {
|
||||
this.sessionManager.startSession(this.agent.state);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -1141,11 +1131,6 @@ export class AgentSession {
|
|||
this.sessionManager.saveMessage(bashMessage);
|
||||
}
|
||||
|
||||
// Initialize session if needed
|
||||
if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {
|
||||
this.sessionManager.startSession(this.agent.state);
|
||||
}
|
||||
|
||||
this._pendingBashMessages = [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ function discoverToolsInDir(dir: string): string[] {
|
|||
|
||||
/**
|
||||
* Discover and load tools from standard locations:
|
||||
* 1. ~/.pi/agent/tools/*.ts (global)
|
||||
* 1. agentDir/tools/*.ts (global)
|
||||
* 2. cwd/.pi/tools/*.ts (project-local)
|
||||
*
|
||||
* Plus any explicitly configured paths from settings or CLI.
|
||||
|
|
@ -311,11 +311,13 @@ function discoverToolsInDir(dir: string): string[] {
|
|||
* @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
|
||||
* @param cwd - Current working directory
|
||||
* @param builtInToolNames - Names of built-in tools to check for conflicts
|
||||
* @param agentDir - Agent config directory. Default: from getAgentDir()
|
||||
*/
|
||||
export async function discoverAndLoadCustomTools(
|
||||
configuredPaths: string[],
|
||||
cwd: string,
|
||||
builtInToolNames: string[],
|
||||
agentDir: string = getAgentDir(),
|
||||
): Promise<CustomToolsLoadResult> {
|
||||
const allPaths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -331,8 +333,8 @@ export async function discoverAndLoadCustomTools(
|
|||
}
|
||||
};
|
||||
|
||||
// 1. Global tools: ~/.pi/agent/tools/
|
||||
const globalToolsDir = path.join(getAgentDir(), "tools");
|
||||
// 1. Global tools: agentDir/tools/
|
||||
const globalToolsDir = path.join(agentDir, "tools");
|
||||
addPaths(discoverToolsInDir(globalToolsDir));
|
||||
|
||||
// 2. Project-local tools: cwd/.pi/tools/
|
||||
|
|
|
|||
|
|
@ -217,15 +217,16 @@ function discoverHooksInDir(dir: string): string[] {
|
|||
|
||||
/**
|
||||
* Discover and load hooks from standard locations:
|
||||
* 1. ~/.pi/agent/hooks/*.ts (global)
|
||||
* 1. agentDir/hooks/*.ts (global)
|
||||
* 2. cwd/.pi/hooks/*.ts (project-local)
|
||||
*
|
||||
* Plus any explicitly configured paths from settings.
|
||||
*
|
||||
* @param configuredPaths - Explicit paths from settings.json
|
||||
* @param cwd - Current working directory
|
||||
*/
|
||||
export async function discoverAndLoadHooks(configuredPaths: string[], cwd: string): Promise<LoadHooksResult> {
|
||||
export async function discoverAndLoadHooks(
|
||||
configuredPaths: string[],
|
||||
cwd: string,
|
||||
agentDir: string = getAgentDir(),
|
||||
): Promise<LoadHooksResult> {
|
||||
const allPaths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
|
|
@ -240,8 +241,8 @@ export async function discoverAndLoadHooks(configuredPaths: string[], cwd: strin
|
|||
}
|
||||
};
|
||||
|
||||
// 1. Global hooks: ~/.pi/agent/hooks/
|
||||
const globalHooksDir = path.join(getAgentDir(), "hooks");
|
||||
// 1. Global hooks: agentDir/hooks/
|
||||
const globalHooksDir = path.join(agentDir, "hooks");
|
||||
addPaths(discoverHooksInDir(globalHooksDir));
|
||||
|
||||
// 2. Project-local hooks: cwd/.pi/hooks/
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import {
|
|||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import AjvModule from "ajv";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { getModelsPath } from "../config.js";
|
||||
import { join } from "path";
|
||||
import { getAgentDir } from "../config.js";
|
||||
import { getOAuthToken, type OAuthProvider, refreshToken } from "./oauth/index.js";
|
||||
|
||||
// Handle both default and named exports
|
||||
|
|
@ -97,8 +98,8 @@ export function resolveApiKey(keyConfig: string): string | undefined {
|
|||
* Load custom models from models.json in agent config dir
|
||||
* Returns { models, error } - either models array or error message
|
||||
*/
|
||||
function loadCustomModels(): { models: Model<Api>[]; error: string | null } {
|
||||
const configPath = getModelsPath();
|
||||
function loadCustomModels(agentDir: string = getAgentDir()): { models: Model<Api>[]; error: string | null } {
|
||||
const configPath = join(agentDir, "models.json");
|
||||
if (!existsSync(configPath)) {
|
||||
return { models: [], error: null };
|
||||
}
|
||||
|
|
@ -232,7 +233,7 @@ function parseModels(config: ModelsConfig): Model<Api>[] {
|
|||
* Get all models (built-in + custom), freshly loaded
|
||||
* Returns { models, error } - either models array or error message
|
||||
*/
|
||||
export function loadAndMergeModels(): { models: Model<Api>[]; error: string | null } {
|
||||
export function loadAndMergeModels(agentDir: string = getAgentDir()): { models: Model<Api>[]; error: string | null } {
|
||||
const builtInModels: Model<Api>[] = [];
|
||||
const providers = getProviders();
|
||||
|
||||
|
|
@ -243,7 +244,7 @@ export function loadAndMergeModels(): { models: Model<Api>[]; error: string | nu
|
|||
}
|
||||
|
||||
// Load custom models
|
||||
const { models: customModels, error } = loadCustomModels();
|
||||
const { models: customModels, error } = loadCustomModels(agentDir);
|
||||
|
||||
if (error) {
|
||||
return { models: [], error };
|
||||
|
|
@ -267,7 +268,8 @@ export function loadAndMergeModels(): { models: Model<Api>[]; error: string | nu
|
|||
|
||||
/**
|
||||
* Get API key for a model (checks custom providers first, then built-in)
|
||||
* Now async to support OAuth token refresh
|
||||
* Now async to support OAuth token refresh.
|
||||
* Note: OAuth storage location is configured globally via setOAuthStorage.
|
||||
*/
|
||||
export async function getApiKeyForModel(model: Model<Api>): Promise<string | undefined> {
|
||||
// For custom providers, check their apiKey config
|
||||
|
|
@ -357,26 +359,17 @@ export async function getApiKeyForModel(model: Model<Api>): Promise<string | und
|
|||
* Get only models that have valid API keys available
|
||||
* Returns { models, error } - either models array or error message
|
||||
*/
|
||||
export async function getAvailableModels(): Promise<{ models: Model<Api>[]; error: string | null }> {
|
||||
const { models: allModels, error } = loadAndMergeModels();
|
||||
export async function getAvailableModels(
|
||||
agentDir: string = getAgentDir(),
|
||||
): Promise<{ models: Model<Api>[]; error: string | null }> {
|
||||
const { models: allModels, error } = loadAndMergeModels(agentDir);
|
||||
|
||||
if (error) {
|
||||
return { models: [], error };
|
||||
}
|
||||
|
||||
const availableModels: Model<Api>[] = [];
|
||||
const copilotCreds = loadOAuthCredentials("github-copilot");
|
||||
const hasCopilotEnv = !!(process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN);
|
||||
const hasCopilot = !!copilotCreds || hasCopilotEnv;
|
||||
|
||||
for (const model of allModels) {
|
||||
if (model.provider === "github-copilot") {
|
||||
if (hasCopilot) {
|
||||
availableModels.push(model);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const apiKey = await getApiKeyForModel(model);
|
||||
if (apiKey) {
|
||||
availableModels.push(model);
|
||||
|
|
@ -390,8 +383,12 @@ export async function getAvailableModels(): Promise<{ models: Model<Api>[]; erro
|
|||
* Find a specific model by provider and ID
|
||||
* Returns { model, error } - either model or error message
|
||||
*/
|
||||
export function findModel(provider: string, modelId: string): { model: Model<Api> | null; error: string | null } {
|
||||
const { models: allModels, error } = loadAndMergeModels();
|
||||
export function findModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
agentDir: string = getAgentDir(),
|
||||
): { model: Model<Api> | null; error: string | null } {
|
||||
const { models: allModels, error } = loadAndMergeModels(agentDir);
|
||||
|
||||
if (error) {
|
||||
return { model: null, error };
|
||||
|
|
|
|||
639
packages/coding-agent/src/core/sdk.ts
Normal file
639
packages/coding-agent/src/core/sdk.ts
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
/**
|
||||
* SDK for programmatic usage of AgentSession.
|
||||
*
|
||||
* Provides a factory function and discovery helpers that allow full control
|
||||
* over agent configuration, or sensible defaults that match CLI behavior.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Minimal - everything auto-discovered
|
||||
* const session = await createAgentSession();
|
||||
*
|
||||
* // With custom hooks
|
||||
* const session = await createAgentSession({
|
||||
* hooks: [
|
||||
* ...await discoverHooks(),
|
||||
* { factory: myHookFactory },
|
||||
* ],
|
||||
* });
|
||||
*
|
||||
* // Full control
|
||||
* const session = await createAgentSession({
|
||||
* model: myModel,
|
||||
* getApiKey: async () => process.env.MY_KEY,
|
||||
* tools: [readTool, bashTool],
|
||||
* hooks: [],
|
||||
* skills: [],
|
||||
* sessionFile: false,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import { type Model, setOAuthStorage } from "@mariozechner/pi-ai";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { getAgentDir } from "../config.js";
|
||||
import { AgentSession } from "./agent-session.js";
|
||||
import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./custom-tools/index.js";
|
||||
import type { CustomAgentTool } from "./custom-tools/types.js";
|
||||
import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js";
|
||||
import type { HookFactory } from "./hooks/types.js";
|
||||
import { messageTransformer } from "./messages.js";
|
||||
import {
|
||||
findModel as findModelInternal,
|
||||
getApiKeyForModel,
|
||||
getAvailableModels,
|
||||
loadAndMergeModels,
|
||||
} from "./model-config.js";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js";
|
||||
import { loadSkills as loadSkillsInternal, type Skill } from "./skills.js";
|
||||
import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands.js";
|
||||
import {
|
||||
buildSystemPrompt as buildSystemPromptInternal,
|
||||
loadProjectContextFiles as loadContextFilesInternal,
|
||||
} from "./system-prompt.js";
|
||||
import {
|
||||
allTools,
|
||||
bashTool,
|
||||
codingTools,
|
||||
editTool,
|
||||
findTool,
|
||||
grepTool,
|
||||
lsTool,
|
||||
readOnlyTools,
|
||||
readTool,
|
||||
type Tool,
|
||||
writeTool,
|
||||
} from "./tools/index.js";
|
||||
|
||||
// Types
|
||||
|
||||
export interface CreateAgentSessionOptions {
|
||||
/** Working directory for project-local discovery. Default: process.cwd() */
|
||||
cwd?: string;
|
||||
/** Global config directory. Default: ~/.pi/agent */
|
||||
agentDir?: string;
|
||||
|
||||
/** Model to use. Default: from settings, else first available */
|
||||
model?: Model<any>;
|
||||
/** Thinking level. Default: from settings, else 'off' (clamped to model capabilities) */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Models available for cycling (Ctrl+P in interactive mode) */
|
||||
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
||||
|
||||
/** API key resolver. Default: defaultGetApiKey() */
|
||||
getApiKey?: (model: Model<any>) => Promise<string | undefined>;
|
||||
|
||||
/** System prompt. String replaces default, function receives default and returns final. */
|
||||
systemPrompt?: string | ((defaultPrompt: string) => string);
|
||||
|
||||
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
|
||||
tools?: Tool[];
|
||||
/** Custom tools (replaces discovery). */
|
||||
customTools?: Array<{ path?: string; tool: CustomAgentTool }>;
|
||||
/** Additional custom tool paths to load (merged with discovery). */
|
||||
additionalCustomToolPaths?: string[];
|
||||
|
||||
/** Hooks (replaces discovery). */
|
||||
hooks?: Array<{ path?: string; factory: HookFactory }>;
|
||||
/** Additional hook paths to load (merged with discovery). */
|
||||
additionalHookPaths?: string[];
|
||||
|
||||
/** Skills. Default: discovered from multiple locations */
|
||||
skills?: Skill[];
|
||||
/** Context files (AGENTS.md content). Default: discovered walking up from cwd */
|
||||
contextFiles?: Array<{ path: string; content: string }>;
|
||||
/** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */
|
||||
slashCommands?: FileSlashCommand[];
|
||||
|
||||
/** Session manager. Default: SessionManager.create(cwd) */
|
||||
sessionManager?: SessionManager;
|
||||
|
||||
/** Settings manager. Default: SettingsManager.create(cwd, agentDir) */
|
||||
settingsManager?: SettingsManager;
|
||||
}
|
||||
|
||||
/** Result from createAgentSession */
|
||||
export interface CreateAgentSessionResult {
|
||||
/** The created session */
|
||||
session: AgentSession;
|
||||
/** Custom tools result (for UI context setup in interactive mode) */
|
||||
customToolsResult: {
|
||||
tools: LoadedCustomTool[];
|
||||
setUIContext: (uiContext: any, hasUI: boolean) => void;
|
||||
};
|
||||
/** Warning if session was restored with a different model than saved */
|
||||
modelFallbackMessage?: string;
|
||||
}
|
||||
|
||||
// Re-exports
|
||||
|
||||
export type { CustomAgentTool } from "./custom-tools/types.js";
|
||||
export type { HookAPI, HookFactory } from "./hooks/types.js";
|
||||
export type { Settings, SkillsSettings } from "./settings-manager.js";
|
||||
export type { Skill } from "./skills.js";
|
||||
export type { FileSlashCommand } from "./slash-commands.js";
|
||||
export type { Tool } from "./tools/index.js";
|
||||
|
||||
export {
|
||||
readTool,
|
||||
bashTool,
|
||||
editTool,
|
||||
writeTool,
|
||||
grepTool,
|
||||
findTool,
|
||||
lsTool,
|
||||
codingTools,
|
||||
readOnlyTools,
|
||||
allTools as allBuiltInTools,
|
||||
};
|
||||
|
||||
// Helper Functions
|
||||
|
||||
function getDefaultAgentDir(): string {
|
||||
return getAgentDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OAuth storage to use the specified agent directory.
|
||||
* Must be called before using OAuth-based authentication.
|
||||
*/
|
||||
export function configureOAuthStorage(agentDir: string = getDefaultAgentDir()): void {
|
||||
const oauthPath = join(agentDir, "oauth.json");
|
||||
|
||||
setOAuthStorage({
|
||||
load: () => {
|
||||
if (!existsSync(oauthPath)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(oauthPath, "utf-8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
save: (storage) => {
|
||||
const dir = dirname(oauthPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
writeFileSync(oauthPath, JSON.stringify(storage, null, 2), "utf-8");
|
||||
chmodSync(oauthPath, 0o600);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Discovery Functions
|
||||
|
||||
/**
|
||||
* Get all models (built-in + custom from models.json).
|
||||
*/
|
||||
export function discoverModels(agentDir: string = getDefaultAgentDir()): Model<any>[] {
|
||||
const { models, error } = loadAndMergeModels(agentDir);
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models that have valid API keys available.
|
||||
*/
|
||||
export async function discoverAvailableModels(agentDir: string = getDefaultAgentDir()): Promise<Model<any>[]> {
|
||||
const { models, error } = await getAvailableModels(agentDir);
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a model by provider and ID.
|
||||
* @returns The model, or null if not found
|
||||
*/
|
||||
export function findModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
agentDir: string = getDefaultAgentDir(),
|
||||
): Model<any> | null {
|
||||
const { model, error } = findModelInternal(provider, modelId, agentDir);
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover hooks from cwd and agentDir.
|
||||
*/
|
||||
export async function discoverHooks(
|
||||
cwd?: string,
|
||||
agentDir?: string,
|
||||
): Promise<Array<{ path: string; factory: HookFactory }>> {
|
||||
const resolvedCwd = cwd ?? process.cwd();
|
||||
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
|
||||
|
||||
const { hooks, errors } = await discoverAndLoadHooks([], resolvedCwd, resolvedAgentDir);
|
||||
|
||||
// Log errors but don't fail
|
||||
for (const { path, error } of errors) {
|
||||
console.error(`Failed to load hook "${path}": ${error}`);
|
||||
}
|
||||
|
||||
return hooks.map((h) => ({
|
||||
path: h.path,
|
||||
factory: createFactoryFromLoadedHook(h),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover custom tools from cwd and agentDir.
|
||||
*/
|
||||
export async function discoverCustomTools(
|
||||
cwd?: string,
|
||||
agentDir?: string,
|
||||
): Promise<Array<{ path: string; tool: CustomAgentTool }>> {
|
||||
const resolvedCwd = cwd ?? process.cwd();
|
||||
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
|
||||
|
||||
const { tools, errors } = await discoverAndLoadCustomTools([], resolvedCwd, Object.keys(allTools), resolvedAgentDir);
|
||||
|
||||
// Log errors but don't fail
|
||||
for (const { path, error } of errors) {
|
||||
console.error(`Failed to load custom tool "${path}": ${error}`);
|
||||
}
|
||||
|
||||
return tools.map((t) => ({
|
||||
path: t.path,
|
||||
tool: t.tool,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover skills from cwd and agentDir.
|
||||
*/
|
||||
export function discoverSkills(cwd?: string, agentDir?: string, settings?: SkillsSettings): Skill[] {
|
||||
const { skills } = loadSkillsInternal({
|
||||
...settings,
|
||||
cwd: cwd ?? process.cwd(),
|
||||
agentDir: agentDir ?? getDefaultAgentDir(),
|
||||
});
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover context files (AGENTS.md) walking up from cwd.
|
||||
*/
|
||||
export function discoverContextFiles(cwd?: string, agentDir?: string): Array<{ path: string; content: string }> {
|
||||
return loadContextFilesInternal({
|
||||
cwd: cwd ?? process.cwd(),
|
||||
agentDir: agentDir ?? getDefaultAgentDir(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover slash commands from cwd and agentDir.
|
||||
*/
|
||||
export function discoverSlashCommands(cwd?: string, agentDir?: string): FileSlashCommand[] {
|
||||
return loadSlashCommandsInternal({
|
||||
cwd: cwd ?? process.cwd(),
|
||||
agentDir: agentDir ?? getDefaultAgentDir(),
|
||||
});
|
||||
}
|
||||
|
||||
// API Key Helpers
|
||||
|
||||
/**
|
||||
* Create the default API key resolver.
|
||||
* Checks custom providers (models.json), OAuth, and environment variables.
|
||||
*/
|
||||
export function defaultGetApiKey(): (model: Model<any>) => Promise<string | undefined> {
|
||||
return getApiKeyForModel;
|
||||
}
|
||||
|
||||
// System Prompt
|
||||
|
||||
export interface BuildSystemPromptOptions {
|
||||
tools?: Tool[];
|
||||
skills?: Skill[];
|
||||
contextFiles?: Array<{ path: string; content: string }>;
|
||||
cwd?: string;
|
||||
appendPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the default system prompt.
|
||||
*/
|
||||
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
|
||||
return buildSystemPromptInternal({
|
||||
cwd: options.cwd,
|
||||
skills: options.skills,
|
||||
contextFiles: options.contextFiles,
|
||||
appendSystemPrompt: options.appendPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
// Settings
|
||||
|
||||
/**
|
||||
* Load settings from agentDir/settings.json merged with cwd/.pi/settings.json.
|
||||
*/
|
||||
export function loadSettings(cwd?: string, agentDir?: string): Settings {
|
||||
const manager = SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir());
|
||||
return {
|
||||
defaultProvider: manager.getDefaultProvider(),
|
||||
defaultModel: manager.getDefaultModel(),
|
||||
defaultThinkingLevel: manager.getDefaultThinkingLevel(),
|
||||
queueMode: manager.getQueueMode(),
|
||||
theme: manager.getTheme(),
|
||||
compaction: manager.getCompactionSettings(),
|
||||
retry: manager.getRetrySettings(),
|
||||
hideThinkingBlock: manager.getHideThinkingBlock(),
|
||||
shellPath: manager.getShellPath(),
|
||||
collapseChangelog: manager.getCollapseChangelog(),
|
||||
hooks: manager.getHookPaths(),
|
||||
hookTimeout: manager.getHookTimeout(),
|
||||
customTools: manager.getCustomToolPaths(),
|
||||
skills: manager.getSkillsSettings(),
|
||||
terminal: { showImages: manager.getShowImages() },
|
||||
};
|
||||
}
|
||||
|
||||
// Internal Helpers
|
||||
|
||||
/**
|
||||
* Create a HookFactory from a LoadedHook.
|
||||
* This allows mixing discovered hooks with inline hooks.
|
||||
*/
|
||||
function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory {
|
||||
return (api) => {
|
||||
for (const [eventType, handlers] of loaded.handlers) {
|
||||
for (const handler of handlers) {
|
||||
api.on(eventType as any, handler as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hook definitions to LoadedHooks for the HookRunner.
|
||||
*/
|
||||
function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] {
|
||||
return definitions.map((def) => {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
|
||||
let sendHandler: (text: string, attachments?: any[]) => void = () => {};
|
||||
|
||||
const api = {
|
||||
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
const list = handlers.get(event) ?? [];
|
||||
list.push(handler);
|
||||
handlers.set(event, list);
|
||||
},
|
||||
send: (text: string, attachments?: any[]) => {
|
||||
sendHandler(text, attachments);
|
||||
},
|
||||
};
|
||||
|
||||
def.factory(api as any);
|
||||
|
||||
return {
|
||||
path: def.path ?? "<inline>",
|
||||
resolvedPath: def.path ?? "<inline>",
|
||||
handlers,
|
||||
setSendHandler: (handler: (text: string, attachments?: any[]) => void) => {
|
||||
sendHandler = handler;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Factory
|
||||
|
||||
/**
|
||||
* Create an AgentSession with the specified options.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Minimal - uses defaults
|
||||
* const { session } = await createAgentSession();
|
||||
*
|
||||
* // With explicit model
|
||||
* const { session } = await createAgentSession({
|
||||
* model: findModel('anthropic', 'claude-sonnet-4-20250514'),
|
||||
* thinkingLevel: 'high',
|
||||
* });
|
||||
*
|
||||
* // Continue previous session
|
||||
* const { session, modelFallbackMessage } = await createAgentSession({
|
||||
* continueSession: true,
|
||||
* });
|
||||
*
|
||||
* // Full control
|
||||
* const { session } = await createAgentSession({
|
||||
* model: myModel,
|
||||
* getApiKey: async () => process.env.MY_KEY,
|
||||
* systemPrompt: 'You are helpful.',
|
||||
* tools: [readTool, bashTool],
|
||||
* hooks: [],
|
||||
* skills: [],
|
||||
* sessionManager: SessionManager.inMemory(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const agentDir = options.agentDir ?? getDefaultAgentDir();
|
||||
|
||||
// Configure OAuth storage for this agentDir
|
||||
configureOAuthStorage(agentDir);
|
||||
|
||||
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
|
||||
const sessionManager = options.sessionManager ?? SessionManager.create(cwd, agentDir);
|
||||
|
||||
// Check if session has existing data to restore
|
||||
const existingSession = sessionManager.loadSession();
|
||||
const hasExistingSession = existingSession.messages.length > 0;
|
||||
|
||||
let model = options.model;
|
||||
let modelFallbackMessage: string | undefined;
|
||||
|
||||
// If session has data, try to restore model from it
|
||||
if (!model && hasExistingSession && existingSession.model) {
|
||||
const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId);
|
||||
if (restoredModel) {
|
||||
const key = await getApiKeyForModel(restoredModel);
|
||||
if (key) {
|
||||
model = restoredModel;
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// If still no model, try settings default
|
||||
if (!model) {
|
||||
const defaultProvider = settingsManager.getDefaultProvider();
|
||||
const defaultModelId = settingsManager.getDefaultModel();
|
||||
if (defaultProvider && defaultModelId) {
|
||||
const settingsModel = findModel(defaultProvider, defaultModelId);
|
||||
if (settingsModel) {
|
||||
const key = await getApiKeyForModel(settingsModel);
|
||||
if (key) {
|
||||
model = settingsModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to first available
|
||||
if (!model) {
|
||||
const available = await discoverAvailableModels();
|
||||
if (available.length === 0) {
|
||||
throw new Error(
|
||||
"No models available. Set an API key environment variable " +
|
||||
"(ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) or provide a model explicitly.",
|
||||
);
|
||||
}
|
||||
model = available[0];
|
||||
if (modelFallbackMessage) {
|
||||
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
let thinkingLevel = options.thinkingLevel;
|
||||
|
||||
// If session has data, restore thinking level from it
|
||||
if (thinkingLevel === undefined && hasExistingSession) {
|
||||
thinkingLevel = existingSession.thinkingLevel as ThinkingLevel;
|
||||
}
|
||||
|
||||
// Fall back to settings default
|
||||
if (thinkingLevel === undefined) {
|
||||
thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? "off";
|
||||
}
|
||||
|
||||
// Clamp to model capabilities
|
||||
if (!model.reasoning) {
|
||||
thinkingLevel = "off";
|
||||
}
|
||||
|
||||
const getApiKey = options.getApiKey ?? defaultGetApiKey();
|
||||
|
||||
const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
|
||||
|
||||
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
|
||||
|
||||
const builtInTools = options.tools ?? codingTools;
|
||||
|
||||
let customToolsResult: { tools: LoadedCustomTool[]; setUIContext: (ctx: any, hasUI: boolean) => void };
|
||||
if (options.customTools !== undefined) {
|
||||
// Use provided custom tools
|
||||
const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({
|
||||
path: ct.path ?? "<inline>",
|
||||
resolvedPath: ct.path ?? "<inline>",
|
||||
tool: ct.tool,
|
||||
}));
|
||||
customToolsResult = {
|
||||
tools: loadedTools,
|
||||
setUIContext: () => {},
|
||||
};
|
||||
} else {
|
||||
// Discover custom tools, merging with additional paths
|
||||
const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])];
|
||||
const result = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir);
|
||||
for (const { path, error } of result.errors) {
|
||||
console.error(`Failed to load custom tool "${path}": ${error}`);
|
||||
}
|
||||
customToolsResult = result;
|
||||
}
|
||||
|
||||
let hookRunner: HookRunner | null = null;
|
||||
if (options.hooks !== undefined) {
|
||||
if (options.hooks.length > 0) {
|
||||
const loadedHooks = createLoadedHooksFromDefinitions(options.hooks);
|
||||
hookRunner = new HookRunner(loadedHooks, cwd, settingsManager.getHookTimeout());
|
||||
}
|
||||
} else {
|
||||
// Discover hooks, merging with additional paths
|
||||
const configuredPaths = [...settingsManager.getHookPaths(), ...(options.additionalHookPaths ?? [])];
|
||||
const { hooks, errors } = await discoverAndLoadHooks(configuredPaths, cwd, agentDir);
|
||||
for (const { path, error } of errors) {
|
||||
console.error(`Failed to load hook "${path}": ${error}`);
|
||||
}
|
||||
if (hooks.length > 0) {
|
||||
hookRunner = new HookRunner(hooks, cwd, settingsManager.getHookTimeout());
|
||||
}
|
||||
}
|
||||
|
||||
let allToolsArray: Tool[] = [...builtInTools, ...customToolsResult.tools.map((lt) => lt.tool as unknown as Tool)];
|
||||
if (hookRunner) {
|
||||
allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[];
|
||||
}
|
||||
|
||||
let systemPrompt: string;
|
||||
const defaultPrompt = buildSystemPromptInternal({
|
||||
cwd,
|
||||
agentDir,
|
||||
skills,
|
||||
contextFiles,
|
||||
});
|
||||
|
||||
if (options.systemPrompt === undefined) {
|
||||
systemPrompt = defaultPrompt;
|
||||
} else if (typeof options.systemPrompt === "string") {
|
||||
systemPrompt = options.systemPrompt;
|
||||
} else {
|
||||
systemPrompt = options.systemPrompt(defaultPrompt);
|
||||
}
|
||||
|
||||
const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir);
|
||||
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
model,
|
||||
thinkingLevel,
|
||||
tools: allToolsArray,
|
||||
},
|
||||
messageTransformer,
|
||||
queueMode: settingsManager.getQueueMode(),
|
||||
transport: new ProviderTransport({
|
||||
getApiKey: async () => {
|
||||
const currentModel = agent.state.model;
|
||||
if (!currentModel) {
|
||||
throw new Error("No model selected");
|
||||
}
|
||||
const key = await getApiKey(currentModel);
|
||||
if (!key) {
|
||||
throw new Error(`No API key found for provider "${currentModel.provider}"`);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Restore messages if session has existing data
|
||||
if (hasExistingSession) {
|
||||
agent.replaceMessages(existingSession.messages);
|
||||
}
|
||||
|
||||
const session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
scopedModels: options.scopedModels,
|
||||
fileCommands: slashCommands,
|
||||
hookRunner,
|
||||
customTools: customToolsResult.tools,
|
||||
skillsSettings: settingsManager.getSkillsSettings(),
|
||||
});
|
||||
|
||||
return {
|
||||
session,
|
||||
customToolsResult,
|
||||
modelFallbackMessage,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
||||
import { randomBytes } from "crypto";
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "fs";
|
||||
import { join, resolve } from "path";
|
||||
import { getAgentDir } from "../config.js";
|
||||
import { getAgentDir as getDefaultAgentDir } from "../config.js";
|
||||
|
||||
function uuidv4(): string {
|
||||
const bytes = randomBytes(16);
|
||||
|
|
@ -12,18 +12,11 @@ function uuidv4(): string {
|
|||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session entry types
|
||||
// ============================================================================
|
||||
|
||||
export interface SessionHeader {
|
||||
type: "session";
|
||||
id: string;
|
||||
timestamp: string;
|
||||
cwd: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
thinkingLevel: string;
|
||||
branchedFrom?: string;
|
||||
}
|
||||
|
||||
|
|
@ -50,11 +43,10 @@ export interface CompactionEntry {
|
|||
type: "compaction";
|
||||
timestamp: string;
|
||||
summary: string;
|
||||
firstKeptEntryIndex: number; // Index into session entries where we start keeping
|
||||
firstKeptEntryIndex: number;
|
||||
tokensBefore: number;
|
||||
}
|
||||
|
||||
/** Union of all session entry types */
|
||||
export type SessionEntry =
|
||||
| SessionHeader
|
||||
| SessionMessageEntry
|
||||
|
|
@ -62,16 +54,22 @@ export type SessionEntry =
|
|||
| ModelChangeEntry
|
||||
| CompactionEntry;
|
||||
|
||||
// ============================================================================
|
||||
// Session loading with compaction support
|
||||
// ============================================================================
|
||||
|
||||
export interface LoadedSession {
|
||||
messages: AppMessage[];
|
||||
thinkingLevel: string;
|
||||
model: { provider: string; modelId: string } | null;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
path: string;
|
||||
id: string;
|
||||
created: Date;
|
||||
modified: Date;
|
||||
messageCount: number;
|
||||
firstMessage: string;
|
||||
allMessagesText: string;
|
||||
}
|
||||
|
||||
export const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
|
||||
|
||||
<summary>
|
||||
|
|
@ -80,9 +78,6 @@ export const SUMMARY_PREFIX = `The conversation history before this point was co
|
|||
export const SUMMARY_SUFFIX = `
|
||||
</summary>`;
|
||||
|
||||
/**
|
||||
* Create a user message containing the summary with the standard prefix.
|
||||
*/
|
||||
export function createSummaryMessage(summary: string): AppMessage {
|
||||
return {
|
||||
role: "user",
|
||||
|
|
@ -91,9 +86,6 @@ export function createSummaryMessage(summary: string): AppMessage {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse session file content into entries.
|
||||
*/
|
||||
export function parseSessionEntries(content: string): SessionEntry[] {
|
||||
const entries: SessionEntry[] = [];
|
||||
const lines = content.trim().split("\n");
|
||||
|
|
@ -111,17 +103,6 @@ export function parseSessionEntries(content: string): SessionEntry[] {
|
|||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load session from entries, handling compaction events.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Find latest compaction event (if any)
|
||||
* 2. Keep all entries from firstKeptEntryIndex onwards (extracting messages)
|
||||
* 3. Prepend summary as user message
|
||||
*/
|
||||
/**
|
||||
* Get the latest compaction entry from session entries, if any.
|
||||
*/
|
||||
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i].type === "compaction") {
|
||||
|
|
@ -132,22 +113,19 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
|
|||
}
|
||||
|
||||
export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||
// Find model and thinking level (always scan all entries)
|
||||
let thinkingLevel = "off";
|
||||
let model: { provider: string; modelId: string } | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "session") {
|
||||
thinkingLevel = entry.thinkingLevel;
|
||||
model = { provider: entry.provider, modelId: entry.modelId };
|
||||
} else if (entry.type === "thinking_level_change") {
|
||||
if (entry.type === "thinking_level_change") {
|
||||
thinkingLevel = entry.thinkingLevel;
|
||||
} else if (entry.type === "model_change") {
|
||||
model = { provider: entry.provider, modelId: entry.modelId };
|
||||
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
model = { provider: entry.message.provider, modelId: entry.message.model };
|
||||
}
|
||||
}
|
||||
|
||||
// Find latest compaction event
|
||||
let latestCompactionIndex = -1;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i].type === "compaction") {
|
||||
|
|
@ -156,7 +134,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
|||
}
|
||||
}
|
||||
|
||||
// No compaction: return all messages
|
||||
if (latestCompactionIndex === -1) {
|
||||
const messages: AppMessage[] = [];
|
||||
for (const entry of entries) {
|
||||
|
|
@ -169,7 +146,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
|||
|
||||
const compactionEvent = entries[latestCompactionIndex] as CompactionEntry;
|
||||
|
||||
// Extract messages from firstKeptEntryIndex to end (skipping compaction entries)
|
||||
const keptMessages: AppMessage[] = [];
|
||||
for (let i = compactionEvent.firstKeptEntryIndex; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
|
|
@ -178,7 +154,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
|||
}
|
||||
}
|
||||
|
||||
// Build final messages: summary + kept messages
|
||||
const messages: AppMessage[] = [];
|
||||
messages.push(createSummaryMessage(compactionEvent.summary));
|
||||
messages.push(...keptMessages);
|
||||
|
|
@ -186,246 +161,103 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
|||
return { messages, thinkingLevel, model };
|
||||
}
|
||||
|
||||
function getSessionDirectory(cwd: string, agentDir: string): string {
|
||||
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||
const sessionDir = join(agentDir, "sessions", safePath);
|
||||
if (!existsSync(sessionDir)) {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
return sessionDir;
|
||||
}
|
||||
|
||||
function loadEntriesFromFile(filePath: string): SessionEntry[] {
|
||||
if (!existsSync(filePath)) return [];
|
||||
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const entries: SessionEntry[] = [];
|
||||
const lines = content.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line) as SessionEntry;
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function findMostRecentSession(sessionDir: string): string | null {
|
||||
try {
|
||||
const files = readdirSync(sessionDir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.map((f) => ({
|
||||
path: join(sessionDir, f),
|
||||
mtime: statSync(join(sessionDir, f)).mtime,
|
||||
}))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
return files[0]?.path || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private sessionId!: string;
|
||||
private sessionFile!: string;
|
||||
private sessionId: string = "";
|
||||
private sessionFile: string = "";
|
||||
private sessionDir: string;
|
||||
private enabled: boolean = true;
|
||||
private sessionInitialized: boolean = false;
|
||||
private pendingEntries: SessionEntry[] = [];
|
||||
// In-memory entries for --no-session mode (when enabled=false)
|
||||
private cwd: string;
|
||||
private persist: boolean;
|
||||
private flushed: boolean = false;
|
||||
private inMemoryEntries: SessionEntry[] = [];
|
||||
|
||||
constructor(continueSession: boolean = false, customSessionPath?: string) {
|
||||
this.sessionDir = this.getSessionDirectory();
|
||||
private constructor(cwd: string, agentDir: string, sessionFile: string | null, persist: boolean) {
|
||||
this.cwd = cwd;
|
||||
this.sessionDir = getSessionDirectory(cwd, agentDir);
|
||||
this.persist = persist;
|
||||
|
||||
if (customSessionPath) {
|
||||
// Use custom session file path
|
||||
this.sessionFile = resolve(customSessionPath);
|
||||
this.loadSessionId();
|
||||
// If file doesn't exist, loadSessionId() won't set sessionId, so generate one
|
||||
if (!this.sessionId) {
|
||||
this.sessionId = uuidv4();
|
||||
}
|
||||
// Mark as initialized since we're loading an existing session
|
||||
this.sessionInitialized = existsSync(this.sessionFile);
|
||||
// Load entries into memory
|
||||
if (this.sessionInitialized) {
|
||||
this.inMemoryEntries = this.loadEntriesFromFile();
|
||||
}
|
||||
} else if (continueSession) {
|
||||
const mostRecent = this.findMostRecentlyModifiedSession();
|
||||
if (mostRecent) {
|
||||
this.sessionFile = mostRecent;
|
||||
this.loadSessionId();
|
||||
// Mark as initialized since we're loading an existing session
|
||||
this.sessionInitialized = true;
|
||||
// Load entries into memory
|
||||
this.inMemoryEntries = this.loadEntriesFromFile();
|
||||
} else {
|
||||
this.initNewSession();
|
||||
}
|
||||
if (sessionFile) {
|
||||
this.setSessionFile(sessionFile);
|
||||
} else {
|
||||
this.initNewSession();
|
||||
this.sessionId = uuidv4();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`);
|
||||
this.setSessionFile(sessionFile);
|
||||
}
|
||||
}
|
||||
|
||||
/** Disable session saving (for --no-session mode) */
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
/** Check if session persistence is enabled */
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
private getSessionDirectory(): string {
|
||||
const cwd = process.cwd();
|
||||
// Replace all path separators and colons (for Windows drive letters) with dashes
|
||||
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||
|
||||
const configDir = getAgentDir();
|
||||
const sessionDir = join(configDir, "sessions", safePath);
|
||||
if (!existsSync(sessionDir)) {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
return sessionDir;
|
||||
}
|
||||
|
||||
private initNewSession(): void {
|
||||
this.sessionId = uuidv4();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
/** Reset to a fresh session. Clears pending entries and starts a new session file. */
|
||||
reset(): void {
|
||||
this.pendingEntries = [];
|
||||
this.inMemoryEntries = [];
|
||||
this.sessionInitialized = false;
|
||||
this.initNewSession();
|
||||
}
|
||||
|
||||
private findMostRecentlyModifiedSession(): string | null {
|
||||
try {
|
||||
const files = readdirSync(this.sessionDir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
path: join(this.sessionDir, f),
|
||||
mtime: statSync(join(this.sessionDir, f)).mtime,
|
||||
}))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
return files[0]?.path || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadSessionId(): void {
|
||||
if (!existsSync(this.sessionFile)) return;
|
||||
|
||||
const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n");
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "session") {
|
||||
this.sessionId = entry.id;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
this.sessionId = uuidv4();
|
||||
}
|
||||
|
||||
startSession(state: AgentState): void {
|
||||
if (this.sessionInitialized) return;
|
||||
this.sessionInitialized = true;
|
||||
|
||||
const entry: SessionHeader = {
|
||||
type: "session",
|
||||
id: this.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
provider: state.model.provider,
|
||||
modelId: state.model.id,
|
||||
thinkingLevel: state.thinkingLevel,
|
||||
};
|
||||
|
||||
// Always track in memory
|
||||
this.inMemoryEntries.push(entry);
|
||||
for (const pending of this.pendingEntries) {
|
||||
this.inMemoryEntries.push(pending);
|
||||
}
|
||||
this.pendingEntries = [];
|
||||
|
||||
// Write to file only if enabled
|
||||
if (this.enabled) {
|
||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||
for (const memEntry of this.inMemoryEntries.slice(1)) {
|
||||
appendFileSync(this.sessionFile, `${JSON.stringify(memEntry)}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveMessage(message: any): void {
|
||||
const entry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
|
||||
if (!this.sessionInitialized) {
|
||||
this.pendingEntries.push(entry);
|
||||
/** Switch to a different session file (used for resume and branching) */
|
||||
setSessionFile(sessionFile: string): void {
|
||||
this.sessionFile = resolve(sessionFile);
|
||||
if (existsSync(this.sessionFile)) {
|
||||
this.inMemoryEntries = loadEntriesFromFile(this.sessionFile);
|
||||
const header = this.inMemoryEntries.find((e) => e.type === "session");
|
||||
this.sessionId = header ? (header as SessionHeader).id : uuidv4();
|
||||
this.flushed = true;
|
||||
} else {
|
||||
// Always track in memory
|
||||
this.sessionId = uuidv4();
|
||||
this.inMemoryEntries = [];
|
||||
this.flushed = false;
|
||||
const entry: SessionHeader = {
|
||||
type: "session",
|
||||
id: this.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: this.cwd,
|
||||
};
|
||||
this.inMemoryEntries.push(entry);
|
||||
// Write to file only if enabled
|
||||
if (this.enabled) {
|
||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveThinkingLevelChange(thinkingLevel: string): void {
|
||||
const entry: ThinkingLevelChangeEntry = {
|
||||
type: "thinking_level_change",
|
||||
timestamp: new Date().toISOString(),
|
||||
thinkingLevel,
|
||||
};
|
||||
|
||||
if (!this.sessionInitialized) {
|
||||
this.pendingEntries.push(entry);
|
||||
} else {
|
||||
// Always track in memory
|
||||
this.inMemoryEntries.push(entry);
|
||||
// Write to file only if enabled
|
||||
if (this.enabled) {
|
||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
}
|
||||
isPersisted(): boolean {
|
||||
return this.persist;
|
||||
}
|
||||
|
||||
saveModelChange(provider: string, modelId: string): void {
|
||||
const entry: ModelChangeEntry = {
|
||||
type: "model_change",
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
modelId,
|
||||
};
|
||||
|
||||
if (!this.sessionInitialized) {
|
||||
this.pendingEntries.push(entry);
|
||||
} else {
|
||||
// Always track in memory
|
||||
this.inMemoryEntries.push(entry);
|
||||
// Write to file only if enabled
|
||||
if (this.enabled) {
|
||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveCompaction(entry: CompactionEntry): void {
|
||||
// Always track in memory
|
||||
this.inMemoryEntries.push(entry);
|
||||
// Write to file only if enabled
|
||||
if (this.enabled) {
|
||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load session data (messages, model, thinking level) with compaction support.
|
||||
*/
|
||||
loadSession(): LoadedSession {
|
||||
const entries = this.loadEntries();
|
||||
return loadSessionFromEntries(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use loadSession().messages instead
|
||||
*/
|
||||
loadMessages(): AppMessage[] {
|
||||
return this.loadSession().messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use loadSession().thinkingLevel instead
|
||||
*/
|
||||
loadThinkingLevel(): string {
|
||||
return this.loadSession().thinkingLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use loadSession().model instead
|
||||
*/
|
||||
loadModel(): { provider: string; modelId: string } | null {
|
||||
return this.loadSession().model;
|
||||
getCwd(): string {
|
||||
return this.cwd;
|
||||
}
|
||||
|
||||
getSessionId(): string {
|
||||
|
|
@ -436,70 +268,168 @@ export class SessionManager {
|
|||
return this.sessionFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load entries directly from the session file (internal helper).
|
||||
*/
|
||||
private loadEntriesFromFile(): SessionEntry[] {
|
||||
if (!existsSync(this.sessionFile)) return [];
|
||||
reset(): void {
|
||||
this.sessionId = uuidv4();
|
||||
this.flushed = false;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`);
|
||||
this.inMemoryEntries = [
|
||||
{
|
||||
type: "session",
|
||||
id: this.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: this.cwd,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const content = readFileSync(this.sessionFile, "utf8");
|
||||
const entries: SessionEntry[] = [];
|
||||
const lines = content.trim().split("\n");
|
||||
_persist(entry: SessionEntry): void {
|
||||
if (!this.persist) return;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line) as SessionEntry;
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
const hasAssistant = this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant");
|
||||
if (!hasAssistant) return;
|
||||
|
||||
if (!this.flushed) {
|
||||
for (const e of this.inMemoryEntries) {
|
||||
appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
|
||||
}
|
||||
this.flushed = true;
|
||||
} else {
|
||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
saveMessage(message: any): void {
|
||||
const entry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
this.inMemoryEntries.push(entry);
|
||||
this._persist(entry);
|
||||
}
|
||||
|
||||
saveThinkingLevelChange(thinkingLevel: string): void {
|
||||
const entry: ThinkingLevelChangeEntry = {
|
||||
type: "thinking_level_change",
|
||||
timestamp: new Date().toISOString(),
|
||||
thinkingLevel,
|
||||
};
|
||||
this.inMemoryEntries.push(entry);
|
||||
this._persist(entry);
|
||||
}
|
||||
|
||||
saveModelChange(provider: string, modelId: string): void {
|
||||
const entry: ModelChangeEntry = {
|
||||
type: "model_change",
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
modelId,
|
||||
};
|
||||
this.inMemoryEntries.push(entry);
|
||||
this._persist(entry);
|
||||
}
|
||||
|
||||
saveCompaction(entry: CompactionEntry): void {
|
||||
this.inMemoryEntries.push(entry);
|
||||
this._persist(entry);
|
||||
}
|
||||
|
||||
loadSession(): LoadedSession {
|
||||
const entries = this.loadEntries();
|
||||
return loadSessionFromEntries(entries);
|
||||
}
|
||||
|
||||
loadMessages(): AppMessage[] {
|
||||
return this.loadSession().messages;
|
||||
}
|
||||
|
||||
loadThinkingLevel(): string {
|
||||
return this.loadSession().thinkingLevel;
|
||||
}
|
||||
|
||||
loadModel(): { provider: string; modelId: string } | null {
|
||||
return this.loadSession().model;
|
||||
}
|
||||
|
||||
loadEntries(): SessionEntry[] {
|
||||
if (this.inMemoryEntries.length > 0) {
|
||||
return [...this.inMemoryEntries];
|
||||
} else {
|
||||
return loadEntriesFromFile(this.sessionFile);
|
||||
}
|
||||
}
|
||||
|
||||
createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null {
|
||||
const newSessionId = uuidv4();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`);
|
||||
|
||||
const newEntries: SessionEntry[] = [];
|
||||
for (let i = 0; i < branchBeforeIndex; i++) {
|
||||
const entry = entries[i];
|
||||
|
||||
if (entry.type === "session") {
|
||||
newEntries.push({
|
||||
...entry,
|
||||
id: newSessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
branchedFrom: this.persist ? this.sessionFile : undefined,
|
||||
});
|
||||
} else {
|
||||
newEntries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all entries from the session file or in-memory store.
|
||||
* When file persistence is enabled, reads from file (source of truth for resumed sessions).
|
||||
* When disabled (--no-session), returns in-memory entries.
|
||||
*/
|
||||
loadEntries(): SessionEntry[] {
|
||||
// If file persistence is enabled and file exists, read from file
|
||||
if (this.enabled && existsSync(this.sessionFile)) {
|
||||
return this.loadEntriesFromFile();
|
||||
if (this.persist) {
|
||||
for (const entry of newEntries) {
|
||||
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
return newSessionFile;
|
||||
}
|
||||
|
||||
// Otherwise return in-memory entries (for --no-session mode)
|
||||
return [...this.inMemoryEntries];
|
||||
this.inMemoryEntries = newEntries;
|
||||
this.sessionId = newSessionId;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all sessions for the current directory with metadata
|
||||
*/
|
||||
loadAllSessions(): Array<{
|
||||
path: string;
|
||||
id: string;
|
||||
created: Date;
|
||||
modified: Date;
|
||||
messageCount: number;
|
||||
firstMessage: string;
|
||||
allMessagesText: string;
|
||||
}> {
|
||||
const sessions: Array<{
|
||||
path: string;
|
||||
id: string;
|
||||
created: Date;
|
||||
modified: Date;
|
||||
messageCount: number;
|
||||
firstMessage: string;
|
||||
allMessagesText: string;
|
||||
}> = [];
|
||||
/** Create a new session for the given directory */
|
||||
static create(cwd: string, agentDir: string = getDefaultAgentDir()): SessionManager {
|
||||
return new SessionManager(cwd, agentDir, null, true);
|
||||
}
|
||||
|
||||
/** Open a specific session file */
|
||||
static open(path: string, agentDir: string = getDefaultAgentDir()): SessionManager {
|
||||
// Extract cwd from session header if possible, otherwise use process.cwd()
|
||||
const entries = loadEntriesFromFile(path);
|
||||
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
|
||||
const cwd = header?.cwd ?? process.cwd();
|
||||
return new SessionManager(cwd, agentDir, path, true);
|
||||
}
|
||||
|
||||
/** Continue the most recent session for the given directory, or create new if none */
|
||||
static continueRecent(cwd: string, agentDir: string = getDefaultAgentDir()): SessionManager {
|
||||
const sessionDir = getSessionDirectory(cwd, agentDir);
|
||||
const mostRecent = findMostRecentSession(sessionDir);
|
||||
if (mostRecent) {
|
||||
return new SessionManager(cwd, agentDir, mostRecent, true);
|
||||
}
|
||||
return new SessionManager(cwd, agentDir, null, true);
|
||||
}
|
||||
|
||||
/** Create an in-memory session (no file persistence) */
|
||||
static inMemory(): SessionManager {
|
||||
return new SessionManager(process.cwd(), getDefaultAgentDir(), null, false);
|
||||
}
|
||||
|
||||
/** List all sessions for a directory */
|
||||
static list(cwd: string, agentDir: string = getDefaultAgentDir()): SessionInfo[] {
|
||||
const sessionDir = getSessionDirectory(cwd, agentDir);
|
||||
const sessions: SessionInfo[] = [];
|
||||
|
||||
try {
|
||||
const files = readdirSync(this.sessionDir)
|
||||
const files = readdirSync(sessionDir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.map((f) => join(this.sessionDir, f));
|
||||
.map((f) => join(sessionDir, f));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
|
|
@ -517,17 +447,14 @@ export class SessionManager {
|
|||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Extract session ID from first session entry
|
||||
if (entry.type === "session" && !sessionId) {
|
||||
sessionId = entry.id;
|
||||
created = new Date(entry.timestamp);
|
||||
}
|
||||
|
||||
// Count messages and collect all text
|
||||
if (entry.type === "message") {
|
||||
messageCount++;
|
||||
|
||||
// Extract text from user and assistant messages
|
||||
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
||||
const textContent = entry.message.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
|
|
@ -537,7 +464,6 @@ export class SessionManager {
|
|||
if (textContent) {
|
||||
allMessages.push(textContent);
|
||||
|
||||
// Get first user message for display
|
||||
if (!firstMessage && entry.message.role === "user") {
|
||||
firstMessage = textContent;
|
||||
}
|
||||
|
|
@ -558,131 +484,16 @@ export class SessionManager {
|
|||
firstMessage: firstMessage || "(no messages)",
|
||||
allMessagesText: allMessages.join(" "),
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
console.error(`Failed to read session file ${file}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by modified date (most recent first)
|
||||
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
} catch (error) {
|
||||
console.error("Failed to load sessions:", error);
|
||||
} catch {
|
||||
// Return empty list on error
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the session file to an existing session
|
||||
*/
|
||||
setSessionFile(path: string): void {
|
||||
this.sessionFile = path;
|
||||
this.loadSessionId();
|
||||
// Mark as initialized since we're loading an existing session
|
||||
this.sessionInitialized = existsSync(path);
|
||||
// Load entries into memory for consistency
|
||||
if (this.sessionInitialized) {
|
||||
this.inMemoryEntries = this.loadEntriesFromFile();
|
||||
} else {
|
||||
this.inMemoryEntries = [];
|
||||
}
|
||||
this.pendingEntries = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should initialize the session based on message history.
|
||||
* Session is initialized when we have at least 1 user message and 1 assistant message.
|
||||
*/
|
||||
shouldInitializeSession(messages: any[]): boolean {
|
||||
if (this.sessionInitialized) return false;
|
||||
|
||||
const userMessages = messages.filter((m) => m.role === "user");
|
||||
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
||||
|
||||
return userMessages.length >= 1 && assistantMessages.length >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branched session from a specific message index.
|
||||
* If branchFromIndex is -1, creates an empty session.
|
||||
* Returns the new session file path.
|
||||
*/
|
||||
createBranchedSession(state: any, branchFromIndex: number): string {
|
||||
// Create a new session ID for the branch
|
||||
const newSessionId = uuidv4();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`);
|
||||
|
||||
// Write session header
|
||||
const entry: SessionHeader = {
|
||||
type: "session",
|
||||
id: newSessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
provider: state.model.provider,
|
||||
modelId: state.model.id,
|
||||
thinkingLevel: state.thinkingLevel,
|
||||
branchedFrom: this.sessionFile,
|
||||
};
|
||||
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
||||
|
||||
// Write messages up to and including the branch point (if >= 0)
|
||||
if (branchFromIndex >= 0) {
|
||||
const messagesToWrite = state.messages.slice(0, branchFromIndex + 1);
|
||||
for (const message of messagesToWrite) {
|
||||
const messageEntry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
appendFileSync(newSessionFile, `${JSON.stringify(messageEntry)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return newSessionFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branched session from session entries up to (but not including) a specific entry index.
|
||||
* This preserves compaction events and all entry types.
|
||||
* Returns the new session file path, or null if in --no-session mode (in-memory only).
|
||||
*/
|
||||
createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null {
|
||||
const newSessionId = uuidv4();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`);
|
||||
|
||||
// Build new entries list (up to but not including branch point)
|
||||
const newEntries: SessionEntry[] = [];
|
||||
for (let i = 0; i < branchBeforeIndex; i++) {
|
||||
const entry = entries[i];
|
||||
|
||||
if (entry.type === "session") {
|
||||
// Rewrite session header with new ID and branchedFrom
|
||||
newEntries.push({
|
||||
...entry,
|
||||
id: newSessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
branchedFrom: this.enabled ? this.sessionFile : undefined,
|
||||
});
|
||||
} else {
|
||||
// Copy other entries as-is
|
||||
newEntries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.enabled) {
|
||||
// Write to file
|
||||
for (const entry of newEntries) {
|
||||
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
return newSessionFile;
|
||||
} else {
|
||||
// In-memory mode: replace inMemoryEntries, no file created
|
||||
this.inMemoryEntries = newEntries;
|
||||
this.sessionId = newSessionId;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { getAgentDir } from "../config.js";
|
||||
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
|
||||
|
||||
export interface CompactionSettings {
|
||||
enabled?: boolean; // default: true
|
||||
|
|
@ -49,39 +49,118 @@ export interface Settings {
|
|||
terminal?: TerminalSettings;
|
||||
}
|
||||
|
||||
export class SettingsManager {
|
||||
private settingsPath: string;
|
||||
private settings: Settings;
|
||||
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
|
||||
function deepMergeSettings(base: Settings, overrides: Settings): Settings {
|
||||
const result: Settings = { ...base };
|
||||
|
||||
constructor(baseDir?: string) {
|
||||
const dir = baseDir || getAgentDir();
|
||||
this.settingsPath = join(dir, "settings.json");
|
||||
this.settings = this.load();
|
||||
for (const key of Object.keys(overrides) as (keyof Settings)[]) {
|
||||
const overrideValue = overrides[key];
|
||||
const baseValue = base[key];
|
||||
|
||||
if (overrideValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For nested objects, merge recursively
|
||||
if (
|
||||
typeof overrideValue === "object" &&
|
||||
overrideValue !== null &&
|
||||
!Array.isArray(overrideValue) &&
|
||||
typeof baseValue === "object" &&
|
||||
baseValue !== null &&
|
||||
!Array.isArray(baseValue)
|
||||
) {
|
||||
(result as Record<string, unknown>)[key] = { ...baseValue, ...overrideValue };
|
||||
} else {
|
||||
// For primitives and arrays, override value wins
|
||||
(result as Record<string, unknown>)[key] = overrideValue;
|
||||
}
|
||||
}
|
||||
|
||||
private load(): Settings {
|
||||
if (!existsSync(this.settingsPath)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export class SettingsManager {
|
||||
private settingsPath: string | null;
|
||||
private projectSettingsPath: string | null;
|
||||
private globalSettings: Settings;
|
||||
private settings: Settings;
|
||||
private persist: boolean;
|
||||
|
||||
private constructor(
|
||||
settingsPath: string | null,
|
||||
projectSettingsPath: string | null,
|
||||
initialSettings: Settings,
|
||||
persist: boolean,
|
||||
) {
|
||||
this.settingsPath = settingsPath;
|
||||
this.projectSettingsPath = projectSettingsPath;
|
||||
this.persist = persist;
|
||||
this.globalSettings = initialSettings;
|
||||
const projectSettings = this.loadProjectSettings();
|
||||
this.settings = deepMergeSettings(this.globalSettings, projectSettings);
|
||||
}
|
||||
|
||||
/** Create a SettingsManager that loads from files */
|
||||
static create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): SettingsManager {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
const projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json");
|
||||
const globalSettings = SettingsManager.loadFromFile(settingsPath);
|
||||
return new SettingsManager(settingsPath, projectSettingsPath, globalSettings, true);
|
||||
}
|
||||
|
||||
/** Create an in-memory SettingsManager (no file I/O) */
|
||||
static inMemory(settings: Partial<Settings> = {}): SettingsManager {
|
||||
return new SettingsManager(null, null, settings, false);
|
||||
}
|
||||
|
||||
private static loadFromFile(path: string): Settings {
|
||||
if (!existsSync(path)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Warning: Could not read settings file ${path}: ${error}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private loadProjectSettings(): Settings {
|
||||
if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(this.settingsPath, "utf-8");
|
||||
const content = readFileSync(this.projectSettingsPath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Warning: Could not read settings file: ${error}`);
|
||||
console.error(`Warning: Could not read project settings file: ${error}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply additional overrides on top of current settings */
|
||||
applyOverrides(overrides: Partial<Settings>): void {
|
||||
this.settings = deepMergeSettings(this.settings, overrides);
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
if (!this.persist || !this.settingsPath) return;
|
||||
|
||||
try {
|
||||
// Ensure directory exists
|
||||
const dir = dirname(this.settingsPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(this.settingsPath, JSON.stringify(this.settings, null, 2), "utf-8");
|
||||
// Save only global settings (project settings are read-only)
|
||||
writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8");
|
||||
|
||||
// Re-merge project settings into active settings
|
||||
const projectSettings = this.loadProjectSettings();
|
||||
this.settings = deepMergeSettings(this.globalSettings, projectSettings);
|
||||
} catch (error) {
|
||||
console.error(`Warning: Could not save settings file: ${error}`);
|
||||
}
|
||||
|
|
@ -92,7 +171,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setLastChangelogVersion(version: string): void {
|
||||
this.settings.lastChangelogVersion = version;
|
||||
this.globalSettings.lastChangelogVersion = version;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -105,18 +184,18 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setDefaultProvider(provider: string): void {
|
||||
this.settings.defaultProvider = provider;
|
||||
this.globalSettings.defaultProvider = provider;
|
||||
this.save();
|
||||
}
|
||||
|
||||
setDefaultModel(modelId: string): void {
|
||||
this.settings.defaultModel = modelId;
|
||||
this.globalSettings.defaultModel = modelId;
|
||||
this.save();
|
||||
}
|
||||
|
||||
setDefaultModelAndProvider(provider: string, modelId: string): void {
|
||||
this.settings.defaultProvider = provider;
|
||||
this.settings.defaultModel = modelId;
|
||||
this.globalSettings.defaultProvider = provider;
|
||||
this.globalSettings.defaultModel = modelId;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +204,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setQueueMode(mode: "all" | "one-at-a-time"): void {
|
||||
this.settings.queueMode = mode;
|
||||
this.globalSettings.queueMode = mode;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +213,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setTheme(theme: string): void {
|
||||
this.settings.theme = theme;
|
||||
this.globalSettings.theme = theme;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +222,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void {
|
||||
this.settings.defaultThinkingLevel = level;
|
||||
this.globalSettings.defaultThinkingLevel = level;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -152,10 +231,10 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setCompactionEnabled(enabled: boolean): void {
|
||||
if (!this.settings.compaction) {
|
||||
this.settings.compaction = {};
|
||||
if (!this.globalSettings.compaction) {
|
||||
this.globalSettings.compaction = {};
|
||||
}
|
||||
this.settings.compaction.enabled = enabled;
|
||||
this.globalSettings.compaction.enabled = enabled;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -180,10 +259,10 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setRetryEnabled(enabled: boolean): void {
|
||||
if (!this.settings.retry) {
|
||||
this.settings.retry = {};
|
||||
if (!this.globalSettings.retry) {
|
||||
this.globalSettings.retry = {};
|
||||
}
|
||||
this.settings.retry.enabled = enabled;
|
||||
this.globalSettings.retry.enabled = enabled;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -200,7 +279,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setHideThinkingBlock(hide: boolean): void {
|
||||
this.settings.hideThinkingBlock = hide;
|
||||
this.globalSettings.hideThinkingBlock = hide;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -209,7 +288,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setShellPath(path: string | undefined): void {
|
||||
this.settings.shellPath = path;
|
||||
this.globalSettings.shellPath = path;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +297,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setCollapseChangelog(collapse: boolean): void {
|
||||
this.settings.collapseChangelog = collapse;
|
||||
this.globalSettings.collapseChangelog = collapse;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -227,7 +306,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setHookPaths(paths: string[]): void {
|
||||
this.settings.hooks = paths;
|
||||
this.globalSettings.hooks = paths;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +315,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setHookTimeout(timeout: number): void {
|
||||
this.settings.hookTimeout = timeout;
|
||||
this.globalSettings.hookTimeout = timeout;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -245,7 +324,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setCustomToolPaths(paths: string[]): void {
|
||||
this.settings.customTools = paths;
|
||||
this.globalSettings.customTools = paths;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -254,10 +333,10 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setSkillsEnabled(enabled: boolean): void {
|
||||
if (!this.settings.skills) {
|
||||
this.settings.skills = {};
|
||||
if (!this.globalSettings.skills) {
|
||||
this.globalSettings.skills = {};
|
||||
}
|
||||
this.settings.skills.enabled = enabled;
|
||||
this.globalSettings.skills.enabled = enabled;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -280,10 +359,10 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
setShowImages(show: boolean): void {
|
||||
if (!this.settings.terminal) {
|
||||
this.settings.terminal = {};
|
||||
if (!this.globalSettings.terminal) {
|
||||
this.globalSettings.terminal = {};
|
||||
}
|
||||
this.settings.terminal.showImages = show;
|
||||
this.globalSettings.terminal.showImages = show;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "fs";
|
|||
import { minimatch } from "minimatch";
|
||||
import { homedir } from "os";
|
||||
import { basename, dirname, join, resolve } from "path";
|
||||
import { CONFIG_DIR_NAME } from "../config.js";
|
||||
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
|
||||
import type { SkillsSettings } from "./settings-manager.js";
|
||||
|
||||
/**
|
||||
|
|
@ -313,12 +313,21 @@ function escapeXml(str: string): string {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export interface LoadSkillsOptions extends SkillsSettings {
|
||||
/** Working directory for project-local skills. Default: process.cwd() */
|
||||
cwd?: string;
|
||||
/** Agent config directory for global skills. Default: ~/.pi/agent */
|
||||
agentDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from all configured locations.
|
||||
* Returns skills and any validation warnings.
|
||||
*/
|
||||
export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult {
|
||||
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
agentDir,
|
||||
enableCodexUser = true,
|
||||
enableClaudeUser = true,
|
||||
enableClaudeProject = true,
|
||||
|
|
@ -329,6 +338,9 @@ export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult {
|
|||
includeSkills = [],
|
||||
} = options;
|
||||
|
||||
// Resolve agentDir - if not provided, use default from config
|
||||
const resolvedAgentDir = agentDir ?? getAgentDir();
|
||||
|
||||
const skillMap = new Map<string, Skill>();
|
||||
const allWarnings: SkillWarning[] = [];
|
||||
const collisionWarnings: SkillWarning[] = [];
|
||||
|
|
@ -375,13 +387,13 @@ export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult {
|
|||
addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude"));
|
||||
}
|
||||
if (enableClaudeProject) {
|
||||
addSkills(loadSkillsFromDirInternal(resolve(process.cwd(), ".claude", "skills"), "claude-project", "claude"));
|
||||
addSkills(loadSkillsFromDirInternal(resolve(cwd, ".claude", "skills"), "claude-project", "claude"));
|
||||
}
|
||||
if (enablePiUser) {
|
||||
addSkills(loadSkillsFromDirInternal(join(homedir(), CONFIG_DIR_NAME, "agent", "skills"), "user", "recursive"));
|
||||
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", "recursive"));
|
||||
}
|
||||
if (enablePiProject) {
|
||||
addSkills(loadSkillsFromDirInternal(resolve(process.cwd(), CONFIG_DIR_NAME, "skills"), "project", "recursive"));
|
||||
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", "recursive"));
|
||||
}
|
||||
for (const customDir of customDirectories) {
|
||||
addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive"));
|
||||
|
|
|
|||
|
|
@ -165,20 +165,31 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st
|
|||
return commands;
|
||||
}
|
||||
|
||||
export interface LoadSlashCommandsOptions {
|
||||
/** Working directory for project-local commands. Default: process.cwd() */
|
||||
cwd?: string;
|
||||
/** Agent config directory for global commands. Default: from getCommandsDir() */
|
||||
agentDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all custom slash commands from:
|
||||
* 1. Global: ~/{CONFIG_DIR_NAME}/agent/commands/
|
||||
* 2. Project: ./{CONFIG_DIR_NAME}/commands/
|
||||
* 1. Global: agentDir/commands/
|
||||
* 2. Project: cwd/{CONFIG_DIR_NAME}/commands/
|
||||
*/
|
||||
export function loadSlashCommands(): FileSlashCommand[] {
|
||||
export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] {
|
||||
const resolvedCwd = options.cwd ?? process.cwd();
|
||||
const resolvedAgentDir = options.agentDir ?? getCommandsDir();
|
||||
|
||||
const commands: FileSlashCommand[] = [];
|
||||
|
||||
// 1. Load global commands from ~/{CONFIG_DIR_NAME}/agent/commands/
|
||||
const globalCommandsDir = getCommandsDir();
|
||||
// 1. Load global commands from agentDir/commands/
|
||||
// Note: if agentDir is provided, it should be the agent dir, not the commands dir
|
||||
const globalCommandsDir = options.agentDir ? join(options.agentDir, "commands") : resolvedAgentDir;
|
||||
commands.push(...loadCommandsFromDir(globalCommandsDir, "user"));
|
||||
|
||||
// 2. Load project commands from ./{CONFIG_DIR_NAME}/commands/
|
||||
const projectCommandsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "commands");
|
||||
// 2. Load project commands from cwd/{CONFIG_DIR_NAME}/commands/
|
||||
const projectCommandsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "commands");
|
||||
commands.push(...loadCommandsFromDir(projectCommandsDir, "project"));
|
||||
|
||||
return commands;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { existsSync, readFileSync } from "fs";
|
|||
import { join, resolve } from "path";
|
||||
import { getAgentDir, getDocsPath, getReadmePath } from "../config.js";
|
||||
import type { SkillsSettings } from "./settings-manager.js";
|
||||
import { formatSkillsForPrompt, loadSkills } from "./skills.js";
|
||||
import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js";
|
||||
import type { ToolName } from "./tools/index.js";
|
||||
|
||||
/** Tool descriptions for system prompt */
|
||||
|
|
@ -58,29 +58,39 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
|
|||
return null;
|
||||
}
|
||||
|
||||
export interface LoadContextFilesOptions {
|
||||
/** Working directory to start walking up from. Default: process.cwd() */
|
||||
cwd?: string;
|
||||
/** Agent config directory for global context. Default: from getAgentDir() */
|
||||
agentDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all project context files in order:
|
||||
* 1. Global: ~/{CONFIG_DIR_NAME}/agent/AGENTS.md or CLAUDE.md
|
||||
* 1. Global: agentDir/AGENTS.md or CLAUDE.md
|
||||
* 2. Parent directories (top-most first) down to cwd
|
||||
* Each returns {path, content} for separate messages
|
||||
*/
|
||||
export function loadProjectContextFiles(): Array<{ path: string; content: string }> {
|
||||
export function loadProjectContextFiles(
|
||||
options: LoadContextFilesOptions = {},
|
||||
): Array<{ path: string; content: string }> {
|
||||
const resolvedCwd = options.cwd ?? process.cwd();
|
||||
const resolvedAgentDir = options.agentDir ?? getAgentDir();
|
||||
|
||||
const contextFiles: Array<{ path: string; content: string }> = [];
|
||||
const seenPaths = new Set<string>();
|
||||
|
||||
// 1. Load global context from ~/{CONFIG_DIR_NAME}/agent/
|
||||
const globalContextDir = getAgentDir();
|
||||
const globalContext = loadContextFileFromDir(globalContextDir);
|
||||
// 1. Load global context from agentDir
|
||||
const globalContext = loadContextFileFromDir(resolvedAgentDir);
|
||||
if (globalContext) {
|
||||
contextFiles.push(globalContext);
|
||||
seenPaths.add(globalContext.path);
|
||||
}
|
||||
|
||||
// 2. Walk up from cwd to root, collecting all context files
|
||||
const cwd = process.cwd();
|
||||
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
|
||||
|
||||
let currentDir = cwd;
|
||||
let currentDir = resolvedCwd;
|
||||
const root = resolve("/");
|
||||
|
||||
while (true) {
|
||||
|
|
@ -107,15 +117,37 @@ export function loadProjectContextFiles(): Array<{ path: string; content: string
|
|||
}
|
||||
|
||||
export interface BuildSystemPromptOptions {
|
||||
/** Custom system prompt (replaces default). */
|
||||
customPrompt?: string;
|
||||
/** Tools to include in prompt. Default: [read, bash, edit, write] */
|
||||
selectedTools?: ToolName[];
|
||||
/** Text to append to system prompt. */
|
||||
appendSystemPrompt?: string;
|
||||
/** Skills settings for discovery. */
|
||||
skillsSettings?: SkillsSettings;
|
||||
/** Working directory. Default: process.cwd() */
|
||||
cwd?: string;
|
||||
/** Agent config directory. Default: from getAgentDir() */
|
||||
agentDir?: string;
|
||||
/** Pre-loaded context files (skips discovery if provided). */
|
||||
contextFiles?: Array<{ path: string; content: string }>;
|
||||
/** Pre-loaded skills (skips discovery if provided). */
|
||||
skills?: Skill[];
|
||||
}
|
||||
|
||||
/** Build the system prompt with tools, guidelines, and context */
|
||||
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
|
||||
const { customPrompt, selectedTools, appendSystemPrompt, skillsSettings } = options;
|
||||
const {
|
||||
customPrompt,
|
||||
selectedTools,
|
||||
appendSystemPrompt,
|
||||
skillsSettings,
|
||||
cwd,
|
||||
agentDir,
|
||||
contextFiles: providedContextFiles,
|
||||
skills: providedSkills,
|
||||
} = options;
|
||||
const resolvedCwd = cwd ?? process.cwd();
|
||||
const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
|
||||
const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt");
|
||||
|
||||
|
|
@ -133,6 +165,14 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
|
||||
const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : "";
|
||||
|
||||
// Resolve context files: use provided or discover
|
||||
const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd, agentDir });
|
||||
|
||||
// Resolve skills: use provided or discover
|
||||
const skills =
|
||||
providedSkills ??
|
||||
(skillsSettings?.enabled !== false ? loadSkills({ ...skillsSettings, cwd: resolvedCwd, agentDir }).skills : []);
|
||||
|
||||
if (resolvedCustomPrompt) {
|
||||
let prompt = resolvedCustomPrompt;
|
||||
|
||||
|
|
@ -141,7 +181,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
}
|
||||
|
||||
// Append project context files
|
||||
const contextFiles = loadProjectContextFiles();
|
||||
if (contextFiles.length > 0) {
|
||||
prompt += "\n\n# Project Context\n\n";
|
||||
prompt += "The following project context files have been loaded:\n\n";
|
||||
|
|
@ -152,14 +191,13 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
|
||||
// Append skills section (only if read tool is available)
|
||||
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
|
||||
if (skillsSettings?.enabled !== false && customPromptHasRead) {
|
||||
const { skills } = loadSkills(skillsSettings ?? {});
|
||||
if (customPromptHasRead && skills.length > 0) {
|
||||
prompt += formatSkillsForPrompt(skills);
|
||||
}
|
||||
|
||||
// Add date/time and working directory last
|
||||
prompt += `\nCurrent date and time: ${dateTime}`;
|
||||
prompt += `\nCurrent working directory: ${process.cwd()}`;
|
||||
prompt += `\nCurrent working directory: ${resolvedCwd}`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
|
@ -248,7 +286,6 @@ Documentation:
|
|||
}
|
||||
|
||||
// Append project context files
|
||||
const contextFiles = loadProjectContextFiles();
|
||||
if (contextFiles.length > 0) {
|
||||
prompt += "\n\n# Project Context\n\n";
|
||||
prompt += "The following project context files have been loaded:\n\n";
|
||||
|
|
@ -258,14 +295,13 @@ Documentation:
|
|||
}
|
||||
|
||||
// Append skills section (only if read tool is available)
|
||||
if (skillsSettings?.enabled !== false && hasRead) {
|
||||
const { skills } = loadSkills(skillsSettings ?? {});
|
||||
if (hasRead && skills.length > 0) {
|
||||
prompt += formatSkillsForPrompt(skills);
|
||||
}
|
||||
|
||||
// Add date/time and working directory last
|
||||
prompt += `\nCurrent date and time: ${dateTime}`;
|
||||
prompt += `\nCurrent working directory: ${process.cwd()}`;
|
||||
prompt += `\nCurrent working directory: ${resolvedCwd}`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
|
||||
export { type BashToolDetails, bashTool } from "./bash.js";
|
||||
export { editTool } from "./edit.js";
|
||||
export { type FindToolDetails, findTool } from "./find.js";
|
||||
|
|
@ -15,8 +17,14 @@ import { lsTool } from "./ls.js";
|
|||
import { readTool } from "./read.js";
|
||||
import { writeTool } from "./write.js";
|
||||
|
||||
/** Tool type (AgentTool from pi-ai) */
|
||||
export type Tool = AgentTool<any>;
|
||||
|
||||
// Default tools for full access mode
|
||||
export const codingTools = [readTool, bashTool, editTool, writeTool];
|
||||
export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool];
|
||||
|
||||
// Read-only tools for exploration without modification
|
||||
export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool];
|
||||
|
||||
// All available tools (including read-only exploration tools)
|
||||
export const allTools = {
|
||||
|
|
|
|||
|
|
@ -83,6 +83,32 @@ export {
|
|||
type OAuthPrompt,
|
||||
type OAuthProvider,
|
||||
} from "./core/oauth/index.js";
|
||||
// SDK for programmatic usage
|
||||
export {
|
||||
type BuildSystemPromptOptions,
|
||||
buildSystemPrompt,
|
||||
type CreateAgentSessionOptions,
|
||||
type CreateAgentSessionResult,
|
||||
// Configuration
|
||||
configureOAuthStorage,
|
||||
// Factory
|
||||
createAgentSession,
|
||||
// Helpers
|
||||
defaultGetApiKey,
|
||||
discoverAvailableModels,
|
||||
discoverContextFiles,
|
||||
discoverCustomTools,
|
||||
discoverHooks,
|
||||
// Discovery
|
||||
discoverModels,
|
||||
discoverSkills,
|
||||
discoverSlashCommands,
|
||||
type FileSlashCommand,
|
||||
findModel as findModelByProviderAndId,
|
||||
loadSettings,
|
||||
// Tools
|
||||
readOnlyTools,
|
||||
} from "./core/sdk.js";
|
||||
export {
|
||||
type CompactionEntry,
|
||||
createSummaryMessage,
|
||||
|
|
@ -93,6 +119,7 @@ export {
|
|||
parseSessionEntries,
|
||||
type SessionEntry,
|
||||
type SessionHeader,
|
||||
type SessionInfo,
|
||||
SessionManager,
|
||||
type SessionMessageEntry,
|
||||
SUMMARY_PREFIX,
|
||||
|
|
|
|||
|
|
@ -1,61 +1,34 @@
|
|||
/**
|
||||
* Main entry point for the coding agent
|
||||
* Main entry point for the coding agent CLI.
|
||||
*
|
||||
* This file handles CLI argument parsing and translates them into
|
||||
* createAgentSession() options. The SDK does the heavy lifting.
|
||||
*/
|
||||
|
||||
import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import { setOAuthStorage, supportsXhigh } from "@mariozechner/pi-ai";
|
||||
import type { Attachment } from "@mariozechner/pi-agent-core";
|
||||
import { supportsXhigh } from "@mariozechner/pi-ai";
|
||||
import chalk from "chalk";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
|
||||
import { type Args, parseArgs, printHelp } from "./cli/args.js";
|
||||
import { processFileArguments } from "./cli/file-processor.js";
|
||||
import { listModels } from "./cli/list-models.js";
|
||||
import { selectSession } from "./cli/session-picker.js";
|
||||
import { getModelsPath, getOAuthPath, VERSION } from "./config.js";
|
||||
import { AgentSession } from "./core/agent-session.js";
|
||||
import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./core/custom-tools/index.js";
|
||||
import { getModelsPath, VERSION } from "./config.js";
|
||||
import type { AgentSession } from "./core/agent-session.js";
|
||||
import type { LoadedCustomTool } from "./core/custom-tools/index.js";
|
||||
import { exportFromFile } from "./core/export-html.js";
|
||||
import { discoverAndLoadHooks, HookRunner, wrapToolsWithHooks } from "./core/hooks/index.js";
|
||||
import { messageTransformer } from "./core/messages.js";
|
||||
import { findModel, getApiKeyForModel, getAvailableModels } from "./core/model-config.js";
|
||||
import { resolveModelScope, restoreModelFromSession, type ScopedModel } from "./core/model-resolver.js";
|
||||
import type { HookUIContext } from "./core/index.js";
|
||||
import { findModel } from "./core/model-config.js";
|
||||
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
|
||||
import { type CreateAgentSessionOptions, configureOAuthStorage, createAgentSession } from "./core/sdk.js";
|
||||
import { SessionManager } from "./core/session-manager.js";
|
||||
import { SettingsManager } from "./core/settings-manager.js";
|
||||
import { loadSlashCommands } from "./core/slash-commands.js";
|
||||
import { buildSystemPrompt } from "./core/system-prompt.js";
|
||||
import { allTools, codingTools } from "./core/tools/index.js";
|
||||
import { allTools } from "./core/tools/index.js";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
|
||||
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
|
||||
import { ensureTool } from "./utils/tools-manager.js";
|
||||
|
||||
/** Configure OAuth storage to use the coding-agent's configurable path */
|
||||
function configureOAuthStorage(): void {
|
||||
const oauthPath = getOAuthPath();
|
||||
|
||||
setOAuthStorage({
|
||||
load: () => {
|
||||
if (!existsSync(oauthPath)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(oauthPath, "utf-8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
save: (storage) => {
|
||||
const dir = dirname(oauthPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
writeFileSync(oauthPath, JSON.stringify(storage, null, 2), "utf-8");
|
||||
chmodSync(oauthPath, 0o600);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Check npm registry for new version (non-blocking) */
|
||||
async function checkForNewVersion(currentVersion: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest");
|
||||
|
|
@ -70,46 +43,39 @@ async function checkForNewVersion(currentVersion: string): Promise<string | null
|
|||
|
||||
return null;
|
||||
} catch {
|
||||
// Silently fail - don't disrupt the user experience
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run interactive mode with TUI */
|
||||
async function runInteractiveMode(
|
||||
session: AgentSession,
|
||||
version: string,
|
||||
changelogMarkdown: string | null,
|
||||
modelFallbackMessage: string | null,
|
||||
modelFallbackMessage: string | undefined,
|
||||
versionCheckPromise: Promise<string | null>,
|
||||
initialMessages: string[],
|
||||
customTools: LoadedCustomTool[],
|
||||
setToolUIContext: (uiContext: import("./core/hooks/types.js").HookUIContext, hasUI: boolean) => void,
|
||||
setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void,
|
||||
initialMessage?: string,
|
||||
initialAttachments?: Attachment[],
|
||||
fdPath: string | null = null,
|
||||
): Promise<void> {
|
||||
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
|
||||
|
||||
// Initialize TUI (subscribes to agent events internally)
|
||||
await mode.init();
|
||||
|
||||
// Handle version check result when it completes (don't block)
|
||||
versionCheckPromise.then((newVersion) => {
|
||||
if (newVersion) {
|
||||
mode.showNewVersionNotification(newVersion);
|
||||
}
|
||||
});
|
||||
|
||||
// Render any existing messages (from --continue mode)
|
||||
mode.renderInitialMessages(session.state);
|
||||
|
||||
// Show model fallback warning at the end of the chat if applicable
|
||||
if (modelFallbackMessage) {
|
||||
mode.showWarning(modelFallbackMessage);
|
||||
}
|
||||
|
||||
// Process initial message with attachments if provided (from @file args)
|
||||
if (initialMessage) {
|
||||
try {
|
||||
await session.prompt(initialMessage, { attachments: initialAttachments });
|
||||
|
|
@ -119,7 +85,6 @@ async function runInteractiveMode(
|
|||
}
|
||||
}
|
||||
|
||||
// Process remaining initial messages if provided (from CLI args)
|
||||
for (const message of initialMessages) {
|
||||
try {
|
||||
await session.prompt(message);
|
||||
|
|
@ -129,11 +94,8 @@ async function runInteractiveMode(
|
|||
}
|
||||
}
|
||||
|
||||
// Interactive loop
|
||||
while (true) {
|
||||
const userInput = await mode.getUserInput();
|
||||
|
||||
// Process the message
|
||||
try {
|
||||
await session.prompt(userInput);
|
||||
} catch (error: unknown) {
|
||||
|
|
@ -143,7 +105,6 @@ async function runInteractiveMode(
|
|||
}
|
||||
}
|
||||
|
||||
/** Prepare initial message from @file arguments */
|
||||
async function prepareInitialMessage(parsed: Args): Promise<{
|
||||
initialMessage?: string;
|
||||
initialAttachments?: Attachment[];
|
||||
|
|
@ -154,11 +115,10 @@ async function prepareInitialMessage(parsed: Args): Promise<{
|
|||
|
||||
const { textContent, imageAttachments } = await processFileArguments(parsed.fileArgs);
|
||||
|
||||
// Combine file content with first plain text message (if any)
|
||||
let initialMessage: string;
|
||||
if (parsed.messages.length > 0) {
|
||||
initialMessage = textContent + parsed.messages[0];
|
||||
parsed.messages.shift(); // Remove first message as it's been combined
|
||||
parsed.messages.shift();
|
||||
} else {
|
||||
initialMessage = textContent;
|
||||
}
|
||||
|
|
@ -169,9 +129,123 @@ async function prepareInitialMessage(parsed: Args): Promise<{
|
|||
};
|
||||
}
|
||||
|
||||
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null {
|
||||
if (parsed.continue || parsed.resume) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastVersion = settingsManager.getLastChangelogVersion();
|
||||
const changelogPath = getChangelogPath();
|
||||
const entries = parseChangelog(changelogPath);
|
||||
|
||||
if (!lastVersion) {
|
||||
if (entries.length > 0) {
|
||||
settingsManager.setLastChangelogVersion(VERSION);
|
||||
return entries.map((e) => e.content).join("\n\n");
|
||||
}
|
||||
} else {
|
||||
const newEntries = getNewEntries(entries, lastVersion);
|
||||
if (newEntries.length > 0) {
|
||||
settingsManager.setLastChangelogVersion(VERSION);
|
||||
return newEntries.map((e) => e.content).join("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createSessionManager(parsed: Args, cwd: string): SessionManager | null {
|
||||
if (parsed.noSession) {
|
||||
return SessionManager.inMemory();
|
||||
}
|
||||
if (parsed.session) {
|
||||
return SessionManager.open(parsed.session);
|
||||
}
|
||||
if (parsed.continue) {
|
||||
return SessionManager.continueRecent(cwd);
|
||||
}
|
||||
// --resume is handled separately (needs picker UI)
|
||||
// Default case (new session) returns null, SDK will create one
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildSessionOptions(
|
||||
parsed: Args,
|
||||
scopedModels: ScopedModel[],
|
||||
sessionManager: SessionManager | null,
|
||||
): CreateAgentSessionOptions {
|
||||
const options: CreateAgentSessionOptions = {};
|
||||
|
||||
if (sessionManager) {
|
||||
options.sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
// Model from CLI
|
||||
if (parsed.provider && parsed.model) {
|
||||
const { model, error } = findModel(parsed.provider, parsed.model);
|
||||
if (error) {
|
||||
console.error(chalk.red(error));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!model) {
|
||||
console.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`));
|
||||
process.exit(1);
|
||||
}
|
||||
options.model = model;
|
||||
} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
|
||||
options.model = scopedModels[0].model;
|
||||
}
|
||||
|
||||
// Thinking level
|
||||
if (parsed.thinking) {
|
||||
options.thinkingLevel = parsed.thinking;
|
||||
} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
|
||||
options.thinkingLevel = scopedModels[0].thinkingLevel;
|
||||
}
|
||||
|
||||
// Scoped models for Ctrl+P cycling
|
||||
if (scopedModels.length > 0) {
|
||||
options.scopedModels = scopedModels;
|
||||
}
|
||||
|
||||
// API key from CLI
|
||||
if (parsed.apiKey) {
|
||||
options.getApiKey = async () => parsed.apiKey!;
|
||||
}
|
||||
|
||||
// System prompt
|
||||
if (parsed.systemPrompt && parsed.appendSystemPrompt) {
|
||||
options.systemPrompt = `${parsed.systemPrompt}\n\n${parsed.appendSystemPrompt}`;
|
||||
} else if (parsed.systemPrompt) {
|
||||
options.systemPrompt = parsed.systemPrompt;
|
||||
} else if (parsed.appendSystemPrompt) {
|
||||
options.systemPrompt = (defaultPrompt) => `${defaultPrompt}\n\n${parsed.appendSystemPrompt}`;
|
||||
}
|
||||
|
||||
// Tools
|
||||
if (parsed.tools) {
|
||||
options.tools = parsed.tools.map((name) => allTools[name]);
|
||||
}
|
||||
|
||||
// Skills
|
||||
if (parsed.noSkills) {
|
||||
options.skills = [];
|
||||
}
|
||||
|
||||
// Additional hook paths from CLI
|
||||
if (parsed.hooks && parsed.hooks.length > 0) {
|
||||
options.additionalHookPaths = parsed.hooks;
|
||||
}
|
||||
|
||||
// Additional custom tool paths from CLI
|
||||
if (parsed.customTools && parsed.customTools.length > 0) {
|
||||
options.additionalCustomToolPaths = parsed.customTools;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function main(args: string[]) {
|
||||
// Configure OAuth storage to use the coding-agent's configurable path
|
||||
// This must happen before any OAuth operations
|
||||
configureOAuthStorage();
|
||||
|
||||
const parsed = parseArgs(args);
|
||||
|
|
@ -186,14 +260,12 @@ export async function main(args: string[]) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Handle --list-models flag: list available models and exit
|
||||
if (parsed.listModels !== undefined) {
|
||||
const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined;
|
||||
await listModels(searchPattern);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle --export flag: convert session file to HTML and exit
|
||||
if (parsed.export) {
|
||||
try {
|
||||
const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;
|
||||
|
|
@ -207,67 +279,46 @@ export async function main(args: string[]) {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate: RPC mode doesn't support @file arguments
|
||||
if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {
|
||||
console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Process @file arguments
|
||||
const cwd = process.cwd();
|
||||
const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed);
|
||||
|
||||
// Determine if we're in interactive mode (needed for theme watcher)
|
||||
const isInteractive = !parsed.print && parsed.mode === undefined;
|
||||
const mode = parsed.mode || "text";
|
||||
|
||||
// Initialize theme (before any TUI rendering)
|
||||
const settingsManager = new SettingsManager();
|
||||
const themeName = settingsManager.getTheme();
|
||||
initTheme(themeName, isInteractive);
|
||||
const settingsManager = SettingsManager.create(cwd);
|
||||
initTheme(settingsManager.getTheme(), isInteractive);
|
||||
|
||||
// Setup session manager
|
||||
const sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session);
|
||||
|
||||
if (parsed.noSession) {
|
||||
sessionManager.disable();
|
||||
}
|
||||
|
||||
// Handle --resume flag: show session selector
|
||||
if (parsed.resume) {
|
||||
const selectedSession = await selectSession(sessionManager);
|
||||
if (!selectedSession) {
|
||||
console.log(chalk.dim("No session selected"));
|
||||
return;
|
||||
}
|
||||
sessionManager.setSessionFile(selectedSession);
|
||||
}
|
||||
|
||||
// Resolve model scope early if provided
|
||||
let scopedModels: ScopedModel[] = [];
|
||||
if (parsed.models && parsed.models.length > 0) {
|
||||
scopedModels = await resolveModelScope(parsed.models);
|
||||
}
|
||||
|
||||
// Determine mode and output behavior
|
||||
const mode = parsed.mode || "text";
|
||||
const shouldPrintMessages = isInteractive;
|
||||
// Create session manager based on CLI flags
|
||||
let sessionManager = createSessionManager(parsed, cwd);
|
||||
|
||||
// Find initial model
|
||||
let initialModel = await findInitialModelForSession(parsed, scopedModels, settingsManager);
|
||||
let initialThinking: ThinkingLevel = "off";
|
||||
|
||||
// Get thinking level from scoped models if applicable
|
||||
if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
|
||||
initialThinking = scopedModels[0].thinkingLevel;
|
||||
} else {
|
||||
// Try saved thinking level
|
||||
const savedThinking = settingsManager.getDefaultThinkingLevel();
|
||||
if (savedThinking) {
|
||||
initialThinking = savedThinking;
|
||||
// Handle --resume: show session picker
|
||||
if (parsed.resume) {
|
||||
const sessions = SessionManager.list(cwd);
|
||||
if (sessions.length === 0) {
|
||||
console.log(chalk.dim("No sessions found"));
|
||||
return;
|
||||
}
|
||||
const selectedPath = await selectSession(sessions);
|
||||
if (!selectedPath) {
|
||||
console.log(chalk.dim("No session selected"));
|
||||
return;
|
||||
}
|
||||
sessionManager = SessionManager.open(selectedPath);
|
||||
}
|
||||
|
||||
// Non-interactive mode: fail early if no model available
|
||||
if (!isInteractive && !initialModel) {
|
||||
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager);
|
||||
const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
|
||||
|
||||
if (!isInteractive && !session.model) {
|
||||
console.error(chalk.red("No models available."));
|
||||
console.error(chalk.yellow("\nSet an API key environment variable:"));
|
||||
console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
|
||||
|
|
@ -275,191 +326,25 @@ export async function main(args: string[]) {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// Non-interactive mode: validate API key exists
|
||||
if (!isInteractive && initialModel) {
|
||||
const apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel));
|
||||
if (!apiKey) {
|
||||
console.error(chalk.red(`No API key found for ${initialModel.provider}`));
|
||||
process.exit(1);
|
||||
// Clamp thinking level to model capabilities (for CLI override case)
|
||||
if (session.model && parsed.thinking) {
|
||||
let effectiveThinking = parsed.thinking;
|
||||
if (!session.model.reasoning) {
|
||||
effectiveThinking = "off";
|
||||
} else if (effectiveThinking === "xhigh" && !supportsXhigh(session.model)) {
|
||||
effectiveThinking = "high";
|
||||
}
|
||||
if (effectiveThinking !== session.thinkingLevel) {
|
||||
session.setThinkingLevel(effectiveThinking);
|
||||
}
|
||||
}
|
||||
|
||||
// Build system prompt
|
||||
const skillsSettings = settingsManager.getSkillsSettings();
|
||||
if (parsed.noSkills) {
|
||||
skillsSettings.enabled = false;
|
||||
}
|
||||
if (parsed.skills && parsed.skills.length > 0) {
|
||||
skillsSettings.includeSkills = parsed.skills;
|
||||
}
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
customPrompt: parsed.systemPrompt,
|
||||
selectedTools: parsed.tools,
|
||||
appendSystemPrompt: parsed.appendSystemPrompt,
|
||||
skillsSettings,
|
||||
});
|
||||
|
||||
// Handle session restoration
|
||||
let modelFallbackMessage: string | null = null;
|
||||
|
||||
if (parsed.continue || parsed.resume || parsed.session) {
|
||||
const savedModel = sessionManager.loadModel();
|
||||
if (savedModel) {
|
||||
const result = await restoreModelFromSession(
|
||||
savedModel.provider,
|
||||
savedModel.modelId,
|
||||
initialModel,
|
||||
shouldPrintMessages,
|
||||
);
|
||||
|
||||
if (result.model) {
|
||||
initialModel = result.model;
|
||||
}
|
||||
modelFallbackMessage = result.fallbackMessage;
|
||||
}
|
||||
|
||||
// Load and restore thinking level
|
||||
const thinkingLevel = sessionManager.loadThinkingLevel() as ThinkingLevel;
|
||||
if (thinkingLevel) {
|
||||
initialThinking = thinkingLevel;
|
||||
if (shouldPrintMessages) {
|
||||
console.log(chalk.dim(`Restored thinking level: ${thinkingLevel}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLI --thinking flag takes highest priority
|
||||
if (parsed.thinking) {
|
||||
initialThinking = parsed.thinking;
|
||||
}
|
||||
|
||||
// Clamp thinking level to model capabilities
|
||||
if (initialModel) {
|
||||
if (!initialModel.reasoning) {
|
||||
initialThinking = "off";
|
||||
} else if (initialThinking === "xhigh" && !supportsXhigh(initialModel)) {
|
||||
initialThinking = "high";
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which tools to use
|
||||
let selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools;
|
||||
|
||||
// Discover and load hooks from:
|
||||
// 1. ~/.pi/agent/hooks/*.ts (global)
|
||||
// 2. cwd/.pi/hooks/*.ts (project-local)
|
||||
// 3. Explicit paths in settings.json
|
||||
// 4. CLI --hook flags
|
||||
let hookRunner: HookRunner | null = null;
|
||||
const cwd = process.cwd();
|
||||
const configuredHookPaths = [...settingsManager.getHookPaths(), ...(parsed.hooks ?? [])];
|
||||
const { hooks, errors } = await discoverAndLoadHooks(configuredHookPaths, cwd);
|
||||
|
||||
// Report hook loading errors
|
||||
for (const { path, error } of errors) {
|
||||
console.error(chalk.red(`Failed to load hook "${path}": ${error}`));
|
||||
}
|
||||
|
||||
if (hooks.length > 0) {
|
||||
const timeout = settingsManager.getHookTimeout();
|
||||
hookRunner = new HookRunner(hooks, cwd, timeout);
|
||||
}
|
||||
|
||||
// Discover and load custom tools from:
|
||||
// 1. ~/.pi/agent/tools/*.ts (global)
|
||||
// 2. cwd/.pi/tools/*.ts (project-local)
|
||||
// 3. Explicit paths in settings.json
|
||||
// 4. CLI --tool flags
|
||||
const configuredToolPaths = [...settingsManager.getCustomToolPaths(), ...(parsed.customTools ?? [])];
|
||||
const builtInToolNames = Object.keys(allTools);
|
||||
const {
|
||||
tools: loadedCustomTools,
|
||||
errors: toolErrors,
|
||||
setUIContext: setToolUIContext,
|
||||
} = await discoverAndLoadCustomTools(configuredToolPaths, cwd, builtInToolNames);
|
||||
|
||||
// Report custom tool loading errors
|
||||
for (const { path, error } of toolErrors) {
|
||||
console.error(chalk.red(`Failed to load custom tool "${path}": ${error}`));
|
||||
}
|
||||
|
||||
// Add custom tools to selected tools
|
||||
if (loadedCustomTools.length > 0) {
|
||||
const customToolInstances = loadedCustomTools.map((lt) => lt.tool);
|
||||
selectedTools = [...selectedTools, ...customToolInstances] as typeof selectedTools;
|
||||
}
|
||||
|
||||
// Wrap tools with hook callbacks (built-in and custom)
|
||||
if (hookRunner) {
|
||||
selectedTools = wrapToolsWithHooks(selectedTools, hookRunner);
|
||||
}
|
||||
|
||||
// Create agent
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
model: initialModel as any, // Can be null in interactive mode
|
||||
thinkingLevel: initialThinking,
|
||||
tools: selectedTools,
|
||||
},
|
||||
messageTransformer,
|
||||
queueMode: settingsManager.getQueueMode(),
|
||||
transport: new ProviderTransport({
|
||||
getApiKey: async () => {
|
||||
const currentModel = agent.state.model;
|
||||
if (!currentModel) {
|
||||
throw new Error("No model selected");
|
||||
}
|
||||
|
||||
if (parsed.apiKey) {
|
||||
return parsed.apiKey;
|
||||
}
|
||||
|
||||
const key = await getApiKeyForModel(currentModel);
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
`No API key found for provider "${currentModel.provider}". Please set the appropriate environment variable or update ${getModelsPath()}`,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Load previous messages if continuing, resuming, or using --session
|
||||
if (parsed.continue || parsed.resume || parsed.session) {
|
||||
const messages = sessionManager.loadMessages();
|
||||
if (messages.length > 0) {
|
||||
agent.replaceMessages(messages);
|
||||
}
|
||||
}
|
||||
|
||||
// Load file commands for slash command expansion
|
||||
const fileCommands = loadSlashCommands();
|
||||
|
||||
// Create session
|
||||
const session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
scopedModels,
|
||||
fileCommands,
|
||||
hookRunner,
|
||||
customTools: loadedCustomTools,
|
||||
skillsSettings,
|
||||
});
|
||||
|
||||
// Route to appropriate mode
|
||||
if (mode === "rpc") {
|
||||
await runRpcMode(session);
|
||||
} else if (isInteractive) {
|
||||
// Check for new version in the background
|
||||
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);
|
||||
|
||||
// Check if we should show changelog
|
||||
const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
|
||||
|
||||
// Show model scope if provided
|
||||
if (scopedModels.length > 0) {
|
||||
const modelList = scopedModels
|
||||
.map((sm) => {
|
||||
|
|
@ -470,7 +355,6 @@ export async function main(args: string[]) {
|
|||
console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
|
||||
}
|
||||
|
||||
// Ensure fd tool is available for file autocomplete
|
||||
const fdPath = await ensureTool("fd");
|
||||
|
||||
await runInteractiveMode(
|
||||
|
|
@ -480,99 +364,18 @@ export async function main(args: string[]) {
|
|||
modelFallbackMessage,
|
||||
versionCheckPromise,
|
||||
parsed.messages,
|
||||
loadedCustomTools,
|
||||
setToolUIContext,
|
||||
customToolsResult.tools,
|
||||
customToolsResult.setUIContext,
|
||||
initialMessage,
|
||||
initialAttachments,
|
||||
fdPath,
|
||||
);
|
||||
} else {
|
||||
// Non-interactive mode (--print flag or --mode flag)
|
||||
await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments);
|
||||
// Clean up and exit (file watchers keep process alive)
|
||||
stopThemeWatcher();
|
||||
// Wait for stdout to fully flush before exiting
|
||||
if (process.stdout.writableLength > 0) {
|
||||
await new Promise<void>((resolve) => process.stdout.once("drain", resolve));
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Find initial model based on CLI args, scoped models, settings, or available models */
|
||||
async function findInitialModelForSession(parsed: Args, scopedModels: ScopedModel[], settingsManager: SettingsManager) {
|
||||
// 1. CLI args take priority
|
||||
if (parsed.provider && parsed.model) {
|
||||
const { model, error } = findModel(parsed.provider, parsed.model);
|
||||
if (error) {
|
||||
console.error(chalk.red(error));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!model) {
|
||||
console.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`));
|
||||
process.exit(1);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
// 2. Use first model from scoped models (skip if continuing/resuming)
|
||||
if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
|
||||
return scopedModels[0].model;
|
||||
}
|
||||
|
||||
// 3. Try saved default from settings
|
||||
const defaultProvider = settingsManager.getDefaultProvider();
|
||||
const defaultModelId = settingsManager.getDefaultModel();
|
||||
if (defaultProvider && defaultModelId) {
|
||||
const { model, error } = findModel(defaultProvider, defaultModelId);
|
||||
if (error) {
|
||||
console.error(chalk.red(error));
|
||||
process.exit(1);
|
||||
}
|
||||
if (model) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try first available model with valid API key
|
||||
const { models: availableModels, error } = await getAvailableModels();
|
||||
|
||||
if (error) {
|
||||
console.error(chalk.red(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (availableModels.length > 0) {
|
||||
return availableModels[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Get changelog markdown to display (only for new sessions with updates) */
|
||||
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null {
|
||||
if (parsed.continue || parsed.resume) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastVersion = settingsManager.getLastChangelogVersion();
|
||||
const changelogPath = getChangelogPath();
|
||||
const entries = parseChangelog(changelogPath);
|
||||
|
||||
if (!lastVersion) {
|
||||
// First run - show all entries
|
||||
if (entries.length > 0) {
|
||||
settingsManager.setLastChangelogVersion(VERSION);
|
||||
return entries.map((e) => e.content).join("\n\n");
|
||||
}
|
||||
} else {
|
||||
// Check for new entries since last version
|
||||
const newEntries = getNewEntries(entries, lastVersion);
|
||||
if (newEntries.length > 0) {
|
||||
settingsManager.setLastChangelogVersion(VERSION);
|
||||
return newEntries.map((e) => e.content).join("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,27 +11,17 @@ import {
|
|||
Text,
|
||||
truncateToWidth,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import type { SessionManager } from "../../../core/session-manager.js";
|
||||
import type { SessionInfo } from "../../../core/session-manager.js";
|
||||
import { fuzzyFilter } from "../../../utils/fuzzy.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
interface SessionItem {
|
||||
path: string;
|
||||
id: string;
|
||||
created: Date;
|
||||
modified: Date;
|
||||
messageCount: number;
|
||||
firstMessage: string;
|
||||
allMessagesText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom session list component with multi-line items and search
|
||||
*/
|
||||
class SessionList implements Component {
|
||||
private allSessions: SessionItem[] = [];
|
||||
private filteredSessions: SessionItem[] = [];
|
||||
private allSessions: SessionInfo[] = [];
|
||||
private filteredSessions: SessionInfo[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private searchInput: Input;
|
||||
public onSelect?: (sessionPath: string) => void;
|
||||
|
|
@ -39,7 +29,7 @@ class SessionList implements Component {
|
|||
public onExit: () => void = () => {};
|
||||
private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
|
||||
|
||||
constructor(sessions: SessionItem[]) {
|
||||
constructor(sessions: SessionInfo[]) {
|
||||
this.allSessions = sessions;
|
||||
this.filteredSessions = sessions;
|
||||
this.searchInput = new Input();
|
||||
|
|
@ -176,16 +166,13 @@ export class SessionSelectorComponent extends Container {
|
|||
private sessionList: SessionList;
|
||||
|
||||
constructor(
|
||||
sessionManager: SessionManager,
|
||||
sessions: SessionInfo[],
|
||||
onSelect: (sessionPath: string) => void,
|
||||
onCancel: () => void,
|
||||
onExit: () => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
// Load all sessions
|
||||
const sessions = sessionManager.loadAllSessions();
|
||||
|
||||
// Add header
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.bold("Resume Session"), 1, 0));
|
||||
|
|
|
|||
|
|
@ -32,7 +32,12 @@ import type { HookUIContext } from "../../core/hooks/index.js";
|
|||
import { isBashExecutionMessage } from "../../core/messages.js";
|
||||
import { invalidateOAuthCache } from "../../core/model-config.js";
|
||||
import { listOAuthProviders, login, logout, type OAuthProvider } from "../../core/oauth/index.js";
|
||||
import { getLatestCompactionEntry, SUMMARY_PREFIX, SUMMARY_SUFFIX } from "../../core/session-manager.js";
|
||||
import {
|
||||
getLatestCompactionEntry,
|
||||
SessionManager,
|
||||
SUMMARY_PREFIX,
|
||||
SUMMARY_SUFFIX,
|
||||
} from "../../core/session-manager.js";
|
||||
import { loadSkills } from "../../core/skills.js";
|
||||
import { loadProjectContextFiles } from "../../core/system-prompt.js";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.js";
|
||||
|
|
@ -1513,8 +1518,9 @@ export class InteractiveMode {
|
|||
|
||||
private showSessionSelector(): void {
|
||||
this.showSelector((done) => {
|
||||
const sessions = SessionManager.list(this.sessionManager.getCwd());
|
||||
const selector = new SessionSelectorComponent(
|
||||
this.sessionManager,
|
||||
sessions,
|
||||
async (sessionPath) => {
|
||||
done();
|
||||
await this.handleResumeSession(sessionPath);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function getShellConfig(): { shell: string; args: string[] } {
|
|||
return cachedShellConfig;
|
||||
}
|
||||
|
||||
const settings = new SettingsManager();
|
||||
const settings = SettingsManager.create();
|
||||
const customShellPath = settings.getShellPath();
|
||||
|
||||
// 1. Check user-specified shell path
|
||||
|
|
|
|||
|
|
@ -56,11 +56,8 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
|
|||
},
|
||||
});
|
||||
|
||||
sessionManager = new SessionManager(false);
|
||||
if (noSession) {
|
||||
sessionManager.disable();
|
||||
}
|
||||
const settingsManager = new SettingsManager(tempDir);
|
||||
sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir);
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
|||
},
|
||||
});
|
||||
|
||||
sessionManager = new SessionManager(false);
|
||||
const settingsManager = new SettingsManager(tempDir);
|
||||
sessionManager = SessionManager.create(tempDir);
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
|
|
@ -174,11 +174,10 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
|||
},
|
||||
});
|
||||
|
||||
// Create session manager and disable file persistence
|
||||
const noSessionManager = new SessionManager(false);
|
||||
noSessionManager.disable();
|
||||
// Create in-memory session manager
|
||||
const noSessionManager = SessionManager.inMemory();
|
||||
|
||||
const settingsManager = new SettingsManager(tempDir);
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
|
||||
const noSessionSession = new AgentSession({
|
||||
agent,
|
||||
|
|
|
|||
|
|
@ -234,9 +234,6 @@ describe("loadSessionFromEntries", () => {
|
|||
id: "1",
|
||||
timestamp: "",
|
||||
cwd: "",
|
||||
provider: "anthropic",
|
||||
modelId: "claude",
|
||||
thinkingLevel: "off",
|
||||
},
|
||||
createMessageEntry(createUserMessage("1")),
|
||||
createMessageEntry(createAssistantMessage("a")),
|
||||
|
|
@ -247,7 +244,7 @@ describe("loadSessionFromEntries", () => {
|
|||
const loaded = loadSessionFromEntries(entries);
|
||||
expect(loaded.messages.length).toBe(4);
|
||||
expect(loaded.thinkingLevel).toBe("off");
|
||||
expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude" });
|
||||
expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
});
|
||||
|
||||
it("should handle single compaction", () => {
|
||||
|
|
@ -258,9 +255,6 @@ describe("loadSessionFromEntries", () => {
|
|||
id: "1",
|
||||
timestamp: "",
|
||||
cwd: "",
|
||||
provider: "anthropic",
|
||||
modelId: "claude",
|
||||
thinkingLevel: "off",
|
||||
},
|
||||
createMessageEntry(createUserMessage("1")),
|
||||
createMessageEntry(createAssistantMessage("a")),
|
||||
|
|
@ -286,9 +280,6 @@ describe("loadSessionFromEntries", () => {
|
|||
id: "1",
|
||||
timestamp: "",
|
||||
cwd: "",
|
||||
provider: "anthropic",
|
||||
modelId: "claude",
|
||||
thinkingLevel: "off",
|
||||
},
|
||||
createMessageEntry(createUserMessage("1")),
|
||||
createMessageEntry(createAssistantMessage("a")),
|
||||
|
|
@ -316,9 +307,6 @@ describe("loadSessionFromEntries", () => {
|
|||
id: "1",
|
||||
timestamp: "",
|
||||
cwd: "",
|
||||
provider: "anthropic",
|
||||
modelId: "claude",
|
||||
thinkingLevel: "off",
|
||||
},
|
||||
createMessageEntry(createUserMessage("1")),
|
||||
createMessageEntry(createAssistantMessage("a")),
|
||||
|
|
@ -341,9 +329,6 @@ describe("loadSessionFromEntries", () => {
|
|||
id: "1",
|
||||
timestamp: "",
|
||||
cwd: "",
|
||||
provider: "anthropic",
|
||||
modelId: "claude",
|
||||
thinkingLevel: "off",
|
||||
},
|
||||
createMessageEntry(createUserMessage("1")),
|
||||
{ type: "model_change", timestamp: "", provider: "openai", modelId: "gpt-4" },
|
||||
|
|
@ -352,7 +337,8 @@ describe("loadSessionFromEntries", () => {
|
|||
];
|
||||
|
||||
const loaded = loadSessionFromEntries(entries);
|
||||
expect(loaded.model).toEqual({ provider: "openai", modelId: "gpt-4" });
|
||||
// model_change is later overwritten by assistant message's model info
|
||||
expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
expect(loaded.thinkingLevel).toBe("high");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -411,12 +411,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
|
|||
const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, [], [], skills);
|
||||
|
||||
// Create session manager and settings manager
|
||||
// Pass model info so new sessions get a header written immediately
|
||||
const sessionManager = new MomSessionManager(channelDir, {
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
thinkingLevel: "off",
|
||||
});
|
||||
const sessionManager = new MomSessionManager(channelDir);
|
||||
const settingsManager = new MomSettingsManager(join(channelDir, ".."));
|
||||
|
||||
// Create agent
|
||||
|
|
|
|||
|
|
@ -10,14 +10,13 @@
|
|||
* - MomSettingsManager: Simple settings for mom (compaction, retry, model preferences)
|
||||
*/
|
||||
|
||||
import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
type CompactionEntry,
|
||||
type LoadedSession,
|
||||
loadSessionFromEntries,
|
||||
type ModelChangeEntry,
|
||||
type SessionEntry,
|
||||
type SessionHeader,
|
||||
type SessionMessageEntry,
|
||||
type ThinkingLevelChangeEntry,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
|
@ -48,11 +47,10 @@ export class MomSessionManager {
|
|||
private contextFile: string;
|
||||
private logFile: string;
|
||||
private channelDir: string;
|
||||
private sessionInitialized: boolean = false;
|
||||
private flushed: boolean = false;
|
||||
private inMemoryEntries: SessionEntry[] = [];
|
||||
private pendingEntries: SessionEntry[] = [];
|
||||
|
||||
constructor(channelDir: string, initialModel?: { provider: string; id: string; thinkingLevel?: string }) {
|
||||
constructor(channelDir: string) {
|
||||
this.channelDir = channelDir;
|
||||
this.contextFile = join(channelDir, "context.jsonl");
|
||||
this.logFile = join(channelDir, "log.jsonl");
|
||||
|
|
@ -66,33 +64,33 @@ export class MomSessionManager {
|
|||
if (existsSync(this.contextFile)) {
|
||||
this.inMemoryEntries = this.loadEntriesFromFile();
|
||||
this.sessionId = this.extractSessionId() || uuidv4();
|
||||
this.sessionInitialized = this.inMemoryEntries.length > 0;
|
||||
this.flushed = true;
|
||||
} else {
|
||||
// New session - write header immediately
|
||||
this.sessionId = uuidv4();
|
||||
if (initialModel) {
|
||||
this.writeSessionHeader(initialModel);
|
||||
}
|
||||
this.inMemoryEntries = [
|
||||
{
|
||||
type: "session",
|
||||
id: this.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: this.channelDir,
|
||||
},
|
||||
];
|
||||
}
|
||||
// Note: syncFromLog() is called explicitly from agent.ts with excludeTimestamp
|
||||
}
|
||||
|
||||
/** Write session header to file (called on new session creation) */
|
||||
private writeSessionHeader(model: { provider: string; id: string; thinkingLevel?: string }): void {
|
||||
this.sessionInitialized = true;
|
||||
private _persist(entry: SessionEntry): void {
|
||||
const hasAssistant = this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant");
|
||||
if (!hasAssistant) return;
|
||||
|
||||
const entry: SessionHeader = {
|
||||
type: "session",
|
||||
id: this.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: this.channelDir,
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
thinkingLevel: model.thinkingLevel || "off",
|
||||
};
|
||||
|
||||
this.inMemoryEntries.push(entry);
|
||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
||||
if (!this.flushed) {
|
||||
for (const e of this.inMemoryEntries) {
|
||||
appendFileSync(this.contextFile, `${JSON.stringify(e)}\n`);
|
||||
}
|
||||
this.flushed = true;
|
||||
} else {
|
||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -248,47 +246,14 @@ export class MomSessionManager {
|
|||
return entries;
|
||||
}
|
||||
|
||||
/** Initialize session with header if not already done */
|
||||
startSession(state: AgentState): void {
|
||||
if (this.sessionInitialized) return;
|
||||
this.sessionInitialized = true;
|
||||
|
||||
const entry: SessionHeader = {
|
||||
type: "session",
|
||||
id: this.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: this.channelDir,
|
||||
provider: state.model?.provider || "unknown",
|
||||
modelId: state.model?.id || "unknown",
|
||||
thinkingLevel: state.thinkingLevel,
|
||||
};
|
||||
|
||||
this.inMemoryEntries.push(entry);
|
||||
for (const pending of this.pendingEntries) {
|
||||
this.inMemoryEntries.push(pending);
|
||||
}
|
||||
this.pendingEntries = [];
|
||||
|
||||
// Write to file
|
||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
||||
for (const memEntry of this.inMemoryEntries.slice(1)) {
|
||||
appendFileSync(this.contextFile, `${JSON.stringify(memEntry)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
saveMessage(message: AppMessage): void {
|
||||
const entry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
|
||||
if (!this.sessionInitialized) {
|
||||
this.pendingEntries.push(entry);
|
||||
} else {
|
||||
this.inMemoryEntries.push(entry);
|
||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
this.inMemoryEntries.push(entry);
|
||||
this._persist(entry);
|
||||
}
|
||||
|
||||
saveThinkingLevelChange(thinkingLevel: string): void {
|
||||
|
|
@ -297,13 +262,8 @@ export class MomSessionManager {
|
|||
timestamp: new Date().toISOString(),
|
||||
thinkingLevel,
|
||||
};
|
||||
|
||||
if (!this.sessionInitialized) {
|
||||
this.pendingEntries.push(entry);
|
||||
} else {
|
||||
this.inMemoryEntries.push(entry);
|
||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
this.inMemoryEntries.push(entry);
|
||||
this._persist(entry);
|
||||
}
|
||||
|
||||
saveModelChange(provider: string, modelId: string): void {
|
||||
|
|
@ -313,18 +273,13 @@ export class MomSessionManager {
|
|||
provider,
|
||||
modelId,
|
||||
};
|
||||
|
||||
if (!this.sessionInitialized) {
|
||||
this.pendingEntries.push(entry);
|
||||
} else {
|
||||
this.inMemoryEntries.push(entry);
|
||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
this.inMemoryEntries.push(entry);
|
||||
this._persist(entry);
|
||||
}
|
||||
|
||||
saveCompaction(entry: CompactionEntry): void {
|
||||
this.inMemoryEntries.push(entry);
|
||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
||||
this._persist(entry);
|
||||
}
|
||||
|
||||
/** Load session with compaction support */
|
||||
|
|
@ -349,20 +304,18 @@ export class MomSessionManager {
|
|||
return this.contextFile;
|
||||
}
|
||||
|
||||
/** Check if session should be initialized */
|
||||
shouldInitializeSession(messages: AppMessage[]): boolean {
|
||||
if (this.sessionInitialized) return false;
|
||||
const userMessages = messages.filter((m) => m.role === "user");
|
||||
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
||||
return userMessages.length >= 1 && assistantMessages.length >= 1;
|
||||
}
|
||||
|
||||
/** Reset session (clears context.jsonl) */
|
||||
reset(): void {
|
||||
this.pendingEntries = [];
|
||||
this.inMemoryEntries = [];
|
||||
this.sessionInitialized = false;
|
||||
this.sessionId = uuidv4();
|
||||
this.flushed = false;
|
||||
this.inMemoryEntries = [
|
||||
{
|
||||
type: "session",
|
||||
id: this.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: this.channelDir,
|
||||
},
|
||||
];
|
||||
// Truncate the context file
|
||||
if (existsSync(this.contextFile)) {
|
||||
writeFileSync(this.contextFile, "");
|
||||
|
|
@ -370,7 +323,7 @@ export class MomSessionManager {
|
|||
}
|
||||
|
||||
// Compatibility methods for AgentSession
|
||||
isEnabled(): boolean {
|
||||
isPersisted(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue