mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 20:01:06 +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]
|
## [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
|
## [0.25.4] - 2025-12-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows-
|
||||||
- [CLI Reference](#cli-reference)
|
- [CLI Reference](#cli-reference)
|
||||||
- [Tools](#tools)
|
- [Tools](#tools)
|
||||||
- [Programmatic Usage](#programmatic-usage)
|
- [Programmatic Usage](#programmatic-usage)
|
||||||
|
- [SDK](#sdk)
|
||||||
|
- [RPC Mode](#rpc-mode)
|
||||||
|
- [HTML Export](#html-export)
|
||||||
- [Philosophy](#philosophy)
|
- [Philosophy](#philosophy)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
|
|
@ -649,7 +652,14 @@ export default factory;
|
||||||
|
|
||||||
### Settings File
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -818,9 +828,43 @@ For adding new tools, see [Custom Tools](#custom-tools) in the Configuration sec
|
||||||
|
|
||||||
## Programmatic Usage
|
## 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
|
### RPC Mode
|
||||||
|
|
||||||
For embedding pi in other applications:
|
For embedding pi from other languages or with process isolation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pi --mode rpc --no-session
|
pi --mode rpc --no-session
|
||||||
|
|
@ -832,9 +876,7 @@ Send JSON commands on stdin:
|
||||||
{"type":"abort"}
|
{"type":"abort"}
|
||||||
```
|
```
|
||||||
|
|
||||||
See [RPC documentation](docs/rpc.md) for full protocol.
|
> See [RPC Documentation](docs/rpc.md) for the 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).
|
|
||||||
|
|
||||||
### HTML Export
|
### 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 { 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";
|
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";
|
||||||
|
|
||||||
/** Show TUI session selector and return selected session path or null if cancelled */
|
/** 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) => {
|
return new Promise((resolve) => {
|
||||||
const ui = new TUI(new ProcessTerminal());
|
const ui = new TUI(new ProcessTerminal());
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
const selector = new SessionSelectorComponent(
|
const selector = new SessionSelectorComponent(
|
||||||
sessionManager,
|
sessions,
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
|
|
|
||||||
|
|
@ -202,11 +202,6 @@ export class AgentSession {
|
||||||
if (event.type === "message_end") {
|
if (event.type === "message_end") {
|
||||||
this.sessionManager.saveMessage(event.message);
|
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)
|
// Track assistant message for auto-compaction (checked on agent_end)
|
||||||
if (event.message.role === "assistant") {
|
if (event.message.role === "assistant") {
|
||||||
this._lastAssistantMessage = event.message;
|
this._lastAssistantMessage = event.message;
|
||||||
|
|
@ -389,7 +384,7 @@ export class AgentSession {
|
||||||
|
|
||||||
/** Current session file path, or null if sessions are disabled */
|
/** Current session file path, or null if sessions are disabled */
|
||||||
get sessionFile(): string | null {
|
get sessionFile(): string | null {
|
||||||
return this.sessionManager.isEnabled() ? this.sessionManager.getSessionFile() : null;
|
return this.sessionManager.isPersisted() ? this.sessionManager.getSessionFile() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current session ID */
|
/** Current session ID */
|
||||||
|
|
@ -1096,11 +1091,6 @@ export class AgentSession {
|
||||||
|
|
||||||
// Save to session
|
// Save to session
|
||||||
this.sessionManager.saveMessage(bashMessage);
|
this.sessionManager.saveMessage(bashMessage);
|
||||||
|
|
||||||
// Initialize session if needed
|
|
||||||
if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {
|
|
||||||
this.sessionManager.startSession(this.agent.state);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -1141,11 +1131,6 @@ export class AgentSession {
|
||||||
this.sessionManager.saveMessage(bashMessage);
|
this.sessionManager.saveMessage(bashMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize session if needed
|
|
||||||
if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {
|
|
||||||
this.sessionManager.startSession(this.agent.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._pendingBashMessages = [];
|
this._pendingBashMessages = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,7 @@ function discoverToolsInDir(dir: string): string[] {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover and load tools from standard locations:
|
* 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)
|
* 2. cwd/.pi/tools/*.ts (project-local)
|
||||||
*
|
*
|
||||||
* Plus any explicitly configured paths from settings or CLI.
|
* 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 configuredPaths - Explicit paths from settings.json and CLI --tool flags
|
||||||
* @param cwd - Current working directory
|
* @param cwd - Current working directory
|
||||||
* @param builtInToolNames - Names of built-in tools to check for conflicts
|
* @param builtInToolNames - Names of built-in tools to check for conflicts
|
||||||
|
* @param agentDir - Agent config directory. Default: from getAgentDir()
|
||||||
*/
|
*/
|
||||||
export async function discoverAndLoadCustomTools(
|
export async function discoverAndLoadCustomTools(
|
||||||
configuredPaths: string[],
|
configuredPaths: string[],
|
||||||
cwd: string,
|
cwd: string,
|
||||||
builtInToolNames: string[],
|
builtInToolNames: string[],
|
||||||
|
agentDir: string = getAgentDir(),
|
||||||
): Promise<CustomToolsLoadResult> {
|
): Promise<CustomToolsLoadResult> {
|
||||||
const allPaths: string[] = [];
|
const allPaths: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
@ -331,8 +333,8 @@ export async function discoverAndLoadCustomTools(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Global tools: ~/.pi/agent/tools/
|
// 1. Global tools: agentDir/tools/
|
||||||
const globalToolsDir = path.join(getAgentDir(), "tools");
|
const globalToolsDir = path.join(agentDir, "tools");
|
||||||
addPaths(discoverToolsInDir(globalToolsDir));
|
addPaths(discoverToolsInDir(globalToolsDir));
|
||||||
|
|
||||||
// 2. Project-local tools: cwd/.pi/tools/
|
// 2. Project-local tools: cwd/.pi/tools/
|
||||||
|
|
|
||||||
|
|
@ -217,15 +217,16 @@ function discoverHooksInDir(dir: string): string[] {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover and load hooks from standard locations:
|
* 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)
|
* 2. cwd/.pi/hooks/*.ts (project-local)
|
||||||
*
|
*
|
||||||
* Plus any explicitly configured paths from settings.
|
* 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 allPaths: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -240,8 +241,8 @@ export async function discoverAndLoadHooks(configuredPaths: string[], cwd: strin
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Global hooks: ~/.pi/agent/hooks/
|
// 1. Global hooks: agentDir/hooks/
|
||||||
const globalHooksDir = path.join(getAgentDir(), "hooks");
|
const globalHooksDir = path.join(agentDir, "hooks");
|
||||||
addPaths(discoverHooksInDir(globalHooksDir));
|
addPaths(discoverHooksInDir(globalHooksDir));
|
||||||
|
|
||||||
// 2. Project-local hooks: cwd/.pi/hooks/
|
// 2. Project-local hooks: cwd/.pi/hooks/
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import {
|
||||||
import { type Static, Type } from "@sinclair/typebox";
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
import AjvModule from "ajv";
|
import AjvModule from "ajv";
|
||||||
import { existsSync, readFileSync } from "fs";
|
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";
|
import { getOAuthToken, type OAuthProvider, refreshToken } from "./oauth/index.js";
|
||||||
|
|
||||||
// Handle both default and named exports
|
// 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
|
* Load custom models from models.json in agent config dir
|
||||||
* Returns { models, error } - either models array or error message
|
* Returns { models, error } - either models array or error message
|
||||||
*/
|
*/
|
||||||
function loadCustomModels(): { models: Model<Api>[]; error: string | null } {
|
function loadCustomModels(agentDir: string = getAgentDir()): { models: Model<Api>[]; error: string | null } {
|
||||||
const configPath = getModelsPath();
|
const configPath = join(agentDir, "models.json");
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
return { models: [], error: null };
|
return { models: [], error: null };
|
||||||
}
|
}
|
||||||
|
|
@ -232,7 +233,7 @@ function parseModels(config: ModelsConfig): Model<Api>[] {
|
||||||
* Get all models (built-in + custom), freshly loaded
|
* Get all models (built-in + custom), freshly loaded
|
||||||
* Returns { models, error } - either models array or error message
|
* 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 builtInModels: Model<Api>[] = [];
|
||||||
const providers = getProviders();
|
const providers = getProviders();
|
||||||
|
|
||||||
|
|
@ -243,7 +244,7 @@ export function loadAndMergeModels(): { models: Model<Api>[]; error: string | nu
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load custom models
|
// Load custom models
|
||||||
const { models: customModels, error } = loadCustomModels();
|
const { models: customModels, error } = loadCustomModels(agentDir);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return { models: [], 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)
|
* 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> {
|
export async function getApiKeyForModel(model: Model<Api>): Promise<string | undefined> {
|
||||||
// For custom providers, check their apiKey config
|
// 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
|
* Get only models that have valid API keys available
|
||||||
* Returns { models, error } - either models array or error message
|
* Returns { models, error } - either models array or error message
|
||||||
*/
|
*/
|
||||||
export async function getAvailableModels(): Promise<{ models: Model<Api>[]; error: string | null }> {
|
export async function getAvailableModels(
|
||||||
const { models: allModels, error } = loadAndMergeModels();
|
agentDir: string = getAgentDir(),
|
||||||
|
): Promise<{ models: Model<Api>[]; error: string | null }> {
|
||||||
|
const { models: allModels, error } = loadAndMergeModels(agentDir);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return { models: [], error };
|
return { models: [], error };
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableModels: Model<Api>[] = [];
|
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) {
|
for (const model of allModels) {
|
||||||
if (model.provider === "github-copilot") {
|
|
||||||
if (hasCopilot) {
|
|
||||||
availableModels.push(model);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = await getApiKeyForModel(model);
|
const apiKey = await getApiKeyForModel(model);
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
availableModels.push(model);
|
availableModels.push(model);
|
||||||
|
|
@ -390,8 +383,12 @@ export async function getAvailableModels(): Promise<{ models: Model<Api>[]; erro
|
||||||
* Find a specific model by provider and ID
|
* Find a specific model by provider and ID
|
||||||
* Returns { model, error } - either model or error message
|
* Returns { model, error } - either model or error message
|
||||||
*/
|
*/
|
||||||
export function findModel(provider: string, modelId: string): { model: Model<Api> | null; error: string | null } {
|
export function findModel(
|
||||||
const { models: allModels, error } = loadAndMergeModels();
|
provider: string,
|
||||||
|
modelId: string,
|
||||||
|
agentDir: string = getAgentDir(),
|
||||||
|
): { model: Model<Api> | null; error: string | null } {
|
||||||
|
const { models: allModels, error } = loadAndMergeModels(agentDir);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return { model: null, 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 { randomBytes } from "crypto";
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "fs";
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "fs";
|
||||||
import { join, resolve } from "path";
|
import { join, resolve } from "path";
|
||||||
import { getAgentDir } from "../config.js";
|
import { getAgentDir as getDefaultAgentDir } from "../config.js";
|
||||||
|
|
||||||
function uuidv4(): string {
|
function uuidv4(): string {
|
||||||
const bytes = randomBytes(16);
|
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)}`;
|
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 {
|
export interface SessionHeader {
|
||||||
type: "session";
|
type: "session";
|
||||||
id: string;
|
id: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
provider: string;
|
|
||||||
modelId: string;
|
|
||||||
thinkingLevel: string;
|
|
||||||
branchedFrom?: string;
|
branchedFrom?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,11 +43,10 @@ export interface CompactionEntry {
|
||||||
type: "compaction";
|
type: "compaction";
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
firstKeptEntryIndex: number; // Index into session entries where we start keeping
|
firstKeptEntryIndex: number;
|
||||||
tokensBefore: number;
|
tokensBefore: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Union of all session entry types */
|
|
||||||
export type SessionEntry =
|
export type SessionEntry =
|
||||||
| SessionHeader
|
| SessionHeader
|
||||||
| SessionMessageEntry
|
| SessionMessageEntry
|
||||||
|
|
@ -62,16 +54,22 @@ export type SessionEntry =
|
||||||
| ModelChangeEntry
|
| ModelChangeEntry
|
||||||
| CompactionEntry;
|
| CompactionEntry;
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Session loading with compaction support
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface LoadedSession {
|
export interface LoadedSession {
|
||||||
messages: AppMessage[];
|
messages: AppMessage[];
|
||||||
thinkingLevel: string;
|
thinkingLevel: string;
|
||||||
model: { provider: string; modelId: string } | null;
|
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:
|
export const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
|
||||||
|
|
||||||
<summary>
|
<summary>
|
||||||
|
|
@ -80,9 +78,6 @@ export const SUMMARY_PREFIX = `The conversation history before this point was co
|
||||||
export const SUMMARY_SUFFIX = `
|
export const SUMMARY_SUFFIX = `
|
||||||
</summary>`;
|
</summary>`;
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a user message containing the summary with the standard prefix.
|
|
||||||
*/
|
|
||||||
export function createSummaryMessage(summary: string): AppMessage {
|
export function createSummaryMessage(summary: string): AppMessage {
|
||||||
return {
|
return {
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
@ -91,9 +86,6 @@ export function createSummaryMessage(summary: string): AppMessage {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse session file content into entries.
|
|
||||||
*/
|
|
||||||
export function parseSessionEntries(content: string): SessionEntry[] {
|
export function parseSessionEntries(content: string): SessionEntry[] {
|
||||||
const entries: SessionEntry[] = [];
|
const entries: SessionEntry[] = [];
|
||||||
const lines = content.trim().split("\n");
|
const lines = content.trim().split("\n");
|
||||||
|
|
@ -111,17 +103,6 @@ export function parseSessionEntries(content: string): SessionEntry[] {
|
||||||
return entries;
|
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 {
|
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
if (entries[i].type === "compaction") {
|
if (entries[i].type === "compaction") {
|
||||||
|
|
@ -132,22 +113,19 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
// Find model and thinking level (always scan all entries)
|
|
||||||
let thinkingLevel = "off";
|
let thinkingLevel = "off";
|
||||||
let model: { provider: string; modelId: string } | null = null;
|
let model: { provider: string; modelId: string } | null = null;
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.type === "session") {
|
if (entry.type === "thinking_level_change") {
|
||||||
thinkingLevel = entry.thinkingLevel;
|
|
||||||
model = { provider: entry.provider, modelId: entry.modelId };
|
|
||||||
} else if (entry.type === "thinking_level_change") {
|
|
||||||
thinkingLevel = entry.thinkingLevel;
|
thinkingLevel = entry.thinkingLevel;
|
||||||
} else if (entry.type === "model_change") {
|
} else if (entry.type === "model_change") {
|
||||||
model = { provider: entry.provider, modelId: entry.modelId };
|
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;
|
let latestCompactionIndex = -1;
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
if (entries[i].type === "compaction") {
|
if (entries[i].type === "compaction") {
|
||||||
|
|
@ -156,7 +134,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No compaction: return all messages
|
|
||||||
if (latestCompactionIndex === -1) {
|
if (latestCompactionIndex === -1) {
|
||||||
const messages: AppMessage[] = [];
|
const messages: AppMessage[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|
@ -169,7 +146,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
|
|
||||||
const compactionEvent = entries[latestCompactionIndex] as CompactionEntry;
|
const compactionEvent = entries[latestCompactionIndex] as CompactionEntry;
|
||||||
|
|
||||||
// Extract messages from firstKeptEntryIndex to end (skipping compaction entries)
|
|
||||||
const keptMessages: AppMessage[] = [];
|
const keptMessages: AppMessage[] = [];
|
||||||
for (let i = compactionEvent.firstKeptEntryIndex; i < entries.length; i++) {
|
for (let i = compactionEvent.firstKeptEntryIndex; i < entries.length; i++) {
|
||||||
const entry = entries[i];
|
const entry = entries[i];
|
||||||
|
|
@ -178,7 +154,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build final messages: summary + kept messages
|
|
||||||
const messages: AppMessage[] = [];
|
const messages: AppMessage[] = [];
|
||||||
messages.push(createSummaryMessage(compactionEvent.summary));
|
messages.push(createSummaryMessage(compactionEvent.summary));
|
||||||
messages.push(...keptMessages);
|
messages.push(...keptMessages);
|
||||||
|
|
@ -186,246 +161,103 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
return { messages, thinkingLevel, model };
|
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 {
|
export class SessionManager {
|
||||||
private sessionId!: string;
|
private sessionId: string = "";
|
||||||
private sessionFile!: string;
|
private sessionFile: string = "";
|
||||||
private sessionDir: string;
|
private sessionDir: string;
|
||||||
private enabled: boolean = true;
|
private cwd: string;
|
||||||
private sessionInitialized: boolean = false;
|
private persist: boolean;
|
||||||
private pendingEntries: SessionEntry[] = [];
|
private flushed: boolean = false;
|
||||||
// In-memory entries for --no-session mode (when enabled=false)
|
|
||||||
private inMemoryEntries: SessionEntry[] = [];
|
private inMemoryEntries: SessionEntry[] = [];
|
||||||
|
|
||||||
constructor(continueSession: boolean = false, customSessionPath?: string) {
|
private constructor(cwd: string, agentDir: string, sessionFile: string | null, persist: boolean) {
|
||||||
this.sessionDir = this.getSessionDirectory();
|
this.cwd = cwd;
|
||||||
|
this.sessionDir = getSessionDirectory(cwd, agentDir);
|
||||||
|
this.persist = persist;
|
||||||
|
|
||||||
if (customSessionPath) {
|
if (sessionFile) {
|
||||||
// Use custom session file path
|
this.setSessionFile(sessionFile);
|
||||||
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();
|
|
||||||
}
|
|
||||||
} else {
|
} 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) */
|
/** Switch to a different session file (used for resume and branching) */
|
||||||
disable() {
|
setSessionFile(sessionFile: string): void {
|
||||||
this.enabled = false;
|
this.sessionFile = resolve(sessionFile);
|
||||||
}
|
if (existsSync(this.sessionFile)) {
|
||||||
|
this.inMemoryEntries = loadEntriesFromFile(this.sessionFile);
|
||||||
/** Check if session persistence is enabled */
|
const header = this.inMemoryEntries.find((e) => e.type === "session");
|
||||||
isEnabled(): boolean {
|
this.sessionId = header ? (header as SessionHeader).id : uuidv4();
|
||||||
return this.enabled;
|
this.flushed = true;
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
} else {
|
} 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);
|
this.inMemoryEntries.push(entry);
|
||||||
// Write to file only if enabled
|
|
||||||
if (this.enabled) {
|
|
||||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveThinkingLevelChange(thinkingLevel: string): void {
|
isPersisted(): boolean {
|
||||||
const entry: ThinkingLevelChangeEntry = {
|
return this.persist;
|
||||||
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`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveModelChange(provider: string, modelId: string): void {
|
getCwd(): string {
|
||||||
const entry: ModelChangeEntry = {
|
return this.cwd;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSessionId(): string {
|
getSessionId(): string {
|
||||||
|
|
@ -436,70 +268,168 @@ export class SessionManager {
|
||||||
return this.sessionFile;
|
return this.sessionFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
reset(): void {
|
||||||
* Load entries directly from the session file (internal helper).
|
this.sessionId = uuidv4();
|
||||||
*/
|
this.flushed = false;
|
||||||
private loadEntriesFromFile(): SessionEntry[] {
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
if (!existsSync(this.sessionFile)) return [];
|
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");
|
_persist(entry: SessionEntry): void {
|
||||||
const entries: SessionEntry[] = [];
|
if (!this.persist) return;
|
||||||
const lines = content.trim().split("\n");
|
|
||||||
|
|
||||||
for (const line of lines) {
|
const hasAssistant = this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant");
|
||||||
if (!line.trim()) continue;
|
if (!hasAssistant) return;
|
||||||
try {
|
|
||||||
const entry = JSON.parse(line) as SessionEntry;
|
if (!this.flushed) {
|
||||||
entries.push(entry);
|
for (const e of this.inMemoryEntries) {
|
||||||
} catch {
|
appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
|
||||||
// Skip malformed lines
|
}
|
||||||
|
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;
|
if (this.persist) {
|
||||||
}
|
for (const entry of newEntries) {
|
||||||
|
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
||||||
/**
|
}
|
||||||
* Load all entries from the session file or in-memory store.
|
return newSessionFile;
|
||||||
* 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();
|
|
||||||
}
|
}
|
||||||
|
this.inMemoryEntries = newEntries;
|
||||||
// Otherwise return in-memory entries (for --no-session mode)
|
this.sessionId = newSessionId;
|
||||||
return [...this.inMemoryEntries];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Create a new session for the given directory */
|
||||||
* Load all sessions for the current directory with metadata
|
static create(cwd: string, agentDir: string = getDefaultAgentDir()): SessionManager {
|
||||||
*/
|
return new SessionManager(cwd, agentDir, null, true);
|
||||||
loadAllSessions(): Array<{
|
}
|
||||||
path: string;
|
|
||||||
id: string;
|
/** Open a specific session file */
|
||||||
created: Date;
|
static open(path: string, agentDir: string = getDefaultAgentDir()): SessionManager {
|
||||||
modified: Date;
|
// Extract cwd from session header if possible, otherwise use process.cwd()
|
||||||
messageCount: number;
|
const entries = loadEntriesFromFile(path);
|
||||||
firstMessage: string;
|
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
|
||||||
allMessagesText: string;
|
const cwd = header?.cwd ?? process.cwd();
|
||||||
}> {
|
return new SessionManager(cwd, agentDir, path, true);
|
||||||
const sessions: Array<{
|
}
|
||||||
path: string;
|
|
||||||
id: string;
|
/** Continue the most recent session for the given directory, or create new if none */
|
||||||
created: Date;
|
static continueRecent(cwd: string, agentDir: string = getDefaultAgentDir()): SessionManager {
|
||||||
modified: Date;
|
const sessionDir = getSessionDirectory(cwd, agentDir);
|
||||||
messageCount: number;
|
const mostRecent = findMostRecentSession(sessionDir);
|
||||||
firstMessage: string;
|
if (mostRecent) {
|
||||||
allMessagesText: string;
|
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 {
|
try {
|
||||||
const files = readdirSync(this.sessionDir)
|
const files = readdirSync(sessionDir)
|
||||||
.filter((f) => f.endsWith(".jsonl"))
|
.filter((f) => f.endsWith(".jsonl"))
|
||||||
.map((f) => join(this.sessionDir, f));
|
.map((f) => join(sessionDir, f));
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -517,17 +447,14 @@ export class SessionManager {
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line);
|
const entry = JSON.parse(line);
|
||||||
|
|
||||||
// Extract session ID from first session entry
|
|
||||||
if (entry.type === "session" && !sessionId) {
|
if (entry.type === "session" && !sessionId) {
|
||||||
sessionId = entry.id;
|
sessionId = entry.id;
|
||||||
created = new Date(entry.timestamp);
|
created = new Date(entry.timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count messages and collect all text
|
|
||||||
if (entry.type === "message") {
|
if (entry.type === "message") {
|
||||||
messageCount++;
|
messageCount++;
|
||||||
|
|
||||||
// Extract text from user and assistant messages
|
|
||||||
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
||||||
const textContent = entry.message.content
|
const textContent = entry.message.content
|
||||||
.filter((c: any) => c.type === "text")
|
.filter((c: any) => c.type === "text")
|
||||||
|
|
@ -537,7 +464,6 @@ export class SessionManager {
|
||||||
if (textContent) {
|
if (textContent) {
|
||||||
allMessages.push(textContent);
|
allMessages.push(textContent);
|
||||||
|
|
||||||
// Get first user message for display
|
|
||||||
if (!firstMessage && entry.message.role === "user") {
|
if (!firstMessage && entry.message.role === "user") {
|
||||||
firstMessage = textContent;
|
firstMessage = textContent;
|
||||||
}
|
}
|
||||||
|
|
@ -558,131 +484,16 @@ export class SessionManager {
|
||||||
firstMessage: firstMessage || "(no messages)",
|
firstMessage: firstMessage || "(no messages)",
|
||||||
allMessagesText: allMessages.join(" "),
|
allMessagesText: allMessages.join(" "),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Skip files that can't be read
|
// 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());
|
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Failed to load sessions:", error);
|
// Return empty list on error
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions;
|
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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { getAgentDir } from "../config.js";
|
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
|
||||||
|
|
||||||
export interface CompactionSettings {
|
export interface CompactionSettings {
|
||||||
enabled?: boolean; // default: true
|
enabled?: boolean; // default: true
|
||||||
|
|
@ -49,39 +49,118 @@ export interface Settings {
|
||||||
terminal?: TerminalSettings;
|
terminal?: TerminalSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SettingsManager {
|
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
|
||||||
private settingsPath: string;
|
function deepMergeSettings(base: Settings, overrides: Settings): Settings {
|
||||||
private settings: Settings;
|
const result: Settings = { ...base };
|
||||||
|
|
||||||
constructor(baseDir?: string) {
|
for (const key of Object.keys(overrides) as (keyof Settings)[]) {
|
||||||
const dir = baseDir || getAgentDir();
|
const overrideValue = overrides[key];
|
||||||
this.settingsPath = join(dir, "settings.json");
|
const baseValue = base[key];
|
||||||
this.settings = this.load();
|
|
||||||
|
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 {
|
return result;
|
||||||
if (!existsSync(this.settingsPath)) {
|
}
|
||||||
|
|
||||||
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(this.settingsPath, "utf-8");
|
const content = readFileSync(this.projectSettingsPath, "utf-8");
|
||||||
return JSON.parse(content);
|
return JSON.parse(content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Warning: Could not read settings file: ${error}`);
|
console.error(`Warning: Could not read project settings file: ${error}`);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply additional overrides on top of current settings */
|
||||||
|
applyOverrides(overrides: Partial<Settings>): void {
|
||||||
|
this.settings = deepMergeSettings(this.settings, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
private save(): void {
|
private save(): void {
|
||||||
|
if (!this.persist || !this.settingsPath) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure directory exists
|
|
||||||
const dir = dirname(this.settingsPath);
|
const dir = dirname(this.settingsPath);
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
mkdirSync(dir, { recursive: true });
|
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) {
|
} catch (error) {
|
||||||
console.error(`Warning: Could not save settings file: ${error}`);
|
console.error(`Warning: Could not save settings file: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +171,7 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setLastChangelogVersion(version: string): void {
|
setLastChangelogVersion(version: string): void {
|
||||||
this.settings.lastChangelogVersion = version;
|
this.globalSettings.lastChangelogVersion = version;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,18 +184,18 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setDefaultProvider(provider: string): void {
|
setDefaultProvider(provider: string): void {
|
||||||
this.settings.defaultProvider = provider;
|
this.globalSettings.defaultProvider = provider;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
setDefaultModel(modelId: string): void {
|
setDefaultModel(modelId: string): void {
|
||||||
this.settings.defaultModel = modelId;
|
this.globalSettings.defaultModel = modelId;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
setDefaultModelAndProvider(provider: string, modelId: string): void {
|
setDefaultModelAndProvider(provider: string, modelId: string): void {
|
||||||
this.settings.defaultProvider = provider;
|
this.globalSettings.defaultProvider = provider;
|
||||||
this.settings.defaultModel = modelId;
|
this.globalSettings.defaultModel = modelId;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +204,7 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setQueueMode(mode: "all" | "one-at-a-time"): void {
|
setQueueMode(mode: "all" | "one-at-a-time"): void {
|
||||||
this.settings.queueMode = mode;
|
this.globalSettings.queueMode = mode;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,7 +213,7 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(theme: string): void {
|
setTheme(theme: string): void {
|
||||||
this.settings.theme = theme;
|
this.globalSettings.theme = theme;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +222,7 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void {
|
setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void {
|
||||||
this.settings.defaultThinkingLevel = level;
|
this.globalSettings.defaultThinkingLevel = level;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,10 +231,10 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setCompactionEnabled(enabled: boolean): void {
|
setCompactionEnabled(enabled: boolean): void {
|
||||||
if (!this.settings.compaction) {
|
if (!this.globalSettings.compaction) {
|
||||||
this.settings.compaction = {};
|
this.globalSettings.compaction = {};
|
||||||
}
|
}
|
||||||
this.settings.compaction.enabled = enabled;
|
this.globalSettings.compaction.enabled = enabled;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,10 +259,10 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setRetryEnabled(enabled: boolean): void {
|
setRetryEnabled(enabled: boolean): void {
|
||||||
if (!this.settings.retry) {
|
if (!this.globalSettings.retry) {
|
||||||
this.settings.retry = {};
|
this.globalSettings.retry = {};
|
||||||
}
|
}
|
||||||
this.settings.retry.enabled = enabled;
|
this.globalSettings.retry.enabled = enabled;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,7 +279,7 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setHideThinkingBlock(hide: boolean): void {
|
setHideThinkingBlock(hide: boolean): void {
|
||||||
this.settings.hideThinkingBlock = hide;
|
this.globalSettings.hideThinkingBlock = hide;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,7 +288,7 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setShellPath(path: string | undefined): void {
|
setShellPath(path: string | undefined): void {
|
||||||
this.settings.shellPath = path;
|
this.globalSettings.shellPath = path;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,7 +297,7 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setCollapseChangelog(collapse: boolean): void {
|
setCollapseChangelog(collapse: boolean): void {
|
||||||
this.settings.collapseChangelog = collapse;
|
this.globalSettings.collapseChangelog = collapse;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,7 +306,7 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setHookPaths(paths: string[]): void {
|
setHookPaths(paths: string[]): void {
|
||||||
this.settings.hooks = paths;
|
this.globalSettings.hooks = paths;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,7 +315,7 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setHookTimeout(timeout: number): void {
|
setHookTimeout(timeout: number): void {
|
||||||
this.settings.hookTimeout = timeout;
|
this.globalSettings.hookTimeout = timeout;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,7 +324,7 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setCustomToolPaths(paths: string[]): void {
|
setCustomToolPaths(paths: string[]): void {
|
||||||
this.settings.customTools = paths;
|
this.globalSettings.customTools = paths;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,10 +333,10 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setSkillsEnabled(enabled: boolean): void {
|
setSkillsEnabled(enabled: boolean): void {
|
||||||
if (!this.settings.skills) {
|
if (!this.globalSettings.skills) {
|
||||||
this.settings.skills = {};
|
this.globalSettings.skills = {};
|
||||||
}
|
}
|
||||||
this.settings.skills.enabled = enabled;
|
this.globalSettings.skills.enabled = enabled;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,10 +359,10 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowImages(show: boolean): void {
|
setShowImages(show: boolean): void {
|
||||||
if (!this.settings.terminal) {
|
if (!this.globalSettings.terminal) {
|
||||||
this.settings.terminal = {};
|
this.globalSettings.terminal = {};
|
||||||
}
|
}
|
||||||
this.settings.terminal.showImages = show;
|
this.globalSettings.terminal.showImages = show;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "fs";
|
||||||
import { minimatch } from "minimatch";
|
import { minimatch } from "minimatch";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { basename, dirname, join, resolve } from "path";
|
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";
|
import type { SkillsSettings } from "./settings-manager.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -313,12 +313,21 @@ function escapeXml(str: string): string {
|
||||||
.replace(/'/g, "'");
|
.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.
|
* Load skills from all configured locations.
|
||||||
* Returns skills and any validation warnings.
|
* Returns skills and any validation warnings.
|
||||||
*/
|
*/
|
||||||
export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult {
|
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
|
||||||
const {
|
const {
|
||||||
|
cwd = process.cwd(),
|
||||||
|
agentDir,
|
||||||
enableCodexUser = true,
|
enableCodexUser = true,
|
||||||
enableClaudeUser = true,
|
enableClaudeUser = true,
|
||||||
enableClaudeProject = true,
|
enableClaudeProject = true,
|
||||||
|
|
@ -329,6 +338,9 @@ export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult {
|
||||||
includeSkills = [],
|
includeSkills = [],
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
// Resolve agentDir - if not provided, use default from config
|
||||||
|
const resolvedAgentDir = agentDir ?? getAgentDir();
|
||||||
|
|
||||||
const skillMap = new Map<string, Skill>();
|
const skillMap = new Map<string, Skill>();
|
||||||
const allWarnings: SkillWarning[] = [];
|
const allWarnings: SkillWarning[] = [];
|
||||||
const collisionWarnings: SkillWarning[] = [];
|
const collisionWarnings: SkillWarning[] = [];
|
||||||
|
|
@ -375,13 +387,13 @@ export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult {
|
||||||
addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude"));
|
addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude"));
|
||||||
}
|
}
|
||||||
if (enableClaudeProject) {
|
if (enableClaudeProject) {
|
||||||
addSkills(loadSkillsFromDirInternal(resolve(process.cwd(), ".claude", "skills"), "claude-project", "claude"));
|
addSkills(loadSkillsFromDirInternal(resolve(cwd, ".claude", "skills"), "claude-project", "claude"));
|
||||||
}
|
}
|
||||||
if (enablePiUser) {
|
if (enablePiUser) {
|
||||||
addSkills(loadSkillsFromDirInternal(join(homedir(), CONFIG_DIR_NAME, "agent", "skills"), "user", "recursive"));
|
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", "recursive"));
|
||||||
}
|
}
|
||||||
if (enablePiProject) {
|
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) {
|
for (const customDir of customDirectories) {
|
||||||
addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive"));
|
addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive"));
|
||||||
|
|
|
||||||
|
|
@ -165,20 +165,31 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st
|
||||||
return commands;
|
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:
|
* Load all custom slash commands from:
|
||||||
* 1. Global: ~/{CONFIG_DIR_NAME}/agent/commands/
|
* 1. Global: agentDir/commands/
|
||||||
* 2. Project: ./{CONFIG_DIR_NAME}/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[] = [];
|
const commands: FileSlashCommand[] = [];
|
||||||
|
|
||||||
// 1. Load global commands from ~/{CONFIG_DIR_NAME}/agent/commands/
|
// 1. Load global commands from agentDir/commands/
|
||||||
const globalCommandsDir = getCommandsDir();
|
// 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"));
|
commands.push(...loadCommandsFromDir(globalCommandsDir, "user"));
|
||||||
|
|
||||||
// 2. Load project commands from ./{CONFIG_DIR_NAME}/commands/
|
// 2. Load project commands from cwd/{CONFIG_DIR_NAME}/commands/
|
||||||
const projectCommandsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "commands");
|
const projectCommandsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "commands");
|
||||||
commands.push(...loadCommandsFromDir(projectCommandsDir, "project"));
|
commands.push(...loadCommandsFromDir(projectCommandsDir, "project"));
|
||||||
|
|
||||||
return commands;
|
return commands;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { existsSync, readFileSync } from "fs";
|
||||||
import { join, resolve } from "path";
|
import { join, resolve } from "path";
|
||||||
import { getAgentDir, getDocsPath, getReadmePath } from "../config.js";
|
import { getAgentDir, getDocsPath, getReadmePath } from "../config.js";
|
||||||
import type { SkillsSettings } from "./settings-manager.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";
|
import type { ToolName } from "./tools/index.js";
|
||||||
|
|
||||||
/** Tool descriptions for system prompt */
|
/** Tool descriptions for system prompt */
|
||||||
|
|
@ -58,29 +58,39 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
|
||||||
return null;
|
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:
|
* 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
|
* 2. Parent directories (top-most first) down to cwd
|
||||||
* Each returns {path, content} for separate messages
|
* 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 contextFiles: Array<{ path: string; content: string }> = [];
|
||||||
const seenPaths = new Set<string>();
|
const seenPaths = new Set<string>();
|
||||||
|
|
||||||
// 1. Load global context from ~/{CONFIG_DIR_NAME}/agent/
|
// 1. Load global context from agentDir
|
||||||
const globalContextDir = getAgentDir();
|
const globalContext = loadContextFileFromDir(resolvedAgentDir);
|
||||||
const globalContext = loadContextFileFromDir(globalContextDir);
|
|
||||||
if (globalContext) {
|
if (globalContext) {
|
||||||
contextFiles.push(globalContext);
|
contextFiles.push(globalContext);
|
||||||
seenPaths.add(globalContext.path);
|
seenPaths.add(globalContext.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Walk up from cwd to root, collecting all context files
|
// 2. Walk up from cwd to root, collecting all context files
|
||||||
const cwd = process.cwd();
|
|
||||||
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
|
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
|
||||||
|
|
||||||
let currentDir = cwd;
|
let currentDir = resolvedCwd;
|
||||||
const root = resolve("/");
|
const root = resolve("/");
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -107,15 +117,37 @@ export function loadProjectContextFiles(): Array<{ path: string; content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildSystemPromptOptions {
|
export interface BuildSystemPromptOptions {
|
||||||
|
/** Custom system prompt (replaces default). */
|
||||||
customPrompt?: string;
|
customPrompt?: string;
|
||||||
|
/** Tools to include in prompt. Default: [read, bash, edit, write] */
|
||||||
selectedTools?: ToolName[];
|
selectedTools?: ToolName[];
|
||||||
|
/** Text to append to system prompt. */
|
||||||
appendSystemPrompt?: string;
|
appendSystemPrompt?: string;
|
||||||
|
/** Skills settings for discovery. */
|
||||||
skillsSettings?: SkillsSettings;
|
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 */
|
/** Build the system prompt with tools, guidelines, and context */
|
||||||
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
|
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 resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
|
||||||
const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append 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}` : "";
|
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) {
|
if (resolvedCustomPrompt) {
|
||||||
let prompt = resolvedCustomPrompt;
|
let prompt = resolvedCustomPrompt;
|
||||||
|
|
||||||
|
|
@ -141,7 +181,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append project context files
|
// Append project context files
|
||||||
const contextFiles = loadProjectContextFiles();
|
|
||||||
if (contextFiles.length > 0) {
|
if (contextFiles.length > 0) {
|
||||||
prompt += "\n\n# Project Context\n\n";
|
prompt += "\n\n# Project Context\n\n";
|
||||||
prompt += "The following project context files have been loaded:\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)
|
// Append skills section (only if read tool is available)
|
||||||
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
|
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
|
||||||
if (skillsSettings?.enabled !== false && customPromptHasRead) {
|
if (customPromptHasRead && skills.length > 0) {
|
||||||
const { skills } = loadSkills(skillsSettings ?? {});
|
|
||||||
prompt += formatSkillsForPrompt(skills);
|
prompt += formatSkillsForPrompt(skills);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add date/time and working directory last
|
// Add date/time and working directory last
|
||||||
prompt += `\nCurrent date and time: ${dateTime}`;
|
prompt += `\nCurrent date and time: ${dateTime}`;
|
||||||
prompt += `\nCurrent working directory: ${process.cwd()}`;
|
prompt += `\nCurrent working directory: ${resolvedCwd}`;
|
||||||
|
|
||||||
return prompt;
|
return prompt;
|
||||||
}
|
}
|
||||||
|
|
@ -248,7 +286,6 @@ Documentation:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append project context files
|
// Append project context files
|
||||||
const contextFiles = loadProjectContextFiles();
|
|
||||||
if (contextFiles.length > 0) {
|
if (contextFiles.length > 0) {
|
||||||
prompt += "\n\n# Project Context\n\n";
|
prompt += "\n\n# Project Context\n\n";
|
||||||
prompt += "The following project context files have been loaded:\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)
|
// Append skills section (only if read tool is available)
|
||||||
if (skillsSettings?.enabled !== false && hasRead) {
|
if (hasRead && skills.length > 0) {
|
||||||
const { skills } = loadSkills(skillsSettings ?? {});
|
|
||||||
prompt += formatSkillsForPrompt(skills);
|
prompt += formatSkillsForPrompt(skills);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add date/time and working directory last
|
// Add date/time and working directory last
|
||||||
prompt += `\nCurrent date and time: ${dateTime}`;
|
prompt += `\nCurrent date and time: ${dateTime}`;
|
||||||
prompt += `\nCurrent working directory: ${process.cwd()}`;
|
prompt += `\nCurrent working directory: ${resolvedCwd}`;
|
||||||
|
|
||||||
return prompt;
|
return prompt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||||
|
|
||||||
export { type BashToolDetails, bashTool } from "./bash.js";
|
export { type BashToolDetails, bashTool } from "./bash.js";
|
||||||
export { editTool } from "./edit.js";
|
export { editTool } from "./edit.js";
|
||||||
export { type FindToolDetails, findTool } from "./find.js";
|
export { type FindToolDetails, findTool } from "./find.js";
|
||||||
|
|
@ -15,8 +17,14 @@ import { lsTool } from "./ls.js";
|
||||||
import { readTool } from "./read.js";
|
import { readTool } from "./read.js";
|
||||||
import { writeTool } from "./write.js";
|
import { writeTool } from "./write.js";
|
||||||
|
|
||||||
|
/** Tool type (AgentTool from pi-ai) */
|
||||||
|
export type Tool = AgentTool<any>;
|
||||||
|
|
||||||
// Default tools for full access mode
|
// 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)
|
// All available tools (including read-only exploration tools)
|
||||||
export const allTools = {
|
export const allTools = {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,32 @@ export {
|
||||||
type OAuthPrompt,
|
type OAuthPrompt,
|
||||||
type OAuthProvider,
|
type OAuthProvider,
|
||||||
} from "./core/oauth/index.js";
|
} 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 {
|
export {
|
||||||
type CompactionEntry,
|
type CompactionEntry,
|
||||||
createSummaryMessage,
|
createSummaryMessage,
|
||||||
|
|
@ -93,6 +119,7 @@ export {
|
||||||
parseSessionEntries,
|
parseSessionEntries,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
type SessionHeader,
|
type SessionHeader,
|
||||||
|
type SessionInfo,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type SessionMessageEntry,
|
type SessionMessageEntry,
|
||||||
SUMMARY_PREFIX,
|
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 type { Attachment } from "@mariozechner/pi-agent-core";
|
||||||
import { setOAuthStorage, supportsXhigh } from "@mariozechner/pi-ai";
|
import { supportsXhigh } from "@mariozechner/pi-ai";
|
||||||
import chalk from "chalk";
|
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 { type Args, parseArgs, printHelp } from "./cli/args.js";
|
||||||
import { processFileArguments } from "./cli/file-processor.js";
|
import { processFileArguments } from "./cli/file-processor.js";
|
||||||
import { listModels } from "./cli/list-models.js";
|
import { listModels } from "./cli/list-models.js";
|
||||||
import { selectSession } from "./cli/session-picker.js";
|
import { selectSession } from "./cli/session-picker.js";
|
||||||
import { getModelsPath, getOAuthPath, VERSION } from "./config.js";
|
import { getModelsPath, VERSION } from "./config.js";
|
||||||
import { AgentSession } from "./core/agent-session.js";
|
import type { AgentSession } from "./core/agent-session.js";
|
||||||
import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./core/custom-tools/index.js";
|
import type { LoadedCustomTool } from "./core/custom-tools/index.js";
|
||||||
import { exportFromFile } from "./core/export-html.js";
|
import { exportFromFile } from "./core/export-html.js";
|
||||||
import { discoverAndLoadHooks, HookRunner, wrapToolsWithHooks } from "./core/hooks/index.js";
|
import type { HookUIContext } from "./core/index.js";
|
||||||
import { messageTransformer } from "./core/messages.js";
|
import { findModel } from "./core/model-config.js";
|
||||||
import { findModel, getApiKeyForModel, getAvailableModels } from "./core/model-config.js";
|
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
|
||||||
import { resolveModelScope, restoreModelFromSession, type ScopedModel } from "./core/model-resolver.js";
|
import { type CreateAgentSessionOptions, configureOAuthStorage, createAgentSession } from "./core/sdk.js";
|
||||||
import { SessionManager } from "./core/session-manager.js";
|
import { SessionManager } from "./core/session-manager.js";
|
||||||
import { SettingsManager } from "./core/settings-manager.js";
|
import { SettingsManager } from "./core/settings-manager.js";
|
||||||
import { loadSlashCommands } from "./core/slash-commands.js";
|
import { allTools } from "./core/tools/index.js";
|
||||||
import { buildSystemPrompt } from "./core/system-prompt.js";
|
|
||||||
import { allTools, codingTools } from "./core/tools/index.js";
|
|
||||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
|
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
|
||||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
|
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
|
||||||
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
|
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
|
||||||
import { ensureTool } from "./utils/tools-manager.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> {
|
async function checkForNewVersion(currentVersion: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest");
|
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;
|
return null;
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail - don't disrupt the user experience
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run interactive mode with TUI */
|
|
||||||
async function runInteractiveMode(
|
async function runInteractiveMode(
|
||||||
session: AgentSession,
|
session: AgentSession,
|
||||||
version: string,
|
version: string,
|
||||||
changelogMarkdown: string | null,
|
changelogMarkdown: string | null,
|
||||||
modelFallbackMessage: string | null,
|
modelFallbackMessage: string | undefined,
|
||||||
versionCheckPromise: Promise<string | null>,
|
versionCheckPromise: Promise<string | null>,
|
||||||
initialMessages: string[],
|
initialMessages: string[],
|
||||||
customTools: LoadedCustomTool[],
|
customTools: LoadedCustomTool[],
|
||||||
setToolUIContext: (uiContext: import("./core/hooks/types.js").HookUIContext, hasUI: boolean) => void,
|
setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void,
|
||||||
initialMessage?: string,
|
initialMessage?: string,
|
||||||
initialAttachments?: Attachment[],
|
initialAttachments?: Attachment[],
|
||||||
fdPath: string | null = null,
|
fdPath: string | null = null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
|
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
|
||||||
|
|
||||||
// Initialize TUI (subscribes to agent events internally)
|
|
||||||
await mode.init();
|
await mode.init();
|
||||||
|
|
||||||
// Handle version check result when it completes (don't block)
|
|
||||||
versionCheckPromise.then((newVersion) => {
|
versionCheckPromise.then((newVersion) => {
|
||||||
if (newVersion) {
|
if (newVersion) {
|
||||||
mode.showNewVersionNotification(newVersion);
|
mode.showNewVersionNotification(newVersion);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render any existing messages (from --continue mode)
|
|
||||||
mode.renderInitialMessages(session.state);
|
mode.renderInitialMessages(session.state);
|
||||||
|
|
||||||
// Show model fallback warning at the end of the chat if applicable
|
|
||||||
if (modelFallbackMessage) {
|
if (modelFallbackMessage) {
|
||||||
mode.showWarning(modelFallbackMessage);
|
mode.showWarning(modelFallbackMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process initial message with attachments if provided (from @file args)
|
|
||||||
if (initialMessage) {
|
if (initialMessage) {
|
||||||
try {
|
try {
|
||||||
await session.prompt(initialMessage, { attachments: initialAttachments });
|
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) {
|
for (const message of initialMessages) {
|
||||||
try {
|
try {
|
||||||
await session.prompt(message);
|
await session.prompt(message);
|
||||||
|
|
@ -129,11 +94,8 @@ async function runInteractiveMode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interactive loop
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const userInput = await mode.getUserInput();
|
const userInput = await mode.getUserInput();
|
||||||
|
|
||||||
// Process the message
|
|
||||||
try {
|
try {
|
||||||
await session.prompt(userInput);
|
await session.prompt(userInput);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|
@ -143,7 +105,6 @@ async function runInteractiveMode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prepare initial message from @file arguments */
|
|
||||||
async function prepareInitialMessage(parsed: Args): Promise<{
|
async function prepareInitialMessage(parsed: Args): Promise<{
|
||||||
initialMessage?: string;
|
initialMessage?: string;
|
||||||
initialAttachments?: Attachment[];
|
initialAttachments?: Attachment[];
|
||||||
|
|
@ -154,11 +115,10 @@ async function prepareInitialMessage(parsed: Args): Promise<{
|
||||||
|
|
||||||
const { textContent, imageAttachments } = await processFileArguments(parsed.fileArgs);
|
const { textContent, imageAttachments } = await processFileArguments(parsed.fileArgs);
|
||||||
|
|
||||||
// Combine file content with first plain text message (if any)
|
|
||||||
let initialMessage: string;
|
let initialMessage: string;
|
||||||
if (parsed.messages.length > 0) {
|
if (parsed.messages.length > 0) {
|
||||||
initialMessage = textContent + parsed.messages[0];
|
initialMessage = textContent + parsed.messages[0];
|
||||||
parsed.messages.shift(); // Remove first message as it's been combined
|
parsed.messages.shift();
|
||||||
} else {
|
} else {
|
||||||
initialMessage = textContent;
|
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[]) {
|
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();
|
configureOAuthStorage();
|
||||||
|
|
||||||
const parsed = parseArgs(args);
|
const parsed = parseArgs(args);
|
||||||
|
|
@ -186,14 +260,12 @@ export async function main(args: string[]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle --list-models flag: list available models and exit
|
|
||||||
if (parsed.listModels !== undefined) {
|
if (parsed.listModels !== undefined) {
|
||||||
const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined;
|
const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined;
|
||||||
await listModels(searchPattern);
|
await listModels(searchPattern);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle --export flag: convert session file to HTML and exit
|
|
||||||
if (parsed.export) {
|
if (parsed.export) {
|
||||||
try {
|
try {
|
||||||
const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;
|
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) {
|
if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {
|
||||||
console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));
|
console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process @file arguments
|
const cwd = process.cwd();
|
||||||
const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed);
|
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 isInteractive = !parsed.print && parsed.mode === undefined;
|
||||||
|
const mode = parsed.mode || "text";
|
||||||
|
|
||||||
// Initialize theme (before any TUI rendering)
|
const settingsManager = SettingsManager.create(cwd);
|
||||||
const settingsManager = new SettingsManager();
|
initTheme(settingsManager.getTheme(), isInteractive);
|
||||||
const themeName = settingsManager.getTheme();
|
|
||||||
initTheme(themeName, 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[] = [];
|
let scopedModels: ScopedModel[] = [];
|
||||||
if (parsed.models && parsed.models.length > 0) {
|
if (parsed.models && parsed.models.length > 0) {
|
||||||
scopedModels = await resolveModelScope(parsed.models);
|
scopedModels = await resolveModelScope(parsed.models);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine mode and output behavior
|
// Create session manager based on CLI flags
|
||||||
const mode = parsed.mode || "text";
|
let sessionManager = createSessionManager(parsed, cwd);
|
||||||
const shouldPrintMessages = isInteractive;
|
|
||||||
|
|
||||||
// Find initial model
|
// Handle --resume: show session picker
|
||||||
let initialModel = await findInitialModelForSession(parsed, scopedModels, settingsManager);
|
if (parsed.resume) {
|
||||||
let initialThinking: ThinkingLevel = "off";
|
const sessions = SessionManager.list(cwd);
|
||||||
|
if (sessions.length === 0) {
|
||||||
// Get thinking level from scoped models if applicable
|
console.log(chalk.dim("No sessions found"));
|
||||||
if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
|
return;
|
||||||
initialThinking = scopedModels[0].thinkingLevel;
|
|
||||||
} else {
|
|
||||||
// Try saved thinking level
|
|
||||||
const savedThinking = settingsManager.getDefaultThinkingLevel();
|
|
||||||
if (savedThinking) {
|
|
||||||
initialThinking = savedThinking;
|
|
||||||
}
|
}
|
||||||
|
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
|
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager);
|
||||||
if (!isInteractive && !initialModel) {
|
const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
|
||||||
|
|
||||||
|
if (!isInteractive && !session.model) {
|
||||||
console.error(chalk.red("No models available."));
|
console.error(chalk.red("No models available."));
|
||||||
console.error(chalk.yellow("\nSet an API key environment variable:"));
|
console.error(chalk.yellow("\nSet an API key environment variable:"));
|
||||||
console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-interactive mode: validate API key exists
|
// Clamp thinking level to model capabilities (for CLI override case)
|
||||||
if (!isInteractive && initialModel) {
|
if (session.model && parsed.thinking) {
|
||||||
const apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel));
|
let effectiveThinking = parsed.thinking;
|
||||||
if (!apiKey) {
|
if (!session.model.reasoning) {
|
||||||
console.error(chalk.red(`No API key found for ${initialModel.provider}`));
|
effectiveThinking = "off";
|
||||||
process.exit(1);
|
} 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") {
|
if (mode === "rpc") {
|
||||||
await runRpcMode(session);
|
await runRpcMode(session);
|
||||||
} else if (isInteractive) {
|
} else if (isInteractive) {
|
||||||
// Check for new version in the background
|
|
||||||
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);
|
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);
|
||||||
|
|
||||||
// Check if we should show changelog
|
|
||||||
const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
|
const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
|
||||||
|
|
||||||
// Show model scope if provided
|
|
||||||
if (scopedModels.length > 0) {
|
if (scopedModels.length > 0) {
|
||||||
const modelList = scopedModels
|
const modelList = scopedModels
|
||||||
.map((sm) => {
|
.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)")}`));
|
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");
|
const fdPath = await ensureTool("fd");
|
||||||
|
|
||||||
await runInteractiveMode(
|
await runInteractiveMode(
|
||||||
|
|
@ -480,99 +364,18 @@ export async function main(args: string[]) {
|
||||||
modelFallbackMessage,
|
modelFallbackMessage,
|
||||||
versionCheckPromise,
|
versionCheckPromise,
|
||||||
parsed.messages,
|
parsed.messages,
|
||||||
loadedCustomTools,
|
customToolsResult.tools,
|
||||||
setToolUIContext,
|
customToolsResult.setUIContext,
|
||||||
initialMessage,
|
initialMessage,
|
||||||
initialAttachments,
|
initialAttachments,
|
||||||
fdPath,
|
fdPath,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Non-interactive mode (--print flag or --mode flag)
|
|
||||||
await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments);
|
await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments);
|
||||||
// Clean up and exit (file watchers keep process alive)
|
|
||||||
stopThemeWatcher();
|
stopThemeWatcher();
|
||||||
// Wait for stdout to fully flush before exiting
|
|
||||||
if (process.stdout.writableLength > 0) {
|
if (process.stdout.writableLength > 0) {
|
||||||
await new Promise<void>((resolve) => process.stdout.once("drain", resolve));
|
await new Promise<void>((resolve) => process.stdout.once("drain", resolve));
|
||||||
}
|
}
|
||||||
process.exit(0);
|
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,
|
Text,
|
||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
} from "@mariozechner/pi-tui";
|
} 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 { fuzzyFilter } from "../../../utils/fuzzy.js";
|
||||||
import { theme } from "../theme/theme.js";
|
import { theme } from "../theme/theme.js";
|
||||||
import { DynamicBorder } from "./dynamic-border.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
|
* Custom session list component with multi-line items and search
|
||||||
*/
|
*/
|
||||||
class SessionList implements Component {
|
class SessionList implements Component {
|
||||||
private allSessions: SessionItem[] = [];
|
private allSessions: SessionInfo[] = [];
|
||||||
private filteredSessions: SessionItem[] = [];
|
private filteredSessions: SessionInfo[] = [];
|
||||||
private selectedIndex: number = 0;
|
private selectedIndex: number = 0;
|
||||||
private searchInput: Input;
|
private searchInput: Input;
|
||||||
public onSelect?: (sessionPath: string) => void;
|
public onSelect?: (sessionPath: string) => void;
|
||||||
|
|
@ -39,7 +29,7 @@ class SessionList implements Component {
|
||||||
public onExit: () => void = () => {};
|
public onExit: () => void = () => {};
|
||||||
private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
|
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.allSessions = sessions;
|
||||||
this.filteredSessions = sessions;
|
this.filteredSessions = sessions;
|
||||||
this.searchInput = new Input();
|
this.searchInput = new Input();
|
||||||
|
|
@ -176,16 +166,13 @@ export class SessionSelectorComponent extends Container {
|
||||||
private sessionList: SessionList;
|
private sessionList: SessionList;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
sessionManager: SessionManager,
|
sessions: SessionInfo[],
|
||||||
onSelect: (sessionPath: string) => void,
|
onSelect: (sessionPath: string) => void,
|
||||||
onCancel: () => void,
|
onCancel: () => void,
|
||||||
onExit: () => void,
|
onExit: () => void,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
// Load all sessions
|
|
||||||
const sessions = sessionManager.loadAllSessions();
|
|
||||||
|
|
||||||
// Add header
|
// Add header
|
||||||
this.addChild(new Spacer(1));
|
this.addChild(new Spacer(1));
|
||||||
this.addChild(new Text(theme.bold("Resume Session"), 1, 0));
|
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 { isBashExecutionMessage } from "../../core/messages.js";
|
||||||
import { invalidateOAuthCache } from "../../core/model-config.js";
|
import { invalidateOAuthCache } from "../../core/model-config.js";
|
||||||
import { listOAuthProviders, login, logout, type OAuthProvider } from "../../core/oauth/index.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 { loadSkills } from "../../core/skills.js";
|
||||||
import { loadProjectContextFiles } from "../../core/system-prompt.js";
|
import { loadProjectContextFiles } from "../../core/system-prompt.js";
|
||||||
import type { TruncationResult } from "../../core/tools/truncate.js";
|
import type { TruncationResult } from "../../core/tools/truncate.js";
|
||||||
|
|
@ -1513,8 +1518,9 @@ export class InteractiveMode {
|
||||||
|
|
||||||
private showSessionSelector(): void {
|
private showSessionSelector(): void {
|
||||||
this.showSelector((done) => {
|
this.showSelector((done) => {
|
||||||
|
const sessions = SessionManager.list(this.sessionManager.getCwd());
|
||||||
const selector = new SessionSelectorComponent(
|
const selector = new SessionSelectorComponent(
|
||||||
this.sessionManager,
|
sessions,
|
||||||
async (sessionPath) => {
|
async (sessionPath) => {
|
||||||
done();
|
done();
|
||||||
await this.handleResumeSession(sessionPath);
|
await this.handleResumeSession(sessionPath);
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export function getShellConfig(): { shell: string; args: string[] } {
|
||||||
return cachedShellConfig;
|
return cachedShellConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = new SettingsManager();
|
const settings = SettingsManager.create();
|
||||||
const customShellPath = settings.getShellPath();
|
const customShellPath = settings.getShellPath();
|
||||||
|
|
||||||
// 1. Check user-specified shell path
|
// 1. Check user-specified shell path
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,8 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
sessionManager = new SessionManager(false);
|
sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir);
|
||||||
if (noSession) {
|
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||||
sessionManager.disable();
|
|
||||||
}
|
|
||||||
const settingsManager = new SettingsManager(tempDir);
|
|
||||||
|
|
||||||
session = new AgentSession({
|
session = new AgentSession({
|
||||||
agent,
|
agent,
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
sessionManager = new SessionManager(false);
|
sessionManager = SessionManager.create(tempDir);
|
||||||
const settingsManager = new SettingsManager(tempDir);
|
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||||
|
|
||||||
session = new AgentSession({
|
session = new AgentSession({
|
||||||
agent,
|
agent,
|
||||||
|
|
@ -174,11 +174,10 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create session manager and disable file persistence
|
// Create in-memory session manager
|
||||||
const noSessionManager = new SessionManager(false);
|
const noSessionManager = SessionManager.inMemory();
|
||||||
noSessionManager.disable();
|
|
||||||
|
|
||||||
const settingsManager = new SettingsManager(tempDir);
|
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||||
|
|
||||||
const noSessionSession = new AgentSession({
|
const noSessionSession = new AgentSession({
|
||||||
agent,
|
agent,
|
||||||
|
|
|
||||||
|
|
@ -234,9 +234,6 @@ describe("loadSessionFromEntries", () => {
|
||||||
id: "1",
|
id: "1",
|
||||||
timestamp: "",
|
timestamp: "",
|
||||||
cwd: "",
|
cwd: "",
|
||||||
provider: "anthropic",
|
|
||||||
modelId: "claude",
|
|
||||||
thinkingLevel: "off",
|
|
||||||
},
|
},
|
||||||
createMessageEntry(createUserMessage("1")),
|
createMessageEntry(createUserMessage("1")),
|
||||||
createMessageEntry(createAssistantMessage("a")),
|
createMessageEntry(createAssistantMessage("a")),
|
||||||
|
|
@ -247,7 +244,7 @@ describe("loadSessionFromEntries", () => {
|
||||||
const loaded = loadSessionFromEntries(entries);
|
const loaded = loadSessionFromEntries(entries);
|
||||||
expect(loaded.messages.length).toBe(4);
|
expect(loaded.messages.length).toBe(4);
|
||||||
expect(loaded.thinkingLevel).toBe("off");
|
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", () => {
|
it("should handle single compaction", () => {
|
||||||
|
|
@ -258,9 +255,6 @@ describe("loadSessionFromEntries", () => {
|
||||||
id: "1",
|
id: "1",
|
||||||
timestamp: "",
|
timestamp: "",
|
||||||
cwd: "",
|
cwd: "",
|
||||||
provider: "anthropic",
|
|
||||||
modelId: "claude",
|
|
||||||
thinkingLevel: "off",
|
|
||||||
},
|
},
|
||||||
createMessageEntry(createUserMessage("1")),
|
createMessageEntry(createUserMessage("1")),
|
||||||
createMessageEntry(createAssistantMessage("a")),
|
createMessageEntry(createAssistantMessage("a")),
|
||||||
|
|
@ -286,9 +280,6 @@ describe("loadSessionFromEntries", () => {
|
||||||
id: "1",
|
id: "1",
|
||||||
timestamp: "",
|
timestamp: "",
|
||||||
cwd: "",
|
cwd: "",
|
||||||
provider: "anthropic",
|
|
||||||
modelId: "claude",
|
|
||||||
thinkingLevel: "off",
|
|
||||||
},
|
},
|
||||||
createMessageEntry(createUserMessage("1")),
|
createMessageEntry(createUserMessage("1")),
|
||||||
createMessageEntry(createAssistantMessage("a")),
|
createMessageEntry(createAssistantMessage("a")),
|
||||||
|
|
@ -316,9 +307,6 @@ describe("loadSessionFromEntries", () => {
|
||||||
id: "1",
|
id: "1",
|
||||||
timestamp: "",
|
timestamp: "",
|
||||||
cwd: "",
|
cwd: "",
|
||||||
provider: "anthropic",
|
|
||||||
modelId: "claude",
|
|
||||||
thinkingLevel: "off",
|
|
||||||
},
|
},
|
||||||
createMessageEntry(createUserMessage("1")),
|
createMessageEntry(createUserMessage("1")),
|
||||||
createMessageEntry(createAssistantMessage("a")),
|
createMessageEntry(createAssistantMessage("a")),
|
||||||
|
|
@ -341,9 +329,6 @@ describe("loadSessionFromEntries", () => {
|
||||||
id: "1",
|
id: "1",
|
||||||
timestamp: "",
|
timestamp: "",
|
||||||
cwd: "",
|
cwd: "",
|
||||||
provider: "anthropic",
|
|
||||||
modelId: "claude",
|
|
||||||
thinkingLevel: "off",
|
|
||||||
},
|
},
|
||||||
createMessageEntry(createUserMessage("1")),
|
createMessageEntry(createUserMessage("1")),
|
||||||
{ type: "model_change", timestamp: "", provider: "openai", modelId: "gpt-4" },
|
{ type: "model_change", timestamp: "", provider: "openai", modelId: "gpt-4" },
|
||||||
|
|
@ -352,7 +337,8 @@ describe("loadSessionFromEntries", () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
const loaded = loadSessionFromEntries(entries);
|
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");
|
expect(loaded.thinkingLevel).toBe("high");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -411,12 +411,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
|
||||||
const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, [], [], skills);
|
const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, [], [], skills);
|
||||||
|
|
||||||
// Create session manager and settings manager
|
// Create session manager and settings manager
|
||||||
// Pass model info so new sessions get a header written immediately
|
const sessionManager = new MomSessionManager(channelDir);
|
||||||
const sessionManager = new MomSessionManager(channelDir, {
|
|
||||||
provider: model.provider,
|
|
||||||
id: model.id,
|
|
||||||
thinkingLevel: "off",
|
|
||||||
});
|
|
||||||
const settingsManager = new MomSettingsManager(join(channelDir, ".."));
|
const settingsManager = new MomSettingsManager(join(channelDir, ".."));
|
||||||
|
|
||||||
// Create agent
|
// Create agent
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,13 @@
|
||||||
* - MomSettingsManager: Simple settings for mom (compaction, retry, model preferences)
|
* - 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 {
|
import {
|
||||||
type CompactionEntry,
|
type CompactionEntry,
|
||||||
type LoadedSession,
|
type LoadedSession,
|
||||||
loadSessionFromEntries,
|
loadSessionFromEntries,
|
||||||
type ModelChangeEntry,
|
type ModelChangeEntry,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
type SessionHeader,
|
|
||||||
type SessionMessageEntry,
|
type SessionMessageEntry,
|
||||||
type ThinkingLevelChangeEntry,
|
type ThinkingLevelChangeEntry,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
@ -48,11 +47,10 @@ export class MomSessionManager {
|
||||||
private contextFile: string;
|
private contextFile: string;
|
||||||
private logFile: string;
|
private logFile: string;
|
||||||
private channelDir: string;
|
private channelDir: string;
|
||||||
private sessionInitialized: boolean = false;
|
private flushed: boolean = false;
|
||||||
private inMemoryEntries: SessionEntry[] = [];
|
private inMemoryEntries: SessionEntry[] = [];
|
||||||
private pendingEntries: SessionEntry[] = [];
|
|
||||||
|
|
||||||
constructor(channelDir: string, initialModel?: { provider: string; id: string; thinkingLevel?: string }) {
|
constructor(channelDir: string) {
|
||||||
this.channelDir = channelDir;
|
this.channelDir = channelDir;
|
||||||
this.contextFile = join(channelDir, "context.jsonl");
|
this.contextFile = join(channelDir, "context.jsonl");
|
||||||
this.logFile = join(channelDir, "log.jsonl");
|
this.logFile = join(channelDir, "log.jsonl");
|
||||||
|
|
@ -66,33 +64,33 @@ export class MomSessionManager {
|
||||||
if (existsSync(this.contextFile)) {
|
if (existsSync(this.contextFile)) {
|
||||||
this.inMemoryEntries = this.loadEntriesFromFile();
|
this.inMemoryEntries = this.loadEntriesFromFile();
|
||||||
this.sessionId = this.extractSessionId() || uuidv4();
|
this.sessionId = this.extractSessionId() || uuidv4();
|
||||||
this.sessionInitialized = this.inMemoryEntries.length > 0;
|
this.flushed = true;
|
||||||
} else {
|
} else {
|
||||||
// New session - write header immediately
|
|
||||||
this.sessionId = uuidv4();
|
this.sessionId = uuidv4();
|
||||||
if (initialModel) {
|
this.inMemoryEntries = [
|
||||||
this.writeSessionHeader(initialModel);
|
{
|
||||||
}
|
type: "session",
|
||||||
|
id: this.sessionId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
cwd: this.channelDir,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
// Note: syncFromLog() is called explicitly from agent.ts with excludeTimestamp
|
// Note: syncFromLog() is called explicitly from agent.ts with excludeTimestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write session header to file (called on new session creation) */
|
private _persist(entry: SessionEntry): void {
|
||||||
private writeSessionHeader(model: { provider: string; id: string; thinkingLevel?: string }): void {
|
const hasAssistant = this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant");
|
||||||
this.sessionInitialized = true;
|
if (!hasAssistant) return;
|
||||||
|
|
||||||
const entry: SessionHeader = {
|
if (!this.flushed) {
|
||||||
type: "session",
|
for (const e of this.inMemoryEntries) {
|
||||||
id: this.sessionId,
|
appendFileSync(this.contextFile, `${JSON.stringify(e)}\n`);
|
||||||
timestamp: new Date().toISOString(),
|
}
|
||||||
cwd: this.channelDir,
|
this.flushed = true;
|
||||||
provider: model.provider,
|
} else {
|
||||||
modelId: model.id,
|
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
||||||
thinkingLevel: model.thinkingLevel || "off",
|
}
|
||||||
};
|
|
||||||
|
|
||||||
this.inMemoryEntries.push(entry);
|
|
||||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -248,47 +246,14 @@ export class MomSessionManager {
|
||||||
return entries;
|
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 {
|
saveMessage(message: AppMessage): void {
|
||||||
const entry: SessionMessageEntry = {
|
const entry: SessionMessageEntry = {
|
||||||
type: "message",
|
type: "message",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
message,
|
message,
|
||||||
};
|
};
|
||||||
|
this.inMemoryEntries.push(entry);
|
||||||
if (!this.sessionInitialized) {
|
this._persist(entry);
|
||||||
this.pendingEntries.push(entry);
|
|
||||||
} else {
|
|
||||||
this.inMemoryEntries.push(entry);
|
|
||||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveThinkingLevelChange(thinkingLevel: string): void {
|
saveThinkingLevelChange(thinkingLevel: string): void {
|
||||||
|
|
@ -297,13 +262,8 @@ export class MomSessionManager {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
};
|
};
|
||||||
|
this.inMemoryEntries.push(entry);
|
||||||
if (!this.sessionInitialized) {
|
this._persist(entry);
|
||||||
this.pendingEntries.push(entry);
|
|
||||||
} else {
|
|
||||||
this.inMemoryEntries.push(entry);
|
|
||||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveModelChange(provider: string, modelId: string): void {
|
saveModelChange(provider: string, modelId: string): void {
|
||||||
|
|
@ -313,18 +273,13 @@ export class MomSessionManager {
|
||||||
provider,
|
provider,
|
||||||
modelId,
|
modelId,
|
||||||
};
|
};
|
||||||
|
this.inMemoryEntries.push(entry);
|
||||||
if (!this.sessionInitialized) {
|
this._persist(entry);
|
||||||
this.pendingEntries.push(entry);
|
|
||||||
} else {
|
|
||||||
this.inMemoryEntries.push(entry);
|
|
||||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCompaction(entry: CompactionEntry): void {
|
saveCompaction(entry: CompactionEntry): void {
|
||||||
this.inMemoryEntries.push(entry);
|
this.inMemoryEntries.push(entry);
|
||||||
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
this._persist(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load session with compaction support */
|
/** Load session with compaction support */
|
||||||
|
|
@ -349,20 +304,18 @@ export class MomSessionManager {
|
||||||
return this.contextFile;
|
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 session (clears context.jsonl) */
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.pendingEntries = [];
|
|
||||||
this.inMemoryEntries = [];
|
|
||||||
this.sessionInitialized = false;
|
|
||||||
this.sessionId = uuidv4();
|
this.sessionId = uuidv4();
|
||||||
|
this.flushed = false;
|
||||||
|
this.inMemoryEntries = [
|
||||||
|
{
|
||||||
|
type: "session",
|
||||||
|
id: this.sessionId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
cwd: this.channelDir,
|
||||||
|
},
|
||||||
|
];
|
||||||
// Truncate the context file
|
// Truncate the context file
|
||||||
if (existsSync(this.contextFile)) {
|
if (existsSync(this.contextFile)) {
|
||||||
writeFileSync(this.contextFile, "");
|
writeFileSync(this.contextFile, "");
|
||||||
|
|
@ -370,7 +323,7 @@ export class MomSessionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compatibility methods for AgentSession
|
// Compatibility methods for AgentSession
|
||||||
isEnabled(): boolean {
|
isPersisted(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue