Merge remote-tracking branch 'origin/main' into exec-approvals
# Conflicts: # apps/desktop/src/hooks/use-local-chat.ts
413
README.md
|
|
@ -35,22 +35,31 @@ skills/ # Bundled skills (commit, code-review)
|
|||
pnpm install
|
||||
```
|
||||
|
||||
### Credentials Configuration
|
||||
### Development
|
||||
|
||||
The Agent reads credentials from JSON5 files (no `.env` required).
|
||||
```bash
|
||||
# Desktop app (recommended for local development)
|
||||
pnpm dev
|
||||
|
||||
Create empty templates:
|
||||
# Gateway + Web app (for remote/mobile clients)
|
||||
pnpm dev:gateway # Start Gateway on :3000
|
||||
pnpm dev:web # Start Web app on :3001
|
||||
pnpm dev:all # Start both Gateway and Web app
|
||||
```
|
||||
|
||||
The Desktop app runs a standalone Hub with embedded Agent Engine - no Gateway required for local use.
|
||||
|
||||
### Credentials
|
||||
|
||||
```bash
|
||||
multica credentials init
|
||||
```
|
||||
|
||||
This creates:
|
||||
Creates:
|
||||
- `~/.super-multica/credentials.json5` — LLM providers + tools
|
||||
- `~/.super-multica/skills.env.json5` — skill/plugin API keys
|
||||
|
||||
- `~/.super-multica/credentials.json5` — core config (LLM providers + built-in tools)
|
||||
- `~/.super-multica/skills.env.json5` — dynamic keys (skills / plugins / integrations)
|
||||
|
||||
Example `credentials.json5` (OpenAI):
|
||||
Example `credentials.json5`:
|
||||
|
||||
```json5
|
||||
{
|
||||
|
|
@ -58,11 +67,7 @@ Example `credentials.json5` (OpenAI):
|
|||
llm: {
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "sk-xxx",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
model: "gpt-4o"
|
||||
}
|
||||
openai: { apiKey: "sk-xxx", model: "gpt-4o" }
|
||||
}
|
||||
},
|
||||
tools: {
|
||||
|
|
@ -71,372 +76,92 @@ Example `credentials.json5` (OpenAI):
|
|||
}
|
||||
```
|
||||
|
||||
Example `skills.env.json5` (dynamic keys):
|
||||
|
||||
```json5
|
||||
{
|
||||
env: {
|
||||
LINEAR_API_KEY: "lin-...",
|
||||
SLACK_BOT_TOKEN: "xoxb-..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Start services directly (no `source .env`):
|
||||
|
||||
```bash
|
||||
multica dev # Start desktop app
|
||||
multica run "hello" # Run CLI mode
|
||||
```
|
||||
|
||||
Optional overrides:
|
||||
|
||||
- `SMC_CREDENTIALS_PATH` — custom path for `credentials.json5`
|
||||
- `SMC_SKILLS_ENV_PATH` — custom path for `skills.env.json5`
|
||||
|
||||
### LLM Providers
|
||||
|
||||
Super Multica supports multiple LLM providers with two authentication methods:
|
||||
|
||||
**OAuth Providers** (use external CLI login):
|
||||
- `claude-code` — Claude Code OAuth (requires `claude login`)
|
||||
- `openai-codex` — OpenAI Codex OAuth (requires `codex login`)
|
||||
**OAuth Providers** (external CLI login):
|
||||
- `claude-code` — requires `claude login`
|
||||
- `openai-codex` — requires `codex login`
|
||||
|
||||
**API Key Providers** (configure in `credentials.json5`):
|
||||
- `anthropic`, `openai`, `kimi-coding`, `google`, `groq`, `mistral`, `xai`, `openrouter`
|
||||
|
||||
#### Check Provider Status
|
||||
Check status: `/provider` in interactive mode
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# In interactive mode
|
||||
/provider
|
||||
|
||||
# Output shows all providers with status
|
||||
🔌 Provider Status
|
||||
|
||||
Current: kimi-coding
|
||||
|
||||
Available Providers:
|
||||
ID Name Auth Status
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
✓ claude-code Claude Code (OAuth) OAuth ready
|
||||
✗ openai-codex Codex (OAuth) OAuth not logged in
|
||||
✓ kimi-coding Kimi Code API Key configured (current)
|
||||
...
|
||||
multica # Interactive mode
|
||||
multica run "prompt" # Single prompt
|
||||
multica chat --profile my-agent # Use profile
|
||||
multica --session abc123 # Continue session
|
||||
multica session list # List sessions
|
||||
multica profile list # List profiles
|
||||
multica skills list # List skills
|
||||
multica help # Show help
|
||||
```
|
||||
|
||||
#### Using OAuth Providers
|
||||
|
||||
```bash
|
||||
# 1. Install and login to Claude Code
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
claude login
|
||||
|
||||
# 2. Start multica with claude-code provider
|
||||
multica --provider claude-code
|
||||
```
|
||||
|
||||
#### Using API Key Providers
|
||||
|
||||
Add your API key to `~/.super-multica/credentials.json5`:
|
||||
|
||||
```json5
|
||||
{
|
||||
llm: {
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: { apiKey: "sk-xxx" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Priority
|
||||
|
||||
Each setting is resolved in order (first match wins):
|
||||
|
||||
1. **CLI argument** — `--provider`, `--model`, `--api-key`, `--base-url`
|
||||
2. **Credentials file** — `credentials.json5` (`llm.provider` + `llm.providers[provider]`)
|
||||
3. **Session metadata** — restored from previous session
|
||||
4. **Default** — `kimi-coding` provider with `kimi-k2-thinking` model
|
||||
|
||||
## Multica CLI
|
||||
|
||||
The unified CLI provides access to all agent features through a single command.
|
||||
|
||||
```bash
|
||||
# Interactive mode (default)
|
||||
multica
|
||||
multica chat
|
||||
multica chat --profile my-agent
|
||||
|
||||
# Run a single prompt
|
||||
multica run "hello"
|
||||
multica run --session demo "remember my name is Alice"
|
||||
|
||||
# Session management
|
||||
multica session list
|
||||
multica session show abc12345
|
||||
multica session delete abc12345
|
||||
|
||||
# Continue a session
|
||||
multica --session abc12345
|
||||
multica run --session abc12345 "what did I say?"
|
||||
|
||||
# Override provider/model
|
||||
multica run --provider openai --model gpt-4o-mini "hi"
|
||||
|
||||
# Use an agent profile
|
||||
multica chat --profile my-agent
|
||||
|
||||
# Set thinking level
|
||||
multica run --thinking high "solve this complex problem"
|
||||
|
||||
# Development servers
|
||||
multica dev # Start desktop app (default)
|
||||
multica dev gateway # Gateway only (:3000) - for remote clients
|
||||
multica dev web # Web app only (:3001)
|
||||
multica dev all # Start gateway + web
|
||||
|
||||
# Help
|
||||
multica help
|
||||
multica run --help
|
||||
multica session --help
|
||||
```
|
||||
|
||||
Short alias: `mu` (same as `multica`)
|
||||
Short alias: `mu`
|
||||
|
||||
## Sessions
|
||||
|
||||
Sessions persist conversation history to `~/.super-multica/sessions/<id>/`. Each session includes:
|
||||
Sessions persist to `~/.super-multica/sessions/<id>/` with JSONL message history and JSON metadata. Context windows are automatically managed with token-aware compaction.
|
||||
|
||||
- `session.jsonl` - Message history in JSONL format
|
||||
- `meta.json` - Session metadata (provider, model, thinking level)
|
||||
## Profiles
|
||||
|
||||
Sessions use UUIDv7 for IDs by default, providing time-ordered unique identifiers.
|
||||
|
||||
### Context Window Management
|
||||
|
||||
The agent automatically manages context windows to prevent token overflow:
|
||||
|
||||
- **Token-aware compaction** - Tracks token usage and compacts when approaching limits
|
||||
- **Compaction modes**: `tokens` (default), `count` (legacy), `summary` (LLM-generated)
|
||||
- **Configurable safety margins** - Ensures space for responses
|
||||
- **Minimum message preservation** - Keeps recent context intact
|
||||
|
||||
## Agent Profiles
|
||||
|
||||
Agent profiles define identity, personality, tools, and memory for an agent. Profiles are stored as markdown files in `~/.super-multica/agent-profiles/<id>/`.
|
||||
|
||||
### Profile CLI
|
||||
Profiles define agent identity, personality, and memory in `~/.super-multica/agent-profiles/<id>/`.
|
||||
|
||||
```bash
|
||||
# Create a new profile with default templates
|
||||
multica profile new my-agent
|
||||
|
||||
# List all profiles
|
||||
multica profile list
|
||||
|
||||
# Show profile contents
|
||||
multica profile show my-agent
|
||||
|
||||
# Open profile directory in file manager
|
||||
multica profile edit my-agent
|
||||
|
||||
# Delete a profile
|
||||
multica profile delete my-agent
|
||||
multica profile new my-agent # Create profile
|
||||
multica profile list # List all
|
||||
multica profile edit my-agent # Open in file manager
|
||||
```
|
||||
|
||||
### Profile Structure
|
||||
|
||||
Each profile contains:
|
||||
|
||||
- `identity.md` - Agent name and role
|
||||
- `soul.md` - Personality and behavioral constraints
|
||||
- `tools.md` - Tool usage instructions
|
||||
- `memory.md` - Persistent knowledge
|
||||
- `bootstrap.md` - Initial conversation context
|
||||
Profile files: `soul.md`, `user.md`, `workspace.md`, `memory.md`, `memory/*.md`
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are modular capabilities that extend agent functionality through `SKILL.md` definition files. For complete documentation, see [Skills System Documentation](./src/agent/skills/README.md).
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Two-source loading** - Global skills (`~/.super-multica/skills/`) and profile-specific skills
|
||||
- **GitHub installation** - `pnpm skills:cli add owner/repo` to install from GitHub
|
||||
- **Slash command invocation** - `/skill-name args` in interactive mode
|
||||
- **Eligibility filtering** - Auto-filter by platform, binaries, and environment
|
||||
- **Hot reload** - File watcher for development
|
||||
|
||||
### Quick Start
|
||||
Skills extend agent functionality via `SKILL.md` files. See [Skills Documentation](./src/agent/skills/README.md).
|
||||
|
||||
```bash
|
||||
# List all skills
|
||||
multica skills list
|
||||
|
||||
# Install skills from GitHub
|
||||
multica skills add anthropics/skills
|
||||
|
||||
# Check skill status with diagnostics
|
||||
multica skills status
|
||||
multica skills status pdf -v
|
||||
|
||||
# Install skill dependencies
|
||||
multica skills install nano-pdf
|
||||
|
||||
# Remove installed skills
|
||||
multica skills remove skills
|
||||
multica skills list # List skills
|
||||
multica skills add owner/repo # Install from GitHub
|
||||
multica skills status # Check status
|
||||
```
|
||||
|
||||
### Built-in Skills
|
||||
Built-in: `commit`, `code-review`, `skill-creator`
|
||||
|
||||
Located in `/skills/`:
|
||||
## Tools
|
||||
|
||||
- **commit** - Git commit helper following conventional commits
|
||||
- **code-review** - Code review assistance
|
||||
- **skill-creator** - Create and manage custom skills (meta-skill for self-extension)
|
||||
Available tools: `read`, `write`, `edit`, `glob`, `exec`, `process`, `web_fetch`, `web_search`, `memory_search`, `sessions_spawn`
|
||||
|
||||
### Creating Custom Skills
|
||||
|
||||
The agent can create new skills to extend its own capabilities. Simply ask the agent to create a skill:
|
||||
|
||||
```
|
||||
User: Create a skill that helps me format JSON
|
||||
Agent: [Creates ~/.super-multica/skills/json-formatter/SKILL.md]
|
||||
```
|
||||
|
||||
Skills are automatically loaded via hot-reload. See the [skill-creator SKILL.md](./skills/skill-creator/SKILL.md) for the complete guide.
|
||||
|
||||
## Agent Tools
|
||||
|
||||
### exec
|
||||
|
||||
Execute short-lived shell commands and return output. Commands running longer than the timeout are automatically backgrounded.
|
||||
|
||||
```
|
||||
exec({ command: "ls -la", cwd: "/path/to/dir", timeoutMs: 30000 })
|
||||
```
|
||||
|
||||
### process
|
||||
|
||||
Manage long-running background processes (servers, watchers, daemons). Output is buffered (up to 64KB) and terminated processes are automatically cleaned up after 1 hour.
|
||||
|
||||
```
|
||||
# Start a background process (returns immediately with process ID)
|
||||
process({ action: "start", command: "npm run dev" })
|
||||
|
||||
# Check process status
|
||||
process({ action: "status", id: "<process-id>" })
|
||||
|
||||
# Read process output
|
||||
process({ action: "output", id: "<process-id>" })
|
||||
|
||||
# Stop a process
|
||||
process({ action: "stop", id: "<process-id>" })
|
||||
|
||||
# Clean up terminated processes
|
||||
process({ action: "cleanup" })
|
||||
```
|
||||
|
||||
### glob
|
||||
|
||||
Pattern-based file discovery using fast-glob.
|
||||
|
||||
```
|
||||
glob({ pattern: "**/*.ts", cwd: "/path/to/dir" })
|
||||
```
|
||||
|
||||
### web_fetch
|
||||
|
||||
Fetch and extract content from URLs with intelligent content extraction.
|
||||
|
||||
```
|
||||
# Basic fetch (returns markdown)
|
||||
web_fetch({ url: "https://example.com" })
|
||||
|
||||
# With options
|
||||
web_fetch({
|
||||
url: "https://example.com",
|
||||
outputFormat: "markdown", # or "text"
|
||||
extractor: "readability" # or "turndown" for full page
|
||||
})
|
||||
```
|
||||
|
||||
Features: SSRF protection, response caching, max 50KB output.
|
||||
|
||||
### web_search
|
||||
|
||||
Search the web using Brave or Perplexity AI.
|
||||
|
||||
```
|
||||
# Basic search
|
||||
web_search({ query: "typescript best practices" })
|
||||
|
||||
# With provider options
|
||||
web_search({
|
||||
query: "latest AI news",
|
||||
provider: "brave", # or "perplexity"
|
||||
count: 5,
|
||||
freshness: "pw" # past week (Brave: pd/pw/pm/py)
|
||||
})
|
||||
```
|
||||
See [Tools Documentation](./src/agent/tools/README.md) for details.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Desktop App (Recommended)
|
||||
```
|
||||
Desktop App (standalone, recommended)
|
||||
└─ Hub (embedded)
|
||||
└─ Agent Engine
|
||||
|
||||
The Electron desktop app runs a standalone Hub with embedded Agent Engine:
|
||||
Web/Mobile Clients
|
||||
→ Gateway (WebSocket, :3000)
|
||||
→ Hub
|
||||
→ Agent Engine
|
||||
```
|
||||
|
||||
- **No Gateway required** for local development
|
||||
- Direct IPC communication for optimal performance
|
||||
- QR code pairing for mobile remote access
|
||||
- Optional Gateway connection for web/remote clients
|
||||
|
||||
### Gateway
|
||||
|
||||
The WebSocket gateway enables remote client access:
|
||||
|
||||
- Real-time message routing between clients and Hub
|
||||
- Streaming support for long-running operations
|
||||
- RPC-style request/response patterns
|
||||
- Device verification and authentication
|
||||
|
||||
### Hub
|
||||
|
||||
The Hub manages agents and communication:
|
||||
|
||||
- Agent lifecycle management
|
||||
- Multi-subscriber event distribution
|
||||
- Device whitelist and token-based verification
|
||||
- **Desktop App**: Electron app with embedded Hub, no Gateway needed
|
||||
- **Gateway**: WebSocket server for remote clients
|
||||
- **Hub**: Agent lifecycle and event distribution
|
||||
|
||||
## Scripts
|
||||
|
||||
### Multica CLI Commands
|
||||
```bash
|
||||
pnpm dev # Desktop app (recommended)
|
||||
pnpm dev:gateway # Gateway only
|
||||
pnpm dev:web # Web app only
|
||||
pnpm dev:all # Gateway + Web
|
||||
|
||||
- `multica` / `mu` - Unified CLI entry point
|
||||
- `multica run <prompt>` - Run a single prompt
|
||||
- `multica chat` - Interactive REPL mode
|
||||
- `multica session <cmd>` - Session management
|
||||
- `multica profile <cmd>` - Profile management
|
||||
- `multica skills <cmd>` - Skills management
|
||||
- `multica tools <cmd>` - Tool policy inspection
|
||||
- `multica credentials <cmd>` - Credentials management
|
||||
- `multica dev [service]` - Development servers
|
||||
- `multica help` - Show help
|
||||
|
||||
### Development (shortcuts)
|
||||
|
||||
- `pnpm dev` - Run desktop app (default, recommended)
|
||||
- `pnpm dev:desktop` - Run desktop app
|
||||
- `pnpm dev:gateway` - Run gateway only (for remote clients)
|
||||
- `pnpm dev:web` - Run web app only
|
||||
- `pnpm dev:all` - Run gateway + web
|
||||
|
||||
### Build & Test
|
||||
|
||||
- `pnpm build` - Build for production
|
||||
- `pnpm build:sdk` - Build SDK package
|
||||
- `pnpm build:cli` - Build CLI binary
|
||||
- `pnpm start` - Run production build
|
||||
- `pnpm typecheck` - Type check without emitting
|
||||
pnpm build # Production build
|
||||
pnpm typecheck # Type check
|
||||
pnpm test # Run tests
|
||||
```
|
||||
|
|
|
|||
2
apps/desktop/electron/electron-env.d.ts
vendored
|
|
@ -91,7 +91,7 @@ interface LocalChatEvent {
|
|||
type?: 'error'
|
||||
content?: string
|
||||
event?: {
|
||||
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_end'
|
||||
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_end' | 'compaction_start' | 'compaction_end'
|
||||
id?: string
|
||||
message?: {
|
||||
role: string
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const TOOL_GROUPS: Record<string, string[]> = {
|
|||
'group:fs': ['read', 'write', 'edit', 'glob'],
|
||||
'group:runtime': ['exec', 'process'],
|
||||
'group:web': ['web_search', 'web_fetch'],
|
||||
'group:memory': ['memory_get', 'memory_set', 'memory_delete', 'memory_list'],
|
||||
'group:memory': ['memory_search'],
|
||||
'group:subagent': ['sessions_spawn'],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -268,6 +268,19 @@ export function registerHubIpcHandlers(): void {
|
|||
return
|
||||
}
|
||||
|
||||
// Compaction events: forward with no stream tracking
|
||||
const isCompactionEvent =
|
||||
event.type === 'compaction_start' || event.type === 'compaction_end'
|
||||
if (isCompactionEvent) {
|
||||
safeLog(`[IPC] Sending compaction event to renderer: ${event.type}`)
|
||||
mainWindowRef.webContents.send('localChat:event', {
|
||||
agentId,
|
||||
streamId: null,
|
||||
event,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Filter events same as Hub.consumeAgent()
|
||||
const maybeMessage = (event as { message?: { role?: string } }).message
|
||||
const isAssistantMessage = maybeMessage?.role === 'assistant'
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export interface LocalChatEvent {
|
|||
type?: 'error'
|
||||
content?: string
|
||||
event?: {
|
||||
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_end'
|
||||
type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_end' | 'compaction_start' | 'compaction_end'
|
||||
id?: string
|
||||
message?: {
|
||||
role: string
|
||||
|
|
|
|||
BIN
apps/web/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 3.6 KiB |
|
|
@ -32,7 +32,7 @@ export const metadata: Metadata = {
|
|||
title: "Multica",
|
||||
},
|
||||
icons: {
|
||||
apple: "/logo-192x192.png",
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||
src: "/logo-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/logo-512x512.png",
|
||||
|
|
|
|||
BIN
apps/web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 7.6 KiB |
338
docs/client-streaming-protocol.md
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
# Client Streaming Protocol
|
||||
|
||||
How clients receive real-time agent events via WebSocket (Gateway mode) or IPC (Desktop mode), and what data structures to use for rendering.
|
||||
|
||||
## Transport Overview
|
||||
|
||||
```
|
||||
Gateway mode (Web App):
|
||||
Client ←──WebSocket──→ Gateway ←──→ Hub ←──→ Agent
|
||||
|
||||
Desktop mode (Electron):
|
||||
Renderer ←──IPC──→ Main Process (Hub + Agent)
|
||||
```
|
||||
|
||||
Both transports deliver the same logical events. The client receives a `StreamPayload` envelope containing an event, and routes it to the store for rendering.
|
||||
|
||||
## StreamPayload Envelope
|
||||
|
||||
Every real-time event arrives wrapped in a `StreamPayload`:
|
||||
|
||||
```ts
|
||||
interface StreamPayload {
|
||||
streamId: string; // groups events belonging to the same assistant turn
|
||||
agentId: string; // which agent produced this event
|
||||
event: AgentEvent | CompactionEvent;
|
||||
}
|
||||
```
|
||||
|
||||
In Gateway mode, these arrive as Socket.io messages with `action = "stream"`. In Desktop IPC mode, they arrive as `localChat:event` messages with the same structure.
|
||||
|
||||
## Event Types
|
||||
|
||||
### 1. Message Lifecycle Events (AgentEvent)
|
||||
|
||||
These events represent an LLM response being generated in real time.
|
||||
|
||||
#### `message_start`
|
||||
|
||||
A new assistant message has begun streaming.
|
||||
|
||||
```json
|
||||
{
|
||||
"streamId": "019abc12-...",
|
||||
"agentId": "019def34-...",
|
||||
"event": {
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Client action:** Create a new empty assistant message bubble. Use `streamId` as the message ID for subsequent updates.
|
||||
|
||||
#### `message_update`
|
||||
|
||||
Partial content has arrived for the current message.
|
||||
|
||||
```json
|
||||
{
|
||||
"streamId": "019abc12-...",
|
||||
"agentId": "019def34-...",
|
||||
"event": {
|
||||
"type": "message_update",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "text", "text": "Here is the partial response so far..." },
|
||||
{ "type": "thinking", "thinking": "Let me consider..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Client action:** Replace the message's `content` array with the new snapshot. Each update contains the full accumulated content, not a delta.
|
||||
|
||||
#### `message_end`
|
||||
|
||||
The assistant message is complete.
|
||||
|
||||
```json
|
||||
{
|
||||
"streamId": "019abc12-...",
|
||||
"agentId": "019def34-...",
|
||||
"event": {
|
||||
"type": "message_end",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "text", "text": "Final complete response." }
|
||||
],
|
||||
"stopReason": "end_turn"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Client action:** Finalize the message. Mark streaming as complete. Extract `stopReason` if needed.
|
||||
|
||||
### 2. Tool Execution Events (AgentEvent)
|
||||
|
||||
These events track tool calls made by the assistant during a turn.
|
||||
|
||||
#### `tool_execution_start`
|
||||
|
||||
The agent has begun executing a tool.
|
||||
|
||||
```json
|
||||
{
|
||||
"streamId": "019abc12-...",
|
||||
"agentId": "019def34-...",
|
||||
"event": {
|
||||
"type": "tool_execution_start",
|
||||
"toolCallId": "toolu_01ABC...",
|
||||
"toolName": "Bash",
|
||||
"args": { "command": "ls -la" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Client action:** Create a tool result message with `toolStatus: "running"`. Display a spinner or loading indicator.
|
||||
|
||||
#### `tool_execution_end`
|
||||
|
||||
The tool has finished executing.
|
||||
|
||||
```json
|
||||
{
|
||||
"streamId": "019abc12-...",
|
||||
"agentId": "019def34-...",
|
||||
"event": {
|
||||
"type": "tool_execution_end",
|
||||
"toolCallId": "toolu_01ABC...",
|
||||
"result": "file1.txt\nfile2.txt\n",
|
||||
"isError": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Client action:** Update the matching tool result message. Set `toolStatus` to `"success"` or `"error"` based on `isError`. Render `result` as the tool output.
|
||||
|
||||
### 3. Compaction Events (CompactionEvent)
|
||||
|
||||
These events notify the client when context window compaction occurs. They use a synthetic `streamId` of `compaction:{agentId}` and do not belong to any message stream.
|
||||
|
||||
#### `compaction_start`
|
||||
|
||||
Context compaction has begun. The agent is removing old messages to free up context window space.
|
||||
|
||||
```json
|
||||
{
|
||||
"streamId": "compaction:019def34-...",
|
||||
"agentId": "019def34-...",
|
||||
"event": {
|
||||
"type": "compaction_start"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Client action:** Show a compaction indicator (e.g., "Compacting context...").
|
||||
|
||||
#### `compaction_end`
|
||||
|
||||
Compaction is complete. Includes statistics about what was removed.
|
||||
|
||||
```json
|
||||
{
|
||||
"streamId": "compaction:019def34-...",
|
||||
"agentId": "019def34-...",
|
||||
"event": {
|
||||
"type": "compaction_end",
|
||||
"removed": 24,
|
||||
"kept": 8,
|
||||
"tokensRemoved": 45000,
|
||||
"tokensKept": 12000,
|
||||
"reason": "tokens"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `removed` | `number` | Number of messages removed |
|
||||
| `kept` | `number` | Number of messages retained |
|
||||
| `tokensRemoved` | `number?` | Estimated tokens freed (absent in count mode) |
|
||||
| `tokensKept` | `number?` | Estimated tokens remaining (absent in count mode) |
|
||||
| `reason` | `string` | What triggered compaction: `"tokens"`, `"count"`, or `"summary"` |
|
||||
|
||||
**Client action:** Hide the compaction indicator. Optionally display a toast or inline notice with the stats.
|
||||
|
||||
## Content Block Types
|
||||
|
||||
Message content is an array of `ContentBlock`, which is a union of:
|
||||
|
||||
```ts
|
||||
// Plain text
|
||||
interface TextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
// LLM reasoning (extended thinking)
|
||||
interface ThinkingContent {
|
||||
type: "thinking";
|
||||
thinking: string;
|
||||
}
|
||||
|
||||
// Tool invocation (appears in assistant messages)
|
||||
interface ToolCall {
|
||||
type: "toolCall";
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Image content (appears in user messages)
|
||||
interface ImageContent {
|
||||
type: "image";
|
||||
source: { type: "base64"; media_type: string; data: string };
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Store Structure
|
||||
|
||||
The recommended Zustand store shape for rendering:
|
||||
|
||||
```ts
|
||||
interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "toolResult";
|
||||
content: ContentBlock[];
|
||||
agentId: string;
|
||||
stopReason?: string;
|
||||
// Tool result fields (role === "toolResult" only)
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
toolStatus?: "running" | "success" | "error" | "interrupted";
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface CompactionStats {
|
||||
removed: number;
|
||||
kept: number;
|
||||
tokensRemoved?: number;
|
||||
tokensKept?: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface MessagesState {
|
||||
messages: Message[];
|
||||
streamingIds: Set<string>; // IDs of messages currently streaming
|
||||
compacting: boolean; // true while compaction is in progress
|
||||
lastCompaction: CompactionStats | null; // stats from most recent compaction
|
||||
}
|
||||
```
|
||||
|
||||
## Event Routing Pseudocode
|
||||
|
||||
```ts
|
||||
function handleStreamEvent(payload: StreamPayload) {
|
||||
const { streamId, agentId, event } = payload;
|
||||
|
||||
switch (event.type) {
|
||||
case "message_start":
|
||||
store.startStream(streamId, agentId);
|
||||
break;
|
||||
case "message_update":
|
||||
store.appendStream(streamId, event.message.content);
|
||||
break;
|
||||
case "message_end":
|
||||
store.endStream(streamId, event.message.content, event.message.stopReason);
|
||||
break;
|
||||
case "tool_execution_start":
|
||||
store.startToolExecution(agentId, event.toolCallId, event.toolName, event.args);
|
||||
break;
|
||||
case "tool_execution_end":
|
||||
store.endToolExecution(event.toolCallId, event.result, event.isError);
|
||||
break;
|
||||
case "compaction_start":
|
||||
store.startCompaction();
|
||||
break;
|
||||
case "compaction_end":
|
||||
store.endCompaction({
|
||||
removed: event.removed,
|
||||
kept: event.kept,
|
||||
tokensRemoved: event.tokensRemoved,
|
||||
tokensKept: event.tokensKept,
|
||||
reason: event.reason,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Message History via RPC
|
||||
|
||||
Clients can also fetch historical messages using the `getAgentMessages` RPC method. See [rpc.md](./rpc.md) for details.
|
||||
|
||||
The response returns `AgentMessage[]` which must be normalized into the `Message` format above. Key differences from streaming:
|
||||
|
||||
- Historical messages don't have `toolStatus` — infer it from `isError` (`"error"` or `"success"`).
|
||||
- Historical messages may have `content` as a plain `string` instead of `ContentBlock[]` — normalize by wrapping in `[{ type: "text", text: content }]`.
|
||||
- Tool arguments are not stored on `toolResult` messages — build a lookup map from assistant `ToolCall` blocks by `toolCallId` to reconstruct `toolArgs`.
|
||||
|
||||
## SDK Imports
|
||||
|
||||
All types are available from `@multica/sdk`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
StreamAction,
|
||||
type StreamPayload,
|
||||
type AgentEvent,
|
||||
type CompactionEvent,
|
||||
type CompactionStartEvent,
|
||||
type CompactionEndEvent,
|
||||
type ContentBlock,
|
||||
type TextContent,
|
||||
type ThinkingContent,
|
||||
type ToolCall,
|
||||
type ImageContent,
|
||||
} from "@multica/sdk";
|
||||
```
|
||||
|
||||
Store types are available from `@multica/store`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
useMessagesStore,
|
||||
type Message,
|
||||
type CompactionStats,
|
||||
type ToolStatus,
|
||||
} from "@multica/store";
|
||||
```
|
||||
|
|
@ -34,6 +34,9 @@ export {
|
|||
StreamAction,
|
||||
type StreamPayload,
|
||||
type AgentEvent,
|
||||
type CompactionEvent,
|
||||
type CompactionStartEvent,
|
||||
type CompactionEndEvent,
|
||||
type ContentBlock,
|
||||
type TextContent,
|
||||
type ThinkingContent,
|
||||
|
|
|
|||
|
|
@ -25,16 +25,36 @@ export type { AgentEvent };
|
|||
*/
|
||||
export type ContentBlock = TextContent | ThinkingContent | ToolCall | ImageContent;
|
||||
|
||||
// --- Compaction event types (Multica-specific, not from pi-agent-core) ---
|
||||
|
||||
/** Emitted when context compaction begins */
|
||||
export type CompactionStartEvent = {
|
||||
type: "compaction_start";
|
||||
};
|
||||
|
||||
/** Emitted when context compaction completes */
|
||||
export type CompactionEndEvent = {
|
||||
type: "compaction_end";
|
||||
removed: number;
|
||||
kept: number;
|
||||
tokensRemoved?: number;
|
||||
tokensKept?: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/** Union of all compaction events */
|
||||
export type CompactionEvent = CompactionStartEvent | CompactionEndEvent;
|
||||
|
||||
// --- Stream event types ---
|
||||
|
||||
/**
|
||||
* Hub forwards AgentEvent from pi-agent-core as-is.
|
||||
* StreamPayload wraps it with routing metadata.
|
||||
* Hub forwards AgentEvent from pi-agent-core and CompactionEvent as-is.
|
||||
* StreamPayload wraps them with routing metadata.
|
||||
*/
|
||||
export interface StreamPayload {
|
||||
streamId: string;
|
||||
agentId: string;
|
||||
event: AgentEvent;
|
||||
event: AgentEvent | CompactionEvent;
|
||||
}
|
||||
|
||||
/** Extract thinking/reasoning content from an AgentEvent that carries a message */
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
type ConnectionState,
|
||||
type StreamPayload,
|
||||
type AgentEvent,
|
||||
type CompactionEndEvent,
|
||||
type GetAgentMessagesResult,
|
||||
type ContentBlock,
|
||||
} from "@multica/sdk"
|
||||
|
|
@ -143,6 +144,21 @@ function createClient(
|
|||
case "tool_execution_update":
|
||||
// Partial results — not rendered yet, ignored for now
|
||||
break
|
||||
case "compaction_start": {
|
||||
store.startCompaction()
|
||||
break
|
||||
}
|
||||
case "compaction_end": {
|
||||
const evt = event as CompactionEndEvent
|
||||
store.endCompaction({
|
||||
removed: evt.removed,
|
||||
kept: evt.kept,
|
||||
tokensRemoved: evt.tokensRemoved,
|
||||
tokensKept: evt.tokensKept,
|
||||
reason: evt.reason,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ export { useConnectionStore } from "./connection-store"
|
|||
export type { ConnectionStore } from "./connection-store"
|
||||
export { useAutoConnect } from "./use-auto-connect"
|
||||
export { useMessagesStore } from "./messages"
|
||||
export type { Message, MessagesStore, SendContext, ToolStatus } from "./messages"
|
||||
export type { Message, MessagesStore, SendContext, ToolStatus, CompactionStats } from "./messages"
|
||||
export { parseConnectionCode, saveConnection, loadConnection, clearConnection } from "./connection"
|
||||
export type { ConnectionInfo } from "./connection"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@ import type { ContentBlock } from "@multica/sdk"
|
|||
|
||||
export type ToolStatus = "running" | "success" | "error" | "interrupted"
|
||||
|
||||
export interface CompactionStats {
|
||||
removed: number
|
||||
kept: number
|
||||
tokensRemoved?: number
|
||||
tokensKept?: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: "user" | "assistant" | "toolResult"
|
||||
|
|
@ -40,6 +48,8 @@ export interface SendContext {
|
|||
interface MessagesState {
|
||||
messages: Message[]
|
||||
streamingIds: Set<string>
|
||||
compacting: boolean
|
||||
lastCompaction: CompactionStats | null
|
||||
}
|
||||
|
||||
interface MessagesActions {
|
||||
|
|
@ -56,6 +66,9 @@ interface MessagesActions {
|
|||
// Tool execution lifecycle
|
||||
startToolExecution: (agentId: string, toolCallId: string, toolName: string, args?: unknown) => void
|
||||
endToolExecution: (toolCallId: string, result?: unknown, isError?: boolean) => void
|
||||
// Compaction lifecycle
|
||||
startCompaction: () => void
|
||||
endCompaction: (stats: CompactionStats) => void
|
||||
}
|
||||
|
||||
export type MessagesStore = MessagesState & MessagesActions
|
||||
|
|
@ -63,6 +76,8 @@ export type MessagesStore = MessagesState & MessagesActions
|
|||
export const useMessagesStore = create<MessagesStore>()((set, get) => ({
|
||||
messages: [],
|
||||
streamingIds: new Set<string>(),
|
||||
compacting: false,
|
||||
lastCompaction: null,
|
||||
|
||||
sendMessage: (text, ctx) => {
|
||||
get().addUserMessage(text, ctx.agentId)
|
||||
|
|
@ -102,7 +117,7 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
|
|||
},
|
||||
|
||||
clearMessages: () => {
|
||||
set({ messages: [], streamingIds: new Set() })
|
||||
set({ messages: [], streamingIds: new Set(), compacting: false, lastCompaction: null })
|
||||
},
|
||||
|
||||
// --- Streaming: build assistant message incrementally ---
|
||||
|
|
@ -180,4 +195,14 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
|
|||
),
|
||||
}))
|
||||
},
|
||||
|
||||
// --- Compaction lifecycle ---
|
||||
|
||||
startCompaction: () => {
|
||||
set({ compacting: true })
|
||||
},
|
||||
|
||||
endCompaction: (stats) => {
|
||||
set({ compacting: false, lastCompaction: stats })
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
|||
import { Agent } from "./runner.js";
|
||||
import { Channel } from "./channel.js";
|
||||
import type { AgentOptions, Message } from "./types.js";
|
||||
import type { MulticaEvent } from "./events.js";
|
||||
|
||||
const devNull = { write: () => true } as unknown as NodeJS.WritableStream;
|
||||
|
||||
/** Discriminated union of legacy Message (error fallback) and raw AgentEvent */
|
||||
export type ChannelItem = Message | AgentEvent;
|
||||
/** Discriminated union of legacy Message, raw AgentEvent, and MulticaEvent */
|
||||
export type ChannelItem = Message | AgentEvent | MulticaEvent;
|
||||
|
||||
export class AsyncAgent {
|
||||
private readonly agent: Agent;
|
||||
|
|
@ -24,8 +25,8 @@ export class AsyncAgent {
|
|||
});
|
||||
this.sessionId = this.agent.sessionId;
|
||||
|
||||
// Forward raw AgentEvent into the channel
|
||||
this.agent.subscribe((event: AgentEvent) => {
|
||||
// Forward raw AgentEvent and MulticaEvent into the channel
|
||||
this.agent.subscribeAll((event: AgentEvent | MulticaEvent) => {
|
||||
this.channel.send(event);
|
||||
});
|
||||
}
|
||||
|
|
@ -42,6 +43,9 @@ export class AsyncAgent {
|
|||
.then(async () => {
|
||||
if (this._closed) return;
|
||||
const result = await this.agent.run(content);
|
||||
// Flush pending session writes so waitForIdle() callers
|
||||
// can safely read session data from disk.
|
||||
await this.agent.flushSession();
|
||||
// Normal text is delivered via message_end event; only handle errors here
|
||||
if (result.error) {
|
||||
this.channel.send({ id: uuidv7(), content: `[error] ${result.error}` });
|
||||
|
|
@ -61,10 +65,11 @@ export class AsyncAgent {
|
|||
/**
|
||||
* Subscribe to agent events directly (supports multiple subscribers).
|
||||
* Unlike read(), this allows multiple consumers to receive the same events.
|
||||
* Receives both pi-agent-core AgentEvent and MulticaEvent (e.g. compaction).
|
||||
*/
|
||||
subscribe(callback: (event: AgentEvent) => void): () => void {
|
||||
subscribe(callback: (event: AgentEvent | MulticaEvent) => void): () => void {
|
||||
console.log(`[AsyncAgent] Adding subscriber for agent: ${this.sessionId}`);
|
||||
const unsubscribe = this.agent.subscribe((event) => {
|
||||
const unsubscribe = this.agent.subscribeAll((event) => {
|
||||
console.log(`[AsyncAgent] Event received: ${event.type}`);
|
||||
callback(event);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,3 +44,13 @@ export {
|
|||
compactMessagesWithSummary,
|
||||
compactMessagesWithChunkedSummary,
|
||||
} from "./summarization.js";
|
||||
|
||||
// Tool result pruning
|
||||
export type {
|
||||
ToolResultPruningSettings,
|
||||
ToolResultPruningResult,
|
||||
} from "./tool-result-pruning.js";
|
||||
export {
|
||||
DEFAULT_TOOL_RESULT_PRUNING_SETTINGS,
|
||||
pruneToolResults,
|
||||
} from "./tool-result-pruning.js";
|
||||
|
|
|
|||
285
src/agent/context-window/tool-result-pruning.test.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { pruneToolResults, DEFAULT_TOOL_RESULT_PRUNING_SETTINGS } from "./tool-result-pruning.js";
|
||||
|
||||
// Helper to create a user message with tool result
|
||||
function createToolResultMessage(
|
||||
toolName: string,
|
||||
content: string,
|
||||
toolUseId: string = "tool-123",
|
||||
): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: toolUseId,
|
||||
name: toolName,
|
||||
content: [{ type: "text", text: content }],
|
||||
},
|
||||
],
|
||||
} as unknown as AgentMessage;
|
||||
}
|
||||
|
||||
// Helper to create an assistant message
|
||||
function createAssistantMessage(text: string): AgentMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
} as unknown as AgentMessage;
|
||||
}
|
||||
|
||||
// Helper to create a user message
|
||||
function createUserMessage(text: string): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: text,
|
||||
} as unknown as AgentMessage;
|
||||
}
|
||||
|
||||
describe("pruneToolResults", () => {
|
||||
it("returns unchanged if utilization is below softTrimRatio", () => {
|
||||
const messages = [
|
||||
createUserMessage("Hello"),
|
||||
createAssistantMessage("Hi there!"),
|
||||
createToolResultMessage("read", "Short content"),
|
||||
];
|
||||
|
||||
const result = pruneToolResults({
|
||||
messages,
|
||||
contextWindowTokens: 200_000, // Very large window
|
||||
});
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.messages).toBe(messages);
|
||||
expect(result.softTrimmed).toBe(0);
|
||||
expect(result.hardCleared).toBe(0);
|
||||
});
|
||||
|
||||
it("soft trims large tool results", () => {
|
||||
// Create a message with a large tool result (5000 chars)
|
||||
const largeContent = "A".repeat(5000);
|
||||
const messages = [
|
||||
createUserMessage("Hello"),
|
||||
createAssistantMessage("Processing..."),
|
||||
createToolResultMessage("read", largeContent),
|
||||
createAssistantMessage("Done!"),
|
||||
createAssistantMessage("Follow up"),
|
||||
createAssistantMessage("Another one"),
|
||||
createAssistantMessage("Protected message"), // This is protected (keepLastAssistants=3)
|
||||
];
|
||||
|
||||
const result = pruneToolResults({
|
||||
messages,
|
||||
contextWindowTokens: 10_000, // Small window to trigger pruning
|
||||
settings: {
|
||||
softTrimRatio: 0.1, // Low threshold to ensure pruning
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.softTrimmed).toBe(1);
|
||||
|
||||
// Check that the trimmed message contains head + tail
|
||||
const trimmedMsg = result.messages[2] as any;
|
||||
const trimmedText = trimmedMsg.content[0].content[0].text;
|
||||
expect(trimmedText).toContain("A".repeat(100)); // Should have some head content
|
||||
expect(trimmedText).toContain("..."); // Truncation marker
|
||||
expect(trimmedText).toContain("[Tool result trimmed:");
|
||||
});
|
||||
|
||||
it("hard clears when utilization exceeds hardClearRatio", () => {
|
||||
// Create multiple messages with large tool results
|
||||
const largeContent = "X".repeat(10000);
|
||||
const messages = [
|
||||
createUserMessage("Start"),
|
||||
createAssistantMessage("Processing 1"),
|
||||
createToolResultMessage("read", largeContent, "tool-1"),
|
||||
createAssistantMessage("Processing 2"),
|
||||
createToolResultMessage("exec", largeContent, "tool-2"),
|
||||
createAssistantMessage("Processing 3"),
|
||||
createToolResultMessage("glob", largeContent, "tool-3"),
|
||||
createAssistantMessage("Done 1"), // Protected
|
||||
createAssistantMessage("Done 2"), // Protected
|
||||
createAssistantMessage("Done 3"), // Protected
|
||||
];
|
||||
|
||||
const result = pruneToolResults({
|
||||
messages,
|
||||
contextWindowTokens: 5_000, // Very small window
|
||||
settings: {
|
||||
softTrimRatio: 0.1,
|
||||
hardClearRatio: 0.2,
|
||||
minPrunableToolChars: 1000, // Lower threshold for test
|
||||
hardClear: {
|
||||
enabled: true,
|
||||
placeholder: "[Cleared]",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
// Should have cleared at least some tool results
|
||||
expect(result.hardCleared).toBeGreaterThan(0);
|
||||
expect(result.charsSaved).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("protects last N assistant messages", () => {
|
||||
const messages = [
|
||||
createUserMessage("Hello"),
|
||||
createAssistantMessage("First"),
|
||||
createToolResultMessage("read", "A".repeat(5000), "tool-1"), // Should be prunable
|
||||
createAssistantMessage("Second"), // Protected (keepLastAssistants=3)
|
||||
createToolResultMessage("read", "B".repeat(5000), "tool-2"), // In protected zone, should NOT be pruned
|
||||
createAssistantMessage("Third"), // Protected
|
||||
createAssistantMessage("Fourth"), // Protected
|
||||
];
|
||||
|
||||
const result = pruneToolResults({
|
||||
messages,
|
||||
contextWindowTokens: 5_000,
|
||||
settings: {
|
||||
softTrimRatio: 0.1,
|
||||
keepLastAssistants: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// The first tool result (before protected zone) may be pruned
|
||||
// But the second one (after "Second" assistant which is in protected zone) should not be
|
||||
if (result.changed) {
|
||||
// Check that tool-2 result is NOT modified (it's in protected zone)
|
||||
const tool2Msg = result.messages[4] as any;
|
||||
const tool2Content = tool2Msg.content[0].content[0].text;
|
||||
expect(tool2Content).toBe("B".repeat(5000)); // Unchanged
|
||||
}
|
||||
});
|
||||
|
||||
it("never prunes before first user message", () => {
|
||||
const messages = [
|
||||
createAssistantMessage("Bootstrap read"), // Before first user message
|
||||
createToolResultMessage("read", "A".repeat(5000), "tool-1"), // Should NOT be pruned
|
||||
createUserMessage("Hello"), // First user message
|
||||
createAssistantMessage("Response"),
|
||||
createToolResultMessage("read", "B".repeat(5000), "tool-2"), // Can be pruned
|
||||
createAssistantMessage("Done 1"),
|
||||
createAssistantMessage("Done 2"),
|
||||
createAssistantMessage("Done 3"),
|
||||
];
|
||||
|
||||
const result = pruneToolResults({
|
||||
messages,
|
||||
contextWindowTokens: 5_000,
|
||||
settings: {
|
||||
softTrimRatio: 0.1,
|
||||
},
|
||||
});
|
||||
|
||||
// The first tool result (before first user message) should NOT be modified
|
||||
const tool1Msg = result.messages[1] as any;
|
||||
const tool1Content = tool1Msg.content[0].content[0].text;
|
||||
expect(tool1Content).toBe("A".repeat(5000)); // Unchanged - bootstrap protection
|
||||
});
|
||||
|
||||
it("respects tool deny list", () => {
|
||||
const messages = [
|
||||
createUserMessage("Hello"),
|
||||
createAssistantMessage("Processing"),
|
||||
createToolResultMessage("read", "A".repeat(5000), "tool-1"),
|
||||
createAssistantMessage("Done 1"),
|
||||
createAssistantMessage("Done 2"),
|
||||
createAssistantMessage("Done 3"),
|
||||
];
|
||||
|
||||
const result = pruneToolResults({
|
||||
messages,
|
||||
contextWindowTokens: 5_000,
|
||||
settings: {
|
||||
softTrimRatio: 0.1,
|
||||
tools: {
|
||||
deny: ["read"], // Don't prune read tool results
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// read tool should not be pruned
|
||||
expect(result.changed).toBe(false);
|
||||
});
|
||||
|
||||
it("respects tool allow list", () => {
|
||||
const messages = [
|
||||
createUserMessage("Hello"),
|
||||
createAssistantMessage("Processing"),
|
||||
createToolResultMessage("read", "A".repeat(5000), "tool-1"),
|
||||
createToolResultMessage("exec", "B".repeat(5000), "tool-2"),
|
||||
createAssistantMessage("Done 1"),
|
||||
createAssistantMessage("Done 2"),
|
||||
createAssistantMessage("Done 3"),
|
||||
];
|
||||
|
||||
const result = pruneToolResults({
|
||||
messages,
|
||||
contextWindowTokens: 5_000,
|
||||
settings: {
|
||||
softTrimRatio: 0.1,
|
||||
tools: {
|
||||
allow: ["exec"], // Only prune exec tool results
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (result.changed) {
|
||||
// read tool should not be pruned
|
||||
const tool1Msg = result.messages[2] as any;
|
||||
const tool1Content = tool1Msg.content[0].content[0].text;
|
||||
expect(tool1Content).toBe("A".repeat(5000)); // Unchanged
|
||||
}
|
||||
});
|
||||
|
||||
it("skips tool results with images", () => {
|
||||
const messages = [
|
||||
createUserMessage("Hello"),
|
||||
createAssistantMessage("Processing"),
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-1",
|
||||
name: "screenshot",
|
||||
content: [
|
||||
{ type: "image", source: { type: "base64", data: "abc123" } },
|
||||
{ type: "text", text: "A".repeat(5000) },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as AgentMessage,
|
||||
createAssistantMessage("Done 1"),
|
||||
createAssistantMessage("Done 2"),
|
||||
createAssistantMessage("Done 3"),
|
||||
];
|
||||
|
||||
const result = pruneToolResults({
|
||||
messages,
|
||||
contextWindowTokens: 5_000,
|
||||
settings: {
|
||||
softTrimRatio: 0.1,
|
||||
},
|
||||
});
|
||||
|
||||
// Image-containing tool result should not be pruned
|
||||
expect(result.softTrimmed).toBe(0);
|
||||
expect(result.hardCleared).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_TOOL_RESULT_PRUNING_SETTINGS", () => {
|
||||
it("has expected default values", () => {
|
||||
expect(DEFAULT_TOOL_RESULT_PRUNING_SETTINGS.softTrimRatio).toBe(0.3);
|
||||
expect(DEFAULT_TOOL_RESULT_PRUNING_SETTINGS.hardClearRatio).toBe(0.5);
|
||||
expect(DEFAULT_TOOL_RESULT_PRUNING_SETTINGS.keepLastAssistants).toBe(3);
|
||||
expect(DEFAULT_TOOL_RESULT_PRUNING_SETTINGS.softTrim.maxChars).toBe(4000);
|
||||
expect(DEFAULT_TOOL_RESULT_PRUNING_SETTINGS.softTrim.headChars).toBe(1500);
|
||||
expect(DEFAULT_TOOL_RESULT_PRUNING_SETTINGS.softTrim.tailChars).toBe(1500);
|
||||
expect(DEFAULT_TOOL_RESULT_PRUNING_SETTINGS.hardClear.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
510
src/agent/context-window/tool-result-pruning.ts
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
/**
|
||||
* Tool Result Pruning
|
||||
*
|
||||
* Smart pruning of tool results to reduce context window usage while preserving
|
||||
* useful information. Implements two-phase pruning:
|
||||
*
|
||||
* 1. Soft Trim: Keep head + tail of large tool results
|
||||
* 2. Hard Clear: Replace old tool results with placeholder
|
||||
*
|
||||
* Based on OpenClaw's microcompact-style context pruning.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ToolResultPruningSettings = {
|
||||
/** Utilization ratio to start soft trimming (default: 0.3) */
|
||||
softTrimRatio: number;
|
||||
/** Utilization ratio to start hard clearing (default: 0.5) */
|
||||
hardClearRatio: number;
|
||||
/** Minimum prunable tool result chars to consider hard clear (default: 50000) */
|
||||
minPrunableToolChars: number;
|
||||
/** Number of recent assistant messages to protect from pruning (default: 3) */
|
||||
keepLastAssistants: number;
|
||||
/** Soft trim settings */
|
||||
softTrim: {
|
||||
/** Max chars before triggering soft trim (default: 4000) */
|
||||
maxChars: number;
|
||||
/** Chars to keep from start (default: 1500) */
|
||||
headChars: number;
|
||||
/** Chars to keep from end (default: 1500) */
|
||||
tailChars: number;
|
||||
};
|
||||
/** Hard clear settings */
|
||||
hardClear: {
|
||||
/** Whether hard clear is enabled (default: true) */
|
||||
enabled: boolean;
|
||||
/** Placeholder text for cleared results */
|
||||
placeholder: string;
|
||||
};
|
||||
/** Tool names to allow/deny pruning */
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_TOOL_RESULT_PRUNING_SETTINGS: ToolResultPruningSettings = {
|
||||
softTrimRatio: 0.3,
|
||||
hardClearRatio: 0.5,
|
||||
minPrunableToolChars: 50_000,
|
||||
keepLastAssistants: 3,
|
||||
softTrim: {
|
||||
maxChars: 4_000,
|
||||
headChars: 1_500,
|
||||
tailChars: 1_500,
|
||||
},
|
||||
hardClear: {
|
||||
enabled: true,
|
||||
placeholder: "[Tool result cleared to save context space]",
|
||||
},
|
||||
};
|
||||
|
||||
export type ToolResultPruningResult = {
|
||||
/** Pruned messages */
|
||||
messages: AgentMessage[];
|
||||
/** Whether any changes were made */
|
||||
changed: boolean;
|
||||
/** Number of soft-trimmed results */
|
||||
softTrimmed: number;
|
||||
/** Number of hard-cleared results */
|
||||
hardCleared: number;
|
||||
/** Estimated chars saved */
|
||||
charsSaved: number;
|
||||
};
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||
const IMAGE_CHAR_ESTIMATE = 8_000;
|
||||
|
||||
// ─── Helper Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract text content from a tool result content block.
|
||||
*/
|
||||
function extractToolResultText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (typeof block === "string") {
|
||||
parts.push(block);
|
||||
} else if (block && typeof block === "object") {
|
||||
if ("text" in block && typeof block.text === "string") {
|
||||
parts.push(block.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content contains images.
|
||||
*/
|
||||
function hasImageContent(content: unknown): boolean {
|
||||
if (!Array.isArray(content)) return false;
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && "type" in block) {
|
||||
if (block.type === "image") return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate character count for a message.
|
||||
*/
|
||||
function estimateMessageChars(message: AgentMessage): number {
|
||||
const msgAny = message as any;
|
||||
|
||||
if (message.role === "user") {
|
||||
const content = msgAny.content;
|
||||
if (typeof content === "string") return content.length;
|
||||
if (!Array.isArray(content)) return 0;
|
||||
|
||||
let chars = 0;
|
||||
for (const block of content) {
|
||||
if (typeof block === "string") {
|
||||
chars += block.length;
|
||||
} else if (block && typeof block === "object") {
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "tool_result") {
|
||||
chars += extractToolResultText(block.content).length;
|
||||
} else if (block.type === "image") {
|
||||
chars += IMAGE_CHAR_ESTIMATE;
|
||||
}
|
||||
}
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const content = msgAny.content;
|
||||
if (typeof content === "string") return content.length;
|
||||
if (!Array.isArray(content)) return 0;
|
||||
|
||||
let chars = 0;
|
||||
for (const block of content) {
|
||||
if (typeof block === "string") {
|
||||
chars += block.length;
|
||||
} else if (block && typeof block === "object") {
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "thinking" && typeof block.thinking === "string") {
|
||||
chars += block.thinking.length;
|
||||
} else if (block.type === "toolCall" || block.type === "tool_use") {
|
||||
try {
|
||||
chars += JSON.stringify(block.arguments ?? block.input ?? {}).length;
|
||||
} catch {
|
||||
chars += 128;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
return 256;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate total character count for messages.
|
||||
*/
|
||||
function estimateContextChars(messages: AgentMessage[]): number {
|
||||
return messages.reduce((sum, m) => sum + estimateMessageChars(m), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index where we should stop protecting assistant messages.
|
||||
* Returns null if not enough assistant messages exist.
|
||||
*/
|
||||
function findAssistantCutoffIndex(
|
||||
messages: AgentMessage[],
|
||||
keepLastAssistants: number,
|
||||
): number | null {
|
||||
if (keepLastAssistants <= 0) return messages.length;
|
||||
|
||||
let remaining = keepLastAssistants;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]?.role !== "assistant") continue;
|
||||
remaining--;
|
||||
if (remaining === 0) return i;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user message is a "real" user message (not just tool results).
|
||||
* Tool results are sent as user messages but they're not real user input.
|
||||
*/
|
||||
function isRealUserMessage(message: AgentMessage): boolean {
|
||||
if (message.role !== "user") return false;
|
||||
|
||||
const msgAny = message as any;
|
||||
const content = msgAny.content;
|
||||
|
||||
// String content is a real user message
|
||||
if (typeof content === "string") return true;
|
||||
|
||||
// Array content - check if it has any non-tool-result blocks
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (typeof block === "string") return true;
|
||||
if (block && typeof block === "object") {
|
||||
// Any type other than tool_result is real user content
|
||||
if (block.type !== "tool_result") return true;
|
||||
}
|
||||
}
|
||||
// Only tool_result blocks - not a real user message
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the first real user message (not tool results).
|
||||
* This is used for bootstrap protection - we never prune before the first real user input.
|
||||
*/
|
||||
function findFirstUserIndex(messages: AgentMessage[]): number | null {
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (msg && isRealUserMessage(msg)) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool should be pruned based on settings.
|
||||
*/
|
||||
function isToolPrunable(toolName: string, settings: ToolResultPruningSettings): boolean {
|
||||
const { tools } = settings;
|
||||
if (!tools) return true;
|
||||
|
||||
// If deny list exists and tool is in it, don't prune
|
||||
if (tools.deny?.includes(toolName)) return false;
|
||||
|
||||
// If allow list exists, only prune if tool is in it
|
||||
if (tools.allow && tools.allow.length > 0) {
|
||||
return tools.allow.includes(toolName);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take first N characters from text.
|
||||
*/
|
||||
function takeHead(text: string, maxChars: number): string {
|
||||
if (maxChars <= 0) return "";
|
||||
if (text.length <= maxChars) return text;
|
||||
return text.slice(0, maxChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take last N characters from text.
|
||||
*/
|
||||
function takeTail(text: string, maxChars: number): string {
|
||||
if (maxChars <= 0) return "";
|
||||
if (text.length <= maxChars) return text;
|
||||
return text.slice(text.length - maxChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft trim a tool result text.
|
||||
*/
|
||||
function softTrimText(
|
||||
text: string,
|
||||
settings: ToolResultPruningSettings,
|
||||
): { trimmed: string; saved: number } | null {
|
||||
const { maxChars, headChars, tailChars } = settings.softTrim;
|
||||
|
||||
if (text.length <= maxChars) return null;
|
||||
if (headChars + tailChars >= text.length) return null;
|
||||
|
||||
const head = takeHead(text, headChars);
|
||||
const tail = takeTail(text, tailChars);
|
||||
const note = `\n\n[Tool result trimmed: kept first ${headChars} chars and last ${tailChars} chars of ${text.length} chars.]`;
|
||||
const trimmed = `${head}\n...\n${tail}${note}`;
|
||||
|
||||
return {
|
||||
trimmed,
|
||||
saved: text.length - trimmed.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a user message containing tool results.
|
||||
* Returns modified message if any tool results were trimmed/cleared.
|
||||
*/
|
||||
function processUserMessageToolResults(
|
||||
message: AgentMessage,
|
||||
settings: ToolResultPruningSettings,
|
||||
mode: "soft" | "hard",
|
||||
): { message: AgentMessage; changed: boolean; charsSaved: number } {
|
||||
const msgAny = message as any;
|
||||
const content = msgAny.content;
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return { message, changed: false, charsSaved: 0 };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
let charsSaved = 0;
|
||||
const newContent: any[] = [];
|
||||
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object" || block.type !== "tool_result") {
|
||||
newContent.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolName = block.name ?? "unknown";
|
||||
|
||||
// Skip non-prunable tools
|
||||
if (!isToolPrunable(toolName, settings)) {
|
||||
newContent.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip image-containing tool results
|
||||
if (hasImageContent(block.content)) {
|
||||
newContent.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalText = extractToolResultText(block.content);
|
||||
|
||||
if (mode === "soft") {
|
||||
const result = softTrimText(originalText, settings);
|
||||
if (result) {
|
||||
newContent.push({
|
||||
...block,
|
||||
content: [{ type: "text", text: result.trimmed }],
|
||||
});
|
||||
changed = true;
|
||||
charsSaved += result.saved;
|
||||
} else {
|
||||
newContent.push(block);
|
||||
}
|
||||
} else {
|
||||
// Hard clear
|
||||
newContent.push({
|
||||
...block,
|
||||
content: [{ type: "text", text: settings.hardClear.placeholder }],
|
||||
});
|
||||
changed = true;
|
||||
charsSaved += originalText.length - settings.hardClear.placeholder.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { message, changed: false, charsSaved: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
message: { ...message, content: newContent } as AgentMessage,
|
||||
changed: true,
|
||||
charsSaved,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main Functions ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Prune tool results in messages to reduce context window usage.
|
||||
*
|
||||
* Two-phase approach:
|
||||
* 1. Soft Trim (at softTrimRatio): Keep head + tail of large tool results
|
||||
* 2. Hard Clear (at hardClearRatio): Replace old tool results with placeholder
|
||||
*
|
||||
* Protections:
|
||||
* - Never prunes before first user message (protects bootstrap/identity reads)
|
||||
* - Protects last N assistant messages and their corresponding tool results
|
||||
* - Skips image-containing tool results
|
||||
* - Respects tool allow/deny lists
|
||||
*/
|
||||
export function pruneToolResults(params: {
|
||||
messages: AgentMessage[];
|
||||
contextWindowTokens: number;
|
||||
settings?: Partial<ToolResultPruningSettings>;
|
||||
}): ToolResultPruningResult {
|
||||
const { messages, contextWindowTokens } = params;
|
||||
const settings: ToolResultPruningSettings = {
|
||||
...DEFAULT_TOOL_RESULT_PRUNING_SETTINGS,
|
||||
...params.settings,
|
||||
softTrim: {
|
||||
...DEFAULT_TOOL_RESULT_PRUNING_SETTINGS.softTrim,
|
||||
...params.settings?.softTrim,
|
||||
},
|
||||
hardClear: {
|
||||
...DEFAULT_TOOL_RESULT_PRUNING_SETTINGS.hardClear,
|
||||
...params.settings?.hardClear,
|
||||
},
|
||||
};
|
||||
|
||||
const charWindow = contextWindowTokens * CHARS_PER_TOKEN_ESTIMATE;
|
||||
if (charWindow <= 0) {
|
||||
return { messages, changed: false, softTrimmed: 0, hardCleared: 0, charsSaved: 0 };
|
||||
}
|
||||
|
||||
// Find cutoff index for protected assistant messages
|
||||
const cutoffIndex = findAssistantCutoffIndex(messages, settings.keepLastAssistants);
|
||||
if (cutoffIndex === null) {
|
||||
return { messages, changed: false, softTrimmed: 0, hardCleared: 0, charsSaved: 0 };
|
||||
}
|
||||
|
||||
// Never prune before first user message (bootstrap protection)
|
||||
const firstUserIndex = findFirstUserIndex(messages);
|
||||
const pruneStartIndex = firstUserIndex === null ? messages.length : firstUserIndex;
|
||||
|
||||
// Calculate current utilization
|
||||
let totalChars = estimateContextChars(messages);
|
||||
let ratio = totalChars / charWindow;
|
||||
|
||||
// No pruning needed
|
||||
if (ratio < settings.softTrimRatio) {
|
||||
return { messages, changed: false, softTrimmed: 0, hardCleared: 0, charsSaved: 0 };
|
||||
}
|
||||
|
||||
let result = messages.slice();
|
||||
let changed = false;
|
||||
let softTrimmed = 0;
|
||||
let hardCleared = 0;
|
||||
let charsSaved = 0;
|
||||
|
||||
// Track which messages have prunable tool results
|
||||
const prunableIndexes: number[] = [];
|
||||
|
||||
// Phase 1: Soft Trim
|
||||
for (let i = pruneStartIndex; i < cutoffIndex; i++) {
|
||||
const msg = result[i];
|
||||
if (!msg || msg.role !== "user") continue;
|
||||
|
||||
const msgAny = msg as any;
|
||||
if (!Array.isArray(msgAny.content)) continue;
|
||||
|
||||
// Check if this message has tool results
|
||||
const hasToolResult = msgAny.content.some(
|
||||
(b: any) => b && typeof b === "object" && b.type === "tool_result",
|
||||
);
|
||||
if (!hasToolResult) continue;
|
||||
|
||||
prunableIndexes.push(i);
|
||||
|
||||
const processed = processUserMessageToolResults(msg, settings, "soft");
|
||||
if (processed.changed) {
|
||||
result[i] = processed.message;
|
||||
changed = true;
|
||||
softTrimmed++;
|
||||
charsSaved += processed.charsSaved;
|
||||
totalChars -= processed.charsSaved;
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate ratio after soft trim
|
||||
ratio = totalChars / charWindow;
|
||||
|
||||
// Phase 2: Hard Clear (if needed)
|
||||
if (ratio >= settings.hardClearRatio && settings.hardClear.enabled) {
|
||||
// Check if we have enough prunable content to make hard clear worthwhile
|
||||
let prunableChars = 0;
|
||||
for (const i of prunableIndexes) {
|
||||
prunableChars += estimateMessageChars(result[i]!);
|
||||
}
|
||||
|
||||
if (prunableChars >= settings.minPrunableToolChars) {
|
||||
for (const i of prunableIndexes) {
|
||||
if (ratio < settings.hardClearRatio) break;
|
||||
|
||||
const msg = result[i]!;
|
||||
const beforeChars = estimateMessageChars(msg);
|
||||
|
||||
const processed = processUserMessageToolResults(msg, settings, "hard");
|
||||
if (processed.changed) {
|
||||
result[i] = processed.message;
|
||||
changed = true;
|
||||
hardCleared++;
|
||||
charsSaved += processed.charsSaved;
|
||||
totalChars -= processed.charsSaved;
|
||||
ratio = totalChars / charWindow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: result,
|
||||
changed,
|
||||
softTrimmed,
|
||||
hardCleared,
|
||||
charsSaved,
|
||||
};
|
||||
}
|
||||
30
src/agent/events.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Super Multica custom events (parallel to pi-agent-core's AgentEvent)
|
||||
*
|
||||
* These events extend the agent's event system with Multica-specific
|
||||
* lifecycle events that pi-agent-core does not provide.
|
||||
*/
|
||||
|
||||
/** Emitted when context compaction begins */
|
||||
export type CompactionStartEvent = {
|
||||
type: "compaction_start";
|
||||
};
|
||||
|
||||
/**
|
||||
* Emitted when context compaction completes.
|
||||
*
|
||||
* Note: `reason` uses a narrow union here for type safety within the agent.
|
||||
* The SDK's `CompactionEndEvent` uses `string` to allow future extensions
|
||||
* without requiring SDK version bumps.
|
||||
*/
|
||||
export type CompactionEndEvent = {
|
||||
type: "compaction_end";
|
||||
removed: number;
|
||||
kept: number;
|
||||
tokensRemoved?: number;
|
||||
tokensKept?: number;
|
||||
reason: "count" | "tokens" | "summary" | "pruning";
|
||||
};
|
||||
|
||||
/** Union of all Multica-specific events */
|
||||
export type MulticaEvent = CompactionStartEvent | CompactionEndEvent;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export * from "./runner.js";
|
||||
export * from "./types.js";
|
||||
export * from "./events.js";
|
||||
export * from "./profile/index.js";
|
||||
export * from "./context-window/index.js";
|
||||
export * from "./skills/index.js";
|
||||
|
|
|
|||
|
|
@ -292,10 +292,10 @@ export class ProfileManager {
|
|||
updateStyle(style: string): void {
|
||||
const profile = this.getOrCreateProfile(false);
|
||||
const currentConfig = profile.config ?? {};
|
||||
const newConfig: ProfileConfig = {
|
||||
...currentConfig,
|
||||
// Use Object.assign to avoid exactOptionalPropertyTypes issues with spread
|
||||
const newConfig: ProfileConfig = Object.assign({}, currentConfig, {
|
||||
style: style as ProfileConfig["style"],
|
||||
};
|
||||
});
|
||||
profile.config = newConfig;
|
||||
this.profile = profile;
|
||||
writeProfileConfig(this.profileId, newConfig, { baseDir: this.baseDir });
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js";
|
||||
import type { MulticaEvent } from "./events.js";
|
||||
import { createAgentOutput } from "./cli/output.js";
|
||||
import { resolveModel, resolveTools } from "./tools.js";
|
||||
import { resolveModel, resolveTools, type ResolveToolsOptions } from "./tools.js";
|
||||
import {
|
||||
resolveApiKey,
|
||||
resolveApiKeyForProfile,
|
||||
|
|
@ -78,11 +79,15 @@ export class Agent {
|
|||
private readonly contextWindowGuard: ContextWindowGuardResult;
|
||||
private readonly debug: boolean;
|
||||
private reasoningMode: ReasoningMode;
|
||||
private toolsOptions: AgentOptions;
|
||||
private toolsOptions: ResolveToolsOptions;
|
||||
private readonly originalToolsConfig?: ToolsConfig;
|
||||
private readonly stderr: NodeJS.WritableStream;
|
||||
private initialized = false;
|
||||
|
||||
// MulticaEvent subscribers (parallel to PiAgentCore's subscriber list)
|
||||
// Typed as AgentEvent | MulticaEvent to match subscribeAll() callback signature
|
||||
private multicaListeners: Array<(event: AgentEvent | MulticaEvent) => void> = [];
|
||||
|
||||
// Auth profile rotation state
|
||||
private resolvedProvider: string;
|
||||
private currentApiKey: string | undefined;
|
||||
|
|
@ -280,7 +285,10 @@ export class Agent {
|
|||
// Merge Profile tools config with options.tools (options takes precedence)
|
||||
const profileToolsConfig = this.profile?.getToolsConfig();
|
||||
const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, options.tools);
|
||||
this.toolsOptions = mergedToolsConfig ? { ...options, tools: mergedToolsConfig } : options;
|
||||
const profileDir = this.profile?.getProfileDir();
|
||||
this.toolsOptions = mergedToolsConfig
|
||||
? { ...options, tools: mergedToolsConfig, profileDir }
|
||||
: { ...options, profileDir };
|
||||
|
||||
const tools = resolveTools(this.toolsOptions);
|
||||
if (this.debug) {
|
||||
|
|
@ -323,6 +331,27 @@ export class Agent {
|
|||
return this.agent.subscribe(fn);
|
||||
}
|
||||
|
||||
/** Subscribe to both AgentEvent and MulticaEvent streams */
|
||||
subscribeAll(fn: (event: AgentEvent | MulticaEvent) => void): () => void {
|
||||
const unsubCore = this.agent.subscribe(fn);
|
||||
this.multicaListeners.push(fn);
|
||||
return () => {
|
||||
unsubCore();
|
||||
const idx = this.multicaListeners.indexOf(fn);
|
||||
if (idx >= 0) this.multicaListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
private emitMulticaEvent(event: MulticaEvent): void {
|
||||
for (const fn of this.multicaListeners) {
|
||||
try {
|
||||
fn(event);
|
||||
} catch {
|
||||
// Don't let listener errors break the agent loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async run(prompt: string): Promise<AgentRunResult> {
|
||||
await this.ensureInitialized();
|
||||
this.output.state.lastAssistantText = "";
|
||||
|
|
@ -422,12 +451,36 @@ export class Agent {
|
|||
|
||||
private async maybeCompact() {
|
||||
const messages = this.agent.state.messages.slice();
|
||||
const result = await this.session.maybeCompact(messages);
|
||||
if (result?.kept) {
|
||||
this.agent.replaceMessages(result.kept);
|
||||
if (!this.session.needsCompaction(messages)) return;
|
||||
|
||||
try {
|
||||
const result = await this.session.maybeCompact(messages);
|
||||
if (!result) return;
|
||||
|
||||
this.emitMulticaEvent({ type: "compaction_start" });
|
||||
if (result?.kept) {
|
||||
this.agent.replaceMessages(result.kept);
|
||||
}
|
||||
this.emitMulticaEvent({
|
||||
type: "compaction_end",
|
||||
removed: result?.removedCount ?? 0,
|
||||
kept: result?.kept.length ?? messages.length,
|
||||
tokensRemoved: result?.tokensRemoved,
|
||||
tokensKept: result?.tokensKept,
|
||||
reason: result?.reason ?? "tokens",
|
||||
});
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all pending session storage writes to complete.
|
||||
*/
|
||||
async flushSession(): Promise<void> {
|
||||
await this.session.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload tools from profile config.
|
||||
* Call this after updating tool status to apply changes
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ export type CompactionResult = {
|
|||
tokensKept?: number | undefined;
|
||||
/** Summary generated in summary mode */
|
||||
summary?: string | undefined;
|
||||
reason: "count" | "tokens" | "summary";
|
||||
/** Reason for compaction: count, tokens, summary, or pruning (tool result trimming only) */
|
||||
reason: "count" | "tokens" | "summary" | "pruning";
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|||
import { getModel, type Model } from "@mariozechner/pi-ai";
|
||||
import type { SessionEntry, SessionMeta } from "./types.js";
|
||||
import { appendEntry, readEntries, resolveSessionPath, writeEntries } from "./storage.js";
|
||||
import { compactMessages, compactMessagesAsync } from "./compaction.js";
|
||||
import { compactMessages, compactMessagesAsync, type CompactionResult } from "./compaction.js";
|
||||
import { estimateTokenUsage, shouldCompact as shouldCompactTokens } from "../context-window/index.js";
|
||||
import { credentialManager } from "../credentials.js";
|
||||
import { repairSessionFileIfNeeded, type RepairReport } from "./session-file-repair.js";
|
||||
import { sanitizeToolCallInputs, sanitizeToolUseResultPairing } from "./session-transcript-repair.js";
|
||||
import {
|
||||
pruneToolResults,
|
||||
type ToolResultPruningSettings,
|
||||
} from "../context-window/tool-result-pruning.js";
|
||||
|
||||
/** Get Kimi model for summarization (use a cheaper model than k2-thinking) */
|
||||
function getSummaryModel(): Model<any> {
|
||||
|
|
@ -53,6 +58,12 @@ export type SessionManagerOptions = {
|
|||
apiKey?: string | undefined;
|
||||
/** Custom summary instructions */
|
||||
customInstructions?: string | undefined;
|
||||
|
||||
// Tool result pruning
|
||||
/** Whether to enable tool result pruning before compaction (default: true in tokens/summary mode) */
|
||||
enableToolResultPruning?: boolean | undefined;
|
||||
/** Tool result pruning settings */
|
||||
toolResultPruning?: Partial<ToolResultPruningSettings> | undefined;
|
||||
};
|
||||
|
||||
export class SessionManager {
|
||||
|
|
@ -73,6 +84,9 @@ export class SessionManager {
|
|||
private apiKey: string | undefined;
|
||||
private readonly customInstructions: string | undefined;
|
||||
private previousSummary: string | undefined;
|
||||
// Tool result pruning
|
||||
private readonly enableToolResultPruning: boolean;
|
||||
private readonly toolResultPruning: Partial<ToolResultPruningSettings> | undefined;
|
||||
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private meta: SessionMeta | undefined;
|
||||
|
|
@ -100,6 +114,12 @@ export class SessionManager {
|
|||
this.apiKey = options.apiKey;
|
||||
this.customInstructions = options.customInstructions;
|
||||
|
||||
// Tool result pruning (enabled by default in tokens/summary mode)
|
||||
this.enableToolResultPruning =
|
||||
options.enableToolResultPruning ??
|
||||
(this.compactionMode === "tokens" || this.compactionMode === "summary");
|
||||
this.toolResultPruning = options.toolResultPruning;
|
||||
|
||||
this.meta = this.loadMeta();
|
||||
}
|
||||
|
||||
|
|
@ -193,7 +213,48 @@ export class SessionManager {
|
|||
);
|
||||
}
|
||||
|
||||
async maybeCompact(messages: AgentMessage[]) {
|
||||
/** Check whether compaction would trigger for the given messages (without executing it) */
|
||||
needsCompaction(messages: AgentMessage[]): boolean {
|
||||
if (this.compactionMode === "count") {
|
||||
return messages.length > this.maxMessages;
|
||||
}
|
||||
// Token and summary modes use the same token-based threshold
|
||||
const estimation = estimateTokenUsage({
|
||||
messages,
|
||||
systemPrompt: this.systemPrompt,
|
||||
contextWindowTokens: this.contextWindowTokens,
|
||||
reserveTokens: this.reserveTokens,
|
||||
});
|
||||
return shouldCompactTokens(estimation);
|
||||
}
|
||||
|
||||
async maybeCompact(messages: AgentMessage[]): Promise<CompactionResult | null> {
|
||||
let workingMessages = messages;
|
||||
let toolResultPruningApplied = false;
|
||||
|
||||
// Phase 1: Tool result pruning (soft trim / hard clear)
|
||||
// This reduces token usage without removing messages
|
||||
if (this.enableToolResultPruning) {
|
||||
const pruneResult = pruneToolResults({
|
||||
messages: workingMessages,
|
||||
contextWindowTokens: this.contextWindowTokens,
|
||||
settings: this.toolResultPruning,
|
||||
});
|
||||
|
||||
if (pruneResult.changed) {
|
||||
workingMessages = pruneResult.messages;
|
||||
toolResultPruningApplied = true;
|
||||
// Log pruning stats
|
||||
if (pruneResult.softTrimmed > 0 || pruneResult.hardCleared > 0) {
|
||||
console.error(
|
||||
`[SessionManager] Tool result pruning: ${pruneResult.softTrimmed} soft-trimmed, ` +
|
||||
`${pruneResult.hardCleared} hard-cleared, ~${Math.round(pruneResult.charsSaved / 1000)}k chars saved`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Message compaction (remove old messages if still needed)
|
||||
let result;
|
||||
|
||||
if (this.compactionMode === "summary") {
|
||||
|
|
@ -203,7 +264,7 @@ export class SessionManager {
|
|||
|
||||
if (!apiKey) {
|
||||
// No API key available, downgrade to tokens mode
|
||||
result = compactMessages(messages, {
|
||||
result = compactMessages(workingMessages, {
|
||||
mode: "tokens",
|
||||
contextWindowTokens: this.contextWindowTokens,
|
||||
systemPrompt: this.systemPrompt,
|
||||
|
|
@ -212,7 +273,7 @@ export class SessionManager {
|
|||
minKeepMessages: this.minKeepMessages,
|
||||
});
|
||||
} else {
|
||||
result = await compactMessagesAsync(messages, {
|
||||
result = await compactMessagesAsync(workingMessages, {
|
||||
mode: "summary",
|
||||
model,
|
||||
apiKey,
|
||||
|
|
@ -231,7 +292,7 @@ export class SessionManager {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
result = compactMessages(messages, {
|
||||
result = compactMessages(workingMessages, {
|
||||
mode: this.compactionMode,
|
||||
// Count mode parameters
|
||||
maxMessages: this.maxMessages,
|
||||
|
|
@ -245,7 +306,14 @@ export class SessionManager {
|
|||
});
|
||||
}
|
||||
|
||||
if (!result) return null;
|
||||
// If no message compaction needed but tool result pruning was applied,
|
||||
// still return the pruned messages
|
||||
if (!result) {
|
||||
if (toolResultPruningApplied) {
|
||||
return { kept: workingMessages, removedCount: 0, reason: "pruning" as const };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries: SessionEntry[] = [];
|
||||
if (this.meta) {
|
||||
|
|
@ -272,8 +340,19 @@ export class SessionManager {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all pending storage writes to complete.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
await this.queue;
|
||||
}
|
||||
|
||||
private enqueue(task: () => Promise<void>) {
|
||||
this.queue = this.queue.then(task, task);
|
||||
this.queue = this.queue.then(task, task).catch((err) => {
|
||||
// Log for debuggability, but preserve failure for awaiters.
|
||||
console.error("[SessionManager] storage write failed:", err);
|
||||
throw err;
|
||||
});
|
||||
return this.queue;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,7 +130,19 @@ export async function acquireSessionWriteLock(params: {
|
|||
const staleMs = params.staleMs ?? 30 * 60 * 1000;
|
||||
const sessionFile = path.resolve(params.sessionFile);
|
||||
const sessionDir = path.dirname(sessionFile);
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
// Retry mkdir to handle transient ENOENT on macOS APFS race conditions
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
break;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT" && attempt < 2) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
let normalizedDir = sessionDir;
|
||||
try {
|
||||
normalizedDir = await fs.realpath(sessionDir);
|
||||
|
|
|
|||
|
|
@ -23,8 +23,17 @@ export function resolveSessionPath(sessionId: string, options?: SessionStorageOp
|
|||
|
||||
export function ensureSessionDir(sessionId: string, options?: SessionStorageOptions) {
|
||||
const dir = resolveSessionDir(sessionId, options);
|
||||
if (!existsSync(dir)) {
|
||||
// mkdirSync with recursive is idempotent (no-op if dir exists),
|
||||
// so skip the existsSync check to avoid a TOCTOU race.
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
} catch (err) {
|
||||
// Retry once on transient ENOENT (macOS APFS race condition)
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,5 +23,5 @@ export type SessionEntry =
|
|||
tokensKept?: number | undefined;
|
||||
/** 摘要模式生成的摘要 */
|
||||
summary?: string | undefined;
|
||||
reason?: "count" | "tokens" | "summary" | undefined;
|
||||
reason?: "count" | "tokens" | "summary" | "pruning" | undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const PROFILE = {
|
|||
config: { name: "TestAgent" },
|
||||
};
|
||||
|
||||
const TOOLS = ["read", "write", "edit", "glob", "exec", "memory_get", "memory_set", "sessions_spawn", "web_search"];
|
||||
const TOOLS = ["read", "write", "edit", "glob", "exec", "sessions_spawn", "web_search"];
|
||||
|
||||
describe("buildSystemPrompt", () => {
|
||||
// ── Full mode ─────────────────────────────────────────────────────────
|
||||
|
|
@ -42,12 +42,6 @@ describe("buildSystemPrompt", () => {
|
|||
expect(result).toContain("## Tool Call Style");
|
||||
});
|
||||
|
||||
it("full mode includes memory section when memory tools present", () => {
|
||||
const result = buildSystemPrompt({ mode: "full", tools: ["memory_get", "memory_set"] });
|
||||
expect(result).toContain("## Memory");
|
||||
expect(result).toContain("search memory first");
|
||||
});
|
||||
|
||||
it("full mode includes sub-agents section when sessions_spawn present", () => {
|
||||
const result = buildSystemPrompt({ mode: "full", tools: ["sessions_spawn"] });
|
||||
expect(result).toContain("## Sub-Agents");
|
||||
|
|
|
|||
|
|
@ -163,11 +163,6 @@ describe("buildToolCallStyleSection", () => {
|
|||
});
|
||||
|
||||
describe("buildConditionalToolSections", () => {
|
||||
it("includes memory section when memory tools present", () => {
|
||||
const result = buildConditionalToolSections(["memory_get", "read"], "full");
|
||||
expect(result.join("\n")).toContain("## Memory");
|
||||
});
|
||||
|
||||
it("includes sub-agents section when sessions_spawn present in full mode", () => {
|
||||
const result = buildConditionalToolSections(["sessions_spawn"], "full");
|
||||
expect(result.join("\n")).toContain("## Sub-Agents");
|
||||
|
|
@ -189,7 +184,7 @@ describe("buildConditionalToolSections", () => {
|
|||
});
|
||||
|
||||
it("returns empty in none mode", () => {
|
||||
expect(buildConditionalToolSections(["memory_get"], "none")).toEqual([]);
|
||||
expect(buildConditionalToolSections(["read"], "none")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@
|
|||
|
||||
import { SAFETY_CONSTITUTION } from "./constitution.js";
|
||||
import { formatRuntimeLine } from "./runtime-info.js";
|
||||
import type { ProfileContent, RuntimeInfo, SubagentContext, SystemPromptMode } from "./types.js";
|
||||
import type {
|
||||
ProfileContent,
|
||||
RuntimeInfo,
|
||||
SubagentContext,
|
||||
SystemPromptMode,
|
||||
} from "./types.js";
|
||||
|
||||
// ─── Core tool summaries ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -20,10 +25,7 @@ const CORE_TOOL_SUMMARIES: Record<string, string> = {
|
|||
process: "Manage background exec sessions",
|
||||
web_search: "Search the web",
|
||||
web_fetch: "Fetch and extract readable content from a URL",
|
||||
memory_get: "Read from agent memory",
|
||||
memory_set: "Write to agent memory",
|
||||
memory_list: "List memory entries",
|
||||
memory_delete: "Delete memory entries",
|
||||
memory_search: "Search memory files by keyword",
|
||||
sessions_spawn: "Spawn a sub-agent session",
|
||||
};
|
||||
|
||||
|
|
@ -37,10 +39,7 @@ const TOOL_ORDER = [
|
|||
"process",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"memory_get",
|
||||
"memory_set",
|
||||
"memory_list",
|
||||
"memory_delete",
|
||||
"memory_search",
|
||||
"sessions_spawn",
|
||||
];
|
||||
|
||||
|
|
@ -98,6 +97,7 @@ export function buildWorkspaceSection(
|
|||
"## Profile",
|
||||
"",
|
||||
`Your profile directory: \`${profileDir}\``,
|
||||
"Use this as the base path for profile files (soul.md, user.md, memory.md, memory/*.md).",
|
||||
"",
|
||||
"Profile files:",
|
||||
"- `soul.md` — Your identity and values",
|
||||
|
|
@ -170,7 +170,9 @@ export function buildToolingSummary(
|
|||
seen.add(tool);
|
||||
const displayName = resolveToolName(tool);
|
||||
const summary = CORE_TOOL_SUMMARIES[tool];
|
||||
toolLines.push(summary ? `- ${displayName}: ${summary}` : `- ${displayName}`);
|
||||
toolLines.push(
|
||||
summary ? `- ${displayName}: ${summary}` : `- ${displayName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,16 +219,16 @@ export function buildConditionalToolSections(
|
|||
const lines: string[] = [];
|
||||
|
||||
// Memory tools
|
||||
const hasMemory =
|
||||
toolSet.has("memory_get") ||
|
||||
toolSet.has("memory_set") ||
|
||||
toolSet.has("memory_list") ||
|
||||
toolSet.has("memory_delete");
|
||||
if (hasMemory) {
|
||||
if (toolSet.has("memory_search")) {
|
||||
lines.push(
|
||||
"## Memory",
|
||||
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: search memory first, then pull only the needed entries.",
|
||||
"Update memory when the user shares important information, decisions, or preferences.",
|
||||
"## Memory Recall",
|
||||
"Before answering anything about prior work, decisions, dates, people, preferences, or todos:",
|
||||
"1. Run `memory_search` to find relevant entries in memory files",
|
||||
"2. Use `read` to pull needed context",
|
||||
"",
|
||||
"To update memory, use `edit` on the appropriate file:",
|
||||
"- `memory.md` — Long-term knowledge (decisions, preferences, important context)",
|
||||
"- `memory/YYYY-MM-DD.md` — Daily logs and session notes",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
|
@ -345,6 +347,7 @@ export function buildExtraPromptSection(
|
|||
const trimmed = extraSystemPrompt?.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const header = mode === "minimal" ? "## Subagent Context" : "## Additional Context";
|
||||
const header =
|
||||
mode === "minimal" ? "## Subagent Context" : "## Additional Context";
|
||||
return [header, trimmed];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { createExecTool } from "./tools/exec.js";
|
|||
import { createProcessTool } from "./tools/process.js";
|
||||
import { createGlobTool } from "./tools/glob.js";
|
||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js";
|
||||
import { createMemoryTools } from "./tools/memory/index.js";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn.js";
|
||||
import { createMemorySearchTool } from "./tools/memory-search.js";
|
||||
import { filterTools } from "./tools/policy.js";
|
||||
import { isMulticaError, isRetryableError } from "../shared/errors.js";
|
||||
import type { ExecApprovalCallback } from "./tools/exec-approval-types.js";
|
||||
|
|
@ -18,10 +18,8 @@ export { resolveModel } from "./providers/index.js";
|
|||
/** Options for creating tools */
|
||||
export interface CreateToolsOptions {
|
||||
cwd: string;
|
||||
/** Profile ID for memory tools (optional) */
|
||||
profileId?: string | undefined;
|
||||
/** Base directory for profiles (optional) */
|
||||
profileBaseDir?: string | undefined;
|
||||
/** Profile directory for memory_search tool (optional) */
|
||||
profileDir?: string | undefined;
|
||||
/** Whether this agent is a subagent (passed to sessions_spawn tool) */
|
||||
isSubagent?: boolean | undefined;
|
||||
/** Session ID of the agent (passed to sessions_spawn tool) */
|
||||
|
|
@ -97,7 +95,7 @@ function wrapTool<TParams extends TSchema, TResult>(
|
|||
export function createAllTools(options: CreateToolsOptions | string): AgentTool<any>[] {
|
||||
// Support legacy string argument for backwards compatibility
|
||||
const opts: CreateToolsOptions = typeof options === "string" ? { cwd: options } : options;
|
||||
const { cwd, profileId, profileBaseDir, isSubagent, sessionId } = opts;
|
||||
const { cwd, profileDir, isSubagent, sessionId } = opts;
|
||||
|
||||
const baseTools = createCodingTools(cwd).filter(
|
||||
(tool) => tool.name !== "bash",
|
||||
|
|
@ -118,13 +116,10 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
webSearchTool as AgentTool<any>,
|
||||
];
|
||||
|
||||
// Add memory tools if profileId is provided
|
||||
if (profileId) {
|
||||
const memoryTools = createMemoryTools({
|
||||
profileId,
|
||||
baseDir: profileBaseDir,
|
||||
});
|
||||
tools.push(...memoryTools);
|
||||
// Add memory_search tool if profileDir is provided
|
||||
if (profileDir) {
|
||||
const memorySearchTool = createMemorySearchTool(profileDir);
|
||||
tools.push(memorySearchTool as AgentTool<any>);
|
||||
}
|
||||
|
||||
// Add sessions_spawn tool (will be filtered by policy for subagents)
|
||||
|
|
@ -137,6 +132,12 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
return tools;
|
||||
}
|
||||
|
||||
/** Extended options for resolveTools that includes profileDir */
|
||||
export interface ResolveToolsOptions extends AgentOptions {
|
||||
/** Profile directory for memory_search tool (computed from profileId if not provided) */
|
||||
profileDir?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve tools for an agent with policy filtering.
|
||||
*
|
||||
|
|
@ -146,14 +147,13 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
* 3. Provider-specific rules
|
||||
* 4. Subagent restrictions
|
||||
*/
|
||||
export function resolveTools(options: AgentOptions): AgentTool<any>[] {
|
||||
export function resolveTools(options: ResolveToolsOptions): AgentTool<any>[] {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
|
||||
// Create all tools (including memory tools if profileId is provided)
|
||||
// Create all tools
|
||||
const allTools = createAllTools({
|
||||
cwd,
|
||||
profileId: options.profileId,
|
||||
profileBaseDir: options.profileBaseDir,
|
||||
profileDir: options.profileDir,
|
||||
isSubagent: options.isSubagent,
|
||||
sessionId: options.sessionId,
|
||||
onExecApprovalNeeded: options.onExecApprovalNeeded,
|
||||
|
|
@ -171,20 +171,8 @@ export function resolveTools(options: AgentOptions): AgentTool<any>[] {
|
|||
|
||||
/**
|
||||
* Get all available tool names (for debugging/listing).
|
||||
* Note: Memory tools require profileId, so they are not included by default.
|
||||
*/
|
||||
export function getAllToolNames(cwd?: string): string[] {
|
||||
const tools = createAllTools({ cwd: cwd ?? process.cwd() });
|
||||
return tools.map((t) => t.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available tool names including memory tools (for debugging/listing).
|
||||
*/
|
||||
export function getAllToolNamesWithMemory(cwd?: string, profileId?: string): string[] {
|
||||
const tools = createAllTools({
|
||||
cwd: cwd ?? process.cwd(),
|
||||
profileId: profileId ?? "test-profile",
|
||||
});
|
||||
return tools.map((t) => t.name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,34 +49,31 @@ The tools system provides LLM agents with capabilities to interact with the exte
|
|||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Name | Description |
|
||||
| ------------- | --------------- | --------------------------------------- |
|
||||
| Read | `read` | Read file contents |
|
||||
| Write | `write` | Write content to files |
|
||||
| Edit | `edit` | Edit existing files |
|
||||
| Glob | `glob` | Find files by pattern |
|
||||
| Exec | `exec` | Execute shell commands |
|
||||
| Process | `process` | Manage long-running processes |
|
||||
| Web Fetch | `web_fetch` | Fetch and extract content from URLs |
|
||||
| Web Search | `web_search` | Search the web (requires API key) |
|
||||
| Memory Get | `memory_get` | Retrieve a value from persistent memory |
|
||||
| Memory Set | `memory_set` | Store a value in persistent memory |
|
||||
| Memory Delete | `memory_delete` | Delete a value from persistent memory |
|
||||
| Memory List | `memory_list` | List all keys in persistent memory |
|
||||
| Tool | Name | Description |
|
||||
| -------------- | ---------------- | ----------------------------------- |
|
||||
| Read | `read` | Read file contents |
|
||||
| Write | `write` | Write content to files |
|
||||
| Edit | `edit` | Edit existing files |
|
||||
| Glob | `glob` | Find files by pattern |
|
||||
| Exec | `exec` | Execute shell commands |
|
||||
| Process | `process` | Manage long-running processes |
|
||||
| Web Fetch | `web_fetch` | Fetch and extract content from URLs |
|
||||
| Web Search | `web_search` | Search the web (requires API key) |
|
||||
| Sessions Spawn | `sessions_spawn` | Spawn a sub-agent session |
|
||||
|
||||
> **Note**: Memory tools require a `profileId` to be specified. They store data in the profile's memory directory.
|
||||
> **Note**: Agents use file-based memory (`memory.md`, `memory/*.md`) via `read` and `edit` tools instead of dedicated memory tools.
|
||||
|
||||
## Tool Groups
|
||||
|
||||
Groups provide shortcuts for allowing/denying multiple tools at once:
|
||||
|
||||
| Group | Tools |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| `group:fs` | read, write, edit, glob |
|
||||
| `group:runtime` | exec, process |
|
||||
| `group:web` | web_search, web_fetch |
|
||||
| `group:memory` | memory_get, memory_set, memory_delete, memory_list |
|
||||
| `group:core` | All of the above (excluding memory) |
|
||||
| Group | Tools |
|
||||
| ---------------- | ------------------------------------ |
|
||||
| `group:fs` | read, write, edit, glob |
|
||||
| `group:runtime` | exec, process |
|
||||
| `group:web` | web_search, web_fetch |
|
||||
| `group:subagent` | sessions_spawn |
|
||||
| `group:core` | All fs, runtime, and web tools |
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
|
|||
|
|
@ -49,34 +49,33 @@
|
|||
|
||||
## 可用工具
|
||||
|
||||
| 工具 | 名称 | 描述 |
|
||||
| ------------- | --------------- | ------------------------ |
|
||||
| Read | `read` | 读取文件内容 |
|
||||
| Write | `write` | 写入文件内容 |
|
||||
| Edit | `edit` | 编辑现有文件 |
|
||||
| Glob | `glob` | 按模式查找文件 |
|
||||
| Exec | `exec` | 执行 Shell 命令 |
|
||||
| Process | `process` | 管理长时间运行的进程 |
|
||||
| Web Fetch | `web_fetch` | 从 URL 获取并提取内容 |
|
||||
| Web Search | `web_search` | 搜索网络(需要 API Key) |
|
||||
| Memory Get | `memory_get` | 从持久化内存中获取值 |
|
||||
| Memory Set | `memory_set` | 向持久化内存中存储值 |
|
||||
| Memory Delete | `memory_delete` | 从持久化内存中删除值 |
|
||||
| Memory List | `memory_list` | 列出持久化内存中的所有键 |
|
||||
| 工具 | 名称 | 描述 |
|
||||
| -------------- | ---------------- | ------------------------------ |
|
||||
| Read | `read` | 读取文件内容 |
|
||||
| Write | `write` | 写入文件内容 |
|
||||
| Edit | `edit` | 编辑现有文件 |
|
||||
| Glob | `glob` | 按模式查找文件 |
|
||||
| Exec | `exec` | 执行 Shell 命令 |
|
||||
| Process | `process` | 管理长时间运行的进程 |
|
||||
| Web Fetch | `web_fetch` | 从 URL 获取并提取内容 |
|
||||
| Web Search | `web_search` | 搜索网络(需要 API Key) |
|
||||
| Memory Search | `memory_search` | 搜索 memory 文件(需要 Profile)|
|
||||
| Sessions Spawn | `sessions_spawn` | 创建子 Agent 会话 |
|
||||
|
||||
> **注意**: Memory 工具需要指定 `profileId`。数据存储在 Profile 的 memory 目录中。
|
||||
> **注意**: `memory_search` 工具通过关键词搜索 `memory.md` 和 `memory/*.md` 文件。Agent 通过 `read` 和 `edit` 工具操作 memory 文件内容。
|
||||
|
||||
## 工具组
|
||||
|
||||
工具组提供了一次性允许/禁止多个工具的快捷方式:
|
||||
|
||||
| 组 | 工具 |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| `group:fs` | read, write, edit, glob |
|
||||
| `group:runtime` | exec, process |
|
||||
| `group:web` | web_search, web_fetch |
|
||||
| `group:memory` | memory_get, memory_set, memory_delete, memory_list |
|
||||
| `group:core` | 以上所有(不包括 memory) |
|
||||
| 组 | 工具 |
|
||||
| ---------------- | ------------------------------ |
|
||||
| `group:fs` | read, write, edit, glob |
|
||||
| `group:runtime` | exec, process |
|
||||
| `group:web` | web_search, web_fetch |
|
||||
| `group:memory` | memory_search |
|
||||
| `group:subagent` | sessions_spawn |
|
||||
| `group:core` | 所有 fs、runtime 和 web 工具 |
|
||||
|
||||
## 使用方法
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
|||
// Web tools
|
||||
"group:web": ["web_search", "web_fetch"],
|
||||
|
||||
// Memory tools (requires profileId)
|
||||
"group:memory": ["memory_get", "memory_set", "memory_delete", "memory_list"],
|
||||
// Memory tools (requires profile)
|
||||
"group:memory": ["memory_search"],
|
||||
|
||||
// Subagent tools
|
||||
"group:subagent": ["sessions_spawn"],
|
||||
|
|
|
|||
154
src/agent/tools/memory-search.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, writeFileSync, rmSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { createMemorySearchTool } from "./memory-search.js";
|
||||
|
||||
describe("memory_search tool", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `memory-search-test-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates tool with correct name and description", () => {
|
||||
const tool = createMemorySearchTool(testDir);
|
||||
expect(tool.name).toBe("memory_search");
|
||||
expect(tool.label).toBe("Memory Search");
|
||||
expect(tool.description).toContain("memory files");
|
||||
});
|
||||
|
||||
it("returns no matches when no memory files exist", async () => {
|
||||
const tool = createMemorySearchTool(testDir);
|
||||
const result = await tool.execute("test-call", { query: "test" }, undefined);
|
||||
expect(result.details?.matches).toHaveLength(0);
|
||||
expect(result.details?.filesSearched).toBe(0);
|
||||
});
|
||||
|
||||
it("searches memory.md file", async () => {
|
||||
// Create memory.md with test content
|
||||
writeFileSync(
|
||||
join(testDir, "memory.md"),
|
||||
"# Memory\n\nUser prefers TypeScript over JavaScript.\n\nDecision: Use ESLint for linting.\n",
|
||||
);
|
||||
|
||||
const tool = createMemorySearchTool(testDir);
|
||||
const result = await tool.execute("test-call", { query: "TypeScript" }, undefined);
|
||||
|
||||
expect(result.details?.matches).toHaveLength(1);
|
||||
expect(result.details?.matches[0]?.file).toBe("memory.md");
|
||||
expect(result.details?.matches[0]?.content).toContain("TypeScript");
|
||||
});
|
||||
|
||||
it("searches memory/*.md files", async () => {
|
||||
// Create memory directory with daily logs
|
||||
const memoryDir = join(testDir, "memory");
|
||||
mkdirSync(memoryDir);
|
||||
writeFileSync(
|
||||
join(memoryDir, "2024-01-15.md"),
|
||||
"# 2024-01-15\n\nDiscussed API design with team.\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(memoryDir, "2024-01-16.md"),
|
||||
"# 2024-01-16\n\nImplemented user authentication.\n",
|
||||
);
|
||||
|
||||
const tool = createMemorySearchTool(testDir);
|
||||
const result = await tool.execute("test-call", { query: "API" }, undefined);
|
||||
|
||||
expect(result.details?.matches).toHaveLength(1);
|
||||
expect(result.details?.matches[0]?.file).toBe("memory/2024-01-15.md");
|
||||
});
|
||||
|
||||
it("searches both memory.md and memory/*.md", async () => {
|
||||
// Create memory.md
|
||||
writeFileSync(join(testDir, "memory.md"), "Important: Always test code.\n");
|
||||
|
||||
// Create memory directory
|
||||
const memoryDir = join(testDir, "memory");
|
||||
mkdirSync(memoryDir);
|
||||
writeFileSync(join(memoryDir, "2024-01-15.md"), "Remember to test before deploy.\n");
|
||||
|
||||
const tool = createMemorySearchTool(testDir);
|
||||
const result = await tool.execute("test-call", { query: "test" }, undefined);
|
||||
|
||||
expect(result.details?.matches).toHaveLength(2);
|
||||
expect(result.details?.filesSearched).toBe(2);
|
||||
});
|
||||
|
||||
it("is case-insensitive by default", async () => {
|
||||
writeFileSync(join(testDir, "memory.md"), "User prefers TYPESCRIPT.\n");
|
||||
|
||||
const tool = createMemorySearchTool(testDir);
|
||||
const result = await tool.execute("test-call", { query: "typescript" }, undefined);
|
||||
|
||||
expect(result.details?.matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("supports case-sensitive search", async () => {
|
||||
writeFileSync(join(testDir, "memory.md"), "User prefers TYPESCRIPT.\n");
|
||||
|
||||
const tool = createMemorySearchTool(testDir);
|
||||
|
||||
// Case-sensitive search should not match
|
||||
const result1 = await tool.execute(
|
||||
"test-call",
|
||||
{ query: "typescript", caseSensitive: true },
|
||||
undefined,
|
||||
);
|
||||
expect(result1.details?.matches).toHaveLength(0);
|
||||
|
||||
// Case-sensitive search should match
|
||||
const result2 = await tool.execute(
|
||||
"test-call",
|
||||
{ query: "TYPESCRIPT", caseSensitive: true },
|
||||
undefined,
|
||||
);
|
||||
expect(result2.details?.matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("includes context lines in results", async () => {
|
||||
writeFileSync(
|
||||
join(testDir, "memory.md"),
|
||||
"Line 1\nLine 2\nMatch here\nLine 4\nLine 5\n",
|
||||
);
|
||||
|
||||
const tool = createMemorySearchTool(testDir);
|
||||
const result = await tool.execute("test-call", { query: "Match" }, undefined);
|
||||
|
||||
expect(result.details?.matches).toHaveLength(1);
|
||||
expect(result.details?.matches[0]?.context.before).toContain("Line 2");
|
||||
expect(result.details?.matches[0]?.context.after).toContain("Line 4");
|
||||
});
|
||||
|
||||
it("respects maxResults limit", async () => {
|
||||
// Create file with multiple matches
|
||||
writeFileSync(
|
||||
join(testDir, "memory.md"),
|
||||
"test line 1\ntest line 2\ntest line 3\ntest line 4\ntest line 5\n",
|
||||
);
|
||||
|
||||
const tool = createMemorySearchTool(testDir);
|
||||
const result = await tool.execute(
|
||||
"test-call",
|
||||
{ query: "test", maxResults: 2 },
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result.details?.matches).toHaveLength(2);
|
||||
expect(result.details?.totalMatches).toBe(5);
|
||||
expect(result.details?.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("throws error for empty query", async () => {
|
||||
const tool = createMemorySearchTool(testDir);
|
||||
await expect(tool.execute("test-call", { query: "" }, undefined)).rejects.toThrow(
|
||||
"Query must not be empty",
|
||||
);
|
||||
});
|
||||
});
|
||||
276
src/agent/tools/memory-search.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import fg from "fast-glob";
|
||||
|
||||
const MemorySearchSchema = Type.Object({
|
||||
query: Type.String({
|
||||
description: "Search query - keywords or phrases to find in memory files.",
|
||||
}),
|
||||
maxResults: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Maximum number of results to return. Defaults to 10.",
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
}),
|
||||
),
|
||||
caseSensitive: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Whether the search is case-sensitive. Defaults to false.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type MemorySearchArgs = {
|
||||
query: string;
|
||||
maxResults?: number;
|
||||
caseSensitive?: boolean;
|
||||
};
|
||||
|
||||
export type MemorySearchMatch = {
|
||||
file: string;
|
||||
line: number;
|
||||
content: string;
|
||||
context: {
|
||||
before: string[];
|
||||
after: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type MemorySearchResult = {
|
||||
matches: MemorySearchMatch[];
|
||||
totalMatches: number;
|
||||
filesSearched: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_RESULTS = 10;
|
||||
const CONTEXT_LINES = 2;
|
||||
|
||||
/**
|
||||
* Create a memory_search tool for searching memory files.
|
||||
*
|
||||
* @param profileDir - Profile directory containing memory.md and memory/ folder
|
||||
*/
|
||||
export function createMemorySearchTool(
|
||||
profileDir: string,
|
||||
): AgentTool<typeof MemorySearchSchema, MemorySearchResult> {
|
||||
return {
|
||||
name: "memory_search",
|
||||
label: "Memory Search",
|
||||
description:
|
||||
"Search through memory files (memory.md and memory/*.md) for keywords or phrases. " +
|
||||
"Use this before answering questions about prior work, decisions, dates, people, preferences, or todos. " +
|
||||
"Returns matching lines with context.",
|
||||
parameters: MemorySearchSchema,
|
||||
execute: async (_toolCallId, args, _signal) => {
|
||||
const { query, maxResults, caseSensitive } = args as MemorySearchArgs;
|
||||
|
||||
if (!query || query.trim() === "") {
|
||||
throw new Error("Query must not be empty");
|
||||
}
|
||||
|
||||
const limit = Math.min(maxResults || DEFAULT_MAX_RESULTS, 50);
|
||||
const searchQuery = caseSensitive ? query : query.toLowerCase();
|
||||
|
||||
// Find all memory files
|
||||
const memoryFiles = await findMemoryFiles(profileDir);
|
||||
|
||||
if (memoryFiles.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No memory files found." }],
|
||||
details: {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
filesSearched: 0,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Search each file
|
||||
const allMatches: MemorySearchMatch[] = [];
|
||||
|
||||
for (const file of memoryFiles) {
|
||||
const matches = await searchFile(file, searchQuery, caseSensitive ?? false, profileDir);
|
||||
allMatches.push(...matches);
|
||||
}
|
||||
|
||||
// Sort by relevance (files with more matches first, then by line number)
|
||||
allMatches.sort((a, b) => {
|
||||
if (a.file !== b.file) {
|
||||
// Count matches per file
|
||||
const aCount = allMatches.filter((m) => m.file === a.file).length;
|
||||
const bCount = allMatches.filter((m) => m.file === b.file).length;
|
||||
return bCount - aCount;
|
||||
}
|
||||
return a.line - b.line;
|
||||
});
|
||||
|
||||
const totalMatches = allMatches.length;
|
||||
const truncated = allMatches.length > limit;
|
||||
const limitedMatches = allMatches.slice(0, limit);
|
||||
|
||||
// Format output
|
||||
const output = formatSearchResults(limitedMatches, totalMatches, truncated, memoryFiles.length);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: {
|
||||
matches: limitedMatches,
|
||||
totalMatches,
|
||||
filesSearched: memoryFiles.length,
|
||||
truncated,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all memory files in the profile directory.
|
||||
*/
|
||||
async function findMemoryFiles(profileDir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
|
||||
// Check for memory.md in profile root
|
||||
const memoryMd = path.join(profileDir, "memory.md");
|
||||
try {
|
||||
await fs.access(memoryMd);
|
||||
files.push(memoryMd);
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
// Check for memory/*.md files
|
||||
const memoryDir = path.join(profileDir, "memory");
|
||||
try {
|
||||
await fs.access(memoryDir);
|
||||
const mdFiles = await fg("*.md", {
|
||||
cwd: memoryDir,
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
});
|
||||
files.push(...mdFiles);
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a single file for the query.
|
||||
*/
|
||||
async function searchFile(
|
||||
filePath: string,
|
||||
query: string,
|
||||
caseSensitive: boolean,
|
||||
profileDir: string,
|
||||
): Promise<MemorySearchMatch[]> {
|
||||
const matches: MemorySearchMatch[] = [];
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
const searchLine = caseSensitive ? line : line.toLowerCase();
|
||||
|
||||
if (searchLine.includes(query)) {
|
||||
// Get context lines
|
||||
const beforeLines: string[] = [];
|
||||
const afterLines: string[] = [];
|
||||
|
||||
for (let j = Math.max(0, i - CONTEXT_LINES); j < i; j++) {
|
||||
beforeLines.push(lines[j]!);
|
||||
}
|
||||
|
||||
for (let j = i + 1; j <= Math.min(lines.length - 1, i + CONTEXT_LINES); j++) {
|
||||
afterLines.push(lines[j]!);
|
||||
}
|
||||
|
||||
// Get relative path for display
|
||||
const relativePath = path.relative(profileDir, filePath);
|
||||
|
||||
matches.push({
|
||||
file: relativePath,
|
||||
line: i + 1, // 1-indexed
|
||||
content: line,
|
||||
context: {
|
||||
before: beforeLines,
|
||||
after: afterLines,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Skip files that can't be read
|
||||
console.error(`Failed to read ${filePath}:`, err);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results for display.
|
||||
*/
|
||||
function formatSearchResults(
|
||||
matches: MemorySearchMatch[],
|
||||
totalMatches: number,
|
||||
truncated: boolean,
|
||||
filesSearched: number,
|
||||
): string {
|
||||
if (matches.length === 0) {
|
||||
return `No matches found in ${filesSearched} memory file(s).`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Found ${totalMatches} match(es) in ${filesSearched} file(s):`);
|
||||
|
||||
if (truncated) {
|
||||
lines.push(`(Showing first ${matches.length} results)`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
|
||||
// Group by file
|
||||
const byFile = new Map<string, MemorySearchMatch[]>();
|
||||
for (const match of matches) {
|
||||
const existing = byFile.get(match.file) || [];
|
||||
existing.push(match);
|
||||
byFile.set(match.file, existing);
|
||||
}
|
||||
|
||||
for (const [file, fileMatches] of byFile) {
|
||||
lines.push(`## ${file}`);
|
||||
lines.push("");
|
||||
|
||||
for (const match of fileMatches) {
|
||||
lines.push(`**Line ${match.line}:**`);
|
||||
|
||||
// Show context before
|
||||
if (match.context.before.length > 0) {
|
||||
for (const ctx of match.context.before) {
|
||||
lines.push(` ${ctx}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show matching line (highlighted)
|
||||
lines.push(`> ${match.content}`);
|
||||
|
||||
// Show context after
|
||||
if (match.context.after.length > 0) {
|
||||
for (const ctx of match.context.after) {
|
||||
lines.push(` ${ctx}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/**
|
||||
* Memory Tools Module
|
||||
*/
|
||||
|
||||
export { createMemoryTools } from "./memory-tools.js";
|
||||
export type { MemoryEntry, MemoryStorageOptions, MemoryListResult } from "./types.js";
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
/**
|
||||
* Memory Tools
|
||||
*
|
||||
* Provides persistent key-value storage for agents.
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { memoryDelete, memoryGet, memoryList, memorySet, validateKey } from "./storage.js";
|
||||
import type { MemoryStorageOptions } from "./types.js";
|
||||
|
||||
// ============================================================================
|
||||
// Schemas
|
||||
// ============================================================================
|
||||
|
||||
const MemoryGetSchema = Type.Object({
|
||||
key: Type.String({ description: "The key to retrieve" }),
|
||||
});
|
||||
|
||||
const MemorySetSchema = Type.Object({
|
||||
key: Type.String({ description: "The key to set (alphanumeric, underscore, dot, hyphen)" }),
|
||||
value: Type.Unknown({ description: "The value to store (will be JSON serialized)" }),
|
||||
description: Type.Optional(
|
||||
Type.String({ description: "Optional description of this memory entry" }),
|
||||
),
|
||||
});
|
||||
|
||||
const MemoryDeleteSchema = Type.Object({
|
||||
key: Type.String({ description: "The key to delete" }),
|
||||
});
|
||||
|
||||
const MemoryListSchema = Type.Object({
|
||||
prefix: Type.Optional(Type.String({ description: "Filter keys by prefix" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of keys to return (default 100)" })),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Helper
|
||||
// ============================================================================
|
||||
|
||||
function jsonResult<T>(data: T): {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: T;
|
||||
} {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tools
|
||||
// ============================================================================
|
||||
|
||||
export function createMemoryGetTool(
|
||||
options: MemoryStorageOptions,
|
||||
): AgentTool<typeof MemoryGetSchema> {
|
||||
return {
|
||||
name: "memory_get",
|
||||
label: "Memory Get",
|
||||
description: "Retrieve a value from persistent memory by key.",
|
||||
parameters: MemoryGetSchema,
|
||||
execute: async (_toolCallId, params) => {
|
||||
const key = typeof params.key === "string" ? params.key.trim() : "";
|
||||
|
||||
const validation = validateKey(key);
|
||||
if (!validation.valid) {
|
||||
return jsonResult({ found: false, error: validation.error });
|
||||
}
|
||||
|
||||
const result = memoryGet(key, options);
|
||||
if (!result.found) {
|
||||
return jsonResult({ found: false, key });
|
||||
}
|
||||
|
||||
return jsonResult({
|
||||
found: true,
|
||||
key,
|
||||
value: result.entry.value,
|
||||
description: result.entry.description,
|
||||
updatedAt: result.entry.updatedAt,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemorySetTool(
|
||||
options: MemoryStorageOptions,
|
||||
): AgentTool<typeof MemorySetSchema> {
|
||||
return {
|
||||
name: "memory_set",
|
||||
label: "Memory Set",
|
||||
description:
|
||||
"Store a value in persistent memory. The value will be JSON serialized. " +
|
||||
"Keys can contain letters, numbers, underscores, dots, and hyphens.",
|
||||
parameters: MemorySetSchema,
|
||||
execute: async (_toolCallId, params) => {
|
||||
const key = typeof params.key === "string" ? params.key.trim() : "";
|
||||
const value = params.value;
|
||||
const description = typeof params.description === "string" ? params.description : undefined;
|
||||
|
||||
const result = memorySet(key, value, description, options);
|
||||
if (!result.success) {
|
||||
return jsonResult({ success: false, error: result.error });
|
||||
}
|
||||
|
||||
return jsonResult({ success: true, key });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryDeleteTool(
|
||||
options: MemoryStorageOptions,
|
||||
): AgentTool<typeof MemoryDeleteSchema> {
|
||||
return {
|
||||
name: "memory_delete",
|
||||
label: "Memory Delete",
|
||||
description: "Delete a value from persistent memory by key.",
|
||||
parameters: MemoryDeleteSchema,
|
||||
execute: async (_toolCallId, params) => {
|
||||
const key = typeof params.key === "string" ? params.key.trim() : "";
|
||||
|
||||
const validation = validateKey(key);
|
||||
if (!validation.valid) {
|
||||
return jsonResult({ success: false, error: validation.error });
|
||||
}
|
||||
|
||||
const result = memoryDelete(key, options);
|
||||
if (!result.success) {
|
||||
return jsonResult({ success: false, error: result.error });
|
||||
}
|
||||
|
||||
return jsonResult({ success: true, key, existed: result.existed });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryListTool(
|
||||
options: MemoryStorageOptions,
|
||||
): AgentTool<typeof MemoryListSchema> {
|
||||
return {
|
||||
name: "memory_list",
|
||||
label: "Memory List",
|
||||
description:
|
||||
"List all keys in persistent memory, sorted by most recently updated. " +
|
||||
"Optionally filter by prefix.",
|
||||
parameters: MemoryListSchema,
|
||||
execute: async (_toolCallId, params) => {
|
||||
const prefix = typeof params.prefix === "string" ? params.prefix : undefined;
|
||||
const limit = typeof params.limit === "number" ? params.limit : undefined;
|
||||
|
||||
const result = memoryList(prefix, limit, options);
|
||||
|
||||
return jsonResult({
|
||||
keys: result.keys,
|
||||
total: result.total,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create all memory tools for a profile
|
||||
*/
|
||||
export function createMemoryTools(
|
||||
options: MemoryStorageOptions,
|
||||
): Array<AgentTool<any>> {
|
||||
return [
|
||||
createMemoryGetTool(options),
|
||||
createMemorySetTool(options),
|
||||
createMemoryDeleteTool(options),
|
||||
createMemoryListTool(options),
|
||||
];
|
||||
}
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
validateKey,
|
||||
memoryGet,
|
||||
memorySet,
|
||||
memoryDelete,
|
||||
memoryList,
|
||||
getMemoryDir,
|
||||
} from "./storage.js";
|
||||
import type { MemoryStorageOptions } from "./types.js";
|
||||
|
||||
describe("memory storage", () => {
|
||||
const testBaseDir = join(tmpdir(), `multica-memory-test-${Date.now()}`);
|
||||
const profileId = "test-profile";
|
||||
|
||||
const options: MemoryStorageOptions = {
|
||||
profileId,
|
||||
baseDir: testBaseDir,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
if (existsSync(testBaseDir)) {
|
||||
rmSync(testBaseDir, { recursive: true });
|
||||
}
|
||||
mkdirSync(testBaseDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testBaseDir)) {
|
||||
rmSync(testBaseDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("validateKey", () => {
|
||||
it("should accept valid alphanumeric keys", () => {
|
||||
expect(validateKey("mykey")).toEqual({ valid: true });
|
||||
expect(validateKey("my_key")).toEqual({ valid: true });
|
||||
expect(validateKey("my-key")).toEqual({ valid: true });
|
||||
expect(validateKey("my.key")).toEqual({ valid: true });
|
||||
expect(validateKey("MyKey123")).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it("should reject empty keys", () => {
|
||||
expect(validateKey("")).toMatchObject({ valid: false, error: "Key is required" });
|
||||
expect(validateKey(" ")).toMatchObject({ valid: false, error: "Key cannot be empty" });
|
||||
});
|
||||
|
||||
it("should reject keys with invalid characters", () => {
|
||||
const result = validateKey("my key");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("can only contain");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject keys that are too long", () => {
|
||||
const longKey = "a".repeat(129);
|
||||
const result = validateKey(longKey);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("exceeds maximum length");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("memorySet and memoryGet", () => {
|
||||
it("should set and get a string value", () => {
|
||||
const result = memorySet("test-key", "test-value", undefined, options);
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const getResult = memoryGet("test-key", options);
|
||||
expect(getResult.found).toBe(true);
|
||||
if (getResult.found) {
|
||||
expect(getResult.entry.value).toBe("test-value");
|
||||
}
|
||||
});
|
||||
|
||||
it("should set and get a complex object", () => {
|
||||
const value = { name: "test", count: 42, nested: { a: 1 } };
|
||||
memorySet("complex-key", value, "A complex object", options);
|
||||
|
||||
const getResult = memoryGet("complex-key", options);
|
||||
expect(getResult.found).toBe(true);
|
||||
if (getResult.found) {
|
||||
expect(getResult.entry.value).toEqual(value);
|
||||
expect(getResult.entry.description).toBe("A complex object");
|
||||
}
|
||||
});
|
||||
|
||||
it("should update existing key and preserve createdAt", async () => {
|
||||
memorySet("update-key", "initial", undefined, options);
|
||||
const firstGet = memoryGet("update-key", options);
|
||||
expect(firstGet.found).toBe(true);
|
||||
|
||||
// Wait a bit to ensure different timestamp
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
memorySet("update-key", "updated", undefined, options);
|
||||
const secondGet = memoryGet("update-key", options);
|
||||
|
||||
expect(secondGet.found).toBe(true);
|
||||
if (firstGet.found && secondGet.found) {
|
||||
expect(secondGet.entry.value).toBe("updated");
|
||||
expect(secondGet.entry.createdAt).toBe(firstGet.entry.createdAt);
|
||||
expect(secondGet.entry.updatedAt).toBeGreaterThan(firstGet.entry.createdAt);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return not found for non-existent key", () => {
|
||||
const result = memoryGet("non-existent", options);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle keys with dots", () => {
|
||||
memorySet("user.settings.theme", "dark", undefined, options);
|
||||
|
||||
const result = memoryGet("user.settings.theme", options);
|
||||
expect(result.found).toBe(true);
|
||||
if (result.found) {
|
||||
expect(result.entry.value).toBe("dark");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject value that is too large", () => {
|
||||
const largeValue = "x".repeat(1024 * 1024 + 1);
|
||||
const result = memorySet("large-key", largeValue, undefined, options);
|
||||
expect(result).toMatchObject({ success: false });
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain("exceeds maximum size");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("memoryDelete", () => {
|
||||
it("should delete existing key", () => {
|
||||
memorySet("delete-me", "value", undefined, options);
|
||||
expect(memoryGet("delete-me", options).found).toBe(true);
|
||||
|
||||
const result = memoryDelete("delete-me", options);
|
||||
expect(result).toEqual({ success: true, existed: true });
|
||||
|
||||
expect(memoryGet("delete-me", options).found).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle deleting non-existent key", () => {
|
||||
const result = memoryDelete("non-existent", options);
|
||||
expect(result).toEqual({ success: true, existed: false });
|
||||
});
|
||||
|
||||
it("should reject invalid key", () => {
|
||||
const result = memoryDelete("invalid key", options);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memoryList", () => {
|
||||
beforeEach(() => {
|
||||
// Create some test keys
|
||||
memorySet("project.config", { name: "test" }, "Project config", options);
|
||||
memorySet("project.settings", { theme: "dark" }, "Settings", options);
|
||||
memorySet("user.name", "Alice", "User name", options);
|
||||
});
|
||||
|
||||
it("should list all keys", () => {
|
||||
const result = memoryList(undefined, undefined, options);
|
||||
|
||||
expect(result.total).toBe(3);
|
||||
expect(result.truncated).toBe(false);
|
||||
expect(result.keys.map((k) => k.key)).toContain("project.config");
|
||||
expect(result.keys.map((k) => k.key)).toContain("project.settings");
|
||||
expect(result.keys.map((k) => k.key)).toContain("user.name");
|
||||
});
|
||||
|
||||
it("should filter by prefix", () => {
|
||||
const result = memoryList("project", undefined, options);
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.keys.map((k) => k.key)).toContain("project.config");
|
||||
expect(result.keys.map((k) => k.key)).toContain("project.settings");
|
||||
expect(result.keys.map((k) => k.key)).not.toContain("user.name");
|
||||
});
|
||||
|
||||
it("should respect limit", () => {
|
||||
const result = memoryList(undefined, 2, options);
|
||||
|
||||
expect(result.keys.length).toBe(2);
|
||||
expect(result.total).toBe(3);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("should sort by updatedAt descending", async () => {
|
||||
// Wait and update one key
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
memorySet("project.config", { name: "updated" }, "Updated config", options);
|
||||
|
||||
const result = memoryList(undefined, undefined, options);
|
||||
|
||||
// project.config should be first as it was updated most recently
|
||||
expect(result.keys[0]?.key).toBe("project.config");
|
||||
});
|
||||
|
||||
it("should return empty array for non-existent directory", () => {
|
||||
const emptyOptions: MemoryStorageOptions = {
|
||||
profileId: "non-existent-profile",
|
||||
baseDir: testBaseDir,
|
||||
};
|
||||
|
||||
const result = memoryList(undefined, undefined, emptyOptions);
|
||||
expect(result.keys).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMemoryDir", () => {
|
||||
it("should return correct memory directory path", () => {
|
||||
const dir = getMemoryDir(options);
|
||||
expect(dir).toContain(profileId);
|
||||
expect(dir).toContain("memory");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
/**
|
||||
* Memory Storage Layer
|
||||
*
|
||||
* Handles file-based storage for agent memory in the profile directory.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getProfileDir } from "../../profile/storage.js";
|
||||
import {
|
||||
DEFAULT_LIST_LIMIT,
|
||||
KEY_PATTERN,
|
||||
MAX_KEY_LENGTH,
|
||||
MAX_LIST_LIMIT,
|
||||
MAX_VALUE_SIZE,
|
||||
type MemoryEntry,
|
||||
type MemoryListResult,
|
||||
type MemoryStorageOptions,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Validate a memory key
|
||||
*/
|
||||
export function validateKey(key: string): { valid: true } | { valid: false; error: string } {
|
||||
if (!key || typeof key !== "string") {
|
||||
return { valid: false, error: "Key is required" };
|
||||
}
|
||||
|
||||
const trimmed = key.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { valid: false, error: "Key cannot be empty" };
|
||||
}
|
||||
|
||||
if (trimmed.length > MAX_KEY_LENGTH) {
|
||||
return { valid: false, error: `Key exceeds maximum length of ${MAX_KEY_LENGTH}` };
|
||||
}
|
||||
|
||||
if (!KEY_PATTERN.test(trimmed)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Key can only contain letters, numbers, underscores, dots, and hyphens",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the memory directory for a profile
|
||||
*/
|
||||
export function getMemoryDir(options: MemoryStorageOptions): string {
|
||||
const profileDir = getProfileDir(options.profileId, { baseDir: options.baseDir });
|
||||
return join(profileDir, "memory");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the memory directory exists
|
||||
*/
|
||||
export function ensureMemoryDir(options: MemoryStorageOptions): string {
|
||||
const memoryDir = getMemoryDir(options);
|
||||
if (!existsSync(memoryDir)) {
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
}
|
||||
return memoryDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path for a memory key
|
||||
*/
|
||||
function getKeyFilePath(key: string, options: MemoryStorageOptions): string {
|
||||
const memoryDir = getMemoryDir(options);
|
||||
// Sanitize key for filename (replace dots with double underscore to avoid extension issues)
|
||||
const safeKey = key.replace(/\./g, "__DOT__");
|
||||
return join(memoryDir, `${safeKey}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a sanitized filename back to the original key
|
||||
*/
|
||||
function decodeKeyFromFilename(filename: string): string {
|
||||
// Remove .json extension and decode
|
||||
const base = filename.replace(/\.json$/, "");
|
||||
return base.replace(/__DOT__/g, ".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a memory value by key
|
||||
*/
|
||||
export function memoryGet(
|
||||
key: string,
|
||||
options: MemoryStorageOptions,
|
||||
): { found: true; entry: MemoryEntry } | { found: false } {
|
||||
const validation = validateKey(key);
|
||||
if (!validation.valid) {
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
const filePath = getKeyFilePath(key.trim(), options);
|
||||
if (!existsSync(filePath)) {
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const entry = JSON.parse(content) as MemoryEntry;
|
||||
return { found: true, entry };
|
||||
} catch {
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a memory value
|
||||
*/
|
||||
export function memorySet(
|
||||
key: string,
|
||||
value: unknown,
|
||||
description: string | undefined,
|
||||
options: MemoryStorageOptions,
|
||||
): { success: true } | { success: false; error: string } {
|
||||
const validation = validateKey(key);
|
||||
if (validation.valid === false) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
// Check value size
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized.length > MAX_VALUE_SIZE) {
|
||||
return { success: false, error: `Value exceeds maximum size of ${MAX_VALUE_SIZE} bytes` };
|
||||
}
|
||||
|
||||
const trimmedKey = key.trim();
|
||||
ensureMemoryDir(options);
|
||||
|
||||
const now = Date.now();
|
||||
const existing = memoryGet(trimmedKey, options);
|
||||
|
||||
const trimmedDescription = description?.trim();
|
||||
const entry: MemoryEntry = {
|
||||
value,
|
||||
...(trimmedDescription ? { description: trimmedDescription } : {}),
|
||||
createdAt: existing.found ? existing.entry.createdAt : now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const filePath = getKeyFilePath(trimmedKey, options);
|
||||
|
||||
try {
|
||||
writeFileSync(filePath, JSON.stringify(entry, null, 2), "utf-8");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: `Failed to write memory: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a memory key
|
||||
*/
|
||||
export function memoryDelete(
|
||||
key: string,
|
||||
options: MemoryStorageOptions,
|
||||
): { success: true; existed: boolean } | { success: false; error: string } {
|
||||
const validation = validateKey(key);
|
||||
if (validation.valid === false) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
const filePath = getKeyFilePath(key.trim(), options);
|
||||
const existed = existsSync(filePath);
|
||||
|
||||
if (existed) {
|
||||
try {
|
||||
rmSync(filePath);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: `Failed to delete memory: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, existed };
|
||||
}
|
||||
|
||||
/**
|
||||
* List memory keys
|
||||
*/
|
||||
export function memoryList(
|
||||
prefix: string | undefined,
|
||||
limit: number | undefined,
|
||||
options: MemoryStorageOptions,
|
||||
): MemoryListResult {
|
||||
const memoryDir = getMemoryDir(options);
|
||||
|
||||
if (!existsSync(memoryDir)) {
|
||||
return { keys: [], total: 0, truncated: false };
|
||||
}
|
||||
|
||||
const effectiveLimit = Math.min(
|
||||
Math.max(1, limit ?? DEFAULT_LIST_LIMIT),
|
||||
MAX_LIST_LIMIT,
|
||||
);
|
||||
|
||||
try {
|
||||
const files = readdirSync(memoryDir).filter((f) => f.endsWith(".json"));
|
||||
const entries: Array<{ key: string; description?: string; updatedAt: number }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const key = decodeKeyFromFilename(file);
|
||||
|
||||
// Apply prefix filter
|
||||
if (prefix && !key.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(memoryDir, file);
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const entry = JSON.parse(content) as MemoryEntry;
|
||||
entries.push({
|
||||
key,
|
||||
...(entry.description ? { description: entry.description } : {}),
|
||||
updatedAt: entry.updatedAt,
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by updatedAt descending (most recent first)
|
||||
entries.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
const total = entries.length;
|
||||
const truncated = total > effectiveLimit;
|
||||
const keys = entries.slice(0, effectiveLimit);
|
||||
|
||||
return { keys, total, truncated };
|
||||
} catch {
|
||||
return { keys: [], total: 0, truncated: false };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
/**
|
||||
* Memory Tool Type Definitions
|
||||
*/
|
||||
|
||||
/** Memory entry stored in JSON file */
|
||||
export interface MemoryEntry {
|
||||
/** The stored value */
|
||||
value: unknown;
|
||||
/** Optional description of this memory entry */
|
||||
description?: string;
|
||||
/** Timestamp when created */
|
||||
createdAt: number;
|
||||
/** Timestamp when last updated */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** Memory index structure */
|
||||
export interface MemoryIndex {
|
||||
/** Version for future migrations */
|
||||
version: 1;
|
||||
/** Map of key to metadata */
|
||||
keys: Record<string, MemoryKeyMeta>;
|
||||
}
|
||||
|
||||
/** Metadata for each key in the index */
|
||||
export interface MemoryKeyMeta {
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Created timestamp */
|
||||
createdAt: number;
|
||||
/** Updated timestamp */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** Options for memory storage */
|
||||
export interface MemoryStorageOptions {
|
||||
/** Profile ID (required for storage path) */
|
||||
profileId: string;
|
||||
/** Base directory for profiles */
|
||||
baseDir?: string | undefined;
|
||||
}
|
||||
|
||||
/** Result from memory_list */
|
||||
export interface MemoryListResult {
|
||||
keys: Array<{
|
||||
key: string;
|
||||
description?: string;
|
||||
updatedAt: number;
|
||||
}>;
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** Valid key pattern: alphanumeric, underscore, dot, hyphen */
|
||||
export const KEY_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
|
||||
/** Maximum key length */
|
||||
export const MAX_KEY_LENGTH = 128;
|
||||
|
||||
/** Maximum value size in bytes (1MB) */
|
||||
export const MAX_VALUE_SIZE = 1024 * 1024;
|
||||
|
||||
/** Default list limit */
|
||||
export const DEFAULT_LIST_LIMIT = 100;
|
||||
|
||||
/** Maximum list limit */
|
||||
export const MAX_LIST_LIMIT = 1000;
|
||||
|
|
@ -313,6 +313,18 @@ export class Hub {
|
|||
content: item.content,
|
||||
});
|
||||
} else {
|
||||
// Compaction events: forward with synthetic streamId (no stream tracking)
|
||||
const isCompactionEvent =
|
||||
item.type === "compaction_start" || item.type === "compaction_end";
|
||||
if (isCompactionEvent) {
|
||||
this.client.send(targetDeviceId, StreamAction, {
|
||||
streamId: `compaction:${agent.sessionId}`,
|
||||
agentId: agent.sessionId,
|
||||
event: item,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter: only forward events useful for frontend rendering
|
||||
const maybeMessage = (item as { message?: { role?: string } }).message;
|
||||
const isAssistantMessage = maybeMessage?.role === "assistant";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalDependencies": ["src/**"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
|
|
|
|||