diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..bb99881 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "aitytech", + "owner": { + "name": "AityTech" + }, + "metadata": { + "description": "AI agent tools by AityTech" + }, + "plugins": [ + { + "name": "agentkits-memory", + "source": "./plugin", + "description": "Persistent memory system for AI coding assistants — decisions, patterns, errors, and context across sessions", + "version": "2.2.0", + "category": "productivity", + "tags": ["memory", "context", "persistence", "mcp"] + } + ] +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e1d6030..fa9fe1a 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,23 @@ +# Funding platforms for AgentKits +# Learn more: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository + +# GitHub Sponsors (personal account - easier to set up) +# Change to [agentkits] once org Sponsors is approved github: [aitytech] -custom: ["https://agentkits.net/pro"] + +# Ko-fi +ko_fi: agentkits + +# Buy Me a Coffee (via custom) +# Patreon (if you have one) +# patreon: agentkits + +# Open Collective (if you have one) +# open_collective: agentkits + +# Polar (if you have one) +# polar: agentkits + +# Custom links +custom: + - https://buymeacoffee.com/agentkits diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..b34e4bb --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "memory": { + "command": "node", + "args": ["dist/mcp/server.js"] + } + } +} diff --git a/README.md b/README.md index 9d4d318..9a89923 100644 --- a/README.md +++ b/README.md @@ -13,30 +13,35 @@ License Claude Code Cursor - Copilot Windsurf Cline + OpenCode +
+ Tests + Coverage

- Persistent Memory System for AI Coding Assistants via MCP + Persistent Memory System for AI Coding Assistants

- Fast. Local. Zero external dependencies. + Your AI assistant forgets everything between sessions. AgentKits Memory fixes that.
+ Decisions, patterns, errors, and context — all persisted locally via MCP.

- Store decisions, patterns, errors, and context that persists across sessions.
- No cloud. No API keys. No setup. Just works. + Website • + Docs • + Quick Start • + How It Works • + Platforms • + CLI • + Web Viewer

- Quick Start • - Web Viewer • - Features • - Ecosystem • - agentkits.net + English · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português · Tiếng Việt · Русский · العربية

--- @@ -48,11 +53,61 @@ | **100% Local** | All data stays on your machine. No cloud, no API keys, no accounts | | **Blazing Fast** | Native SQLite (better-sqlite3) = instant queries, zero latency | | **Zero Config** | Works out of the box. No database setup required | -| **Cross-Platform** | Windows, macOS, Linux - same code, same speed | -| **MCP Server** | `memory_save`, `memory_search`, `memory_recall`, `memory_list`, `memory_status` | -| **Web Viewer** | Browser UI to view, add, edit, delete memories | -| **Vector Search** | Optional HNSW semantic similarity (no external service) | -| **Auto-Capture** | Hooks for session context, tool usage, summaries | +| **Multi-Platform** | Claude Code, Cursor, Windsurf, Cline, OpenCode — one setup command | +| **MCP Server** | 9 tools: save, search, timeline, details, recall, list, update, delete, status | +| **Auto-Capture** | Hooks capture session context, tool usage, summaries automatically | +| **AI Enrichment** | Background workers enrich observations with AI-generated summaries | +| **Vector Search** | HNSW semantic similarity with multilingual embeddings (100+ languages) | +| **Web Viewer** | Browser UI to view, search, add, edit, delete memories | +| **3-Layer Search** | Progressive disclosure saves ~87% tokens vs fetching everything | +| **Lifecycle Mgmt** | Auto-compress, archive, and clean up old sessions | +| **Export/Import** | Backup and restore memories as JSON | + +--- + +## How It Works + +``` +Session 1: "Use JWT for auth" Session 2: "Add login endpoint" +┌──────────────────────────┐ ┌──────────────────────────┐ +│ You code with AI... │ │ AI already knows: │ +│ AI makes decisions │ │ ✓ JWT auth decision │ +│ AI encounters errors │ ───► │ ✓ Error solutions │ +│ AI learns patterns │ saved │ ✓ Code patterns │ +│ │ │ ✓ Session context │ +└──────────────────────────┘ └──────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite, 100% local) +``` + +1. **Setup once** — `npx agentkits-memory-setup` configures your platform +2. **Auto-capture** — Hooks record decisions, tool usage, and summaries as you work +3. **Context injection** — Next session starts with relevant history from past sessions +4. **Background processing** — Workers enrich observations with AI, generate embeddings, compress old data +5. **Search anytime** — AI uses MCP tools (`memory_search` → `memory_details`) to find past context + +All data stays in `.claude/memory/memory.db` on your machine. No cloud. No API keys required. + +--- + +## Design Decisions That Matter + +Most memory tools scatter data across markdown files, require Python runtimes, or send your code to external APIs. AgentKits Memory makes fundamentally different choices: + +| Design Choice | Why It Matters | +|---------------|----------------| +| **Single SQLite database** | One file (`memory.db`) holds everything — memories, sessions, observations, embeddings. No scattered files to sync, no merge conflicts, no orphaned data. Backup = copy one file | +| **Native Node.js, zero Python** | Runs wherever Node runs. No conda, no pip, no virtualenv. Same language as your MCP server — one `npx` command, done | +| **Token-efficient 3-layer search** | Search index first (~50 tokens/result), then timeline context, then full details. Only fetch what you need. Other tools dump entire memory files into context, burning tokens on irrelevant content | +| **Auto-capture via hooks** | Decisions, patterns, and errors are recorded as they happen — not after you remember to save them. Session context injection happens automatically on next session start | +| **Local embeddings, no API calls** | Vector search uses a local ONNX model (multilingual-e5-small). Semantic search works offline, costs nothing, and supports 100+ languages | +| **Background workers** | AI enrichment, embedding generation, and compression run asynchronously. Your coding flow is never blocked | +| **Multi-platform from day one** | One `--platform=all` flag configures Claude Code, Cursor, Windsurf, Cline, and OpenCode simultaneously. Same memory database, different editors | +| **Structured observation data** | Tool usage is captured with type classification (read/write/execute/search), file tracking, intent detection, and AI-generated narratives — not raw text dumps | +| **No process leaks** | Background workers self-terminate after 5 minutes, use PID-based lock files with stale-lock cleanup, and handle SIGTERM/SIGINT gracefully. No zombie processes, no orphaned workers | +| **No memory leaks** | Hooks run as short-lived processes (not long-running daemons). Database connections close on shutdown. Embedding subprocess has bounded respawn (max 2), pending request timeouts, and graceful cleanup of all timers and queues | --- @@ -66,68 +121,103 @@ npx agentkits-memory-web Then open **http://localhost:1905** in your browser. +### Session List + +Browse all sessions with timeline view and activity details. + +![Session List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-session-list_v2.png) + ### Memory List Browse all stored memories with search and namespace filtering. -![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list.png) +![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list_v2.png) ### Add Memory Create new memories with key, namespace, type, content, and tags. -![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory.png) +![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory_v2.png) ### Memory Details View full memory details with edit and delete options. -![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail.png) +![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail_v2.png) ### Manage Embeddings Generate and manage vector embeddings for semantic search. -![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding.png) +![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding_v2.png) --- ## Quick Start -### 1. Install +### Option 1: Claude Code Plugin Marketplace (Recommended for Claude Code) + +Install as a plugin with one command — no manual configuration needed: + +```bash +/plugin marketplace add aitytech/agentkits-memory +/plugin install agentkits-memory@aitytech +``` + +This installs hooks, MCP server, and memory workflow skill automatically. Restart Claude Code after installation. + +### Option 2: Automated Setup (All Platforms) + +```bash +npx agentkits-memory-setup +``` + +This auto-detects your platform and configures everything: MCP server, hooks (Claude Code/OpenCode), rules files (Cursor/Windsurf/Cline), and downloads the embedding model. + +**Target a specific platform:** ```bash -npm install @aitytech/agentkits-memory +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all ``` -### 2. Configure MCP Server +### Option 3: Manual MCP Configuration -Add to your `.mcp.json` (or `.claude/.mcp.json`): +If you prefer manual setup, add to your MCP config: ```json { "mcpServers": { "memory": { "command": "npx", - "args": ["agentkits-memory-server"] + "args": ["-y", "agentkits-memory-server"] } } } ``` -### 3. Use Memory Tools +Config file locations: +- **Claude Code**: `.claude/settings.json` (embedded in `mcpServers` key) +- **Cursor**: `.cursor/mcp.json` +- **Windsurf**: `.windsurf/mcp.json` +- **Cline / OpenCode**: `.mcp.json` (project root) + +### 3. MCP Tools Once configured, your AI assistant can use these tools: | Tool | Description | |------|-------------| +| `memory_status` | Check memory system status (call first!) | | `memory_save` | Save decisions, patterns, errors, or context | -| `memory_search` | **[Step 1]** Search memories - returns lightweight index | -| `memory_timeline` | **[Step 2]** Get context around a memory | +| `memory_search` | **[Step 1]** Search index — lightweight IDs + titles (~50 tokens/result) | +| `memory_timeline` | **[Step 2]** Get temporal context around a memory | | `memory_details` | **[Step 3]** Get full content for specific IDs | -| `memory_recall` | Quick recall - returns full content (legacy) | +| `memory_recall` | Quick topic overview — grouped summary | | `memory_list` | List recent memories | -| `memory_status` | Check memory system status | +| `memory_update` | Update existing memory content or tags | +| `memory_delete` | Remove outdated memories | --- @@ -186,20 +276,38 @@ memory_details({ ids: ["abc"] }) ## CLI Commands ```bash +# One-command setup (auto-detects platform) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # specific platform +npx agentkits-memory-setup --platform=all # all platforms +npx agentkits-memory-setup --force # re-install/update + # Start MCP server npx agentkits-memory-server -# Start web viewer (port 1905) +# Web viewer (port 1905) npx agentkits-memory-web -# View stored memories (terminal) +# Terminal viewer npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # database statistics +npx agentkits-memory-viewer --json # JSON output -# Save memory from CLI +# Save from CLI npx agentkits-memory-save "Use JWT with refresh tokens" --category pattern --tags auth,security -# Setup hooks for auto-capture -npx agentkits-memory-setup +# Settings +npx agentkits-memory-hook settings . # view current settings +npx agentkits-memory-hook settings . --reset # reset to defaults +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# Export / Import +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# Lifecycle management +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . ``` --- @@ -239,47 +347,184 @@ const entry = await memory.getByKey('patterns', 'auth-pattern'); ## Auto-Capture Hooks -The package includes hooks for automatically capturing AI coding sessions: +Hooks automatically capture your AI coding sessions (Claude Code and OpenCode only): | Hook | Trigger | Action | |------|---------|--------| -| `context` | Session Start | Injects previous session context | -| `session-init` | First User Prompt | Initializes session record | -| `observation` | After Tool Use | Captures tool usage | -| `summarize` | Session End | Generates session summary | +| `context` | Session Start | Injects previous session context + memory status | +| `session-init` | User Prompt | Initializes/resumes session, records prompts | +| `observation` | After Tool Use | Captures tool usage with intent detection | +| `summarize` | Session End | Generates structured session summary | +| `user-message` | Session Start | Displays memory status to user (stderr) | Setup hooks: ```bash npx agentkits-memory-setup ``` -Or manually copy `hooks.json` to your project: +**What gets captured automatically:** +- File reads/writes with paths +- Code changes as structured diffs (before → after) +- Developer intent (bugfix, feature, refactor, investigation, etc.) +- Session summaries with decisions, errors, and next steps +- Multi-prompt tracking within sessions + +--- + +## Multi-Platform Support + +| Platform | MCP | Hooks | Rules File | Setup | +|----------|-----|-------|------------|-------| +| **Claude Code** | `.claude/settings.json` | ✅ Full | CLAUDE.md (skill) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ Full | — | `--platform=opencode` | + +- **MCP Server** works with all platforms (memory tools via MCP protocol) +- **Hooks** provide auto-capture on Claude Code and OpenCode +- **Rules files** teach Cursor/Windsurf/Cline the memory workflow +- **Memory data** always stored in `.claude/memory/` (single source of truth) + +--- + +## Background Workers + +After each session, background workers process queued tasks: + +| Worker | Task | Description | +|--------|------|-------------| +| `embed-session` | Embeddings | Generate vector embeddings for semantic search | +| `enrich-session` | AI Enrichment | Enrich observations with AI-generated summaries, facts, concepts | +| `compress-session` | Compression | Compress old observations (10:1–25:1) and generate session digests (20:1–100:1) | + +Workers run automatically after session end. Each worker: +- Processes up to 200 items per run +- Uses lock files to prevent concurrent execution +- Auto-terminates after 5 minutes (prevents zombies) +- Retries failed tasks up to 3 times + +--- + +## AI Provider Configuration + +AI enrichment uses pluggable providers. Default is `claude-cli` (no API key needed). + +| Provider | Type | Default Model | Notes | +|----------|------|---------------|-------| +| **Claude CLI** | `claude-cli` | `haiku` | Uses `claude --print`, no API key needed | +| **OpenAI** | `openai` | `gpt-4o-mini` | Any OpenAI model | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | Google AI Studio key | +| **OpenRouter** | `openai` | any | Set `baseUrl` to `https://openrouter.ai/api/v1` | +| **GLM (Zhipu)** | `openai` | any | Set `baseUrl` to `https://open.bigmodel.cn/api/paas/v4` | +| **Ollama** | `openai` | any | Set `baseUrl` to `http://localhost:11434/v1` | + +### Option 1: Environment Variables + ```bash -cp node_modules/@aitytech/agentkits-memory/hooks.json .claude/hooks.json +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter (uses OpenAI-compatible format) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# Local Ollama (no API key needed) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# Disable AI enrichment entirely +export AGENTKITS_AI_ENRICHMENT=false ``` +### Option 2: Persistent Settings + +```bash +# Saved to .claude/memory/settings.json — persists across sessions +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# View current settings +npx agentkits-memory-hook settings . + +# Reset to defaults +npx agentkits-memory-hook settings . --reset +``` + +> **Priority:** Environment variables override settings.json. Settings.json overrides defaults. + +--- + +## Lifecycle Management + +Manage memory growth over time: + +```bash +# Compress observations older than 7 days, archive sessions older than 30 days +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# Also auto-delete archived sessions older than 90 days +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# View lifecycle statistics +npx agentkits-memory-hook lifecycle-stats . +``` + +| Stage | What Happens | +|-------|-------------| +| **Compress** | AI-compresses observations, generates session digests | +| **Archive** | Marks old sessions as archived (excluded from context) | +| **Delete** | Removes archived sessions (opt-in, requires `--delete`) | + +--- + +## Export / Import + +Backup and restore your project memories: + +```bash +# Export all sessions for a project +npx agentkits-memory-hook export . my-project ./backup.json + +# Import from backup (deduplicates automatically) +npx agentkits-memory-hook import . ./backup.json +``` + +Export format includes sessions, observations, prompts, and summaries. + --- ## Memory Categories | Category | Use Case | |----------|----------| -| `decision` | Architecture decisions, ADRs | -| `pattern` | Reusable code patterns | -| `error` | Error solutions and fixes | -| `context` | Project context and facts | -| `observation` | Session observations | +| `decision` | Architecture decisions, tech stack picks, trade-offs | +| `pattern` | Coding conventions, project patterns, recurring approaches | +| `error` | Bug fixes, error solutions, debugging insights | +| `context` | Project background, team conventions, environment setup | +| `observation` | Auto-captured session observations | --- ## Storage -Memories are stored in `.claude/memory/memory.db` within your project directory. +Memories are stored in `.claude/memory/` within your project directory. ``` .claude/memory/ -├── memory.db # SQLite database -└── memory.db-wal # Write-ahead log (temp) +├── memory.db # SQLite database (all data) +├── memory.db-wal # Write-ahead log (temp) +├── settings.json # Persistent settings (AI provider, context config) +└── embeddings-cache/ # Cached vector embeddings ``` --- @@ -372,6 +617,42 @@ interface ProjectMemoryConfig { --- +## Code Quality + +AgentKits Memory is thoroughly tested with **970 unit tests** across 21 test suites. + +| Metric | Coverage | +|--------|----------| +| **Statements** | 90.29% | +| **Branches** | 80.85% | +| **Functions** | 90.54% | +| **Lines** | 91.74% | + +### Test Categories + +| Category | Tests | What's Covered | +|----------|-------|----------------| +| Core Memory Service | 56 | CRUD, search, pagination, categories, tags, import/export | +| SQLite Backend | 65 | Schema, migrations, FTS5, transactions, error handling | +| HNSW Vector Index | 47 | Insert, search, delete, persistence, edge cases | +| Hybrid Search | 44 | FTS + vector fusion, scoring, ranking, filters | +| Token Economics | 27 | 3-layer search budgets, truncation, optimization | +| Embedding System | 63 | Cache, subprocess, local models, CJK support | +| Hook System | 502 | Context, session-init, observation, summarize, AI enrichment, service lifecycle, queue workers, adapters, types | +| MCP Server | 48 | All 9 MCP tools, validation, error responses | +| CLI | 34 | Platform detection, rules generation | +| Integration | 84 | End-to-end flows, embedding integration, multi-session | + +```bash +# Run tests +npm test + +# Run with coverage +npm run test:coverage +``` + +--- + ## Requirements - **Node.js LTS**: 18.x, 20.x, or 22.x (recommended) diff --git a/assets/agentkits-memory-add-memory.png b/assets/agentkits-memory-add-memory_v2.png similarity index 100% rename from assets/agentkits-memory-add-memory.png rename to assets/agentkits-memory-add-memory_v2.png diff --git a/assets/agentkits-memory-embedding.png b/assets/agentkits-memory-embedding_v2.png similarity index 100% rename from assets/agentkits-memory-embedding.png rename to assets/agentkits-memory-embedding_v2.png diff --git a/assets/agentkits-memory-memory-detail.png b/assets/agentkits-memory-memory-detail_v2.png similarity index 100% rename from assets/agentkits-memory-memory-detail.png rename to assets/agentkits-memory-memory-detail_v2.png diff --git a/assets/agentkits-memory-memory-list.png b/assets/agentkits-memory-memory-list_v2.png similarity index 100% rename from assets/agentkits-memory-memory-list.png rename to assets/agentkits-memory-memory-list_v2.png diff --git a/assets/agentkits-memory-session-list_v2.png b/assets/agentkits-memory-session-list_v2.png new file mode 100644 index 0000000..16a97fa Binary files /dev/null and b/assets/agentkits-memory-session-list_v2.png differ diff --git a/coverage-tmp/coverage-final.json b/coverage-tmp/coverage-final.json new file mode 100644 index 0000000..4044f6f --- /dev/null +++ b/coverage-tmp/coverage-final.json @@ -0,0 +1,10 @@ +{"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/embeddings/local-embeddings.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/embeddings/local-embeddings.ts","statementMap":{"0":{"start":{"line":95,"column":18},"end":{"line":95,"column":null}},"1":{"start":{"line":96,"column":34},"end":{"line":96,"column":null}},"2":{"start":{"line":100,"column":4},"end":{"line":100,"column":null}},"3":{"start":{"line":104,"column":18},"end":{"line":104,"column":null}},"4":{"start":{"line":105,"column":4},"end":{"line":112,"column":null}},"5":{"start":{"line":107,"column":20},"end":{"line":107,"column":null}},"6":{"start":{"line":108,"column":6},"end":{"line":110,"column":null}},"7":{"start":{"line":109,"column":8},"end":{"line":109,"column":null}},"8":{"start":{"line":111,"column":6},"end":{"line":111,"column":null}},"9":{"start":{"line":113,"column":4},"end":{"line":113,"column":null}},"10":{"start":{"line":117,"column":4},"end":{"line":120,"column":null}},"11":{"start":{"line":118,"column":6},"end":{"line":118,"column":null}},"12":{"start":{"line":119,"column":6},"end":{"line":119,"column":null}},"13":{"start":{"line":123,"column":4},"end":{"line":128,"column":null}},"14":{"start":{"line":124,"column":21},"end":{"line":124,"column":null}},"15":{"start":{"line":125,"column":6},"end":{"line":127,"column":null}},"16":{"start":{"line":126,"column":8},"end":{"line":126,"column":null}},"17":{"start":{"line":130,"column":4},"end":{"line":130,"column":null}},"18":{"start":{"line":131,"column":4},"end":{"line":131,"column":null}},"19":{"start":{"line":135,"column":4},"end":{"line":135,"column":null}},"20":{"start":{"line":136,"column":4},"end":{"line":136,"column":null}},"21":{"start":{"line":140,"column":4},"end":{"line":140,"column":null}},"22":{"start":{"line":148,"column":20},"end":{"line":148,"column":null}},"23":{"start":{"line":150,"column":13},"end":{"line":150,"column":null}},"24":{"start":{"line":151,"column":2},"end":{"line":154,"column":null}},"25":{"start":{"line":151,"column":15},"end":{"line":151,"column":18}},"26":{"start":{"line":152,"column":4},"end":{"line":152,"column":null}},"27":{"start":{"line":153,"column":4},"end":{"line":153,"column":null}},"28":{"start":{"line":156,"column":2},"end":{"line":161,"column":null}},"29":{"start":{"line":156,"column":15},"end":{"line":156,"column":18}},"30":{"start":{"line":158,"column":4},"end":{"line":158,"column":null}},"31":{"start":{"line":159,"column":4},"end":{"line":159,"column":null}},"32":{"start":{"line":160,"column":4},"end":{"line":160,"column":null}},"33":{"start":{"line":164,"column":13},"end":{"line":164,"column":null}},"34":{"start":{"line":165,"column":2},"end":{"line":167,"column":null}},"35":{"start":{"line":165,"column":15},"end":{"line":165,"column":18}},"36":{"start":{"line":166,"column":4},"end":{"line":166,"column":null}},"37":{"start":{"line":168,"column":2},"end":{"line":168,"column":null}},"38":{"start":{"line":169,"column":2},"end":{"line":173,"column":null}},"39":{"start":{"line":170,"column":4},"end":{"line":172,"column":null}},"40":{"start":{"line":170,"column":17},"end":{"line":170,"column":20}},"41":{"start":{"line":171,"column":6},"end":{"line":171,"column":null}},"42":{"start":{"line":175,"column":2},"end":{"line":175,"column":null}},"43":{"start":{"line":186,"column":49},"end":{"line":186,"column":null}},"44":{"start":{"line":187,"column":26},"end":{"line":187,"column":null}},"45":{"start":{"line":188,"column":47},"end":{"line":188,"column":null}},"46":{"start":{"line":189,"column":18},"end":{"line":194,"column":null}},"47":{"start":{"line":197,"column":4},"end":{"line":206,"column":null}},"48":{"start":{"line":208,"column":4},"end":{"line":210,"column":null}},"49":{"start":{"line":209,"column":6},"end":{"line":209,"column":null}},"50":{"start":{"line":217,"column":4},"end":{"line":219,"column":null}},"51":{"start":{"line":218,"column":6},"end":{"line":218,"column":null}},"52":{"start":{"line":221,"column":4},"end":{"line":223,"column":null}},"53":{"start":{"line":222,"column":6},"end":{"line":222,"column":null}},"54":{"start":{"line":225,"column":4},"end":{"line":228,"column":null}},"55":{"start":{"line":226,"column":6},"end":{"line":226,"column":null}},"56":{"start":{"line":227,"column":6},"end":{"line":227,"column":null}},"57":{"start":{"line":230,"column":4},"end":{"line":230,"column":null}},"58":{"start":{"line":231,"column":4},"end":{"line":231,"column":null}},"59":{"start":{"line":235,"column":4},"end":{"line":261,"column":null}},"60":{"start":{"line":237,"column":27},"end":{"line":237,"column":null}},"61":{"start":{"line":239,"column":31},"end":{"line":249,"column":null}},"62":{"start":{"line":241,"column":12},"end":{"line":247,"column":null}},"63":{"start":{"line":242,"column":14},"end":{"line":244,"column":null}},"64":{"start":{"line":245,"column":12},"end":{"line":247,"column":null}},"65":{"start":{"line":246,"column":14},"end":{"line":246,"column":null}},"66":{"start":{"line":251,"column":6},"end":{"line":253,"column":null}},"67":{"start":{"line":256,"column":6},"end":{"line":259,"column":null}},"68":{"start":{"line":260,"column":6},"end":{"line":260,"column":null}},"69":{"start":{"line":268,"column":22},"end":{"line":268,"column":null}},"70":{"start":{"line":271,"column":4},"end":{"line":282,"column":null}},"71":{"start":{"line":272,"column":21},"end":{"line":272,"column":null}},"72":{"start":{"line":273,"column":6},"end":{"line":280,"column":null}},"73":{"start":{"line":274,"column":8},"end":{"line":274,"column":null}},"74":{"start":{"line":275,"column":8},"end":{"line":279,"column":null}},"75":{"start":{"line":281,"column":6},"end":{"line":281,"column":null}},"76":{"start":{"line":287,"column":4},"end":{"line":302,"column":null}},"77":{"start":{"line":288,"column":6},"end":{"line":288,"column":null}},"78":{"start":{"line":290,"column":6},"end":{"line":290,"column":null}},"79":{"start":{"line":292,"column":6},"end":{"line":301,"column":null}},"80":{"start":{"line":294,"column":8},"end":{"line":294,"column":null}},"81":{"start":{"line":296,"column":23},"end":{"line":299,"column":null}},"82":{"start":{"line":300,"column":8},"end":{"line":300,"column":null}},"83":{"start":{"line":304,"column":19},"end":{"line":304,"column":null}},"84":{"start":{"line":307,"column":4},"end":{"line":307,"column":null}},"85":{"start":{"line":308,"column":4},"end":{"line":308,"column":null}},"86":{"start":{"line":311,"column":4},"end":{"line":313,"column":null}},"87":{"start":{"line":312,"column":6},"end":{"line":312,"column":null}},"88":{"start":{"line":315,"column":4},"end":{"line":319,"column":null}},"89":{"start":{"line":327,"column":4},"end":{"line":327,"column":null}},"90":{"start":{"line":327,"column":43},"end":{"line":327,"column":59}},"91":{"start":{"line":334,"column":4},"end":{"line":337,"column":null}},"92":{"start":{"line":335,"column":21},"end":{"line":335,"column":null}},"93":{"start":{"line":336,"column":6},"end":{"line":336,"column":null}},"94":{"start":{"line":344,"column":4},"end":{"line":355,"column":null}},"95":{"start":{"line":362,"column":4},"end":{"line":364,"column":null}},"96":{"start":{"line":363,"column":6},"end":{"line":363,"column":null}},"97":{"start":{"line":371,"column":4},"end":{"line":371,"column":null}},"98":{"start":{"line":378,"column":4},"end":{"line":378,"column":null}},"99":{"start":{"line":379,"column":4},"end":{"line":379,"column":null}},"100":{"start":{"line":380,"column":4},"end":{"line":380,"column":null}},"101":{"start":{"line":390,"column":2},"end":{"line":390,"column":null}},"102":{"start":{"line":413,"column":18},"end":{"line":413,"column":null}},"103":{"start":{"line":414,"column":2},"end":{"line":414,"column":null}},"104":{"start":{"line":415,"column":2},"end":{"line":415,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":99,"column":2},"end":{"line":99,"column":14}},"loc":{"start":{"line":99,"column":31},"end":{"line":101,"column":null}},"line":99},"1":{"name":"(anonymous_1)","decl":{"start":{"line":103,"column":2},"end":{"line":103,"column":6}},"loc":{"start":{"line":103,"column":45},"end":{"line":114,"column":null}},"line":103},"2":{"name":"(anonymous_2)","decl":{"start":{"line":116,"column":2},"end":{"line":116,"column":6}},"loc":{"start":{"line":116,"column":46},"end":{"line":132,"column":null}},"line":116},"3":{"name":"(anonymous_3)","decl":{"start":{"line":134,"column":2},"end":{"line":134,"column":16}},"loc":{"start":{"line":134,"column":16},"end":{"line":137,"column":null}},"line":134},"4":{"name":"(anonymous_4)","decl":{"start":{"line":139,"column":6},"end":{"line":139,"column":21}},"loc":{"start":{"line":139,"column":21},"end":{"line":141,"column":null}},"line":139},"5":{"name":"createMockEmbedding","decl":{"start":{"line":147,"column":9},"end":{"line":147,"column":29}},"loc":{"start":{"line":147,"column":77},"end":{"line":176,"column":null}},"line":147},"6":{"name":"(anonymous_6)","decl":{"start":{"line":196,"column":2},"end":{"line":196,"column":14}},"loc":{"start":{"line":196,"column":50},"end":{"line":211,"column":null}},"line":196},"7":{"name":"(anonymous_7)","decl":{"start":{"line":216,"column":8},"end":{"line":216,"column":36}},"loc":{"start":{"line":216,"column":36},"end":{"line":232,"column":null}},"line":216},"8":{"name":"(anonymous_8)","decl":{"start":{"line":234,"column":16},"end":{"line":234,"column":43}},"loc":{"start":{"line":234,"column":43},"end":{"line":262,"column":null}},"line":234},"9":{"name":"(anonymous_9)","decl":{"start":{"line":240,"column":10},"end":{"line":240,"column":11}},"loc":{"start":{"line":240,"column":63},"end":{"line":248,"column":null}},"line":240},"10":{"name":"(anonymous_10)","decl":{"start":{"line":267,"column":8},"end":{"line":267,"column":14}},"loc":{"start":{"line":267,"column":54},"end":{"line":320,"column":null}},"line":267},"11":{"name":"(anonymous_11)","decl":{"start":{"line":325,"column":8},"end":{"line":325,"column":19}},"loc":{"start":{"line":325,"column":64},"end":{"line":328,"column":null}},"line":325},"12":{"name":"(anonymous_12)","decl":{"start":{"line":327,"column":33},"end":{"line":327,"column":34}},"loc":{"start":{"line":327,"column":43},"end":{"line":327,"column":59}},"line":327},"13":{"name":"(anonymous_13)","decl":{"start":{"line":333,"column":2},"end":{"line":333,"column":37}},"loc":{"start":{"line":333,"column":37},"end":{"line":338,"column":null}},"line":333},"14":{"name":"(anonymous_14)","decl":{"start":{"line":334,"column":11},"end":{"line":334,"column":18}},"loc":{"start":{"line":334,"column":61},"end":{"line":337,"column":null}},"line":334},"15":{"name":"(anonymous_15)","decl":{"start":{"line":343,"column":2},"end":{"line":343,"column":30}},"loc":{"start":{"line":343,"column":30},"end":{"line":356,"column":null}},"line":343},"16":{"name":"(anonymous_16)","decl":{"start":{"line":361,"column":2},"end":{"line":361,"column":21}},"loc":{"start":{"line":361,"column":21},"end":{"line":365,"column":null}},"line":361},"17":{"name":"(anonymous_17)","decl":{"start":{"line":370,"column":2},"end":{"line":370,"column":26}},"loc":{"start":{"line":370,"column":26},"end":{"line":372,"column":null}},"line":370},"18":{"name":"(anonymous_18)","decl":{"start":{"line":377,"column":8},"end":{"line":377,"column":34}},"loc":{"start":{"line":377,"column":34},"end":{"line":381,"column":null}},"line":377},"19":{"name":"createLocalEmbeddings","decl":{"start":{"line":387,"column":16},"end":{"line":387,"column":null}},"loc":{"start":{"line":389,"column":26},"end":{"line":391,"column":null}},"line":389},"20":{"name":"createEmbeddingGenerator","decl":{"start":{"line":410,"column":22},"end":{"line":410,"column":null}},"loc":{"start":{"line":412,"column":31},"end":{"line":416,"column":null}},"line":412}},"branchMap":{"0":{"loc":{"start":{"line":105,"column":4},"end":{"line":112,"column":null}},"type":"if","locations":[{"start":{"line":105,"column":4},"end":{"line":112,"column":null}},{"start":{},"end":{}}],"line":105},"1":{"loc":{"start":{"line":108,"column":6},"end":{"line":110,"column":null}},"type":"if","locations":[{"start":{"line":108,"column":6},"end":{"line":110,"column":null}},{"start":{},"end":{}}],"line":108},"2":{"loc":{"start":{"line":117,"column":4},"end":{"line":120,"column":null}},"type":"if","locations":[{"start":{"line":117,"column":4},"end":{"line":120,"column":null}},{"start":{},"end":{}}],"line":117},"3":{"loc":{"start":{"line":123,"column":11},"end":{"line":123,"column":75}},"type":"binary-expr","locations":[{"start":{"line":123,"column":11},"end":{"line":123,"column":46}},{"start":{"line":123,"column":46},"end":{"line":123,"column":75}}],"line":123},"4":{"loc":{"start":{"line":125,"column":6},"end":{"line":127,"column":null}},"type":"if","locations":[{"start":{"line":125,"column":6},"end":{"line":127,"column":null}},{"start":{},"end":{}}],"line":125},"5":{"loc":{"start":{"line":169,"column":2},"end":{"line":173,"column":null}},"type":"if","locations":[{"start":{"line":169,"column":2},"end":{"line":173,"column":null}},{"start":{},"end":{}}],"line":169},"6":{"loc":{"start":{"line":196,"column":14},"end":{"line":196,"column":50}},"type":"default-arg","locations":[{"start":{"line":196,"column":46},"end":{"line":196,"column":50}}],"line":196},"7":{"loc":{"start":{"line":198,"column":16},"end":{"line":198,"column":null}},"type":"binary-expr","locations":[{"start":{"line":198,"column":16},"end":{"line":198,"column":35}},{"start":{"line":198,"column":35},"end":{"line":198,"column":null}}],"line":198},"8":{"loc":{"start":{"line":200,"column":15},"end":{"line":200,"column":null}},"type":"binary-expr","locations":[{"start":{"line":200,"column":15},"end":{"line":200,"column":33}},{"start":{"line":200,"column":33},"end":{"line":200,"column":null}}],"line":200},"9":{"loc":{"start":{"line":201,"column":18},"end":{"line":201,"column":null}},"type":"binary-expr","locations":[{"start":{"line":201,"column":18},"end":{"line":201,"column":39}},{"start":{"line":201,"column":39},"end":{"line":201,"column":null}}],"line":201},"10":{"loc":{"start":{"line":202,"column":20},"end":{"line":202,"column":null}},"type":"binary-expr","locations":[{"start":{"line":202,"column":20},"end":{"line":202,"column":43}},{"start":{"line":202,"column":43},"end":{"line":202,"column":null}}],"line":202},"11":{"loc":{"start":{"line":203,"column":20},"end":{"line":203,"column":null}},"type":"binary-expr","locations":[{"start":{"line":203,"column":20},"end":{"line":203,"column":43}},{"start":{"line":203,"column":43},"end":{"line":203,"column":null}}],"line":203},"12":{"loc":{"start":{"line":204,"column":20},"end":{"line":204,"column":null}},"type":"binary-expr","locations":[{"start":{"line":204,"column":20},"end":{"line":204,"column":43}},{"start":{"line":204,"column":43},"end":{"line":204,"column":null}}],"line":204},"13":{"loc":{"start":{"line":205,"column":16},"end":{"line":205,"column":null}},"type":"binary-expr","locations":[{"start":{"line":205,"column":16},"end":{"line":205,"column":35}},{"start":{"line":205,"column":35},"end":{"line":205,"column":null}}],"line":205},"14":{"loc":{"start":{"line":208,"column":4},"end":{"line":210,"column":null}},"type":"if","locations":[{"start":{"line":208,"column":4},"end":{"line":210,"column":null}},{"start":{},"end":{}}],"line":208},"15":{"loc":{"start":{"line":217,"column":4},"end":{"line":219,"column":null}},"type":"if","locations":[{"start":{"line":217,"column":4},"end":{"line":219,"column":null}},{"start":{},"end":{}}],"line":217},"16":{"loc":{"start":{"line":221,"column":4},"end":{"line":223,"column":null}},"type":"if","locations":[{"start":{"line":221,"column":4},"end":{"line":223,"column":null}},{"start":{},"end":{}}],"line":221},"17":{"loc":{"start":{"line":225,"column":4},"end":{"line":228,"column":null}},"type":"if","locations":[{"start":{"line":225,"column":4},"end":{"line":228,"column":null}},{"start":{},"end":{}}],"line":225},"18":{"loc":{"start":{"line":239,"column":31},"end":{"line":249,"column":null}},"type":"cond-expr","locations":[{"start":{"line":240,"column":10},"end":{"line":248,"column":null}},{"start":{"line":249,"column":10},"end":{"line":249,"column":null}}],"line":239},"19":{"loc":{"start":{"line":241,"column":12},"end":{"line":247,"column":null}},"type":"if","locations":[{"start":{"line":241,"column":12},"end":{"line":247,"column":null}},{"start":{"line":245,"column":12},"end":{"line":247,"column":null}}],"line":241},"20":{"loc":{"start":{"line":241,"column":16},"end":{"line":241,"column":83}},"type":"binary-expr","locations":[{"start":{"line":241,"column":16},"end":{"line":241,"column":50}},{"start":{"line":241,"column":50},"end":{"line":241,"column":83}}],"line":241},"21":{"loc":{"start":{"line":245,"column":12},"end":{"line":247,"column":null}},"type":"if","locations":[{"start":{"line":245,"column":12},"end":{"line":247,"column":null}},{"start":{},"end":{}}],"line":245},"22":{"loc":{"start":{"line":271,"column":4},"end":{"line":282,"column":null}},"type":"if","locations":[{"start":{"line":271,"column":4},"end":{"line":282,"column":null}},{"start":{},"end":{}}],"line":271},"23":{"loc":{"start":{"line":273,"column":6},"end":{"line":280,"column":null}},"type":"if","locations":[{"start":{"line":273,"column":6},"end":{"line":280,"column":null}},{"start":{},"end":{}}],"line":273},"24":{"loc":{"start":{"line":287,"column":4},"end":{"line":302,"column":null}},"type":"if","locations":[{"start":{"line":287,"column":4},"end":{"line":302,"column":null}},{"start":{"line":289,"column":11},"end":{"line":302,"column":null}}],"line":287},"25":{"loc":{"start":{"line":292,"column":6},"end":{"line":301,"column":null}},"type":"if","locations":[{"start":{"line":292,"column":6},"end":{"line":301,"column":null}},{"start":{"line":295,"column":13},"end":{"line":301,"column":null}}],"line":292},"26":{"loc":{"start":{"line":311,"column":4},"end":{"line":313,"column":null}},"type":"if","locations":[{"start":{"line":311,"column":4},"end":{"line":313,"column":null}},{"start":{},"end":{}}],"line":311},"27":{"loc":{"start":{"line":349,"column":8},"end":{"line":351,"column":null}},"type":"cond-expr","locations":[{"start":{"line":350,"column":12},"end":{"line":350,"column":null}},{"start":{"line":351,"column":12},"end":{"line":351,"column":null}}],"line":349},"28":{"loc":{"start":{"line":353,"column":19},"end":{"line":353,"column":null}},"type":"binary-expr","locations":[{"start":{"line":353,"column":19},"end":{"line":353,"column":45}},{"start":{"line":353,"column":45},"end":{"line":353,"column":null}}],"line":353},"29":{"loc":{"start":{"line":362,"column":4},"end":{"line":364,"column":null}},"type":"if","locations":[{"start":{"line":362,"column":4},"end":{"line":364,"column":null}},{"start":{},"end":{}}],"line":362}},"s":{"0":3,"1":3,"2":3,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":0,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0,"42":0,"43":3,"44":3,"45":3,"46":3,"47":3,"48":3,"49":3,"50":3,"51":0,"52":3,"53":0,"54":3,"55":0,"56":0,"57":3,"58":3,"59":3,"60":3,"61":3,"62":0,"63":0,"64":0,"65":0,"66":3,"67":0,"68":0,"69":0,"70":0,"71":0,"72":0,"73":0,"74":0,"75":0,"76":0,"77":0,"78":0,"79":0,"80":0,"81":0,"82":0,"83":0,"84":0,"85":0,"86":0,"87":0,"88":0,"89":0,"90":0,"91":0,"92":0,"93":0,"94":0,"95":0,"96":0,"97":0,"98":0,"99":0,"100":0,"101":0,"102":0,"103":0,"104":0},"f":{"0":3,"1":0,"2":0,"3":0,"4":0,"5":0,"6":3,"7":3,"8":3,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0},"b":{"0":[0,0],"1":[0,0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0],"6":[3],"7":[3,3],"8":[3,3],"9":[3,3],"10":[3,3],"11":[3,3],"12":[3,3],"13":[3,0],"14":[3,0],"15":[0,3],"16":[0,3],"17":[0,3],"18":[0,3],"19":[0,0],"20":[0,0],"21":[0,0],"22":[0,0],"23":[0,0],"24":[0,0],"25":[0,0],"26":[0,0],"27":[0,0],"28":[0,0],"29":[0,0]},"meta":{"lastBranch":30,"lastFunction":21,"lastStatement":105,"seen":{"s:95:18:95:Infinity":0,"s:96:34:96:Infinity":1,"f:99:2:99:14":0,"s:100:4:100:Infinity":2,"f:103:2:103:6":1,"s:104:18:104:Infinity":3,"b:105:4:112:Infinity:undefined:undefined:undefined:undefined":0,"s:105:4:112:Infinity":4,"s:107:20:107:Infinity":5,"b:108:6:110:Infinity:undefined:undefined:undefined:undefined":1,"s:108:6:110:Infinity":6,"s:109:8:109:Infinity":7,"s:111:6:111:Infinity":8,"s:113:4:113:Infinity":9,"f:116:2:116:6":2,"b:117:4:120:Infinity:undefined:undefined:undefined:undefined":2,"s:117:4:120:Infinity":10,"s:118:6:118:Infinity":11,"s:119:6:119:Infinity":12,"s:123:4:128:Infinity":13,"b:123:11:123:46:123:46:123:75":3,"s:124:21:124:Infinity":14,"b:125:6:127:Infinity:undefined:undefined:undefined:undefined":4,"s:125:6:127:Infinity":15,"s:126:8:126:Infinity":16,"s:130:4:130:Infinity":17,"s:131:4:131:Infinity":18,"f:134:2:134:16":3,"s:135:4:135:Infinity":19,"s:136:4:136:Infinity":20,"f:139:6:139:21":4,"s:140:4:140:Infinity":21,"f:147:9:147:29":5,"s:148:20:148:Infinity":22,"s:150:13:150:Infinity":23,"s:151:2:154:Infinity":24,"s:151:15:151:18":25,"s:152:4:152:Infinity":26,"s:153:4:153:Infinity":27,"s:156:2:161:Infinity":28,"s:156:15:156:18":29,"s:158:4:158:Infinity":30,"s:159:4:159:Infinity":31,"s:160:4:160:Infinity":32,"s:164:13:164:Infinity":33,"s:165:2:167:Infinity":34,"s:165:15:165:18":35,"s:166:4:166:Infinity":36,"s:168:2:168:Infinity":37,"b:169:2:173:Infinity:undefined:undefined:undefined:undefined":5,"s:169:2:173:Infinity":38,"s:170:4:172:Infinity":39,"s:170:17:170:20":40,"s:171:6:171:Infinity":41,"s:175:2:175:Infinity":42,"s:186:49:186:Infinity":43,"s:187:26:187:Infinity":44,"s:188:47:188:Infinity":45,"s:189:18:194:Infinity":46,"f:196:2:196:14":6,"b:196:46:196:50":6,"s:197:4:206:Infinity":47,"b:198:16:198:35:198:35:198:Infinity":7,"b:200:15:200:33:200:33:200:Infinity":8,"b:201:18:201:39:201:39:201:Infinity":9,"b:202:20:202:43:202:43:202:Infinity":10,"b:203:20:203:43:203:43:203:Infinity":11,"b:204:20:204:43:204:43:204:Infinity":12,"b:205:16:205:35:205:35:205:Infinity":13,"b:208:4:210:Infinity:undefined:undefined:undefined:undefined":14,"s:208:4:210:Infinity":48,"s:209:6:209:Infinity":49,"f:216:8:216:36":7,"b:217:4:219:Infinity:undefined:undefined:undefined:undefined":15,"s:217:4:219:Infinity":50,"s:218:6:218:Infinity":51,"b:221:4:223:Infinity:undefined:undefined:undefined:undefined":16,"s:221:4:223:Infinity":52,"s:222:6:222:Infinity":53,"b:225:4:228:Infinity:undefined:undefined:undefined:undefined":17,"s:225:4:228:Infinity":54,"s:226:6:226:Infinity":55,"s:227:6:227:Infinity":56,"s:230:4:230:Infinity":57,"s:231:4:231:Infinity":58,"f:234:16:234:43":8,"s:235:4:261:Infinity":59,"s:237:27:237:Infinity":60,"s:239:31:249:Infinity":61,"b:240:10:248:Infinity:249:10:249:Infinity":18,"f:240:10:240:11":9,"b:241:12:247:Infinity:245:12:247:Infinity":19,"s:241:12:247:Infinity":62,"b:241:16:241:50:241:50:241:83":20,"s:242:14:244:Infinity":63,"b:245:12:247:Infinity:undefined:undefined:undefined:undefined":21,"s:245:12:247:Infinity":64,"s:246:14:246:Infinity":65,"s:251:6:253:Infinity":66,"s:256:6:259:Infinity":67,"s:260:6:260:Infinity":68,"f:267:8:267:14":10,"s:268:22:268:Infinity":69,"b:271:4:282:Infinity:undefined:undefined:undefined:undefined":22,"s:271:4:282:Infinity":70,"s:272:21:272:Infinity":71,"b:273:6:280:Infinity:undefined:undefined:undefined:undefined":23,"s:273:6:280:Infinity":72,"s:274:8:274:Infinity":73,"s:275:8:279:Infinity":74,"s:281:6:281:Infinity":75,"b:287:4:302:Infinity:289:11:302:Infinity":24,"s:287:4:302:Infinity":76,"s:288:6:288:Infinity":77,"s:290:6:290:Infinity":78,"b:292:6:301:Infinity:295:13:301:Infinity":25,"s:292:6:301:Infinity":79,"s:294:8:294:Infinity":80,"s:296:23:299:Infinity":81,"s:300:8:300:Infinity":82,"s:304:19:304:Infinity":83,"s:307:4:307:Infinity":84,"s:308:4:308:Infinity":85,"b:311:4:313:Infinity:undefined:undefined:undefined:undefined":26,"s:311:4:313:Infinity":86,"s:312:6:312:Infinity":87,"s:315:4:319:Infinity":88,"f:325:8:325:19":11,"s:327:4:327:Infinity":89,"f:327:33:327:34":12,"s:327:43:327:59":90,"f:333:2:333:37":13,"s:334:4:337:Infinity":91,"f:334:11:334:18":14,"s:335:21:335:Infinity":92,"s:336:6:336:Infinity":93,"f:343:2:343:30":15,"s:344:4:355:Infinity":94,"b:350:12:350:Infinity:351:12:351:Infinity":27,"b:353:19:353:45:353:45:353:Infinity":28,"f:361:2:361:21":16,"b:362:4:364:Infinity:undefined:undefined:undefined:undefined":29,"s:362:4:364:Infinity":95,"s:363:6:363:Infinity":96,"f:370:2:370:26":17,"s:371:4:371:Infinity":97,"f:377:8:377:34":18,"s:378:4:378:Infinity":98,"s:379:4:379:Infinity":99,"s:380:4:380:Infinity":100,"f:387:16:387:Infinity":19,"s:390:2:390:Infinity":101,"f:410:22:410:Infinity":20,"s:413:18:413:Infinity":102,"s:414:2:414:Infinity":103,"s:415:2:415:Infinity":104}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/ai-enrichment.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/ai-enrichment.ts","statementMap":{"0":{"start":{"line":30,"column":30},"end":{"line":30,"column":null}},"1":{"start":{"line":33,"column":36},"end":{"line":33,"column":null}},"2":{"start":{"line":36,"column":111},"end":{"line":36,"column":null}},"3":{"start":{"line":45,"column":16},"end":{"line":45,"column":null}},"4":{"start":{"line":46,"column":2},"end":{"line":46,"column":null}},"5":{"start":{"line":46,"column":14},"end":{"line":46,"column":null}},"6":{"start":{"line":47,"column":2},"end":{"line":47,"column":null}},"7":{"start":{"line":56,"column":21},"end":{"line":56,"column":null}},"8":{"start":{"line":57,"column":2},"end":{"line":57,"column":null}},"9":{"start":{"line":57,"column":28},"end":{"line":57,"column":null}},"10":{"start":{"line":60,"column":2},"end":{"line":60,"column":null}},"11":{"start":{"line":70,"column":2},"end":{"line":72,"column":null}},"12":{"start":{"line":71,"column":4},"end":{"line":71,"column":null}},"13":{"start":{"line":74,"column":2},"end":{"line":90,"column":null}},"14":{"start":{"line":75,"column":10},"end":{"line":86,"column":null}},"15":{"start":{"line":87,"column":4},"end":{"line":87,"column":null}},"16":{"start":{"line":89,"column":4},"end":{"line":89,"column":null}},"17":{"start":{"line":99,"column":21},"end":{"line":99,"column":null}},"18":{"start":{"line":100,"column":2},"end":{"line":100,"column":null}},"19":{"start":{"line":100,"column":28},"end":{"line":100,"column":null}},"20":{"start":{"line":102,"column":2},"end":{"line":102,"column":null}},"21":{"start":{"line":102,"column":30},"end":{"line":102,"column":null}},"22":{"start":{"line":104,"column":2},"end":{"line":115,"column":null}},"23":{"start":{"line":105,"column":4},"end":{"line":109,"column":null}},"24":{"start":{"line":110,"column":4},"end":{"line":110,"column":null}},"25":{"start":{"line":111,"column":4},"end":{"line":111,"column":null}},"26":{"start":{"line":113,"column":4},"end":{"line":113,"column":null}},"27":{"start":{"line":114,"column":4},"end":{"line":114,"column":null}},"28":{"start":{"line":126,"column":2},"end":{"line":130,"column":null}},"29":{"start":{"line":145,"column":2},"end":{"line":178,"column":null}},"30":{"start":{"line":147,"column":18},"end":{"line":147,"column":null}},"31":{"start":{"line":148,"column":4},"end":{"line":152,"column":null}},"32":{"start":{"line":149,"column":6},"end":{"line":149,"column":null}},"33":{"start":{"line":150,"column":4},"end":{"line":152,"column":null}},"34":{"start":{"line":151,"column":6},"end":{"line":151,"column":null}},"35":{"start":{"line":153,"column":4},"end":{"line":155,"column":null}},"36":{"start":{"line":154,"column":6},"end":{"line":154,"column":null}},"37":{"start":{"line":156,"column":4},"end":{"line":156,"column":null}},"38":{"start":{"line":158,"column":19},"end":{"line":158,"column":null}},"39":{"start":{"line":161,"column":4},"end":{"line":168,"column":null}},"40":{"start":{"line":167,"column":6},"end":{"line":167,"column":null}},"41":{"start":{"line":170,"column":4},"end":{"line":175,"column":null}},"42":{"start":{"line":173,"column":58},"end":{"line":173,"column":85}},"43":{"start":{"line":174,"column":64},"end":{"line":174,"column":90}},"44":{"start":{"line":177,"column":4},"end":{"line":177,"column":null}},"45":{"start":{"line":194,"column":2},"end":{"line":194,"column":null}},"46":{"start":{"line":194,"column":31},"end":{"line":194,"column":null}},"47":{"start":{"line":196,"column":2},"end":{"line":205,"column":null}},"48":{"start":{"line":197,"column":19},"end":{"line":197,"column":null}},"49":{"start":{"line":198,"column":25},"end":{"line":198,"column":null}},"50":{"start":{"line":200,"column":23},"end":{"line":200,"column":null}},"51":{"start":{"line":201,"column":4},"end":{"line":201,"column":null}},"52":{"start":{"line":201,"column":21},"end":{"line":201,"column":null}},"53":{"start":{"line":202,"column":4},"end":{"line":202,"column":null}},"54":{"start":{"line":204,"column":4},"end":{"line":204,"column":null}},"55":{"start":{"line":212,"column":2},"end":{"line":212,"column":null}},"56":{"start":{"line":219,"column":2},"end":{"line":219,"column":null}},"57":{"start":{"line":220,"column":2},"end":{"line":220,"column":null}},"58":{"start":{"line":227,"column":2},"end":{"line":227,"column":null}},"59":{"start":{"line":238,"column":2},"end":{"line":238,"column":null}},"60":{"start":{"line":239,"column":2},"end":{"line":241,"column":null}},"61":{"start":{"line":240,"column":4},"end":{"line":240,"column":null}},"62":{"start":{"line":261,"column":2},"end":{"line":267,"column":null}},"63":{"start":{"line":280,"column":2},"end":{"line":309,"column":null}},"64":{"start":{"line":281,"column":18},"end":{"line":281,"column":null}},"65":{"start":{"line":282,"column":4},"end":{"line":283,"column":null}},"66":{"start":{"line":282,"column":39},"end":{"line":282,"column":null}},"67":{"start":{"line":282,"column":64},"end":{"line":283,"column":null}},"68":{"start":{"line":283,"column":40},"end":{"line":283,"column":null}},"69":{"start":{"line":284,"column":4},"end":{"line":284,"column":null}},"70":{"start":{"line":284,"column":33},"end":{"line":284,"column":null}},"71":{"start":{"line":285,"column":4},"end":{"line":285,"column":null}},"72":{"start":{"line":287,"column":19},"end":{"line":287,"column":null}},"73":{"start":{"line":290,"column":22},"end":{"line":290,"column":null}},"74":{"start":{"line":291,"column":4},"end":{"line":291,"column":null}},"75":{"start":{"line":291,"column":20},"end":{"line":291,"column":null}},"76":{"start":{"line":295,"column":4},"end":{"line":301,"column":null}},"77":{"start":{"line":296,"column":6},"end":{"line":296,"column":null}},"78":{"start":{"line":297,"column":4},"end":{"line":301,"column":null}},"79":{"start":{"line":298,"column":6},"end":{"line":298,"column":null}},"80":{"start":{"line":298,"column":55},"end":{"line":298,"column":64}},"81":{"start":{"line":300,"column":6},"end":{"line":300,"column":null}},"82":{"start":{"line":303,"column":4},"end":{"line":306,"column":null}},"83":{"start":{"line":308,"column":4},"end":{"line":308,"column":null}},"84":{"start":{"line":324,"column":2},"end":{"line":324,"column":null}},"85":{"start":{"line":324,"column":31},"end":{"line":324,"column":null}},"86":{"start":{"line":326,"column":2},"end":{"line":335,"column":null}},"87":{"start":{"line":327,"column":19},"end":{"line":327,"column":null}},"88":{"start":{"line":328,"column":25},"end":{"line":328,"column":null}},"89":{"start":{"line":330,"column":23},"end":{"line":330,"column":null}},"90":{"start":{"line":331,"column":4},"end":{"line":331,"column":null}},"91":{"start":{"line":331,"column":21},"end":{"line":331,"column":null}},"92":{"start":{"line":332,"column":4},"end":{"line":332,"column":null}},"93":{"start":{"line":334,"column":4},"end":{"line":334,"column":null}}},"fnMap":{"0":{"name":"isEnvEnabled","decl":{"start":{"line":44,"column":9},"end":{"line":44,"column":40}},"loc":{"start":{"line":44,"column":40},"end":{"line":48,"column":null}},"line":44},"1":{"name":"isAIEnrichmentEnabled","decl":{"start":{"line":55,"column":16},"end":{"line":55,"column":49}},"loc":{"start":{"line":55,"column":49},"end":{"line":61,"column":null}},"line":55},"2":{"name":"runClaudePrint","decl":{"start":{"line":68,"column":9},"end":{"line":68,"column":24}},"loc":{"start":{"line":68,"column":96},"end":{"line":91,"column":null}},"line":68},"3":{"name":"isClaudeCliAvailable","decl":{"start":{"line":97,"column":9},"end":{"line":97,"column":41}},"loc":{"start":{"line":97,"column":41},"end":{"line":116,"column":null}},"line":97},"4":{"name":"buildExtractionPrompt","decl":{"start":{"line":121,"column":16},"end":{"line":121,"column":null}},"loc":{"start":{"line":125,"column":10},"end":{"line":139,"column":null}},"line":125},"5":{"name":"parseAIResponse","decl":{"start":{"line":144,"column":16},"end":{"line":144,"column":32}},"loc":{"start":{"line":144,"column":74},"end":{"line":179,"column":null}},"line":144},"6":{"name":"(anonymous_6)","decl":{"start":{"line":173,"column":42},"end":{"line":173,"column":43}},"loc":{"start":{"line":173,"column":58},"end":{"line":173,"column":85}},"line":173},"7":{"name":"(anonymous_7)","decl":{"start":{"line":174,"column":48},"end":{"line":174,"column":49}},"loc":{"start":{"line":174,"column":64},"end":{"line":174,"column":90}},"line":174},"8":{"name":"enrichWithAI","decl":{"start":{"line":188,"column":22},"end":{"line":188,"column":null}},"loc":{"start":{"line":193,"column":39},"end":{"line":206,"column":null}},"line":193},"9":{"name":"isAIEnrichmentAvailable","decl":{"start":{"line":211,"column":22},"end":{"line":211,"column":66}},"loc":{"start":{"line":211,"column":66},"end":{"line":213,"column":null}},"line":211},"10":{"name":"resetAIEnrichmentCache","decl":{"start":{"line":218,"column":16},"end":{"line":218,"column":47}},"loc":{"start":{"line":218,"column":47},"end":{"line":221,"column":null}},"line":218},"11":{"name":"_setCliAvailableForTesting","decl":{"start":{"line":226,"column":16},"end":{"line":226,"column":43}},"loc":{"start":{"line":226,"column":69},"end":{"line":228,"column":null}},"line":226},"12":{"name":"_setRunClaudePrintMockForTesting","decl":{"start":{"line":235,"column":16},"end":{"line":235,"column":null}},"loc":{"start":{"line":237,"column":8},"end":{"line":242,"column":null}},"line":237},"13":{"name":"buildSummaryPrompt","decl":{"start":{"line":257,"column":16},"end":{"line":257,"column":null}},"loc":{"start":{"line":260,"column":10},"end":{"line":274,"column":null}},"line":260},"14":{"name":"parseSummaryResponse","decl":{"start":{"line":279,"column":16},"end":{"line":279,"column":37}},"loc":{"start":{"line":279,"column":75},"end":{"line":310,"column":null}},"line":279},"15":{"name":"(anonymous_15)","decl":{"start":{"line":298,"column":39},"end":{"line":298,"column":40}},"loc":{"start":{"line":298,"column":55},"end":{"line":298,"column":64}},"line":298},"16":{"name":"enrichSummaryWithAI","decl":{"start":{"line":319,"column":22},"end":{"line":319,"column":null}},"loc":{"start":{"line":323,"column":35},"end":{"line":336,"column":null}},"line":323}},"branchMap":{"0":{"loc":{"start":{"line":46,"column":2},"end":{"line":46,"column":null}},"type":"if","locations":[{"start":{"line":46,"column":2},"end":{"line":46,"column":null}},{"start":{},"end":{}}],"line":46},"1":{"loc":{"start":{"line":47,"column":9},"end":{"line":47,"column":null}},"type":"binary-expr","locations":[{"start":{"line":47,"column":9},"end":{"line":47,"column":29}},{"start":{"line":47,"column":29},"end":{"line":47,"column":null}}],"line":47},"2":{"loc":{"start":{"line":57,"column":2},"end":{"line":57,"column":null}},"type":"if","locations":[{"start":{"line":57,"column":2},"end":{"line":57,"column":null}},{"start":{},"end":{}}],"line":57},"3":{"loc":{"start":{"line":70,"column":2},"end":{"line":72,"column":null}},"type":"if","locations":[{"start":{"line":70,"column":2},"end":{"line":72,"column":null}},{"start":{},"end":{}}],"line":70},"4":{"loc":{"start":{"line":87,"column":11},"end":{"line":87,"column":null}},"type":"binary-expr","locations":[{"start":{"line":87,"column":11},"end":{"line":87,"column":28}},{"start":{"line":87,"column":28},"end":{"line":87,"column":null}}],"line":87},"5":{"loc":{"start":{"line":100,"column":2},"end":{"line":100,"column":null}},"type":"if","locations":[{"start":{"line":100,"column":2},"end":{"line":100,"column":null}},{"start":{},"end":{}}],"line":100},"6":{"loc":{"start":{"line":102,"column":2},"end":{"line":102,"column":null}},"type":"if","locations":[{"start":{"line":102,"column":2},"end":{"line":102,"column":null}},{"start":{},"end":{}}],"line":102},"7":{"loc":{"start":{"line":148,"column":4},"end":{"line":152,"column":null}},"type":"if","locations":[{"start":{"line":148,"column":4},"end":{"line":152,"column":null}},{"start":{"line":150,"column":4},"end":{"line":152,"column":null}}],"line":148},"8":{"loc":{"start":{"line":150,"column":4},"end":{"line":152,"column":null}},"type":"if","locations":[{"start":{"line":150,"column":4},"end":{"line":152,"column":null}},{"start":{},"end":{}}],"line":150},"9":{"loc":{"start":{"line":153,"column":4},"end":{"line":155,"column":null}},"type":"if","locations":[{"start":{"line":153,"column":4},"end":{"line":155,"column":null}},{"start":{},"end":{}}],"line":153},"10":{"loc":{"start":{"line":161,"column":4},"end":{"line":168,"column":null}},"type":"if","locations":[{"start":{"line":161,"column":4},"end":{"line":168,"column":null}},{"start":{},"end":{}}],"line":161},"11":{"loc":{"start":{"line":162,"column":6},"end":{"line":165,"column":null}},"type":"binary-expr","locations":[{"start":{"line":162,"column":6},"end":{"line":162,"column":null}},{"start":{"line":163,"column":6},"end":{"line":163,"column":null}},{"start":{"line":164,"column":6},"end":{"line":164,"column":null}},{"start":{"line":165,"column":6},"end":{"line":165,"column":null}}],"line":162},"12":{"loc":{"start":{"line":192,"column":2},"end":{"line":192,"column":null}},"type":"default-arg","locations":[{"start":{"line":192,"column":22},"end":{"line":192,"column":null}}],"line":192},"13":{"loc":{"start":{"line":194,"column":2},"end":{"line":194,"column":null}},"type":"if","locations":[{"start":{"line":194,"column":2},"end":{"line":194,"column":null}},{"start":{},"end":{}}],"line":194},"14":{"loc":{"start":{"line":201,"column":4},"end":{"line":201,"column":null}},"type":"if","locations":[{"start":{"line":201,"column":4},"end":{"line":201,"column":null}},{"start":{},"end":{}}],"line":201},"15":{"loc":{"start":{"line":239,"column":2},"end":{"line":241,"column":null}},"type":"if","locations":[{"start":{"line":239,"column":2},"end":{"line":241,"column":null}},{"start":{},"end":{}}],"line":239},"16":{"loc":{"start":{"line":282,"column":4},"end":{"line":283,"column":null}},"type":"if","locations":[{"start":{"line":282,"column":4},"end":{"line":283,"column":null}},{"start":{"line":282,"column":64},"end":{"line":283,"column":null}}],"line":282},"17":{"loc":{"start":{"line":282,"column":64},"end":{"line":283,"column":null}},"type":"if","locations":[{"start":{"line":282,"column":64},"end":{"line":283,"column":null}},{"start":{},"end":{}}],"line":282},"18":{"loc":{"start":{"line":284,"column":4},"end":{"line":284,"column":null}},"type":"if","locations":[{"start":{"line":284,"column":4},"end":{"line":284,"column":null}},{"start":{},"end":{}}],"line":284},"19":{"loc":{"start":{"line":290,"column":22},"end":{"line":290,"column":null}},"type":"cond-expr","locations":[{"start":{"line":290,"column":61},"end":{"line":290,"column":80}},{"start":{"line":290,"column":80},"end":{"line":290,"column":null}}],"line":290},"20":{"loc":{"start":{"line":291,"column":4},"end":{"line":291,"column":null}},"type":"if","locations":[{"start":{"line":291,"column":4},"end":{"line":291,"column":null}},{"start":{},"end":{}}],"line":291},"21":{"loc":{"start":{"line":295,"column":4},"end":{"line":301,"column":null}},"type":"if","locations":[{"start":{"line":295,"column":4},"end":{"line":301,"column":null}},{"start":{"line":297,"column":4},"end":{"line":301,"column":null}}],"line":295},"22":{"loc":{"start":{"line":297,"column":4},"end":{"line":301,"column":null}},"type":"if","locations":[{"start":{"line":297,"column":4},"end":{"line":301,"column":null}},{"start":{"line":299,"column":11},"end":{"line":301,"column":null}}],"line":297},"23":{"loc":{"start":{"line":322,"column":2},"end":{"line":322,"column":null}},"type":"default-arg","locations":[{"start":{"line":322,"column":22},"end":{"line":322,"column":null}}],"line":322},"24":{"loc":{"start":{"line":324,"column":2},"end":{"line":324,"column":null}},"type":"if","locations":[{"start":{"line":324,"column":2},"end":{"line":324,"column":null}},{"start":{},"end":{}}],"line":324},"25":{"loc":{"start":{"line":331,"column":4},"end":{"line":331,"column":null}},"type":"if","locations":[{"start":{"line":331,"column":4},"end":{"line":331,"column":null}},{"start":{},"end":{}}],"line":331}},"s":{"0":5,"1":5,"2":5,"3":43,"4":43,"5":25,"6":18,"7":11,"8":11,"9":8,"10":3,"11":24,"12":18,"13":6,"14":6,"15":6,"16":6,"17":32,"18":32,"19":5,"20":27,"21":20,"22":7,"23":7,"24":7,"25":7,"26":0,"27":0,"28":24,"29":27,"30":27,"31":27,"32":2,"33":25,"34":1,"35":27,"36":3,"37":27,"38":27,"39":27,"40":5,"41":18,"42":20,"43":20,"44":4,"45":24,"46":4,"47":20,"48":20,"49":20,"50":20,"51":20,"52":8,"53":9,"54":3,"55":3,"56":193,"57":193,"58":2,"59":87,"60":87,"61":20,"62":6,"63":12,"64":12,"65":12,"66":1,"67":11,"68":0,"69":12,"70":1,"71":12,"72":12,"73":12,"74":12,"75":1,"76":8,"77":6,"78":2,"79":1,"80":2,"81":1,"82":8,"83":3,"84":5,"85":1,"86":4,"87":4,"88":4,"89":4,"90":4,"91":0,"92":4,"93":0},"f":{"0":43,"1":11,"2":24,"3":32,"4":24,"5":27,"6":20,"7":20,"8":24,"9":3,"10":193,"11":2,"12":87,"13":6,"14":12,"15":2,"16":5},"b":{"0":[25,18],"1":[18,15],"2":[8,3],"3":[18,6],"4":[6,0],"5":[5,27],"6":[20,7],"7":[2,25],"8":[1,24],"9":[3,24],"10":[5,22],"11":[27,22,20,19],"12":[24],"13":[4,20],"14":[8,12],"15":[20,67],"16":[1,11],"17":[0,11],"18":[1,11],"19":[8,1],"20":[1,11],"21":[6,2],"22":[1,1],"23":[5],"24":[1,4],"25":[0,4]},"meta":{"lastBranch":26,"lastFunction":17,"lastStatement":94,"seen":{"s:30:30:30:Infinity":0,"s:33:36:33:Infinity":1,"s:36:111:36:Infinity":2,"f:44:9:44:40":0,"s:45:16:45:Infinity":3,"b:46:2:46:Infinity:undefined:undefined:undefined:undefined":0,"s:46:2:46:Infinity":4,"s:46:14:46:Infinity":5,"s:47:2:47:Infinity":6,"b:47:9:47:29:47:29:47:Infinity":1,"f:55:16:55:49":1,"s:56:21:56:Infinity":7,"b:57:2:57:Infinity:undefined:undefined:undefined:undefined":2,"s:57:2:57:Infinity":8,"s:57:28:57:Infinity":9,"s:60:2:60:Infinity":10,"f:68:9:68:24":2,"b:70:2:72:Infinity:undefined:undefined:undefined:undefined":3,"s:70:2:72:Infinity":11,"s:71:4:71:Infinity":12,"s:74:2:90:Infinity":13,"s:75:10:86:Infinity":14,"s:87:4:87:Infinity":15,"b:87:11:87:28:87:28:87:Infinity":4,"s:89:4:89:Infinity":16,"f:97:9:97:41":3,"s:99:21:99:Infinity":17,"b:100:2:100:Infinity:undefined:undefined:undefined:undefined":5,"s:100:2:100:Infinity":18,"s:100:28:100:Infinity":19,"b:102:2:102:Infinity:undefined:undefined:undefined:undefined":6,"s:102:2:102:Infinity":20,"s:102:30:102:Infinity":21,"s:104:2:115:Infinity":22,"s:105:4:109:Infinity":23,"s:110:4:110:Infinity":24,"s:111:4:111:Infinity":25,"s:113:4:113:Infinity":26,"s:114:4:114:Infinity":27,"f:121:16:121:Infinity":4,"s:126:2:130:Infinity":28,"f:144:16:144:32":5,"s:145:2:178:Infinity":29,"s:147:18:147:Infinity":30,"b:148:4:152:Infinity:150:4:152:Infinity":7,"s:148:4:152:Infinity":31,"s:149:6:149:Infinity":32,"b:150:4:152:Infinity:undefined:undefined:undefined:undefined":8,"s:150:4:152:Infinity":33,"s:151:6:151:Infinity":34,"b:153:4:155:Infinity:undefined:undefined:undefined:undefined":9,"s:153:4:155:Infinity":35,"s:154:6:154:Infinity":36,"s:156:4:156:Infinity":37,"s:158:19:158:Infinity":38,"b:161:4:168:Infinity:undefined:undefined:undefined:undefined":10,"s:161:4:168:Infinity":39,"b:162:6:162:Infinity:163:6:163:Infinity:164:6:164:Infinity:165:6:165:Infinity":11,"s:167:6:167:Infinity":40,"s:170:4:175:Infinity":41,"f:173:42:173:43":6,"s:173:58:173:85":42,"f:174:48:174:49":7,"s:174:64:174:90":43,"s:177:4:177:Infinity":44,"f:188:22:188:Infinity":8,"b:192:22:192:Infinity":12,"b:194:2:194:Infinity:undefined:undefined:undefined:undefined":13,"s:194:2:194:Infinity":45,"s:194:31:194:Infinity":46,"s:196:2:205:Infinity":47,"s:197:19:197:Infinity":48,"s:198:25:198:Infinity":49,"s:200:23:200:Infinity":50,"b:201:4:201:Infinity:undefined:undefined:undefined:undefined":14,"s:201:4:201:Infinity":51,"s:201:21:201:Infinity":52,"s:202:4:202:Infinity":53,"s:204:4:204:Infinity":54,"f:211:22:211:66":9,"s:212:2:212:Infinity":55,"f:218:16:218:47":10,"s:219:2:219:Infinity":56,"s:220:2:220:Infinity":57,"f:226:16:226:43":11,"s:227:2:227:Infinity":58,"f:235:16:235:Infinity":12,"s:238:2:238:Infinity":59,"b:239:2:241:Infinity:undefined:undefined:undefined:undefined":15,"s:239:2:241:Infinity":60,"s:240:4:240:Infinity":61,"f:257:16:257:Infinity":13,"s:261:2:267:Infinity":62,"f:279:16:279:37":14,"s:280:2:309:Infinity":63,"s:281:18:281:Infinity":64,"b:282:4:283:Infinity:282:64:283:Infinity":16,"s:282:4:283:Infinity":65,"s:282:39:282:Infinity":66,"b:282:64:283:Infinity:undefined:undefined:undefined:undefined":17,"s:282:64:283:Infinity":67,"s:283:40:283:Infinity":68,"b:284:4:284:Infinity:undefined:undefined:undefined:undefined":18,"s:284:4:284:Infinity":69,"s:284:33:284:Infinity":70,"s:285:4:285:Infinity":71,"s:287:19:287:Infinity":72,"s:290:22:290:Infinity":73,"b:290:61:290:80:290:80:290:Infinity":19,"b:291:4:291:Infinity:undefined:undefined:undefined:undefined":20,"s:291:4:291:Infinity":74,"s:291:20:291:Infinity":75,"b:295:4:301:Infinity:297:4:301:Infinity":21,"s:295:4:301:Infinity":76,"s:296:6:296:Infinity":77,"b:297:4:301:Infinity:299:11:301:Infinity":22,"s:297:4:301:Infinity":78,"s:298:6:298:Infinity":79,"f:298:39:298:40":15,"s:298:55:298:64":80,"s:300:6:300:Infinity":81,"s:303:4:306:Infinity":82,"s:308:4:308:Infinity":83,"f:319:22:319:Infinity":16,"b:322:22:322:Infinity":23,"b:324:2:324:Infinity:undefined:undefined:undefined:undefined":24,"s:324:2:324:Infinity":84,"s:324:31:324:Infinity":85,"s:326:2:335:Infinity":86,"s:327:19:327:Infinity":87,"s:328:25:328:Infinity":88,"s:330:23:330:Infinity":89,"b:331:4:331:Infinity:undefined:undefined:undefined:undefined":25,"s:331:4:331:Infinity":90,"s:331:21:331:Infinity":91,"s:332:4:332:Infinity":92,"s:334:4:334:Infinity":93}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/context.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/context.ts","statementMap":{"0":{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},"1":{"start":{"line":30,"column":4},"end":{"line":30,"column":null}},"2":{"start":{"line":37,"column":4},"end":{"line":39,"column":null}},"3":{"start":{"line":38,"column":6},"end":{"line":38,"column":null}},"4":{"start":{"line":46,"column":4},"end":{"line":78,"column":null}},"5":{"start":{"line":48,"column":6},"end":{"line":48,"column":null}},"6":{"start":{"line":51,"column":22},"end":{"line":51,"column":null}},"7":{"start":{"line":52,"column":25},"end":{"line":52,"column":null}},"8":{"start":{"line":54,"column":6},"end":{"line":61,"column":null}},"9":{"start":{"line":56,"column":8},"end":{"line":60,"column":null}},"10":{"start":{"line":64,"column":6},"end":{"line":68,"column":null}},"11":{"start":{"line":71,"column":6},"end":{"line":71,"column":null}},"12":{"start":{"line":73,"column":6},"end":{"line":77,"column":null}},"13":{"start":{"line":86,"column":4},"end":{"line":86,"column":null}},"14":{"start":{"line":116,"column":18},"end":{"line":116,"column":null}},"15":{"start":{"line":117,"column":2},"end":{"line":117,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":28,"column":2},"end":{"line":28,"column":14}},"loc":{"start":{"line":28,"column":63},"end":{"line":31,"column":null}},"line":28},"1":{"name":"(anonymous_1)","decl":{"start":{"line":36,"column":8},"end":{"line":36,"column":34}},"loc":{"start":{"line":36,"column":34},"end":{"line":40,"column":null}},"line":36},"2":{"name":"(anonymous_2)","decl":{"start":{"line":45,"column":8},"end":{"line":45,"column":16}},"loc":{"start":{"line":45,"column":65},"end":{"line":79,"column":null}},"line":45},"3":{"name":"(anonymous_3)","decl":{"start":{"line":85,"column":10},"end":{"line":85,"column":34}},"loc":{"start":{"line":85,"column":59},"end":{"line":109,"column":null}},"line":85},"4":{"name":"createContextHook","decl":{"start":{"line":115,"column":16},"end":{"line":115,"column":34}},"loc":{"start":{"line":115,"column":60},"end":{"line":118,"column":null}},"line":115}},"branchMap":{"0":{"loc":{"start":{"line":28,"column":42},"end":{"line":28,"column":63}},"type":"default-arg","locations":[{"start":{"line":28,"column":56},"end":{"line":28,"column":63}}],"line":28},"1":{"loc":{"start":{"line":37,"column":4},"end":{"line":39,"column":null}},"type":"if","locations":[{"start":{"line":37,"column":4},"end":{"line":39,"column":null}},{"start":{},"end":{}}],"line":37},"2":{"loc":{"start":{"line":52,"column":25},"end":{"line":52,"column":null}},"type":"binary-expr","locations":[{"start":{"line":52,"column":25},"end":{"line":52,"column":45}},{"start":{"line":52,"column":45},"end":{"line":52,"column":null}}],"line":52},"3":{"loc":{"start":{"line":54,"column":6},"end":{"line":61,"column":null}},"type":"if","locations":[{"start":{"line":54,"column":6},"end":{"line":61,"column":null}},{"start":{},"end":{}}],"line":54},"4":{"loc":{"start":{"line":76,"column":15},"end":{"line":76,"column":null}},"type":"cond-expr","locations":[{"start":{"line":76,"column":40},"end":{"line":76,"column":56}},{"start":{"line":76,"column":56},"end":{"line":76,"column":null}}],"line":76}},"s":{"0":5,"1":5,"2":5,"3":5,"4":5,"5":5,"6":4,"7":4,"8":5,"9":2,"10":2,"11":1,"12":1,"13":2,"14":4,"15":4},"f":{"0":5,"1":5,"2":5,"3":2,"4":4},"b":{"0":[5],"1":[5,0],"2":[4,4],"3":[2,3],"4":[1,0]},"meta":{"lastBranch":5,"lastFunction":5,"lastStatement":16,"seen":{"f:28:2:28:14":0,"b:28:56:28:63":0,"s:29:4:29:Infinity":0,"s:30:4:30:Infinity":1,"f:36:8:36:34":1,"b:37:4:39:Infinity:undefined:undefined:undefined:undefined":1,"s:37:4:39:Infinity":2,"s:38:6:38:Infinity":3,"f:45:8:45:16":2,"s:46:4:78:Infinity":4,"s:48:6:48:Infinity":5,"s:51:22:51:Infinity":6,"s:52:25:52:Infinity":7,"b:52:25:52:45:52:45:52:Infinity":2,"b:54:6:61:Infinity:undefined:undefined:undefined:undefined":3,"s:54:6:61:Infinity":8,"s:56:8:60:Infinity":9,"s:64:6:68:Infinity":10,"s:71:6:71:Infinity":11,"s:73:6:77:Infinity":12,"b:76:40:76:56:76:56:76:Infinity":4,"f:85:10:85:34":3,"s:86:4:86:Infinity":13,"f:115:16:115:34":4,"s:116:18:116:Infinity":14,"s:117:2:117:Infinity":15}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/observation.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/observation.ts","statementMap":{"0":{"start":{"line":21,"column":19},"end":{"line":38,"column":null}},"1":{"start":{"line":51,"column":4},"end":{"line":51,"column":null}},"2":{"start":{"line":52,"column":4},"end":{"line":52,"column":null}},"3":{"start":{"line":59,"column":4},"end":{"line":61,"column":null}},"4":{"start":{"line":60,"column":6},"end":{"line":60,"column":null}},"5":{"start":{"line":68,"column":4},"end":{"line":127,"column":null}},"6":{"start":{"line":70,"column":6},"end":{"line":75,"column":null}},"7":{"start":{"line":71,"column":8},"end":{"line":74,"column":null}},"8":{"start":{"line":78,"column":6},"end":{"line":83,"column":null}},"9":{"start":{"line":79,"column":8},"end":{"line":82,"column":null}},"10":{"start":{"line":86,"column":23},"end":{"line":86,"column":null}},"11":{"start":{"line":87,"column":26},"end":{"line":87,"column":null}},"12":{"start":{"line":88,"column":6},"end":{"line":93,"column":null}},"13":{"start":{"line":89,"column":8},"end":{"line":92,"column":null}},"14":{"start":{"line":96,"column":6},"end":{"line":96,"column":null}},"15":{"start":{"line":99,"column":6},"end":{"line":99,"column":null}},"16":{"start":{"line":102,"column":18},"end":{"line":109,"column":null}},"17":{"start":{"line":114,"column":6},"end":{"line":117,"column":null}},"18":{"start":{"line":120,"column":6},"end":{"line":120,"column":null}},"19":{"start":{"line":122,"column":6},"end":{"line":126,"column":null}},"20":{"start":{"line":135,"column":18},"end":{"line":135,"column":null}},"21":{"start":{"line":136,"column":2},"end":{"line":136,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":50,"column":2},"end":{"line":50,"column":14}},"loc":{"start":{"line":50,"column":63},"end":{"line":53,"column":null}},"line":50},"1":{"name":"(anonymous_1)","decl":{"start":{"line":58,"column":8},"end":{"line":58,"column":34}},"loc":{"start":{"line":58,"column":34},"end":{"line":62,"column":null}},"line":58},"2":{"name":"(anonymous_2)","decl":{"start":{"line":67,"column":8},"end":{"line":67,"column":16}},"loc":{"start":{"line":67,"column":65},"end":{"line":128,"column":null}},"line":67},"3":{"name":"createObservationHook","decl":{"start":{"line":134,"column":16},"end":{"line":134,"column":38}},"loc":{"start":{"line":134,"column":68},"end":{"line":137,"column":null}},"line":134}},"branchMap":{"0":{"loc":{"start":{"line":50,"column":42},"end":{"line":50,"column":63}},"type":"default-arg","locations":[{"start":{"line":50,"column":56},"end":{"line":50,"column":63}}],"line":50},"1":{"loc":{"start":{"line":59,"column":4},"end":{"line":61,"column":null}},"type":"if","locations":[{"start":{"line":59,"column":4},"end":{"line":61,"column":null}},{"start":{},"end":{}}],"line":59},"2":{"loc":{"start":{"line":70,"column":6},"end":{"line":75,"column":null}},"type":"if","locations":[{"start":{"line":70,"column":6},"end":{"line":75,"column":null}},{"start":{},"end":{}}],"line":70},"3":{"loc":{"start":{"line":78,"column":6},"end":{"line":83,"column":null}},"type":"if","locations":[{"start":{"line":78,"column":6},"end":{"line":83,"column":null}},{"start":{},"end":{}}],"line":78},"4":{"loc":{"start":{"line":86,"column":38},"end":{"line":86,"column":59}},"type":"binary-expr","locations":[{"start":{"line":86,"column":38},"end":{"line":86,"column":57}},{"start":{"line":86,"column":57},"end":{"line":86,"column":59}}],"line":86},"5":{"loc":{"start":{"line":87,"column":41},"end":{"line":87,"column":65}},"type":"binary-expr","locations":[{"start":{"line":87,"column":41},"end":{"line":87,"column":63}},{"start":{"line":87,"column":63},"end":{"line":87,"column":65}}],"line":87},"6":{"loc":{"start":{"line":88,"column":6},"end":{"line":93,"column":null}},"type":"if","locations":[{"start":{"line":88,"column":6},"end":{"line":93,"column":null}},{"start":{},"end":{}}],"line":88},"7":{"loc":{"start":{"line":88,"column":10},"end":{"line":88,"column":53}},"type":"binary-expr","locations":[{"start":{"line":88,"column":10},"end":{"line":88,"column":31}},{"start":{"line":88,"column":31},"end":{"line":88,"column":53}}],"line":88},"8":{"loc":{"start":{"line":125,"column":15},"end":{"line":125,"column":null}},"type":"cond-expr","locations":[{"start":{"line":125,"column":40},"end":{"line":125,"column":56}},{"start":{"line":125,"column":56},"end":{"line":125,"column":null}}],"line":125}},"s":{"0":2,"1":17,"2":17,"3":31,"4":31,"5":73,"6":73,"7":1,"8":72,"9":4,"10":68,"11":73,"12":73,"13":0,"14":68,"15":67,"16":67,"17":67,"18":1,"19":1,"20":16,"21":16},"f":{"0":17,"1":31,"2":73,"3":16},"b":{"0":[17],"1":[31,0],"2":[1,72],"3":[4,68],"4":[68,0],"5":[73,0],"6":[0,73],"7":[73,0],"8":[1,0]},"meta":{"lastBranch":9,"lastFunction":4,"lastStatement":22,"seen":{"s:21:19:38:Infinity":0,"f:50:2:50:14":0,"b:50:56:50:63":0,"s:51:4:51:Infinity":1,"s:52:4:52:Infinity":2,"f:58:8:58:34":1,"b:59:4:61:Infinity:undefined:undefined:undefined:undefined":1,"s:59:4:61:Infinity":3,"s:60:6:60:Infinity":4,"f:67:8:67:16":2,"s:68:4:127:Infinity":5,"b:70:6:75:Infinity:undefined:undefined:undefined:undefined":2,"s:70:6:75:Infinity":6,"s:71:8:74:Infinity":7,"b:78:6:83:Infinity:undefined:undefined:undefined:undefined":3,"s:78:6:83:Infinity":8,"s:79:8:82:Infinity":9,"s:86:23:86:Infinity":10,"b:86:38:86:57:86:57:86:59":4,"s:87:26:87:Infinity":11,"b:87:41:87:63:87:63:87:65":5,"b:88:6:93:Infinity:undefined:undefined:undefined:undefined":6,"s:88:6:93:Infinity":12,"b:88:10:88:31:88:31:88:53":7,"s:89:8:92:Infinity":13,"s:96:6:96:Infinity":14,"s:99:6:99:Infinity":15,"s:102:18:109:Infinity":16,"s:114:6:117:Infinity":17,"s:120:6:120:Infinity":18,"s:122:6:126:Infinity":19,"b:125:40:125:56:125:56:125:Infinity":8,"f:134:16:134:38":3,"s:135:18:135:Infinity":20,"s:136:2:136:Infinity":21}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/service.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/service.ts","statementMap":{"0":{"start":{"line":53,"column":48},"end":{"line":59,"column":null}},"1":{"start":{"line":69,"column":38},"end":{"line":69,"column":null}},"2":{"start":{"line":70,"column":33},"end":{"line":70,"column":null}},"3":{"start":{"line":415,"column":47},"end":{"line":415,"column":null}},"4":{"start":{"line":74,"column":4},"end":{"line":74,"column":null}},"5":{"start":{"line":75,"column":4},"end":{"line":75,"column":null}},"6":{"start":{"line":82,"column":4},"end":{"line":82,"column":null}},"7":{"start":{"line":82,"column":26},"end":{"line":82,"column":null}},"8":{"start":{"line":85,"column":16},"end":{"line":85,"column":null}},"9":{"start":{"line":86,"column":4},"end":{"line":88,"column":null}},"10":{"start":{"line":87,"column":6},"end":{"line":87,"column":null}},"11":{"start":{"line":91,"column":4},"end":{"line":91,"column":null}},"12":{"start":{"line":94,"column":4},"end":{"line":94,"column":null}},"13":{"start":{"line":96,"column":4},"end":{"line":96,"column":null}},"14":{"start":{"line":99,"column":4},"end":{"line":99,"column":null}},"15":{"start":{"line":101,"column":4},"end":{"line":101,"column":null}},"16":{"start":{"line":108,"column":4},"end":{"line":108,"column":null}},"17":{"start":{"line":108,"column":39},"end":{"line":108,"column":null}},"18":{"start":{"line":110,"column":4},"end":{"line":110,"column":null}},"19":{"start":{"line":111,"column":4},"end":{"line":111,"column":null}},"20":{"start":{"line":112,"column":4},"end":{"line":112,"column":null}},"21":{"start":{"line":121,"column":4},"end":{"line":121,"column":null}},"22":{"start":{"line":124,"column":21},"end":{"line":124,"column":null}},"23":{"start":{"line":125,"column":4},"end":{"line":127,"column":null}},"24":{"start":{"line":126,"column":6},"end":{"line":126,"column":null}},"25":{"start":{"line":130,"column":16},"end":{"line":130,"column":null}},"26":{"start":{"line":131,"column":19},"end":{"line":134,"column":null}},"27":{"start":{"line":136,"column":4},"end":{"line":144,"column":null}},"28":{"start":{"line":153,"column":4},"end":{"line":153,"column":null}},"29":{"start":{"line":156,"column":4},"end":{"line":156,"column":null}},"30":{"start":{"line":159,"column":25},"end":{"line":159,"column":null}},"31":{"start":{"line":160,"column":16},"end":{"line":160,"column":null}},"32":{"start":{"line":162,"column":19},"end":{"line":165,"column":null}},"33":{"start":{"line":167,"column":15},"end":{"line":167,"column":null}},"34":{"start":{"line":170,"column":4},"end":{"line":172,"column":null}},"35":{"start":{"line":171,"column":6},"end":{"line":171,"column":null}},"36":{"start":{"line":174,"column":4},"end":{"line":180,"column":null}},"37":{"start":{"line":187,"column":4},"end":{"line":187,"column":null}},"38":{"start":{"line":187,"column":18},"end":{"line":187,"column":null}},"39":{"start":{"line":189,"column":16},"end":{"line":191,"column":null}},"40":{"start":{"line":193,"column":4},"end":{"line":193,"column":null}},"41":{"start":{"line":200,"column":4},"end":{"line":200,"column":null}},"42":{"start":{"line":202,"column":17},"end":{"line":206,"column":null}},"43":{"start":{"line":208,"column":4},"end":{"line":214,"column":null}},"44":{"start":{"line":208,"column":28},"end":{"line":214,"column":6}},"45":{"start":{"line":221,"column":4},"end":{"line":221,"column":null}},"46":{"start":{"line":223,"column":17},"end":{"line":229,"column":null}},"47":{"start":{"line":231,"column":4},"end":{"line":237,"column":null}},"48":{"start":{"line":231,"column":28},"end":{"line":237,"column":6}},"49":{"start":{"line":244,"column":4},"end":{"line":244,"column":null}},"50":{"start":{"line":244,"column":18},"end":{"line":244,"column":null}},"51":{"start":{"line":246,"column":16},"end":{"line":246,"column":null}},"52":{"start":{"line":248,"column":4},"end":{"line":250,"column":null}},"53":{"start":{"line":249,"column":6},"end":{"line":249,"column":null}},"54":{"start":{"line":252,"column":4},"end":{"line":252,"column":null}},"55":{"start":{"line":259,"column":4},"end":{"line":259,"column":null}},"56":{"start":{"line":261,"column":16},"end":{"line":261,"column":null}},"57":{"start":{"line":262,"column":4},"end":{"line":266,"column":null}},"58":{"start":{"line":273,"column":4},"end":{"line":273,"column":null}},"59":{"start":{"line":275,"column":17},"end":{"line":280,"column":null}},"60":{"start":{"line":282,"column":4},"end":{"line":282,"column":null}},"61":{"start":{"line":282,"column":27},"end":{"line":282,"column":49}},"62":{"start":{"line":298,"column":4},"end":{"line":298,"column":null}},"63":{"start":{"line":300,"column":10},"end":{"line":300,"column":null}},"64":{"start":{"line":301,"column":16},"end":{"line":301,"column":null}},"65":{"start":{"line":302,"column":10},"end":{"line":302,"column":null}},"66":{"start":{"line":303,"column":10},"end":{"line":303,"column":null}},"67":{"start":{"line":304,"column":25},"end":{"line":304,"column":null}},"68":{"start":{"line":305,"column":37},"end":{"line":305,"column":null}},"69":{"start":{"line":308,"column":21},"end":{"line":308,"column":null}},"70":{"start":{"line":309,"column":10},"end":{"line":312,"column":null}},"71":{"start":{"line":316,"column":10},"end":{"line":316,"column":null}},"72":{"start":{"line":317,"column":10},"end":{"line":317,"column":null}},"73":{"start":{"line":318,"column":10},"end":{"line":318,"column":null}},"74":{"start":{"line":319,"column":10},"end":{"line":319,"column":null}},"75":{"start":{"line":321,"column":4},"end":{"line":324,"column":null}},"76":{"start":{"line":327,"column":4},"end":{"line":327,"column":null}},"77":{"start":{"line":328,"column":4},"end":{"line":328,"column":null}},"78":{"start":{"line":331,"column":4},"end":{"line":335,"column":null}},"79":{"start":{"line":337,"column":4},"end":{"line":355,"column":null}},"80":{"start":{"line":364,"column":4},"end":{"line":364,"column":null}},"81":{"start":{"line":366,"column":16},"end":{"line":368,"column":null}},"82":{"start":{"line":370,"column":4},"end":{"line":370,"column":null}},"83":{"start":{"line":370,"column":14},"end":{"line":370,"column":null}},"84":{"start":{"line":372,"column":21},"end":{"line":372,"column":null}},"85":{"start":{"line":372,"column":102},"end":{"line":372,"column":106}},"86":{"start":{"line":373,"column":4},"end":{"line":373,"column":null}},"87":{"start":{"line":373,"column":19},"end":{"line":373,"column":null}},"88":{"start":{"line":375,"column":4},"end":{"line":385,"column":null}},"89":{"start":{"line":387,"column":4},"end":{"line":387,"column":null}},"90":{"start":{"line":397,"column":4},"end":{"line":409,"column":null}},"91":{"start":{"line":398,"column":20},"end":{"line":398,"column":null}},"92":{"start":{"line":399,"column":6},"end":{"line":402,"column":null}},"93":{"start":{"line":400,"column":25},"end":{"line":400,"column":null}},"94":{"start":{"line":401,"column":8},"end":{"line":401,"column":null}},"95":{"start":{"line":401,"column":33},"end":{"line":401,"column":null}},"96":{"start":{"line":403,"column":6},"end":{"line":403,"column":null}},"97":{"start":{"line":404,"column":4},"end":{"line":409,"column":null}},"98":{"start":{"line":405,"column":6},"end":{"line":405,"column":null}},"99":{"start":{"line":407,"column":20},"end":{"line":407,"column":null}},"100":{"start":{"line":408,"column":6},"end":{"line":408,"column":null}},"101":{"start":{"line":426,"column":4},"end":{"line":426,"column":null}},"102":{"start":{"line":426,"column":18},"end":{"line":426,"column":null}},"103":{"start":{"line":427,"column":4},"end":{"line":429,"column":null}},"104":{"start":{"line":439,"column":21},"end":{"line":439,"column":null}},"105":{"start":{"line":442,"column":4},"end":{"line":459,"column":null}},"106":{"start":{"line":443,"column":6},"end":{"line":458,"column":null}},"107":{"start":{"line":444,"column":20},"end":{"line":444,"column":null}},"108":{"start":{"line":445,"column":8},"end":{"line":455,"column":null}},"109":{"start":{"line":446,"column":10},"end":{"line":452,"column":null}},"110":{"start":{"line":447,"column":12},"end":{"line":447,"column":null}},"111":{"start":{"line":448,"column":12},"end":{"line":448,"column":null}},"112":{"start":{"line":451,"column":12},"end":{"line":451,"column":null}},"113":{"start":{"line":451,"column":18},"end":{"line":451,"column":40}},"114":{"start":{"line":454,"column":10},"end":{"line":454,"column":null}},"115":{"start":{"line":454,"column":16},"end":{"line":454,"column":38}},"116":{"start":{"line":457,"column":8},"end":{"line":457,"column":null}},"117":{"start":{"line":457,"column":14},"end":{"line":457,"column":36}},"118":{"start":{"line":463,"column":4},"end":{"line":468,"column":null}},"119":{"start":{"line":464,"column":6},"end":{"line":464,"column":null}},"120":{"start":{"line":467,"column":6},"end":{"line":467,"column":null}},"121":{"start":{"line":471,"column":4},"end":{"line":476,"column":null}},"122":{"start":{"line":472,"column":6},"end":{"line":472,"column":null}},"123":{"start":{"line":473,"column":6},"end":{"line":473,"column":null}},"124":{"start":{"line":475,"column":6},"end":{"line":475,"column":null}},"125":{"start":{"line":475,"column":12},"end":{"line":475,"column":27}},"126":{"start":{"line":479,"column":4},"end":{"line":490,"column":null}},"127":{"start":{"line":480,"column":22},"end":{"line":480,"column":null}},"128":{"start":{"line":481,"column":12},"end":{"line":485,"column":null}},"129":{"start":{"line":486,"column":6},"end":{"line":486,"column":null}},"130":{"start":{"line":489,"column":6},"end":{"line":489,"column":null}},"131":{"start":{"line":489,"column":12},"end":{"line":489,"column":34}},"132":{"start":{"line":499,"column":4},"end":{"line":499,"column":null}},"133":{"start":{"line":501,"column":21},"end":{"line":501,"column":null}},"134":{"start":{"line":502,"column":4},"end":{"line":502,"column":null}},"135":{"start":{"line":504,"column":16},"end":{"line":504,"column":null}},"136":{"start":{"line":505,"column":4},"end":{"line":596,"column":null}},"137":{"start":{"line":506,"column":41},"end":{"line":506,"column":null}},"138":{"start":{"line":507,"column":23},"end":{"line":507,"column":null}},"139":{"start":{"line":508,"column":25},"end":{"line":508,"column":null}},"140":{"start":{"line":509,"column":6},"end":{"line":509,"column":null}},"141":{"start":{"line":511,"column":47},"end":{"line":515,"column":null}},"142":{"start":{"line":518,"column":29},"end":{"line":526,"column":null}},"143":{"start":{"line":519,"column":21},"end":{"line":521,"column":null}},"144":{"start":{"line":522,"column":8},"end":{"line":524,"column":null}},"145":{"start":{"line":523,"column":10},"end":{"line":523,"column":null}},"146":{"start":{"line":525,"column":8},"end":{"line":525,"column":null}},"147":{"start":{"line":529,"column":6},"end":{"line":566,"column":null}},"148":{"start":{"line":530,"column":21},"end":{"line":530,"column":null}},"149":{"start":{"line":532,"column":8},"end":{"line":532,"column":null}},"150":{"start":{"line":532,"column":19},"end":{"line":532,"column":null}},"151":{"start":{"line":534,"column":22},"end":{"line":534,"column":null}},"152":{"start":{"line":535,"column":8},"end":{"line":538,"column":null}},"153":{"start":{"line":536,"column":10},"end":{"line":536,"column":null}},"154":{"start":{"line":537,"column":10},"end":{"line":537,"column":null}},"155":{"start":{"line":540,"column":8},"end":{"line":565,"column":null}},"156":{"start":{"line":541,"column":22},"end":{"line":543,"column":null}},"157":{"start":{"line":545,"column":10},"end":{"line":548,"column":null}},"158":{"start":{"line":546,"column":12},"end":{"line":546,"column":null}},"159":{"start":{"line":547,"column":12},"end":{"line":547,"column":null}},"160":{"start":{"line":550,"column":23},"end":{"line":552,"column":null}},"161":{"start":{"line":553,"column":10},"end":{"line":556,"column":null}},"162":{"start":{"line":554,"column":12},"end":{"line":554,"column":null}},"163":{"start":{"line":555,"column":12},"end":{"line":555,"column":null}},"164":{"start":{"line":558,"column":25},"end":{"line":558,"column":null}},"165":{"start":{"line":559,"column":25},"end":{"line":559,"column":null}},"166":{"start":{"line":560,"column":10},"end":{"line":560,"column":null}},"167":{"start":{"line":561,"column":10},"end":{"line":561,"column":null}},"168":{"start":{"line":562,"column":10},"end":{"line":562,"column":null}},"169":{"start":{"line":564,"column":10},"end":{"line":564,"column":null}},"170":{"start":{"line":569,"column":6},"end":{"line":593,"column":null}},"171":{"start":{"line":570,"column":8},"end":{"line":592,"column":null}},"172":{"start":{"line":571,"column":10},"end":{"line":571,"column":null}},"173":{"start":{"line":571,"column":61},"end":{"line":571,"column":null}},"174":{"start":{"line":572,"column":10},"end":{"line":591,"column":null}},"175":{"start":{"line":573,"column":30},"end":{"line":573,"column":null}},"176":{"start":{"line":574,"column":25},"end":{"line":576,"column":null}},"177":{"start":{"line":578,"column":12},"end":{"line":590,"column":null}},"178":{"start":{"line":579,"column":14},"end":{"line":579,"column":null}},"179":{"start":{"line":579,"column":65},"end":{"line":579,"column":null}},"180":{"start":{"line":580,"column":27},"end":{"line":582,"column":null}},"181":{"start":{"line":583,"column":14},"end":{"line":583,"column":null}},"182":{"start":{"line":583,"column":25},"end":{"line":583,"column":null}},"183":{"start":{"line":584,"column":14},"end":{"line":589,"column":null}},"184":{"start":{"line":585,"column":31},"end":{"line":585,"column":null}},"185":{"start":{"line":586,"column":31},"end":{"line":586,"column":null}},"186":{"start":{"line":587,"column":16},"end":{"line":587,"column":null}},"187":{"start":{"line":588,"column":16},"end":{"line":588,"column":null}},"188":{"start":{"line":595,"column":6},"end":{"line":595,"column":null}},"189":{"start":{"line":595,"column":12},"end":{"line":595,"column":34}},"190":{"start":{"line":598,"column":4},"end":{"line":598,"column":null}},"191":{"start":{"line":607,"column":4},"end":{"line":607,"column":null}},"192":{"start":{"line":609,"column":21},"end":{"line":609,"column":null}},"193":{"start":{"line":610,"column":4},"end":{"line":610,"column":null}},"194":{"start":{"line":612,"column":16},"end":{"line":612,"column":null}},"195":{"start":{"line":613,"column":4},"end":{"line":645,"column":null}},"196":{"start":{"line":615,"column":30},"end":{"line":623,"column":null}},"197":{"start":{"line":616,"column":21},"end":{"line":618,"column":null}},"198":{"start":{"line":619,"column":8},"end":{"line":621,"column":null}},"199":{"start":{"line":620,"column":10},"end":{"line":620,"column":null}},"200":{"start":{"line":622,"column":8},"end":{"line":622,"column":null}},"201":{"start":{"line":625,"column":6},"end":{"line":642,"column":null}},"202":{"start":{"line":626,"column":21},"end":{"line":626,"column":null}},"203":{"start":{"line":628,"column":8},"end":{"line":628,"column":null}},"204":{"start":{"line":628,"column":19},"end":{"line":628,"column":null}},"205":{"start":{"line":630,"column":8},"end":{"line":641,"column":null}},"206":{"start":{"line":631,"column":10},"end":{"line":636,"column":null}},"207":{"start":{"line":632,"column":12},"end":{"line":632,"column":null}},"208":{"start":{"line":633,"column":10},"end":{"line":636,"column":null}},"209":{"start":{"line":637,"column":10},"end":{"line":637,"column":null}},"210":{"start":{"line":638,"column":10},"end":{"line":638,"column":null}},"211":{"start":{"line":640,"column":10},"end":{"line":640,"column":null}},"212":{"start":{"line":644,"column":6},"end":{"line":644,"column":null}},"213":{"start":{"line":644,"column":12},"end":{"line":644,"column":34}},"214":{"start":{"line":647,"column":4},"end":{"line":647,"column":null}},"215":{"start":{"line":654,"column":4},"end":{"line":654,"column":null}},"216":{"start":{"line":656,"column":17},"end":{"line":661,"column":null}},"217":{"start":{"line":663,"column":4},"end":{"line":663,"column":null}},"218":{"start":{"line":663,"column":27},"end":{"line":663,"column":53}},"219":{"start":{"line":670,"column":4},"end":{"line":670,"column":null}},"220":{"start":{"line":672,"column":17},"end":{"line":677,"column":null}},"221":{"start":{"line":679,"column":4},"end":{"line":679,"column":null}},"222":{"start":{"line":679,"column":27},"end":{"line":679,"column":53}},"223":{"start":{"line":688,"column":4},"end":{"line":688,"column":null}},"224":{"start":{"line":690,"column":31},"end":{"line":693,"column":null}},"225":{"start":{"line":695,"column":29},"end":{"line":698,"column":null}},"226":{"start":{"line":700,"column":24},"end":{"line":700,"column":null}},"227":{"start":{"line":701,"column":29},"end":{"line":701,"column":null}},"228":{"start":{"line":704,"column":21},"end":{"line":706,"column":null}},"229":{"start":{"line":708,"column":4},"end":{"line":714,"column":null}},"230":{"start":{"line":727,"column":28},"end":{"line":727,"column":null}},"231":{"start":{"line":729,"column":4},"end":{"line":729,"column":null}},"232":{"start":{"line":730,"column":4},"end":{"line":730,"column":null}},"233":{"start":{"line":733,"column":4},"end":{"line":733,"column":null}},"234":{"start":{"line":734,"column":4},"end":{"line":734,"column":null}},"235":{"start":{"line":735,"column":4},"end":{"line":735,"column":null}},"236":{"start":{"line":736,"column":4},"end":{"line":736,"column":null}},"237":{"start":{"line":739,"column":4},"end":{"line":760,"column":null}},"238":{"start":{"line":740,"column":6},"end":{"line":740,"column":null}},"239":{"start":{"line":741,"column":6},"end":{"line":741,"column":null}},"240":{"start":{"line":743,"column":6},"end":{"line":759,"column":null}},"241":{"start":{"line":744,"column":21},"end":{"line":744,"column":null}},"242":{"start":{"line":745,"column":8},"end":{"line":745,"column":null}},"243":{"start":{"line":746,"column":8},"end":{"line":748,"column":null}},"244":{"start":{"line":747,"column":10},"end":{"line":747,"column":null}},"245":{"start":{"line":749,"column":8},"end":{"line":751,"column":null}},"246":{"start":{"line":750,"column":10},"end":{"line":750,"column":null}},"247":{"start":{"line":752,"column":8},"end":{"line":754,"column":null}},"248":{"start":{"line":753,"column":10},"end":{"line":753,"column":null}},"249":{"start":{"line":755,"column":8},"end":{"line":757,"column":null}},"250":{"start":{"line":756,"column":10},"end":{"line":756,"column":null}},"251":{"start":{"line":758,"column":8},"end":{"line":758,"column":null}},"252":{"start":{"line":763,"column":4},"end":{"line":772,"column":null}},"253":{"start":{"line":764,"column":6},"end":{"line":764,"column":null}},"254":{"start":{"line":765,"column":6},"end":{"line":765,"column":null}},"255":{"start":{"line":767,"column":6},"end":{"line":770,"column":null}},"256":{"start":{"line":768,"column":21},"end":{"line":768,"column":null}},"257":{"start":{"line":769,"column":8},"end":{"line":769,"column":null}},"258":{"start":{"line":771,"column":6},"end":{"line":771,"column":null}},"259":{"start":{"line":775,"column":4},"end":{"line":792,"column":null}},"260":{"start":{"line":776,"column":6},"end":{"line":776,"column":null}},"261":{"start":{"line":777,"column":6},"end":{"line":777,"column":null}},"262":{"start":{"line":779,"column":6},"end":{"line":790,"column":null}},"263":{"start":{"line":780,"column":21},"end":{"line":780,"column":null}},"264":{"start":{"line":781,"column":21},"end":{"line":781,"column":null}},"265":{"start":{"line":782,"column":23},"end":{"line":782,"column":null}},"266":{"start":{"line":783,"column":8},"end":{"line":783,"column":null}},"267":{"start":{"line":784,"column":8},"end":{"line":786,"column":null}},"268":{"start":{"line":785,"column":10},"end":{"line":785,"column":null}},"269":{"start":{"line":787,"column":8},"end":{"line":789,"column":null}},"270":{"start":{"line":788,"column":10},"end":{"line":788,"column":null}},"271":{"start":{"line":791,"column":6},"end":{"line":791,"column":null}},"272":{"start":{"line":795,"column":4},"end":{"line":815,"column":null}},"273":{"start":{"line":796,"column":6},"end":{"line":796,"column":null}},"274":{"start":{"line":797,"column":6},"end":{"line":797,"column":null}},"275":{"start":{"line":799,"column":6},"end":{"line":814,"column":null}},"276":{"start":{"line":800,"column":21},"end":{"line":800,"column":null}},"277":{"start":{"line":801,"column":23},"end":{"line":801,"column":null}},"278":{"start":{"line":802,"column":8},"end":{"line":802,"column":null}},"279":{"start":{"line":804,"column":8},"end":{"line":806,"column":null}},"280":{"start":{"line":805,"column":10},"end":{"line":805,"column":null}},"281":{"start":{"line":808,"column":8},"end":{"line":810,"column":null}},"282":{"start":{"line":809,"column":10},"end":{"line":809,"column":null}},"283":{"start":{"line":812,"column":8},"end":{"line":812,"column":null}},"284":{"start":{"line":813,"column":8},"end":{"line":813,"column":null}},"285":{"start":{"line":818,"column":4},"end":{"line":821,"column":null}},"286":{"start":{"line":819,"column":6},"end":{"line":819,"column":null}},"287":{"start":{"line":820,"column":6},"end":{"line":820,"column":null}},"288":{"start":{"line":824,"column":21},"end":{"line":824,"column":null}},"289":{"start":{"line":825,"column":26},"end":{"line":825,"column":null}},"290":{"start":{"line":826,"column":4},"end":{"line":832,"column":null}},"291":{"start":{"line":827,"column":35},"end":{"line":827,"column":null}},"292":{"start":{"line":828,"column":28},"end":{"line":828,"column":null}},"293":{"start":{"line":829,"column":6},"end":{"line":829,"column":null}},"294":{"start":{"line":830,"column":6},"end":{"line":830,"column":null}},"295":{"start":{"line":831,"column":6},"end":{"line":831,"column":null}},"296":{"start":{"line":834,"column":4},"end":{"line":834,"column":null}},"297":{"start":{"line":841,"column":23},"end":{"line":841,"column":null}},"298":{"start":{"line":843,"column":28},"end":{"line":843,"column":null}},"299":{"start":{"line":844,"column":4},"end":{"line":844,"column":null}},"300":{"start":{"line":844,"column":28},"end":{"line":844,"column":null}},"301":{"start":{"line":845,"column":4},"end":{"line":845,"column":null}},"302":{"start":{"line":845,"column":30},"end":{"line":845,"column":null}},"303":{"start":{"line":846,"column":4},"end":{"line":848,"column":null}},"304":{"start":{"line":847,"column":6},"end":{"line":847,"column":null}},"305":{"start":{"line":849,"column":4},"end":{"line":849,"column":null}},"306":{"start":{"line":849,"column":30},"end":{"line":849,"column":null}},"307":{"start":{"line":850,"column":4},"end":{"line":850,"column":null}},"308":{"start":{"line":857,"column":25},"end":{"line":857,"column":null}},"309":{"start":{"line":858,"column":20},"end":{"line":858,"column":null}},"310":{"start":{"line":859,"column":20},"end":{"line":859,"column":null}},"311":{"start":{"line":862,"column":35},"end":{"line":862,"column":null}},"312":{"start":{"line":863,"column":39},"end":{"line":863,"column":null}},"313":{"start":{"line":864,"column":31},"end":{"line":864,"column":null}},"314":{"start":{"line":866,"column":4},"end":{"line":881,"column":null}},"315":{"start":{"line":867,"column":6},"end":{"line":880,"column":null}},"316":{"start":{"line":868,"column":22},"end":{"line":868,"column":null}},"317":{"start":{"line":869,"column":25},"end":{"line":869,"column":null}},"318":{"start":{"line":871,"column":8},"end":{"line":877,"column":null}},"319":{"start":{"line":872,"column":10},"end":{"line":872,"column":null}},"320":{"start":{"line":873,"column":8},"end":{"line":877,"column":null}},"321":{"start":{"line":874,"column":10},"end":{"line":874,"column":null}},"322":{"start":{"line":875,"column":8},"end":{"line":877,"column":null}},"323":{"start":{"line":876,"column":10},"end":{"line":876,"column":null}},"324":{"start":{"line":884,"column":20},"end":{"line":886,"column":null}},"325":{"start":{"line":885,"column":25},"end":{"line":885,"column":81}},"326":{"start":{"line":889,"column":43},"end":{"line":889,"column":null}},"327":{"start":{"line":890,"column":4},"end":{"line":892,"column":null}},"328":{"start":{"line":891,"column":6},"end":{"line":891,"column":null}},"329":{"start":{"line":893,"column":37},"end":{"line":893,"column":null}},"330":{"start":{"line":894,"column":4},"end":{"line":894,"column":null}},"331":{"start":{"line":894,"column":22},"end":{"line":894,"column":null}},"332":{"start":{"line":895,"column":4},"end":{"line":895,"column":null}},"333":{"start":{"line":895,"column":21},"end":{"line":895,"column":null}},"334":{"start":{"line":896,"column":4},"end":{"line":896,"column":null}},"335":{"start":{"line":896,"column":24},"end":{"line":896,"column":null}},"336":{"start":{"line":897,"column":4},"end":{"line":897,"column":null}},"337":{"start":{"line":897,"column":23},"end":{"line":897,"column":null}},"338":{"start":{"line":900,"column":18},"end":{"line":902,"column":null}},"339":{"start":{"line":904,"column":4},"end":{"line":914,"column":null}},"340":{"start":{"line":923,"column":4},"end":{"line":923,"column":null}},"341":{"start":{"line":925,"column":16},"end":{"line":925,"column":null}},"342":{"start":{"line":926,"column":19},"end":{"line":941,"column":null}},"343":{"start":{"line":943,"column":15},"end":{"line":943,"column":null}},"344":{"start":{"line":946,"column":4},"end":{"line":948,"column":null}},"345":{"start":{"line":947,"column":6},"end":{"line":947,"column":null}},"346":{"start":{"line":950,"column":4},"end":{"line":954,"column":null}},"347":{"start":{"line":961,"column":4},"end":{"line":961,"column":null}},"348":{"start":{"line":963,"column":17},"end":{"line":968,"column":null}},"349":{"start":{"line":970,"column":4},"end":{"line":970,"column":null}},"350":{"start":{"line":970,"column":27},"end":{"line":970,"column":49}},"351":{"start":{"line":980,"column":4},"end":{"line":980,"column":null}},"352":{"start":{"line":983,"column":17},"end":{"line":985,"column":null}},"353":{"start":{"line":987,"column":4},"end":{"line":987,"column":null}},"354":{"start":{"line":987,"column":27},"end":{"line":987,"column":null}},"355":{"start":{"line":989,"column":20},"end":{"line":989,"column":null}},"356":{"start":{"line":992,"column":24},"end":{"line":992,"column":null}},"357":{"start":{"line":993,"column":4},"end":{"line":993,"column":null}},"358":{"start":{"line":993,"column":22},"end":{"line":993,"column":null}},"359":{"start":{"line":996,"column":25},"end":{"line":1001,"column":null}},"360":{"start":{"line":1004,"column":21},"end":{"line":1004,"column":null}},"361":{"start":{"line":1004,"column":86},"end":{"line":1004,"column":90}},"362":{"start":{"line":1005,"column":4},"end":{"line":1005,"column":null}},"363":{"start":{"line":1005,"column":19},"end":{"line":1005,"column":null}},"364":{"start":{"line":1008,"column":4},"end":{"line":1012,"column":null}},"365":{"start":{"line":1014,"column":4},"end":{"line":1014,"column":null}},"366":{"start":{"line":1018,"column":4},"end":{"line":1030,"column":null}},"367":{"start":{"line":1036,"column":4},"end":{"line":1038,"column":null}},"368":{"start":{"line":1037,"column":6},"end":{"line":1037,"column":null}},"369":{"start":{"line":1042,"column":4},"end":{"line":1042,"column":null}},"370":{"start":{"line":1042,"column":18},"end":{"line":1042,"column":null}},"371":{"start":{"line":1044,"column":4},"end":{"line":1056,"column":null}},"372":{"start":{"line":1058,"column":4},"end":{"line":1080,"column":null}},"373":{"start":{"line":1083,"column":4},"end":{"line":1094,"column":null}},"374":{"start":{"line":1097,"column":4},"end":{"line":1113,"column":null}},"375":{"start":{"line":1117,"column":4},"end":{"line":1126,"column":null}},"376":{"start":{"line":1129,"column":4},"end":{"line":1129,"column":null}},"377":{"start":{"line":1130,"column":4},"end":{"line":1130,"column":null}},"378":{"start":{"line":1131,"column":4},"end":{"line":1131,"column":null}},"379":{"start":{"line":1132,"column":4},"end":{"line":1132,"column":null}},"380":{"start":{"line":1133,"column":4},"end":{"line":1133,"column":null}},"381":{"start":{"line":1134,"column":4},"end":{"line":1134,"column":null}},"382":{"start":{"line":1135,"column":4},"end":{"line":1135,"column":null}},"383":{"start":{"line":1136,"column":4},"end":{"line":1136,"column":null}},"384":{"start":{"line":1139,"column":4},"end":{"line":1139,"column":null}},"385":{"start":{"line":1146,"column":4},"end":{"line":1146,"column":null}},"386":{"start":{"line":1146,"column":18},"end":{"line":1146,"column":null}},"387":{"start":{"line":1148,"column":4},"end":{"line":1177,"column":null}},"388":{"start":{"line":1149,"column":25},"end":{"line":1149,"column":null}},"389":{"start":{"line":1150,"column":26},"end":{"line":1150,"column":null}},"390":{"start":{"line":1150,"column":54},"end":{"line":1150,"column":60}},"391":{"start":{"line":1152,"column":50},"end":{"line":1160,"column":null}},"392":{"start":{"line":1162,"column":6},"end":{"line":1166,"column":null}},"393":{"start":{"line":1163,"column":8},"end":{"line":1165,"column":null}},"394":{"start":{"line":1164,"column":10},"end":{"line":1164,"column":null}},"395":{"start":{"line":1169,"column":6},"end":{"line":1174,"column":null}},"396":{"start":{"line":1170,"column":21},"end":{"line":1170,"column":null}},"397":{"start":{"line":1171,"column":8},"end":{"line":1173,"column":null}},"398":{"start":{"line":1171,"column":28},"end":{"line":1171,"column":50}},"399":{"start":{"line":1172,"column":10},"end":{"line":1172,"column":null}},"400":{"start":{"line":1181,"column":4},"end":{"line":1191,"column":null}},"401":{"start":{"line":1195,"column":4},"end":{"line":1213,"column":null}},"402":{"start":{"line":1217,"column":16},"end":{"line":1217,"column":null}},"403":{"start":{"line":1218,"column":17},"end":{"line":1218,"column":null}},"404":{"start":{"line":1220,"column":20},"end":{"line":1220,"column":null}},"405":{"start":{"line":1221,"column":18},"end":{"line":1221,"column":null}},"406":{"start":{"line":1222,"column":17},"end":{"line":1222,"column":null}},"407":{"start":{"line":1224,"column":4},"end":{"line":1224,"column":null}},"408":{"start":{"line":1224,"column":21},"end":{"line":1224,"column":null}},"409":{"start":{"line":1225,"column":4},"end":{"line":1225,"column":null}},"410":{"start":{"line":1225,"column":22},"end":{"line":1225,"column":null}},"411":{"start":{"line":1226,"column":4},"end":{"line":1226,"column":null}},"412":{"start":{"line":1226,"column":20},"end":{"line":1226,"column":null}},"413":{"start":{"line":1227,"column":4},"end":{"line":1227,"column":null}},"414":{"start":{"line":1227,"column":18},"end":{"line":1227,"column":null}},"415":{"start":{"line":1229,"column":4},"end":{"line":1229,"column":null}},"416":{"start":{"line":1233,"column":4},"end":{"line":1239,"column":null}},"417":{"start":{"line":1234,"column":19},"end":{"line":1234,"column":null}},"418":{"start":{"line":1235,"column":20},"end":{"line":1235,"column":null}},"419":{"start":{"line":1236,"column":22},"end":{"line":1236,"column":null}},"420":{"start":{"line":1237,"column":21},"end":{"line":1237,"column":null}},"421":{"start":{"line":1238,"column":15},"end":{"line":1238,"column":null}},"422":{"start":{"line":1247,"column":2},"end":{"line":1247,"column":null}},"423":{"start":{"line":1256,"column":2},"end":{"line":1256,"column":null}},"424":{"start":{"line":1256,"column":54},"end":{"line":1256,"column":null}},"425":{"start":{"line":1258,"column":2},"end":{"line":1301,"column":null}},"426":{"start":{"line":1259,"column":10},"end":{"line":1259,"column":null}},"427":{"start":{"line":1260,"column":4},"end":{"line":1260,"column":null}},"428":{"start":{"line":1260,"column":18},"end":{"line":1260,"column":null}},"429":{"start":{"line":1262,"column":18},"end":{"line":1262,"column":null}},"430":{"start":{"line":1265,"column":4},"end":{"line":1296,"column":null}},"431":{"start":{"line":1265,"column":17},"end":{"line":1265,"column":35}},"432":{"start":{"line":1266,"column":6},"end":{"line":1295,"column":null}},"433":{"start":{"line":1267,"column":21},"end":{"line":1267,"column":null}},"434":{"start":{"line":1268,"column":8},"end":{"line":1268,"column":null}},"435":{"start":{"line":1268,"column":39},"end":{"line":1268,"column":null}},"436":{"start":{"line":1270,"column":27},"end":{"line":1270,"column":null}},"437":{"start":{"line":1271,"column":8},"end":{"line":1271,"column":null}},"438":{"start":{"line":1271,"column":25},"end":{"line":1271,"column":null}},"439":{"start":{"line":1273,"column":19},"end":{"line":1273,"column":null}},"440":{"start":{"line":1274,"column":8},"end":{"line":1282,"column":null}},"441":{"start":{"line":1275,"column":10},"end":{"line":1275,"column":null}},"442":{"start":{"line":1276,"column":8},"end":{"line":1282,"column":null}},"443":{"start":{"line":1278,"column":10},"end":{"line":1281,"column":null}},"444":{"start":{"line":1279,"column":45},"end":{"line":1279,"column":62}},"445":{"start":{"line":1280,"column":42},"end":{"line":1280,"column":48}},"446":{"start":{"line":1284,"column":8},"end":{"line":1284,"column":null}},"447":{"start":{"line":1284,"column":19},"end":{"line":1284,"column":null}},"448":{"start":{"line":1287,"column":8},"end":{"line":1287,"column":null}},"449":{"start":{"line":1288,"column":8},"end":{"line":1288,"column":null}},"450":{"start":{"line":1291,"column":8},"end":{"line":1291,"column":null}},"451":{"start":{"line":1294,"column":8},"end":{"line":1294,"column":null}},"452":{"start":{"line":1298,"column":4},"end":{"line":1298,"column":null}},"453":{"start":{"line":1300,"column":4},"end":{"line":1300,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":73,"column":2},"end":{"line":73,"column":14}},"loc":{"start":{"line":73,"column":74},"end":{"line":76,"column":null}},"line":73},"1":{"name":"(anonymous_1)","decl":{"start":{"line":81,"column":8},"end":{"line":81,"column":36}},"loc":{"start":{"line":81,"column":36},"end":{"line":102,"column":null}},"line":81},"2":{"name":"(anonymous_2)","decl":{"start":{"line":107,"column":8},"end":{"line":107,"column":34}},"loc":{"start":{"line":107,"column":34},"end":{"line":113,"column":null}},"line":107},"3":{"name":"(anonymous_3)","decl":{"start":{"line":120,"column":8},"end":{"line":120,"column":20}},"loc":{"start":{"line":120,"column":97},"end":{"line":145,"column":null}},"line":120},"4":{"name":"(anonymous_4)","decl":{"start":{"line":152,"column":8},"end":{"line":152,"column":23}},"loc":{"start":{"line":152,"column":100},"end":{"line":181,"column":null}},"line":152},"5":{"name":"(anonymous_5)","decl":{"start":{"line":186,"column":2},"end":{"line":186,"column":18}},"loc":{"start":{"line":186,"column":45},"end":{"line":194,"column":null}},"line":186},"6":{"name":"(anonymous_6)","decl":{"start":{"line":199,"column":8},"end":{"line":199,"column":26}},"loc":{"start":{"line":199,"column":68},"end":{"line":215,"column":null}},"line":199},"7":{"name":"(anonymous_7)","decl":{"start":{"line":208,"column":20},"end":{"line":208,"column":28}},"loc":{"start":{"line":208,"column":28},"end":{"line":214,"column":6}},"line":208},"8":{"name":"(anonymous_8)","decl":{"start":{"line":220,"column":8},"end":{"line":220,"column":25}},"loc":{"start":{"line":220,"column":85},"end":{"line":238,"column":null}},"line":220},"9":{"name":"(anonymous_9)","decl":{"start":{"line":231,"column":20},"end":{"line":231,"column":28}},"loc":{"start":{"line":231,"column":28},"end":{"line":237,"column":6}},"line":231},"10":{"name":"(anonymous_10)","decl":{"start":{"line":243,"column":2},"end":{"line":243,"column":13}},"loc":{"start":{"line":243,"column":54},"end":{"line":253,"column":null}},"line":243},"11":{"name":"(anonymous_11)","decl":{"start":{"line":258,"column":8},"end":{"line":258,"column":24}},"loc":{"start":{"line":258,"column":76},"end":{"line":267,"column":null}},"line":258},"12":{"name":"(anonymous_12)","decl":{"start":{"line":272,"column":8},"end":{"line":272,"column":26}},"loc":{"start":{"line":272,"column":88},"end":{"line":283,"column":null}},"line":272},"13":{"name":"(anonymous_13)","decl":{"start":{"line":282,"column":20},"end":{"line":282,"column":27}},"loc":{"start":{"line":282,"column":27},"end":{"line":282,"column":49}},"line":282},"14":{"name":"(anonymous_14)","decl":{"start":{"line":290,"column":8},"end":{"line":290,"column":null}},"loc":{"start":{"line":297,"column":26},"end":{"line":356,"column":null}},"line":297},"15":{"name":"(anonymous_15)","decl":{"start":{"line":363,"column":8},"end":{"line":363,"column":26}},"loc":{"start":{"line":363,"column":56},"end":{"line":388,"column":null}},"line":363},"16":{"name":"(anonymous_16)","decl":{"start":{"line":372,"column":96},"end":{"line":372,"column":102}},"loc":{"start":{"line":372,"column":102},"end":{"line":372,"column":106}},"line":372},"17":{"name":"(anonymous_17)","decl":{"start":{"line":393,"column":10},"end":{"line":393,"column":null}},"loc":{"start":{"line":396,"column":12},"end":{"line":410,"column":null}},"line":396},"18":{"name":"(anonymous_18)","decl":{"start":{"line":421,"column":2},"end":{"line":421,"column":null}},"loc":{"start":{"line":425,"column":10},"end":{"line":430,"column":null}},"line":425},"19":{"name":"(anonymous_19)","decl":{"start":{"line":438,"column":2},"end":{"line":438,"column":22}},"loc":{"start":{"line":438,"column":79},"end":{"line":491,"column":null}},"line":438},"20":{"name":"(anonymous_20)","decl":{"start":{"line":498,"column":8},"end":{"line":498,"column":49}},"loc":{"start":{"line":498,"column":49},"end":{"line":599,"column":null}},"line":498},"21":{"name":"(anonymous_21)","decl":{"start":{"line":518,"column":50},"end":{"line":518,"column":56}},"loc":{"start":{"line":518,"column":56},"end":{"line":526,"column":7}},"line":518},"22":{"name":"(anonymous_22)","decl":{"start":{"line":606,"column":8},"end":{"line":606,"column":50}},"loc":{"start":{"line":606,"column":50},"end":{"line":648,"column":null}},"line":606},"23":{"name":"(anonymous_23)","decl":{"start":{"line":615,"column":51},"end":{"line":615,"column":57}},"loc":{"start":{"line":615,"column":57},"end":{"line":623,"column":7}},"line":615},"24":{"name":"(anonymous_24)","decl":{"start":{"line":653,"column":8},"end":{"line":653,"column":31}},"loc":{"start":{"line":653,"column":94},"end":{"line":664,"column":null}},"line":653},"25":{"name":"(anonymous_25)","decl":{"start":{"line":663,"column":20},"end":{"line":663,"column":27}},"loc":{"start":{"line":663,"column":27},"end":{"line":663,"column":53}},"line":663},"26":{"name":"(anonymous_26)","decl":{"start":{"line":669,"column":8},"end":{"line":669,"column":30}},"loc":{"start":{"line":669,"column":91},"end":{"line":680,"column":null}},"line":669},"27":{"name":"(anonymous_27)","decl":{"start":{"line":679,"column":20},"end":{"line":679,"column":27}},"loc":{"start":{"line":679,"column":27},"end":{"line":679,"column":53}},"line":679},"28":{"name":"(anonymous_28)","decl":{"start":{"line":687,"column":8},"end":{"line":687,"column":19}},"loc":{"start":{"line":687,"column":60},"end":{"line":715,"column":null}},"line":687},"29":{"name":"(anonymous_29)","decl":{"start":{"line":720,"column":10},"end":{"line":720,"column":null}},"loc":{"start":{"line":726,"column":12},"end":{"line":835,"column":null}},"line":726},"30":{"name":"(anonymous_30)","decl":{"start":{"line":840,"column":8},"end":{"line":840,"column":24}},"loc":{"start":{"line":840,"column":60},"end":{"line":851,"column":null}},"line":840},"31":{"name":"(anonymous_31)","decl":{"start":{"line":856,"column":8},"end":{"line":856,"column":34}},"loc":{"start":{"line":856,"column":104},"end":{"line":915,"column":null}},"line":856},"32":{"name":"(anonymous_32)","decl":{"start":{"line":885,"column":20},"end":{"line":885,"column":25}},"loc":{"start":{"line":885,"column":25},"end":{"line":885,"column":81}},"line":885},"33":{"name":"(anonymous_33)","decl":{"start":{"line":922,"column":8},"end":{"line":922,"column":27}},"loc":{"start":{"line":922,"column":103},"end":{"line":955,"column":null}},"line":922},"34":{"name":"(anonymous_34)","decl":{"start":{"line":960,"column":8},"end":{"line":960,"column":27}},"loc":{"start":{"line":960,"column":90},"end":{"line":971,"column":null}},"line":960},"35":{"name":"(anonymous_35)","decl":{"start":{"line":970,"column":20},"end":{"line":970,"column":27}},"loc":{"start":{"line":970,"column":27},"end":{"line":970,"column":49}},"line":970},"36":{"name":"(anonymous_36)","decl":{"start":{"line":979,"column":8},"end":{"line":979,"column":29}},"loc":{"start":{"line":979,"column":90},"end":{"line":1015,"column":null}},"line":979},"37":{"name":"(anonymous_37)","decl":{"start":{"line":1004,"column":80},"end":{"line":1004,"column":86}},"loc":{"start":{"line":1004,"column":86},"end":{"line":1004,"column":90}},"line":1004},"38":{"name":"(anonymous_38)","decl":{"start":{"line":1017,"column":10},"end":{"line":1017,"column":23}},"loc":{"start":{"line":1017,"column":69},"end":{"line":1031,"column":null}},"line":1017},"39":{"name":"(anonymous_39)","decl":{"start":{"line":1035,"column":16},"end":{"line":1035,"column":51}},"loc":{"start":{"line":1035,"column":51},"end":{"line":1039,"column":null}},"line":1035},"40":{"name":"(anonymous_40)","decl":{"start":{"line":1041,"column":10},"end":{"line":1041,"column":31}},"loc":{"start":{"line":1041,"column":31},"end":{"line":1140,"column":null}},"line":1041},"41":{"name":"(anonymous_41)","decl":{"start":{"line":1145,"column":10},"end":{"line":1145,"column":32}},"loc":{"start":{"line":1145,"column":32},"end":{"line":1178,"column":null}},"line":1145},"42":{"name":"(anonymous_42)","decl":{"start":{"line":1150,"column":49},"end":{"line":1150,"column":54}},"loc":{"start":{"line":1150,"column":54},"end":{"line":1150,"column":60}},"line":1150},"43":{"name":"(anonymous_43)","decl":{"start":{"line":1171,"column":23},"end":{"line":1171,"column":28}},"loc":{"start":{"line":1171,"column":28},"end":{"line":1171,"column":50}},"line":1171},"44":{"name":"(anonymous_44)","decl":{"start":{"line":1180,"column":10},"end":{"line":1180,"column":23}},"loc":{"start":{"line":1180,"column":68},"end":{"line":1192,"column":null}},"line":1180},"45":{"name":"(anonymous_45)","decl":{"start":{"line":1194,"column":10},"end":{"line":1194,"column":27}},"loc":{"start":{"line":1194,"column":70},"end":{"line":1214,"column":null}},"line":1194},"46":{"name":"(anonymous_46)","decl":{"start":{"line":1216,"column":10},"end":{"line":1216,"column":29}},"loc":{"start":{"line":1216,"column":56},"end":{"line":1230,"column":null}},"line":1216},"47":{"name":"(anonymous_47)","decl":{"start":{"line":1232,"column":10},"end":{"line":1232,"column":29}},"loc":{"start":{"line":1232,"column":51},"end":{"line":1240,"column":null}},"line":1232},"48":{"name":"createHookService","decl":{"start":{"line":1246,"column":16},"end":{"line":1246,"column":34}},"loc":{"start":{"line":1246,"column":66},"end":{"line":1248,"column":null}},"line":1246},"49":{"name":"extractLastAssistantMessage","decl":{"start":{"line":1255,"column":16},"end":{"line":1255,"column":44}},"loc":{"start":{"line":1255,"column":83},"end":{"line":1302,"column":null}},"line":1255},"50":{"name":"(anonymous_50)","decl":{"start":{"line":1279,"column":20},"end":{"line":1279,"column":21}},"loc":{"start":{"line":1279,"column":45},"end":{"line":1279,"column":62}},"line":1279},"51":{"name":"(anonymous_51)","decl":{"start":{"line":1280,"column":17},"end":{"line":1280,"column":18}},"loc":{"start":{"line":1280,"column":42},"end":{"line":1280,"column":48}},"line":1280}},"branchMap":{"0":{"loc":{"start":{"line":73,"column":27},"end":{"line":73,"column":74}},"type":"default-arg","locations":[{"start":{"line":73,"column":70},"end":{"line":73,"column":74}}],"line":73},"1":{"loc":{"start":{"line":82,"column":4},"end":{"line":82,"column":null}},"type":"if","locations":[{"start":{"line":82,"column":4},"end":{"line":82,"column":null}},{"start":{},"end":{}}],"line":82},"2":{"loc":{"start":{"line":86,"column":4},"end":{"line":88,"column":null}},"type":"if","locations":[{"start":{"line":86,"column":4},"end":{"line":88,"column":null}},{"start":{},"end":{}}],"line":86},"3":{"loc":{"start":{"line":108,"column":4},"end":{"line":108,"column":null}},"type":"if","locations":[{"start":{"line":108,"column":4},"end":{"line":108,"column":null}},{"start":{},"end":{}}],"line":108},"4":{"loc":{"start":{"line":108,"column":8},"end":{"line":108,"column":39}},"type":"binary-expr","locations":[{"start":{"line":108,"column":8},"end":{"line":108,"column":29}},{"start":{"line":108,"column":29},"end":{"line":108,"column":39}}],"line":108},"5":{"loc":{"start":{"line":125,"column":4},"end":{"line":127,"column":null}},"type":"if","locations":[{"start":{"line":125,"column":4},"end":{"line":127,"column":null}},{"start":{},"end":{}}],"line":125},"6":{"loc":{"start":{"line":134,"column":31},"end":{"line":134,"column":45}},"type":"binary-expr","locations":[{"start":{"line":134,"column":31},"end":{"line":134,"column":41}},{"start":{"line":134,"column":41},"end":{"line":134,"column":45}}],"line":134},"7":{"loc":{"start":{"line":140,"column":14},"end":{"line":140,"column":null}},"type":"binary-expr","locations":[{"start":{"line":140,"column":14},"end":{"line":140,"column":24}},{"start":{"line":140,"column":24},"end":{"line":140,"column":null}}],"line":140},"8":{"loc":{"start":{"line":167,"column":15},"end":{"line":167,"column":null}},"type":"cond-expr","locations":[{"start":{"line":167,"column":36},"end":{"line":167,"column":69}},{"start":{"line":167,"column":69},"end":{"line":167,"column":null}}],"line":167},"9":{"loc":{"start":{"line":170,"column":4},"end":{"line":172,"column":null}},"type":"if","locations":[{"start":{"line":170,"column":4},"end":{"line":172,"column":null}},{"start":{},"end":{}}],"line":170},"10":{"loc":{"start":{"line":187,"column":4},"end":{"line":187,"column":null}},"type":"if","locations":[{"start":{"line":187,"column":4},"end":{"line":187,"column":null}},{"start":{},"end":{}}],"line":187},"11":{"loc":{"start":{"line":193,"column":11},"end":{"line":193,"column":null}},"type":"binary-expr","locations":[{"start":{"line":193,"column":11},"end":{"line":193,"column":25}},{"start":{"line":193,"column":25},"end":{"line":193,"column":null}}],"line":193},"12":{"loc":{"start":{"line":220,"column":42},"end":{"line":220,"column":85}},"type":"default-arg","locations":[{"start":{"line":220,"column":58},"end":{"line":220,"column":85}}],"line":220},"13":{"loc":{"start":{"line":244,"column":4},"end":{"line":244,"column":null}},"type":"if","locations":[{"start":{"line":244,"column":4},"end":{"line":244,"column":null}},{"start":{},"end":{}}],"line":244},"14":{"loc":{"start":{"line":248,"column":4},"end":{"line":250,"column":null}},"type":"if","locations":[{"start":{"line":248,"column":4},"end":{"line":250,"column":null}},{"start":{},"end":{}}],"line":248},"15":{"loc":{"start":{"line":266,"column":16},"end":{"line":266,"column":31}},"type":"binary-expr","locations":[{"start":{"line":266,"column":16},"end":{"line":266,"column":27}},{"start":{"line":266,"column":27},"end":{"line":266,"column":31}}],"line":266},"16":{"loc":{"start":{"line":272,"column":43},"end":{"line":272,"column":88}},"type":"default-arg","locations":[{"start":{"line":272,"column":59},"end":{"line":272,"column":88}}],"line":272},"17":{"loc":{"start":{"line":308,"column":36},"end":{"line":308,"column":51}},"type":"binary-expr","locations":[{"start":{"line":308,"column":36},"end":{"line":308,"column":49}},{"start":{"line":308,"column":49},"end":{"line":308,"column":51}}],"line":308},"18":{"loc":{"start":{"line":310,"column":21},"end":{"line":310,"column":39}},"type":"binary-expr","locations":[{"start":{"line":310,"column":21},"end":{"line":310,"column":37}},{"start":{"line":310,"column":37},"end":{"line":310,"column":39}}],"line":310},"19":{"loc":{"start":{"line":324,"column":91},"end":{"line":324,"column":113}},"type":"binary-expr","locations":[{"start":{"line":324,"column":91},"end":{"line":324,"column":107}},{"start":{"line":324,"column":107},"end":{"line":324,"column":113}}],"line":324},"20":{"loc":{"start":{"line":348,"column":20},"end":{"line":348,"column":null}},"type":"binary-expr","locations":[{"start":{"line":348,"column":20},"end":{"line":348,"column":36}},{"start":{"line":348,"column":36},"end":{"line":348,"column":null}}],"line":348},"21":{"loc":{"start":{"line":370,"column":4},"end":{"line":370,"column":null}},"type":"if","locations":[{"start":{"line":370,"column":4},"end":{"line":370,"column":null}},{"start":{},"end":{}}],"line":370},"22":{"loc":{"start":{"line":373,"column":4},"end":{"line":373,"column":null}},"type":"if","locations":[{"start":{"line":373,"column":4},"end":{"line":373,"column":null}},{"start":{},"end":{}}],"line":373},"23":{"loc":{"start":{"line":397,"column":4},"end":{"line":409,"column":null}},"type":"if","locations":[{"start":{"line":397,"column":4},"end":{"line":409,"column":null}},{"start":{"line":404,"column":4},"end":{"line":409,"column":null}}],"line":397},"24":{"loc":{"start":{"line":400,"column":37},"end":{"line":400,"column":68}},"type":"binary-expr","locations":[{"start":{"line":400,"column":37},"end":{"line":400,"column":64}},{"start":{"line":400,"column":64},"end":{"line":400,"column":68}}],"line":400},"25":{"loc":{"start":{"line":401,"column":8},"end":{"line":401,"column":null}},"type":"if","locations":[{"start":{"line":401,"column":8},"end":{"line":401,"column":null}},{"start":{},"end":{}}],"line":401},"26":{"loc":{"start":{"line":404,"column":4},"end":{"line":409,"column":null}},"type":"if","locations":[{"start":{"line":404,"column":4},"end":{"line":409,"column":null}},{"start":{"line":406,"column":11},"end":{"line":409,"column":null}}],"line":404},"27":{"loc":{"start":{"line":405,"column":15},"end":{"line":405,"column":49}},"type":"binary-expr","locations":[{"start":{"line":405,"column":15},"end":{"line":405,"column":45}},{"start":{"line":405,"column":45},"end":{"line":405,"column":49}}],"line":405},"28":{"loc":{"start":{"line":426,"column":4},"end":{"line":426,"column":null}},"type":"if","locations":[{"start":{"line":426,"column":4},"end":{"line":426,"column":null}},{"start":{},"end":{}}],"line":426},"29":{"loc":{"start":{"line":442,"column":4},"end":{"line":459,"column":null}},"type":"if","locations":[{"start":{"line":442,"column":4},"end":{"line":459,"column":null}},{"start":{},"end":{}}],"line":442},"30":{"loc":{"start":{"line":445,"column":8},"end":{"line":455,"column":null}},"type":"if","locations":[{"start":{"line":445,"column":8},"end":{"line":455,"column":null}},{"start":{"line":453,"column":15},"end":{"line":455,"column":null}}],"line":445},"31":{"loc":{"start":{"line":522,"column":8},"end":{"line":524,"column":null}},"type":"if","locations":[{"start":{"line":522,"column":8},"end":{"line":524,"column":null}},{"start":{},"end":{}}],"line":522},"32":{"loc":{"start":{"line":532,"column":8},"end":{"line":532,"column":null}},"type":"if","locations":[{"start":{"line":532,"column":8},"end":{"line":532,"column":null}},{"start":{},"end":{}}],"line":532},"33":{"loc":{"start":{"line":535,"column":8},"end":{"line":538,"column":null}},"type":"if","locations":[{"start":{"line":535,"column":8},"end":{"line":538,"column":null}},{"start":{},"end":{}}],"line":535},"34":{"loc":{"start":{"line":545,"column":10},"end":{"line":548,"column":null}},"type":"if","locations":[{"start":{"line":545,"column":10},"end":{"line":548,"column":null}},{"start":{},"end":{}}],"line":545},"35":{"loc":{"start":{"line":553,"column":10},"end":{"line":556,"column":null}},"type":"if","locations":[{"start":{"line":553,"column":10},"end":{"line":556,"column":null}},{"start":{},"end":{}}],"line":553},"36":{"loc":{"start":{"line":569,"column":6},"end":{"line":593,"column":null}},"type":"if","locations":[{"start":{"line":569,"column":6},"end":{"line":593,"column":null}},{"start":{},"end":{}}],"line":569},"37":{"loc":{"start":{"line":571,"column":10},"end":{"line":571,"column":null}},"type":"if","locations":[{"start":{"line":571,"column":10},"end":{"line":571,"column":null}},{"start":{},"end":{}}],"line":571},"38":{"loc":{"start":{"line":579,"column":14},"end":{"line":579,"column":null}},"type":"if","locations":[{"start":{"line":579,"column":14},"end":{"line":579,"column":null}},{"start":{},"end":{}}],"line":579},"39":{"loc":{"start":{"line":583,"column":14},"end":{"line":583,"column":null}},"type":"if","locations":[{"start":{"line":583,"column":14},"end":{"line":583,"column":null}},{"start":{},"end":{}}],"line":583},"40":{"loc":{"start":{"line":619,"column":8},"end":{"line":621,"column":null}},"type":"if","locations":[{"start":{"line":619,"column":8},"end":{"line":621,"column":null}},{"start":{},"end":{}}],"line":619},"41":{"loc":{"start":{"line":628,"column":8},"end":{"line":628,"column":null}},"type":"if","locations":[{"start":{"line":628,"column":8},"end":{"line":628,"column":null}},{"start":{},"end":{}}],"line":628},"42":{"loc":{"start":{"line":631,"column":10},"end":{"line":636,"column":null}},"type":"if","locations":[{"start":{"line":631,"column":10},"end":{"line":636,"column":null}},{"start":{"line":633,"column":10},"end":{"line":636,"column":null}}],"line":631},"43":{"loc":{"start":{"line":633,"column":10},"end":{"line":636,"column":null}},"type":"if","locations":[{"start":{"line":633,"column":10},"end":{"line":636,"column":null}},{"start":{},"end":{}}],"line":633},"44":{"loc":{"start":{"line":653,"column":50},"end":{"line":653,"column":94}},"type":"default-arg","locations":[{"start":{"line":653,"column":66},"end":{"line":653,"column":94}}],"line":653},"45":{"loc":{"start":{"line":669,"column":47},"end":{"line":669,"column":91}},"type":"default-arg","locations":[{"start":{"line":669,"column":63},"end":{"line":669,"column":91}}],"line":669},"46":{"loc":{"start":{"line":739,"column":4},"end":{"line":760,"column":null}},"type":"if","locations":[{"start":{"line":739,"column":4},"end":{"line":760,"column":null}},{"start":{},"end":{}}],"line":739},"47":{"loc":{"start":{"line":746,"column":8},"end":{"line":748,"column":null}},"type":"if","locations":[{"start":{"line":746,"column":8},"end":{"line":748,"column":null}},{"start":{},"end":{}}],"line":746},"48":{"loc":{"start":{"line":749,"column":8},"end":{"line":751,"column":null}},"type":"if","locations":[{"start":{"line":749,"column":8},"end":{"line":751,"column":null}},{"start":{},"end":{}}],"line":749},"49":{"loc":{"start":{"line":752,"column":8},"end":{"line":754,"column":null}},"type":"if","locations":[{"start":{"line":752,"column":8},"end":{"line":754,"column":null}},{"start":{},"end":{}}],"line":752},"50":{"loc":{"start":{"line":755,"column":8},"end":{"line":757,"column":null}},"type":"if","locations":[{"start":{"line":755,"column":8},"end":{"line":757,"column":null}},{"start":{},"end":{}}],"line":755},"51":{"loc":{"start":{"line":763,"column":4},"end":{"line":772,"column":null}},"type":"if","locations":[{"start":{"line":763,"column":4},"end":{"line":772,"column":null}},{"start":{},"end":{}}],"line":763},"52":{"loc":{"start":{"line":769,"column":72},"end":{"line":769,"column":115}},"type":"cond-expr","locations":[{"start":{"line":769,"column":105},"end":{"line":769,"column":113}},{"start":{"line":769,"column":113},"end":{"line":769,"column":115}}],"line":769},"53":{"loc":{"start":{"line":775,"column":4},"end":{"line":792,"column":null}},"type":"if","locations":[{"start":{"line":775,"column":4},"end":{"line":792,"column":null}},{"start":{},"end":{}}],"line":775},"54":{"loc":{"start":{"line":782,"column":23},"end":{"line":782,"column":null}},"type":"binary-expr","locations":[{"start":{"line":782,"column":23},"end":{"line":782,"column":39}},{"start":{"line":782,"column":39},"end":{"line":782,"column":52}},{"start":{"line":782,"column":52},"end":{"line":782,"column":null}}],"line":782},"55":{"loc":{"start":{"line":784,"column":8},"end":{"line":786,"column":null}},"type":"if","locations":[{"start":{"line":784,"column":8},"end":{"line":786,"column":null}},{"start":{},"end":{}}],"line":784},"56":{"loc":{"start":{"line":787,"column":8},"end":{"line":789,"column":null}},"type":"if","locations":[{"start":{"line":787,"column":8},"end":{"line":789,"column":null}},{"start":{},"end":{}}],"line":787},"57":{"loc":{"start":{"line":787,"column":12},"end":{"line":787,"column":53}},"type":"binary-expr","locations":[{"start":{"line":787,"column":12},"end":{"line":787,"column":28}},{"start":{"line":787,"column":28},"end":{"line":787,"column":53}}],"line":787},"58":{"loc":{"start":{"line":795,"column":4},"end":{"line":815,"column":null}},"type":"if","locations":[{"start":{"line":795,"column":4},"end":{"line":815,"column":null}},{"start":{},"end":{}}],"line":795},"59":{"loc":{"start":{"line":795,"column":8},"end":{"line":795,"column":55}},"type":"binary-expr","locations":[{"start":{"line":795,"column":8},"end":{"line":795,"column":34}},{"start":{"line":795,"column":34},"end":{"line":795,"column":55}}],"line":795},"60":{"loc":{"start":{"line":801,"column":23},"end":{"line":801,"column":null}},"type":"cond-expr","locations":[{"start":{"line":801,"column":56},"end":{"line":801,"column":62}},{"start":{"line":801,"column":62},"end":{"line":801,"column":null}}],"line":801},"61":{"loc":{"start":{"line":804,"column":8},"end":{"line":806,"column":null}},"type":"if","locations":[{"start":{"line":804,"column":8},"end":{"line":806,"column":null}},{"start":{},"end":{}}],"line":804},"62":{"loc":{"start":{"line":805,"column":69},"end":{"line":805,"column":109}},"type":"cond-expr","locations":[{"start":{"line":805,"column":99},"end":{"line":805,"column":107}},{"start":{"line":805,"column":107},"end":{"line":805,"column":109}}],"line":805},"63":{"loc":{"start":{"line":808,"column":8},"end":{"line":810,"column":null}},"type":"if","locations":[{"start":{"line":808,"column":8},"end":{"line":810,"column":null}},{"start":{},"end":{}}],"line":808},"64":{"loc":{"start":{"line":818,"column":4},"end":{"line":821,"column":null}},"type":"if","locations":[{"start":{"line":818,"column":4},"end":{"line":821,"column":null}},{"start":{},"end":{}}],"line":818},"65":{"loc":{"start":{"line":818,"column":8},"end":{"line":818,"column":84}},"type":"binary-expr","locations":[{"start":{"line":818,"column":8},"end":{"line":818,"column":37}},{"start":{"line":818,"column":37},"end":{"line":818,"column":62}},{"start":{"line":818,"column":62},"end":{"line":818,"column":84}}],"line":818},"66":{"loc":{"start":{"line":825,"column":26},"end":{"line":825,"column":null}},"type":"binary-expr","locations":[{"start":{"line":825,"column":26},"end":{"line":825,"column":46}},{"start":{"line":825,"column":46},"end":{"line":825,"column":null}}],"line":825},"67":{"loc":{"start":{"line":826,"column":4},"end":{"line":832,"column":null}},"type":"if","locations":[{"start":{"line":826,"column":4},"end":{"line":832,"column":null}},{"start":{},"end":{}}],"line":826},"68":{"loc":{"start":{"line":826,"column":8},"end":{"line":826,"column":43}},"type":"binary-expr","locations":[{"start":{"line":826,"column":8},"end":{"line":826,"column":24}},{"start":{"line":826,"column":24},"end":{"line":826,"column":43}}],"line":826},"69":{"loc":{"start":{"line":844,"column":4},"end":{"line":844,"column":null}},"type":"if","locations":[{"start":{"line":844,"column":4},"end":{"line":844,"column":null}},{"start":{},"end":{}}],"line":844},"70":{"loc":{"start":{"line":845,"column":4},"end":{"line":845,"column":null}},"type":"if","locations":[{"start":{"line":845,"column":4},"end":{"line":845,"column":null}},{"start":{},"end":{}}],"line":845},"71":{"loc":{"start":{"line":846,"column":4},"end":{"line":848,"column":null}},"type":"if","locations":[{"start":{"line":846,"column":4},"end":{"line":848,"column":null}},{"start":{},"end":{}}],"line":846},"72":{"loc":{"start":{"line":849,"column":4},"end":{"line":849,"column":null}},"type":"if","locations":[{"start":{"line":849,"column":4},"end":{"line":849,"column":null}},{"start":{},"end":{}}],"line":849},"73":{"loc":{"start":{"line":850,"column":11},"end":{"line":850,"column":null}},"type":"binary-expr","locations":[{"start":{"line":850,"column":11},"end":{"line":850,"column":31}},{"start":{"line":850,"column":31},"end":{"line":850,"column":null}}],"line":850},"74":{"loc":{"start":{"line":869,"column":25},"end":{"line":869,"column":null}},"type":"binary-expr","locations":[{"start":{"line":869,"column":25},"end":{"line":869,"column":44}},{"start":{"line":869,"column":44},"end":{"line":869,"column":58}},{"start":{"line":869,"column":58},"end":{"line":869,"column":null}}],"line":869},"75":{"loc":{"start":{"line":871,"column":8},"end":{"line":877,"column":null}},"type":"if","locations":[{"start":{"line":871,"column":8},"end":{"line":877,"column":null}},{"start":{"line":873,"column":8},"end":{"line":877,"column":null}}],"line":871},"76":{"loc":{"start":{"line":871,"column":12},"end":{"line":871,"column":45}},"type":"binary-expr","locations":[{"start":{"line":871,"column":12},"end":{"line":871,"column":35}},{"start":{"line":871,"column":35},"end":{"line":871,"column":45}}],"line":871},"77":{"loc":{"start":{"line":873,"column":8},"end":{"line":877,"column":null}},"type":"if","locations":[{"start":{"line":873,"column":8},"end":{"line":877,"column":null}},{"start":{"line":875,"column":8},"end":{"line":877,"column":null}}],"line":873},"78":{"loc":{"start":{"line":873,"column":19},"end":{"line":873,"column":53}},"type":"binary-expr","locations":[{"start":{"line":873,"column":19},"end":{"line":873,"column":43}},{"start":{"line":873,"column":43},"end":{"line":873,"column":53}}],"line":873},"79":{"loc":{"start":{"line":875,"column":8},"end":{"line":877,"column":null}},"type":"if","locations":[{"start":{"line":875,"column":8},"end":{"line":877,"column":null}},{"start":{},"end":{}}],"line":875},"80":{"loc":{"start":{"line":875,"column":19},"end":{"line":875,"column":60}},"type":"binary-expr","locations":[{"start":{"line":875,"column":19},"end":{"line":875,"column":45}},{"start":{"line":875,"column":45},"end":{"line":875,"column":60}}],"line":875},"81":{"loc":{"start":{"line":884,"column":20},"end":{"line":886,"column":null}},"type":"cond-expr","locations":[{"start":{"line":885,"column":8},"end":{"line":885,"column":null}},{"start":{"line":886,"column":8},"end":{"line":886,"column":null}}],"line":884},"82":{"loc":{"start":{"line":886,"column":8},"end":{"line":886,"column":null}},"type":"binary-expr","locations":[{"start":{"line":886,"column":8},"end":{"line":886,"column":27}},{"start":{"line":886,"column":27},"end":{"line":886,"column":null}}],"line":886},"83":{"loc":{"start":{"line":891,"column":26},"end":{"line":891,"column":51}},"type":"binary-expr","locations":[{"start":{"line":891,"column":26},"end":{"line":891,"column":46}},{"start":{"line":891,"column":46},"end":{"line":891,"column":51}}],"line":891},"84":{"loc":{"start":{"line":894,"column":4},"end":{"line":894,"column":null}},"type":"if","locations":[{"start":{"line":894,"column":4},"end":{"line":894,"column":null}},{"start":{},"end":{}}],"line":894},"85":{"loc":{"start":{"line":895,"column":4},"end":{"line":895,"column":null}},"type":"if","locations":[{"start":{"line":895,"column":4},"end":{"line":895,"column":null}},{"start":{},"end":{}}],"line":895},"86":{"loc":{"start":{"line":896,"column":4},"end":{"line":896,"column":null}},"type":"if","locations":[{"start":{"line":896,"column":4},"end":{"line":896,"column":null}},{"start":{},"end":{}}],"line":896},"87":{"loc":{"start":{"line":897,"column":4},"end":{"line":897,"column":null}},"type":"if","locations":[{"start":{"line":897,"column":4},"end":{"line":897,"column":null}},{"start":{},"end":{}}],"line":897},"88":{"loc":{"start":{"line":900,"column":18},"end":{"line":902,"column":null}},"type":"cond-expr","locations":[{"start":{"line":901,"column":8},"end":{"line":901,"column":null}},{"start":{"line":902,"column":8},"end":{"line":902,"column":null}}],"line":900},"89":{"loc":{"start":{"line":901,"column":55},"end":{"line":901,"column":115}},"type":"cond-expr","locations":[{"start":{"line":901,"column":77},"end":{"line":901,"column":113}},{"start":{"line":901,"column":113},"end":{"line":901,"column":115}}],"line":901},"90":{"loc":{"start":{"line":906,"column":15},"end":{"line":906,"column":null}},"type":"binary-expr","locations":[{"start":{"line":906,"column":15},"end":{"line":906,"column":35}},{"start":{"line":906,"column":35},"end":{"line":906,"column":null}}],"line":906},"91":{"loc":{"start":{"line":908,"column":17},"end":{"line":908,"column":null}},"type":"binary-expr","locations":[{"start":{"line":908,"column":17},"end":{"line":908,"column":46}},{"start":{"line":908,"column":46},"end":{"line":908,"column":null}}],"line":908},"92":{"loc":{"start":{"line":946,"column":4},"end":{"line":948,"column":null}},"type":"if","locations":[{"start":{"line":946,"column":4},"end":{"line":948,"column":null}},{"start":{},"end":{}}],"line":946},"93":{"loc":{"start":{"line":960,"column":44},"end":{"line":960,"column":90}},"type":"default-arg","locations":[{"start":{"line":960,"column":60},"end":{"line":960,"column":90}}],"line":960},"94":{"loc":{"start":{"line":987,"column":4},"end":{"line":987,"column":null}},"type":"if","locations":[{"start":{"line":987,"column":4},"end":{"line":987,"column":null}},{"start":{},"end":{}}],"line":987},"95":{"loc":{"start":{"line":993,"column":4},"end":{"line":993,"column":null}},"type":"if","locations":[{"start":{"line":993,"column":4},"end":{"line":993,"column":null}},{"start":{},"end":{}}],"line":993},"96":{"loc":{"start":{"line":997,"column":6},"end":{"line":997,"column":null}},"type":"cond-expr","locations":[{"start":{"line":997,"column":24},"end":{"line":997,"column":56}},{"start":{"line":997,"column":56},"end":{"line":997,"column":null}}],"line":997},"97":{"loc":{"start":{"line":998,"column":6},"end":{"line":998,"column":null}},"type":"cond-expr","locations":[{"start":{"line":998,"column":26},"end":{"line":998,"column":62}},{"start":{"line":998,"column":62},"end":{"line":998,"column":null}}],"line":998},"98":{"loc":{"start":{"line":999,"column":6},"end":{"line":999,"column":null}},"type":"cond-expr","locations":[{"start":{"line":999,"column":41},"end":{"line":999,"column":97}},{"start":{"line":999,"column":97},"end":{"line":999,"column":null}}],"line":999},"99":{"loc":{"start":{"line":1000,"column":6},"end":{"line":1000,"column":null}},"type":"cond-expr","locations":[{"start":{"line":1000,"column":22},"end":{"line":1000,"column":50}},{"start":{"line":1000,"column":50},"end":{"line":1000,"column":null}}],"line":1000},"100":{"loc":{"start":{"line":1005,"column":4},"end":{"line":1005,"column":null}},"type":"if","locations":[{"start":{"line":1005,"column":4},"end":{"line":1005,"column":null}},{"start":{},"end":{}}],"line":1005},"101":{"loc":{"start":{"line":1022,"column":15},"end":{"line":1022,"column":null}},"type":"binary-expr","locations":[{"start":{"line":1022,"column":15},"end":{"line":1022,"column":40}},{"start":{"line":1022,"column":40},"end":{"line":1022,"column":null}}],"line":1022},"102":{"loc":{"start":{"line":1023,"column":17},"end":{"line":1023,"column":null}},"type":"binary-expr","locations":[{"start":{"line":1023,"column":17},"end":{"line":1023,"column":44}},{"start":{"line":1023,"column":44},"end":{"line":1023,"column":null}}],"line":1023},"103":{"loc":{"start":{"line":1024,"column":29},"end":{"line":1024,"column":62}},"type":"binary-expr","locations":[{"start":{"line":1024,"column":29},"end":{"line":1024,"column":58}},{"start":{"line":1024,"column":58},"end":{"line":1024,"column":62}}],"line":1024},"104":{"loc":{"start":{"line":1025,"column":33},"end":{"line":1025,"column":70}},"type":"binary-expr","locations":[{"start":{"line":1025,"column":33},"end":{"line":1025,"column":66}},{"start":{"line":1025,"column":66},"end":{"line":1025,"column":70}}],"line":1025},"105":{"loc":{"start":{"line":1026,"column":17},"end":{"line":1026,"column":null}},"type":"binary-expr","locations":[{"start":{"line":1026,"column":17},"end":{"line":1026,"column":45}},{"start":{"line":1026,"column":45},"end":{"line":1026,"column":null}}],"line":1026},"106":{"loc":{"start":{"line":1027,"column":13},"end":{"line":1027,"column":null}},"type":"binary-expr","locations":[{"start":{"line":1027,"column":13},"end":{"line":1027,"column":36}},{"start":{"line":1027,"column":36},"end":{"line":1027,"column":null}}],"line":1027},"107":{"loc":{"start":{"line":1028,"column":20},"end":{"line":1028,"column":null}},"type":"binary-expr","locations":[{"start":{"line":1028,"column":20},"end":{"line":1028,"column":51}},{"start":{"line":1028,"column":51},"end":{"line":1028,"column":null}}],"line":1028},"108":{"loc":{"start":{"line":1036,"column":4},"end":{"line":1038,"column":null}},"type":"if","locations":[{"start":{"line":1036,"column":4},"end":{"line":1038,"column":null}},{"start":{},"end":{}}],"line":1036},"109":{"loc":{"start":{"line":1042,"column":4},"end":{"line":1042,"column":null}},"type":"if","locations":[{"start":{"line":1042,"column":4},"end":{"line":1042,"column":null}},{"start":{},"end":{}}],"line":1042},"110":{"loc":{"start":{"line":1146,"column":4},"end":{"line":1146,"column":null}},"type":"if","locations":[{"start":{"line":1146,"column":4},"end":{"line":1146,"column":null}},{"start":{},"end":{}}],"line":1146},"111":{"loc":{"start":{"line":1163,"column":8},"end":{"line":1165,"column":null}},"type":"if","locations":[{"start":{"line":1163,"column":8},"end":{"line":1165,"column":null}},{"start":{},"end":{}}],"line":1163},"112":{"loc":{"start":{"line":1171,"column":8},"end":{"line":1173,"column":null}},"type":"if","locations":[{"start":{"line":1171,"column":8},"end":{"line":1173,"column":null}},{"start":{},"end":{}}],"line":1171},"113":{"loc":{"start":{"line":1207,"column":29},"end":{"line":1207,"column":62}},"type":"binary-expr","locations":[{"start":{"line":1207,"column":29},"end":{"line":1207,"column":58}},{"start":{"line":1207,"column":58},"end":{"line":1207,"column":62}}],"line":1207},"114":{"loc":{"start":{"line":1208,"column":33},"end":{"line":1208,"column":70}},"type":"binary-expr","locations":[{"start":{"line":1208,"column":33},"end":{"line":1208,"column":66}},{"start":{"line":1208,"column":66},"end":{"line":1208,"column":70}}],"line":1208},"115":{"loc":{"start":{"line":1211,"column":25},"end":{"line":1211,"column":53}},"type":"binary-expr","locations":[{"start":{"line":1211,"column":25},"end":{"line":1211,"column":49}},{"start":{"line":1211,"column":49},"end":{"line":1211,"column":53}}],"line":1211},"116":{"loc":{"start":{"line":1212,"column":28},"end":{"line":1212,"column":59}},"type":"binary-expr","locations":[{"start":{"line":1212,"column":28},"end":{"line":1212,"column":55}},{"start":{"line":1212,"column":55},"end":{"line":1212,"column":59}}],"line":1212},"117":{"loc":{"start":{"line":1224,"column":4},"end":{"line":1224,"column":null}},"type":"if","locations":[{"start":{"line":1224,"column":4},"end":{"line":1224,"column":null}},{"start":{},"end":{}}],"line":1224},"118":{"loc":{"start":{"line":1225,"column":4},"end":{"line":1225,"column":null}},"type":"if","locations":[{"start":{"line":1225,"column":4},"end":{"line":1225,"column":null}},{"start":{},"end":{}}],"line":1225},"119":{"loc":{"start":{"line":1226,"column":4},"end":{"line":1226,"column":null}},"type":"if","locations":[{"start":{"line":1226,"column":4},"end":{"line":1226,"column":null}},{"start":{},"end":{}}],"line":1226},"120":{"loc":{"start":{"line":1227,"column":4},"end":{"line":1227,"column":null}},"type":"if","locations":[{"start":{"line":1227,"column":4},"end":{"line":1227,"column":null}},{"start":{},"end":{}}],"line":1227},"121":{"loc":{"start":{"line":1233,"column":4},"end":{"line":1239,"column":null}},"type":"switch","locations":[{"start":{"line":1234,"column":6},"end":{"line":1234,"column":null}},{"start":{"line":1235,"column":6},"end":{"line":1235,"column":null}},{"start":{"line":1236,"column":6},"end":{"line":1236,"column":null}},{"start":{"line":1237,"column":6},"end":{"line":1237,"column":null}},{"start":{"line":1238,"column":6},"end":{"line":1238,"column":null}}],"line":1233},"122":{"loc":{"start":{"line":1256,"column":2},"end":{"line":1256,"column":null}},"type":"if","locations":[{"start":{"line":1256,"column":2},"end":{"line":1256,"column":null}},{"start":{},"end":{}}],"line":1256},"123":{"loc":{"start":{"line":1256,"column":6},"end":{"line":1256,"column":54}},"type":"binary-expr","locations":[{"start":{"line":1256,"column":6},"end":{"line":1256,"column":25}},{"start":{"line":1256,"column":25},"end":{"line":1256,"column":54}}],"line":1256},"124":{"loc":{"start":{"line":1260,"column":4},"end":{"line":1260,"column":null}},"type":"if","locations":[{"start":{"line":1260,"column":4},"end":{"line":1260,"column":null}},{"start":{},"end":{}}],"line":1260},"125":{"loc":{"start":{"line":1268,"column":8},"end":{"line":1268,"column":null}},"type":"if","locations":[{"start":{"line":1268,"column":8},"end":{"line":1268,"column":null}},{"start":{},"end":{}}],"line":1268},"126":{"loc":{"start":{"line":1271,"column":8},"end":{"line":1271,"column":null}},"type":"if","locations":[{"start":{"line":1271,"column":8},"end":{"line":1271,"column":null}},{"start":{},"end":{}}],"line":1271},"127":{"loc":{"start":{"line":1274,"column":8},"end":{"line":1282,"column":null}},"type":"if","locations":[{"start":{"line":1274,"column":8},"end":{"line":1282,"column":null}},{"start":{"line":1276,"column":8},"end":{"line":1282,"column":null}}],"line":1274},"128":{"loc":{"start":{"line":1276,"column":8},"end":{"line":1282,"column":null}},"type":"if","locations":[{"start":{"line":1276,"column":8},"end":{"line":1282,"column":null}},{"start":{},"end":{}}],"line":1276},"129":{"loc":{"start":{"line":1284,"column":8},"end":{"line":1284,"column":null}},"type":"if","locations":[{"start":{"line":1284,"column":8},"end":{"line":1284,"column":null}},{"start":{},"end":{}}],"line":1284}},"s":{"0":4,"1":150,"2":150,"3":150,"4":146,"5":146,"6":196,"7":54,"8":142,"9":142,"10":101,"11":142,"12":142,"13":142,"14":142,"15":142,"16":186,"17":44,"18":142,"19":142,"20":142,"21":186,"22":186,"23":186,"24":97,"25":89,"26":89,"27":186,"28":30,"29":30,"30":30,"31":30,"32":30,"33":30,"34":30,"35":30,"36":30,"37":165,"38":1,"39":164,"40":164,"41":21,"42":21,"43":21,"44":13,"45":23,"46":23,"47":23,"48":7,"49":221,"50":0,"51":221,"52":221,"53":129,"54":92,"55":10,"56":10,"57":10,"58":25,"59":25,"60":25,"61":23,"62":132,"63":132,"64":132,"65":132,"66":132,"67":132,"68":132,"69":132,"70":132,"71":132,"72":132,"73":132,"74":132,"75":132,"76":132,"77":132,"78":132,"79":132,"80":6,"81":6,"82":6,"83":1,"84":5,"85":0,"86":5,"87":3,"88":2,"89":2,"90":0,"91":0,"92":0,"93":0,"94":0,"95":0,"96":0,"97":0,"98":0,"99":0,"100":0,"101":314,"102":1,"103":313,"104":8,"105":8,"106":4,"107":4,"108":4,"109":2,"110":2,"111":2,"112":1,"113":1,"114":2,"115":2,"116":0,"117":0,"118":7,"119":7,"120":0,"121":7,"122":7,"123":7,"124":0,"125":0,"126":7,"127":7,"128":7,"129":7,"130":0,"131":0,"132":3,"133":3,"134":3,"135":3,"136":3,"137":3,"138":3,"139":3,"140":3,"141":3,"142":3,"143":4,"144":4,"145":1,"146":4,"147":3,"148":4,"149":4,"150":3,"151":1,"152":1,"153":1,"154":1,"155":0,"156":0,"157":0,"158":0,"159":0,"160":0,"161":0,"162":0,"163":0,"164":0,"165":0,"166":0,"167":0,"168":0,"169":0,"170":3,"171":3,"172":9,"173":0,"174":9,"175":9,"176":9,"177":9,"178":0,"179":0,"180":0,"181":0,"182":0,"183":0,"184":0,"185":0,"186":0,"187":0,"188":3,"189":3,"190":3,"191":6,"192":6,"193":6,"194":6,"195":6,"196":6,"197":9,"198":9,"199":3,"200":9,"201":6,"202":9,"203":9,"204":6,"205":3,"206":3,"207":2,"208":1,"209":3,"210":3,"211":0,"212":6,"213":6,"214":6,"215":33,"216":33,"217":33,"218":114,"219":24,"220":24,"221":24,"222":24,"223":21,"224":21,"225":21,"226":21,"227":21,"228":21,"229":21,"230":21,"231":21,"232":21,"233":21,"234":21,"235":21,"236":21,"237":21,"238":2,"239":2,"240":2,"241":2,"242":2,"243":2,"244":2,"245":2,"246":2,"247":2,"248":2,"249":2,"250":1,"251":2,"252":21,"253":3,"254":3,"255":3,"256":4,"257":4,"258":3,"259":21,"260":15,"261":15,"262":15,"263":20,"264":20,"265":20,"266":20,"267":20,"268":20,"269":20,"270":8,"271":15,"272":21,"273":15,"274":15,"275":15,"276":15,"277":15,"278":15,"279":15,"280":7,"281":15,"282":5,"283":15,"284":15,"285":21,"286":4,"287":4,"288":21,"289":21,"290":21,"291":17,"292":17,"293":17,"294":17,"295":17,"296":21,"297":8,"298":8,"299":8,"300":3,"301":8,"302":8,"303":8,"304":6,"305":8,"306":0,"307":8,"308":17,"309":17,"310":17,"311":17,"312":17,"313":17,"314":17,"315":45,"316":45,"317":45,"318":45,"319":9,"320":36,"321":17,"322":19,"323":16,"324":17,"325":8,"326":17,"327":17,"328":45,"329":17,"330":17,"331":10,"332":17,"333":6,"334":17,"335":8,"336":17,"337":2,"338":17,"339":17,"340":16,"341":16,"342":16,"343":16,"344":16,"345":16,"346":16,"347":26,"348":26,"349":26,"350":9,"351":4,"352":4,"353":4,"354":1,"355":3,"356":3,"357":3,"358":1,"359":2,"360":4,"361":0,"362":2,"363":2,"364":1,"365":1,"366":12,"367":566,"368":36,"369":142,"370":0,"371":142,"372":142,"373":142,"374":142,"375":142,"376":142,"377":142,"378":142,"379":142,"380":142,"381":142,"382":142,"383":142,"384":142,"385":142,"386":0,"387":142,"388":142,"389":142,"390":2556,"391":142,"392":142,"393":994,"394":0,"395":142,"396":426,"397":426,"398":5112,"399":0,"400":152,"401":138,"402":41,"403":41,"404":41,"405":41,"406":41,"407":41,"408":33,"409":8,"410":8,"411":6,"412":6,"413":4,"414":4,"415":2,"416":20,"417":13,"418":3,"419":1,"420":1,"421":2,"422":1,"423":15,"424":2,"425":13,"426":13,"427":13,"428":1,"429":12,"430":12,"431":12,"432":16,"433":16,"434":16,"435":2,"436":13,"437":16,"438":2,"439":11,"440":11,"441":9,"442":2,"443":2,"444":4,"445":2,"446":11,"447":1,"448":10,"449":10,"450":10,"451":1,"452":6,"453":0},"f":{"0":146,"1":196,"2":186,"3":186,"4":30,"5":165,"6":21,"7":13,"8":23,"9":7,"10":221,"11":10,"12":25,"13":23,"14":132,"15":6,"16":0,"17":0,"18":314,"19":8,"20":3,"21":4,"22":6,"23":9,"24":33,"25":114,"26":24,"27":24,"28":21,"29":21,"30":8,"31":17,"32":8,"33":16,"34":26,"35":9,"36":4,"37":0,"38":12,"39":566,"40":142,"41":142,"42":2556,"43":5112,"44":152,"45":138,"46":41,"47":20,"48":1,"49":15,"50":4,"51":2},"b":{"0":[146],"1":[54,142],"2":[101,41],"3":[44,142],"4":[186,142],"5":[97,89],"6":[89,60],"7":[186,60],"8":[30,0],"9":[30,0],"10":[1,164],"11":[164,139],"12":[23],"13":[0,221],"14":[129,92],"15":[10,0],"16":[25],"17":[132,1],"18":[132,1],"19":[132,116],"20":[132,116],"21":[1,5],"22":[3,2],"23":[0,0],"24":[0,0],"25":[0,0],"26":[0,0],"27":[0,0],"28":[1,313],"29":[4,4],"30":[2,2],"31":[1,3],"32":[3,1],"33":[1,0],"34":[0,0],"35":[0,0],"36":[3,0],"37":[0,9],"38":[0,0],"39":[0,0],"40":[3,6],"41":[6,3],"42":[2,1],"43":[1,0],"44":[33],"45":[24],"46":[2,19],"47":[2,0],"48":[2,0],"49":[2,0],"50":[1,1],"51":[3,18],"52":[0,4],"53":[15,6],"54":[20,0,0],"55":[20,0],"56":[8,12],"57":[20,20],"58":[15,6],"59":[21,19],"60":[5,10],"61":[7,8],"62":[1,6],"63":[5,10],"64":[4,17],"65":[21,6,4],"66":[21,19],"67":[17,4],"68":[21,6],"69":[3,5],"70":[8,0],"71":[6,2],"72":[0,8],"73":[8,0],"74":[45,21,19],"75":[9,36],"76":[45,9],"77":[17,19],"78":[36,17],"79":[16,3],"80":[19,16],"81":[7,10],"82":[10,9],"83":[45,26],"84":[10,7],"85":[6,11],"86":[8,9],"87":[2,15],"88":[8,9],"89":[1,7],"90":[17,0],"91":[17,4],"92":[16,0],"93":[26],"94":[1,3],"95":[1,2],"96":[2,0],"97":[1,1],"98":[1,1],"99":[0,2],"100":[1,1],"101":[12,0],"102":[12,6],"103":[12,0],"104":[12,0],"105":[12,9],"106":[12,10],"107":[12,0],"108":[36,530],"109":[0,142],"110":[0,142],"111":[0,994],"112":[0,426],"113":[138,0],"114":[138,0],"115":[138,0],"116":[138,0],"117":[33,8],"118":[2,6],"119":[2,4],"120":[2,2],"121":[13,3,1,1,2],"122":[2,13],"123":[15,14],"124":[1,12],"125":[2,14],"126":[2,14],"127":[9,2],"128":[2,0],"129":[1,10]},"meta":{"lastBranch":130,"lastFunction":52,"lastStatement":454,"seen":{"s:53:48:59:Infinity":0,"s:69:38:69:Infinity":1,"s:70:33:70:Infinity":2,"s:415:47:415:Infinity":3,"f:73:2:73:14":0,"b:73:70:73:74":0,"s:74:4:74:Infinity":4,"s:75:4:75:Infinity":5,"f:81:8:81:36":1,"b:82:4:82:Infinity:undefined:undefined:undefined:undefined":1,"s:82:4:82:Infinity":6,"s:82:26:82:Infinity":7,"s:85:16:85:Infinity":8,"b:86:4:88:Infinity:undefined:undefined:undefined:undefined":2,"s:86:4:88:Infinity":9,"s:87:6:87:Infinity":10,"s:91:4:91:Infinity":11,"s:94:4:94:Infinity":12,"s:96:4:96:Infinity":13,"s:99:4:99:Infinity":14,"s:101:4:101:Infinity":15,"f:107:8:107:34":2,"b:108:4:108:Infinity:undefined:undefined:undefined:undefined":3,"s:108:4:108:Infinity":16,"b:108:8:108:29:108:29:108:39":4,"s:108:39:108:Infinity":17,"s:110:4:110:Infinity":18,"s:111:4:111:Infinity":19,"s:112:4:112:Infinity":20,"f:120:8:120:20":3,"s:121:4:121:Infinity":21,"s:124:21:124:Infinity":22,"b:125:4:127:Infinity:undefined:undefined:undefined:undefined":5,"s:125:4:127:Infinity":23,"s:126:6:126:Infinity":24,"s:130:16:130:Infinity":25,"s:131:19:134:Infinity":26,"b:134:31:134:41:134:41:134:45":6,"s:136:4:144:Infinity":27,"b:140:14:140:24:140:24:140:Infinity":7,"f:152:8:152:23":4,"s:153:4:153:Infinity":28,"s:156:4:156:Infinity":29,"s:159:25:159:Infinity":30,"s:160:16:160:Infinity":31,"s:162:19:165:Infinity":32,"s:167:15:167:Infinity":33,"b:167:36:167:69:167:69:167:Infinity":8,"b:170:4:172:Infinity:undefined:undefined:undefined:undefined":9,"s:170:4:172:Infinity":34,"s:171:6:171:Infinity":35,"s:174:4:180:Infinity":36,"f:186:2:186:18":5,"b:187:4:187:Infinity:undefined:undefined:undefined:undefined":10,"s:187:4:187:Infinity":37,"s:187:18:187:Infinity":38,"s:189:16:191:Infinity":39,"s:193:4:193:Infinity":40,"b:193:11:193:25:193:25:193:Infinity":11,"f:199:8:199:26":6,"s:200:4:200:Infinity":41,"s:202:17:206:Infinity":42,"s:208:4:214:Infinity":43,"f:208:20:208:28":7,"s:208:28:214:6":44,"f:220:8:220:25":8,"b:220:58:220:85":12,"s:221:4:221:Infinity":45,"s:223:17:229:Infinity":46,"s:231:4:237:Infinity":47,"f:231:20:231:28":9,"s:231:28:237:6":48,"f:243:2:243:13":10,"b:244:4:244:Infinity:undefined:undefined:undefined:undefined":13,"s:244:4:244:Infinity":49,"s:244:18:244:Infinity":50,"s:246:16:246:Infinity":51,"b:248:4:250:Infinity:undefined:undefined:undefined:undefined":14,"s:248:4:250:Infinity":52,"s:249:6:249:Infinity":53,"s:252:4:252:Infinity":54,"f:258:8:258:24":11,"s:259:4:259:Infinity":55,"s:261:16:261:Infinity":56,"s:262:4:266:Infinity":57,"b:266:16:266:27:266:27:266:31":15,"f:272:8:272:26":12,"b:272:59:272:88":16,"s:273:4:273:Infinity":58,"s:275:17:280:Infinity":59,"s:282:4:282:Infinity":60,"f:282:20:282:27":13,"s:282:27:282:49":61,"f:290:8:290:Infinity":14,"s:298:4:298:Infinity":62,"s:300:10:300:Infinity":63,"s:301:16:301:Infinity":64,"s:302:10:302:Infinity":65,"s:303:10:303:Infinity":66,"s:304:25:304:Infinity":67,"s:305:37:305:Infinity":68,"s:308:21:308:Infinity":69,"b:308:36:308:49:308:49:308:51":17,"s:309:10:312:Infinity":70,"b:310:21:310:37:310:37:310:39":18,"s:316:10:316:Infinity":71,"s:317:10:317:Infinity":72,"s:318:10:318:Infinity":73,"s:319:10:319:Infinity":74,"s:321:4:324:Infinity":75,"b:324:91:324:107:324:107:324:113":19,"s:327:4:327:Infinity":76,"s:328:4:328:Infinity":77,"s:331:4:335:Infinity":78,"s:337:4:355:Infinity":79,"b:348:20:348:36:348:36:348:Infinity":20,"f:363:8:363:26":15,"s:364:4:364:Infinity":80,"s:366:16:368:Infinity":81,"b:370:4:370:Infinity:undefined:undefined:undefined:undefined":21,"s:370:4:370:Infinity":82,"s:370:14:370:Infinity":83,"s:372:21:372:Infinity":84,"f:372:96:372:102":16,"s:372:102:372:106":85,"b:373:4:373:Infinity:undefined:undefined:undefined:undefined":22,"s:373:4:373:Infinity":86,"s:373:19:373:Infinity":87,"s:375:4:385:Infinity":88,"s:387:4:387:Infinity":89,"f:393:10:393:Infinity":17,"b:397:4:409:Infinity:404:4:409:Infinity":23,"s:397:4:409:Infinity":90,"s:398:20:398:Infinity":91,"s:399:6:402:Infinity":92,"s:400:25:400:Infinity":93,"b:400:37:400:64:400:64:400:68":24,"b:401:8:401:Infinity:undefined:undefined:undefined:undefined":25,"s:401:8:401:Infinity":94,"s:401:33:401:Infinity":95,"s:403:6:403:Infinity":96,"b:404:4:409:Infinity:406:11:409:Infinity":26,"s:404:4:409:Infinity":97,"s:405:6:405:Infinity":98,"b:405:15:405:45:405:45:405:49":27,"s:407:20:407:Infinity":99,"s:408:6:408:Infinity":100,"f:421:2:421:Infinity":18,"b:426:4:426:Infinity:undefined:undefined:undefined:undefined":28,"s:426:4:426:Infinity":101,"s:426:18:426:Infinity":102,"s:427:4:429:Infinity":103,"f:438:2:438:22":19,"s:439:21:439:Infinity":104,"b:442:4:459:Infinity:undefined:undefined:undefined:undefined":29,"s:442:4:459:Infinity":105,"s:443:6:458:Infinity":106,"s:444:20:444:Infinity":107,"b:445:8:455:Infinity:453:15:455:Infinity":30,"s:445:8:455:Infinity":108,"s:446:10:452:Infinity":109,"s:447:12:447:Infinity":110,"s:448:12:448:Infinity":111,"s:451:12:451:Infinity":112,"s:451:18:451:40":113,"s:454:10:454:Infinity":114,"s:454:16:454:38":115,"s:457:8:457:Infinity":116,"s:457:14:457:36":117,"s:463:4:468:Infinity":118,"s:464:6:464:Infinity":119,"s:467:6:467:Infinity":120,"s:471:4:476:Infinity":121,"s:472:6:472:Infinity":122,"s:473:6:473:Infinity":123,"s:475:6:475:Infinity":124,"s:475:12:475:27":125,"s:479:4:490:Infinity":126,"s:480:22:480:Infinity":127,"s:481:12:485:Infinity":128,"s:486:6:486:Infinity":129,"s:489:6:489:Infinity":130,"s:489:12:489:34":131,"f:498:8:498:49":20,"s:499:4:499:Infinity":132,"s:501:21:501:Infinity":133,"s:502:4:502:Infinity":134,"s:504:16:504:Infinity":135,"s:505:4:596:Infinity":136,"s:506:41:506:Infinity":137,"s:507:23:507:Infinity":138,"s:508:25:508:Infinity":139,"s:509:6:509:Infinity":140,"s:511:47:515:Infinity":141,"s:518:29:526:Infinity":142,"f:518:50:518:56":21,"s:519:21:521:Infinity":143,"b:522:8:524:Infinity:undefined:undefined:undefined:undefined":31,"s:522:8:524:Infinity":144,"s:523:10:523:Infinity":145,"s:525:8:525:Infinity":146,"s:529:6:566:Infinity":147,"s:530:21:530:Infinity":148,"b:532:8:532:Infinity:undefined:undefined:undefined:undefined":32,"s:532:8:532:Infinity":149,"s:532:19:532:Infinity":150,"s:534:22:534:Infinity":151,"b:535:8:538:Infinity:undefined:undefined:undefined:undefined":33,"s:535:8:538:Infinity":152,"s:536:10:536:Infinity":153,"s:537:10:537:Infinity":154,"s:540:8:565:Infinity":155,"s:541:22:543:Infinity":156,"b:545:10:548:Infinity:undefined:undefined:undefined:undefined":34,"s:545:10:548:Infinity":157,"s:546:12:546:Infinity":158,"s:547:12:547:Infinity":159,"s:550:23:552:Infinity":160,"b:553:10:556:Infinity:undefined:undefined:undefined:undefined":35,"s:553:10:556:Infinity":161,"s:554:12:554:Infinity":162,"s:555:12:555:Infinity":163,"s:558:25:558:Infinity":164,"s:559:25:559:Infinity":165,"s:560:10:560:Infinity":166,"s:561:10:561:Infinity":167,"s:562:10:562:Infinity":168,"s:564:10:564:Infinity":169,"b:569:6:593:Infinity:undefined:undefined:undefined:undefined":36,"s:569:6:593:Infinity":170,"s:570:8:592:Infinity":171,"b:571:10:571:Infinity:undefined:undefined:undefined:undefined":37,"s:571:10:571:Infinity":172,"s:571:61:571:Infinity":173,"s:572:10:591:Infinity":174,"s:573:30:573:Infinity":175,"s:574:25:576:Infinity":176,"s:578:12:590:Infinity":177,"b:579:14:579:Infinity:undefined:undefined:undefined:undefined":38,"s:579:14:579:Infinity":178,"s:579:65:579:Infinity":179,"s:580:27:582:Infinity":180,"b:583:14:583:Infinity:undefined:undefined:undefined:undefined":39,"s:583:14:583:Infinity":181,"s:583:25:583:Infinity":182,"s:584:14:589:Infinity":183,"s:585:31:585:Infinity":184,"s:586:31:586:Infinity":185,"s:587:16:587:Infinity":186,"s:588:16:588:Infinity":187,"s:595:6:595:Infinity":188,"s:595:12:595:34":189,"s:598:4:598:Infinity":190,"f:606:8:606:50":22,"s:607:4:607:Infinity":191,"s:609:21:609:Infinity":192,"s:610:4:610:Infinity":193,"s:612:16:612:Infinity":194,"s:613:4:645:Infinity":195,"s:615:30:623:Infinity":196,"f:615:51:615:57":23,"s:616:21:618:Infinity":197,"b:619:8:621:Infinity:undefined:undefined:undefined:undefined":40,"s:619:8:621:Infinity":198,"s:620:10:620:Infinity":199,"s:622:8:622:Infinity":200,"s:625:6:642:Infinity":201,"s:626:21:626:Infinity":202,"b:628:8:628:Infinity:undefined:undefined:undefined:undefined":41,"s:628:8:628:Infinity":203,"s:628:19:628:Infinity":204,"s:630:8:641:Infinity":205,"b:631:10:636:Infinity:633:10:636:Infinity":42,"s:631:10:636:Infinity":206,"s:632:12:632:Infinity":207,"b:633:10:636:Infinity:undefined:undefined:undefined:undefined":43,"s:633:10:636:Infinity":208,"s:637:10:637:Infinity":209,"s:638:10:638:Infinity":210,"s:640:10:640:Infinity":211,"s:644:6:644:Infinity":212,"s:644:12:644:34":213,"s:647:4:647:Infinity":214,"f:653:8:653:31":24,"b:653:66:653:94":44,"s:654:4:654:Infinity":215,"s:656:17:661:Infinity":216,"s:663:4:663:Infinity":217,"f:663:20:663:27":25,"s:663:27:663:53":218,"f:669:8:669:30":26,"b:669:63:669:91":45,"s:670:4:670:Infinity":219,"s:672:17:677:Infinity":220,"s:679:4:679:Infinity":221,"f:679:20:679:27":27,"s:679:27:679:53":222,"f:687:8:687:19":28,"s:688:4:688:Infinity":223,"s:690:31:693:Infinity":224,"s:695:29:698:Infinity":225,"s:700:24:700:Infinity":226,"s:701:29:701:Infinity":227,"s:704:21:706:Infinity":228,"s:708:4:714:Infinity":229,"f:720:10:720:Infinity":29,"s:727:28:727:Infinity":230,"s:729:4:729:Infinity":231,"s:730:4:730:Infinity":232,"s:733:4:733:Infinity":233,"s:734:4:734:Infinity":234,"s:735:4:735:Infinity":235,"s:736:4:736:Infinity":236,"b:739:4:760:Infinity:undefined:undefined:undefined:undefined":46,"s:739:4:760:Infinity":237,"s:740:6:740:Infinity":238,"s:741:6:741:Infinity":239,"s:743:6:759:Infinity":240,"s:744:21:744:Infinity":241,"s:745:8:745:Infinity":242,"b:746:8:748:Infinity:undefined:undefined:undefined:undefined":47,"s:746:8:748:Infinity":243,"s:747:10:747:Infinity":244,"b:749:8:751:Infinity:undefined:undefined:undefined:undefined":48,"s:749:8:751:Infinity":245,"s:750:10:750:Infinity":246,"b:752:8:754:Infinity:undefined:undefined:undefined:undefined":49,"s:752:8:754:Infinity":247,"s:753:10:753:Infinity":248,"b:755:8:757:Infinity:undefined:undefined:undefined:undefined":50,"s:755:8:757:Infinity":249,"s:756:10:756:Infinity":250,"s:758:8:758:Infinity":251,"b:763:4:772:Infinity:undefined:undefined:undefined:undefined":51,"s:763:4:772:Infinity":252,"s:764:6:764:Infinity":253,"s:765:6:765:Infinity":254,"s:767:6:770:Infinity":255,"s:768:21:768:Infinity":256,"s:769:8:769:Infinity":257,"b:769:105:769:113:769:113:769:115":52,"s:771:6:771:Infinity":258,"b:775:4:792:Infinity:undefined:undefined:undefined:undefined":53,"s:775:4:792:Infinity":259,"s:776:6:776:Infinity":260,"s:777:6:777:Infinity":261,"s:779:6:790:Infinity":262,"s:780:21:780:Infinity":263,"s:781:21:781:Infinity":264,"s:782:23:782:Infinity":265,"b:782:23:782:39:782:39:782:52:782:52:782:Infinity":54,"s:783:8:783:Infinity":266,"b:784:8:786:Infinity:undefined:undefined:undefined:undefined":55,"s:784:8:786:Infinity":267,"s:785:10:785:Infinity":268,"b:787:8:789:Infinity:undefined:undefined:undefined:undefined":56,"s:787:8:789:Infinity":269,"b:787:12:787:28:787:28:787:53":57,"s:788:10:788:Infinity":270,"s:791:6:791:Infinity":271,"b:795:4:815:Infinity:undefined:undefined:undefined:undefined":58,"s:795:4:815:Infinity":272,"b:795:8:795:34:795:34:795:55":59,"s:796:6:796:Infinity":273,"s:797:6:797:Infinity":274,"s:799:6:814:Infinity":275,"s:800:21:800:Infinity":276,"s:801:23:801:Infinity":277,"b:801:56:801:62:801:62:801:Infinity":60,"s:802:8:802:Infinity":278,"b:804:8:806:Infinity:undefined:undefined:undefined:undefined":61,"s:804:8:806:Infinity":279,"s:805:10:805:Infinity":280,"b:805:99:805:107:805:107:805:109":62,"b:808:8:810:Infinity:undefined:undefined:undefined:undefined":63,"s:808:8:810:Infinity":281,"s:809:10:809:Infinity":282,"s:812:8:812:Infinity":283,"s:813:8:813:Infinity":284,"b:818:4:821:Infinity:undefined:undefined:undefined:undefined":64,"s:818:4:821:Infinity":285,"b:818:8:818:37:818:37:818:62:818:62:818:84":65,"s:819:6:819:Infinity":286,"s:820:6:820:Infinity":287,"s:824:21:824:Infinity":288,"s:825:26:825:Infinity":289,"b:825:26:825:46:825:46:825:Infinity":66,"b:826:4:832:Infinity:undefined:undefined:undefined:undefined":67,"s:826:4:832:Infinity":290,"b:826:8:826:24:826:24:826:43":68,"s:827:35:827:Infinity":291,"s:828:28:828:Infinity":292,"s:829:6:829:Infinity":293,"s:830:6:830:Infinity":294,"s:831:6:831:Infinity":295,"s:834:4:834:Infinity":296,"f:840:8:840:24":30,"s:841:23:841:Infinity":297,"s:843:28:843:Infinity":298,"b:844:4:844:Infinity:undefined:undefined:undefined:undefined":69,"s:844:4:844:Infinity":299,"s:844:28:844:Infinity":300,"b:845:4:845:Infinity:undefined:undefined:undefined:undefined":70,"s:845:4:845:Infinity":301,"s:845:30:845:Infinity":302,"b:846:4:848:Infinity:undefined:undefined:undefined:undefined":71,"s:846:4:848:Infinity":303,"s:847:6:847:Infinity":304,"b:849:4:849:Infinity:undefined:undefined:undefined:undefined":72,"s:849:4:849:Infinity":305,"s:849:30:849:Infinity":306,"s:850:4:850:Infinity":307,"b:850:11:850:31:850:31:850:Infinity":73,"f:856:8:856:34":31,"s:857:25:857:Infinity":308,"s:858:20:858:Infinity":309,"s:859:20:859:Infinity":310,"s:862:35:862:Infinity":311,"s:863:39:863:Infinity":312,"s:864:31:864:Infinity":313,"s:866:4:881:Infinity":314,"s:867:6:880:Infinity":315,"s:868:22:868:Infinity":316,"s:869:25:869:Infinity":317,"b:869:25:869:44:869:44:869:58:869:58:869:Infinity":74,"b:871:8:877:Infinity:873:8:877:Infinity":75,"s:871:8:877:Infinity":318,"b:871:12:871:35:871:35:871:45":76,"s:872:10:872:Infinity":319,"b:873:8:877:Infinity:875:8:877:Infinity":77,"s:873:8:877:Infinity":320,"b:873:19:873:43:873:43:873:53":78,"s:874:10:874:Infinity":321,"b:875:8:877:Infinity:undefined:undefined:undefined:undefined":79,"s:875:8:877:Infinity":322,"b:875:19:875:45:875:45:875:60":80,"s:876:10:876:Infinity":323,"s:884:20:886:Infinity":324,"b:885:8:885:Infinity:886:8:886:Infinity":81,"f:885:20:885:25":32,"s:885:25:885:81":325,"b:886:8:886:27:886:27:886:Infinity":82,"s:889:43:889:Infinity":326,"s:890:4:892:Infinity":327,"s:891:6:891:Infinity":328,"b:891:26:891:46:891:46:891:51":83,"s:893:37:893:Infinity":329,"b:894:4:894:Infinity:undefined:undefined:undefined:undefined":84,"s:894:4:894:Infinity":330,"s:894:22:894:Infinity":331,"b:895:4:895:Infinity:undefined:undefined:undefined:undefined":85,"s:895:4:895:Infinity":332,"s:895:21:895:Infinity":333,"b:896:4:896:Infinity:undefined:undefined:undefined:undefined":86,"s:896:4:896:Infinity":334,"s:896:24:896:Infinity":335,"b:897:4:897:Infinity:undefined:undefined:undefined:undefined":87,"s:897:4:897:Infinity":336,"s:897:23:897:Infinity":337,"s:900:18:902:Infinity":338,"b:901:8:901:Infinity:902:8:902:Infinity":88,"b:901:77:901:113:901:113:901:115":89,"s:904:4:914:Infinity":339,"b:906:15:906:35:906:35:906:Infinity":90,"b:908:17:908:46:908:46:908:Infinity":91,"f:922:8:922:27":33,"s:923:4:923:Infinity":340,"s:925:16:925:Infinity":341,"s:926:19:941:Infinity":342,"s:943:15:943:Infinity":343,"b:946:4:948:Infinity:undefined:undefined:undefined:undefined":92,"s:946:4:948:Infinity":344,"s:947:6:947:Infinity":345,"s:950:4:954:Infinity":346,"f:960:8:960:27":34,"b:960:60:960:90":93,"s:961:4:961:Infinity":347,"s:963:17:968:Infinity":348,"s:970:4:970:Infinity":349,"f:970:20:970:27":35,"s:970:27:970:49":350,"f:979:8:979:29":36,"s:980:4:980:Infinity":351,"s:983:17:985:Infinity":352,"b:987:4:987:Infinity:undefined:undefined:undefined:undefined":94,"s:987:4:987:Infinity":353,"s:987:27:987:Infinity":354,"s:989:20:989:Infinity":355,"s:992:24:992:Infinity":356,"b:993:4:993:Infinity:undefined:undefined:undefined:undefined":95,"s:993:4:993:Infinity":357,"s:993:22:993:Infinity":358,"s:996:25:1001:Infinity":359,"b:997:24:997:56:997:56:997:Infinity":96,"b:998:26:998:62:998:62:998:Infinity":97,"b:999:41:999:97:999:97:999:Infinity":98,"b:1000:22:1000:50:1000:50:1000:Infinity":99,"s:1004:21:1004:Infinity":360,"f:1004:80:1004:86":37,"s:1004:86:1004:90":361,"b:1005:4:1005:Infinity:undefined:undefined:undefined:undefined":100,"s:1005:4:1005:Infinity":362,"s:1005:19:1005:Infinity":363,"s:1008:4:1012:Infinity":364,"s:1014:4:1014:Infinity":365,"f:1017:10:1017:23":38,"s:1018:4:1030:Infinity":366,"b:1022:15:1022:40:1022:40:1022:Infinity":101,"b:1023:17:1023:44:1023:44:1023:Infinity":102,"b:1024:29:1024:58:1024:58:1024:62":103,"b:1025:33:1025:66:1025:66:1025:70":104,"b:1026:17:1026:45:1026:45:1026:Infinity":105,"b:1027:13:1027:36:1027:36:1027:Infinity":106,"b:1028:20:1028:51:1028:51:1028:Infinity":107,"f:1035:16:1035:51":39,"b:1036:4:1038:Infinity:undefined:undefined:undefined:undefined":108,"s:1036:4:1038:Infinity":367,"s:1037:6:1037:Infinity":368,"f:1041:10:1041:31":40,"b:1042:4:1042:Infinity:undefined:undefined:undefined:undefined":109,"s:1042:4:1042:Infinity":369,"s:1042:18:1042:Infinity":370,"s:1044:4:1056:Infinity":371,"s:1058:4:1080:Infinity":372,"s:1083:4:1094:Infinity":373,"s:1097:4:1113:Infinity":374,"s:1117:4:1126:Infinity":375,"s:1129:4:1129:Infinity":376,"s:1130:4:1130:Infinity":377,"s:1131:4:1131:Infinity":378,"s:1132:4:1132:Infinity":379,"s:1133:4:1133:Infinity":380,"s:1134:4:1134:Infinity":381,"s:1135:4:1135:Infinity":382,"s:1136:4:1136:Infinity":383,"s:1139:4:1139:Infinity":384,"f:1145:10:1145:32":41,"b:1146:4:1146:Infinity:undefined:undefined:undefined:undefined":110,"s:1146:4:1146:Infinity":385,"s:1146:18:1146:Infinity":386,"s:1148:4:1177:Infinity":387,"s:1149:25:1149:Infinity":388,"s:1150:26:1150:Infinity":389,"f:1150:49:1150:54":42,"s:1150:54:1150:60":390,"s:1152:50:1160:Infinity":391,"s:1162:6:1166:Infinity":392,"b:1163:8:1165:Infinity:undefined:undefined:undefined:undefined":111,"s:1163:8:1165:Infinity":393,"s:1164:10:1164:Infinity":394,"s:1169:6:1174:Infinity":395,"s:1170:21:1170:Infinity":396,"b:1171:8:1173:Infinity:undefined:undefined:undefined:undefined":112,"s:1171:8:1173:Infinity":397,"f:1171:23:1171:28":43,"s:1171:28:1171:50":398,"s:1172:10:1172:Infinity":399,"f:1180:10:1180:23":44,"s:1181:4:1191:Infinity":400,"f:1194:10:1194:27":45,"s:1195:4:1213:Infinity":401,"b:1207:29:1207:58:1207:58:1207:62":113,"b:1208:33:1208:66:1208:66:1208:70":114,"b:1211:25:1211:49:1211:49:1211:53":115,"b:1212:28:1212:55:1212:55:1212:59":116,"f:1216:10:1216:29":46,"s:1217:16:1217:Infinity":402,"s:1218:17:1218:Infinity":403,"s:1220:20:1220:Infinity":404,"s:1221:18:1221:Infinity":405,"s:1222:17:1222:Infinity":406,"b:1224:4:1224:Infinity:undefined:undefined:undefined:undefined":117,"s:1224:4:1224:Infinity":407,"s:1224:21:1224:Infinity":408,"b:1225:4:1225:Infinity:undefined:undefined:undefined:undefined":118,"s:1225:4:1225:Infinity":409,"s:1225:22:1225:Infinity":410,"b:1226:4:1226:Infinity:undefined:undefined:undefined:undefined":119,"s:1226:4:1226:Infinity":411,"s:1226:20:1226:Infinity":412,"b:1227:4:1227:Infinity:undefined:undefined:undefined:undefined":120,"s:1227:4:1227:Infinity":413,"s:1227:18:1227:Infinity":414,"s:1229:4:1229:Infinity":415,"f:1232:10:1232:29":47,"b:1234:6:1234:Infinity:1235:6:1235:Infinity:1236:6:1236:Infinity:1237:6:1237:Infinity:1238:6:1238:Infinity":121,"s:1233:4:1239:Infinity":416,"s:1234:19:1234:Infinity":417,"s:1235:20:1235:Infinity":418,"s:1236:22:1236:Infinity":419,"s:1237:21:1237:Infinity":420,"s:1238:15:1238:Infinity":421,"f:1246:16:1246:34":48,"s:1247:2:1247:Infinity":422,"f:1255:16:1255:44":49,"b:1256:2:1256:Infinity:undefined:undefined:undefined:undefined":122,"s:1256:2:1256:Infinity":423,"b:1256:6:1256:25:1256:25:1256:54":123,"s:1256:54:1256:Infinity":424,"s:1258:2:1301:Infinity":425,"s:1259:10:1259:Infinity":426,"b:1260:4:1260:Infinity:undefined:undefined:undefined:undefined":124,"s:1260:4:1260:Infinity":427,"s:1260:18:1260:Infinity":428,"s:1262:18:1262:Infinity":429,"s:1265:4:1296:Infinity":430,"s:1265:17:1265:35":431,"s:1266:6:1295:Infinity":432,"s:1267:21:1267:Infinity":433,"b:1268:8:1268:Infinity:undefined:undefined:undefined:undefined":125,"s:1268:8:1268:Infinity":434,"s:1268:39:1268:Infinity":435,"s:1270:27:1270:Infinity":436,"b:1271:8:1271:Infinity:undefined:undefined:undefined:undefined":126,"s:1271:8:1271:Infinity":437,"s:1271:25:1271:Infinity":438,"s:1273:19:1273:Infinity":439,"b:1274:8:1282:Infinity:1276:8:1282:Infinity":127,"s:1274:8:1282:Infinity":440,"s:1275:10:1275:Infinity":441,"b:1276:8:1282:Infinity:undefined:undefined:undefined:undefined":128,"s:1276:8:1282:Infinity":442,"s:1278:10:1281:Infinity":443,"f:1279:20:1279:21":50,"s:1279:45:1279:62":444,"f:1280:17:1280:18":51,"s:1280:42:1280:48":445,"b:1284:8:1284:Infinity:undefined:undefined:undefined:undefined":129,"s:1284:8:1284:Infinity":446,"s:1284:19:1284:Infinity":447,"s:1287:8:1287:Infinity":448,"s:1288:8:1288:Infinity":449,"s:1291:8:1291:Infinity":450,"s:1294:8:1294:Infinity":451,"s:1298:4:1298:Infinity":452,"s:1300:4:1300:Infinity":453}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/session-init.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/session-init.ts","statementMap":{"0":{"start":{"line":28,"column":4},"end":{"line":28,"column":null}},"1":{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},"2":{"start":{"line":36,"column":4},"end":{"line":38,"column":null}},"3":{"start":{"line":37,"column":6},"end":{"line":37,"column":null}},"4":{"start":{"line":45,"column":4},"end":{"line":78,"column":null}},"5":{"start":{"line":47,"column":6},"end":{"line":47,"column":null}},"6":{"start":{"line":50,"column":6},"end":{"line":54,"column":null}},"7":{"start":{"line":57,"column":6},"end":{"line":63,"column":null}},"8":{"start":{"line":58,"column":8},"end":{"line":62,"column":null}},"9":{"start":{"line":65,"column":6},"end":{"line":68,"column":null}},"10":{"start":{"line":71,"column":6},"end":{"line":71,"column":null}},"11":{"start":{"line":73,"column":6},"end":{"line":77,"column":null}},"12":{"start":{"line":86,"column":18},"end":{"line":86,"column":null}},"13":{"start":{"line":87,"column":2},"end":{"line":87,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":27,"column":2},"end":{"line":27,"column":14}},"loc":{"start":{"line":27,"column":63},"end":{"line":30,"column":null}},"line":27},"1":{"name":"(anonymous_1)","decl":{"start":{"line":35,"column":8},"end":{"line":35,"column":34}},"loc":{"start":{"line":35,"column":34},"end":{"line":39,"column":null}},"line":35},"2":{"name":"(anonymous_2)","decl":{"start":{"line":44,"column":8},"end":{"line":44,"column":16}},"loc":{"start":{"line":44,"column":65},"end":{"line":79,"column":null}},"line":44},"3":{"name":"createSessionInitHook","decl":{"start":{"line":85,"column":16},"end":{"line":85,"column":38}},"loc":{"start":{"line":85,"column":68},"end":{"line":88,"column":null}},"line":85}},"branchMap":{"0":{"loc":{"start":{"line":27,"column":42},"end":{"line":27,"column":63}},"type":"default-arg","locations":[{"start":{"line":27,"column":56},"end":{"line":27,"column":63}}],"line":27},"1":{"loc":{"start":{"line":36,"column":4},"end":{"line":38,"column":null}},"type":"if","locations":[{"start":{"line":36,"column":4},"end":{"line":38,"column":null}},{"start":{},"end":{}}],"line":36},"2":{"loc":{"start":{"line":57,"column":6},"end":{"line":63,"column":null}},"type":"if","locations":[{"start":{"line":57,"column":6},"end":{"line":63,"column":null}},{"start":{},"end":{}}],"line":57},"3":{"loc":{"start":{"line":76,"column":15},"end":{"line":76,"column":null}},"type":"cond-expr","locations":[{"start":{"line":76,"column":40},"end":{"line":76,"column":56}},{"start":{"line":76,"column":56},"end":{"line":76,"column":null}}],"line":76}},"s":{"0":19,"1":19,"2":36,"3":36,"4":19,"5":19,"6":18,"7":18,"8":11,"9":18,"10":1,"11":1,"12":18,"13":18},"f":{"0":19,"1":36,"2":19,"3":18},"b":{"0":[19],"1":[36,0],"2":[11,7],"3":[1,0]},"meta":{"lastBranch":4,"lastFunction":4,"lastStatement":14,"seen":{"f:27:2:27:14":0,"b:27:56:27:63":0,"s:28:4:28:Infinity":0,"s:29:4:29:Infinity":1,"f:35:8:35:34":1,"b:36:4:38:Infinity:undefined:undefined:undefined:undefined":1,"s:36:4:38:Infinity":2,"s:37:6:37:Infinity":3,"f:44:8:44:16":2,"s:45:4:78:Infinity":4,"s:47:6:47:Infinity":5,"s:50:6:54:Infinity":6,"b:57:6:63:Infinity:undefined:undefined:undefined:undefined":2,"s:57:6:63:Infinity":7,"s:58:8:62:Infinity":8,"s:65:6:68:Infinity":9,"s:71:6:71:Infinity":10,"s:73:6:77:Infinity":11,"b:76:40:76:56:76:56:76:Infinity":3,"f:85:16:85:38":3,"s:86:18:86:Infinity":12,"s:87:2:87:Infinity":13}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/summarize.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/summarize.ts","statementMap":{"0":{"start":{"line":31,"column":4},"end":{"line":31,"column":null}},"1":{"start":{"line":32,"column":4},"end":{"line":32,"column":null}},"2":{"start":{"line":39,"column":4},"end":{"line":41,"column":null}},"3":{"start":{"line":40,"column":6},"end":{"line":40,"column":null}},"4":{"start":{"line":48,"column":4},"end":{"line":118,"column":null}},"5":{"start":{"line":50,"column":6},"end":{"line":50,"column":null}},"6":{"start":{"line":53,"column":22},"end":{"line":53,"column":null}},"7":{"start":{"line":54,"column":6},"end":{"line":60,"column":null}},"8":{"start":{"line":56,"column":8},"end":{"line":59,"column":null}},"9":{"start":{"line":63,"column":25},"end":{"line":63,"column":null}},"10":{"start":{"line":66,"column":6},"end":{"line":66,"column":null}},"11":{"start":{"line":69,"column":26},"end":{"line":69,"column":null}},"12":{"start":{"line":70,"column":6},"end":{"line":70,"column":null}},"13":{"start":{"line":73,"column":6},"end":{"line":73,"column":null}},"14":{"start":{"line":74,"column":6},"end":{"line":76,"column":null}},"15":{"start":{"line":75,"column":8},"end":{"line":75,"column":null}},"16":{"start":{"line":79,"column":6},"end":{"line":93,"column":null}},"17":{"start":{"line":80,"column":8},"end":{"line":92,"column":null}},"18":{"start":{"line":81,"column":26},"end":{"line":81,"column":null}},"19":{"start":{"line":82,"column":16},"end":{"line":88,"column":null}},"20":{"start":{"line":89,"column":10},"end":{"line":89,"column":null}},"21":{"start":{"line":96,"column":6},"end":{"line":96,"column":null}},"22":{"start":{"line":98,"column":6},"end":{"line":101,"column":null}},"23":{"start":{"line":104,"column":6},"end":{"line":104,"column":null}},"24":{"start":{"line":107,"column":6},"end":{"line":111,"column":null}},"25":{"start":{"line":108,"column":8},"end":{"line":108,"column":null}},"26":{"start":{"line":113,"column":6},"end":{"line":117,"column":null}},"27":{"start":{"line":126,"column":18},"end":{"line":126,"column":null}},"28":{"start":{"line":127,"column":2},"end":{"line":127,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":30,"column":2},"end":{"line":30,"column":14}},"loc":{"start":{"line":30,"column":63},"end":{"line":33,"column":null}},"line":30},"1":{"name":"(anonymous_1)","decl":{"start":{"line":38,"column":8},"end":{"line":38,"column":34}},"loc":{"start":{"line":38,"column":34},"end":{"line":42,"column":null}},"line":38},"2":{"name":"(anonymous_2)","decl":{"start":{"line":47,"column":8},"end":{"line":47,"column":16}},"loc":{"start":{"line":47,"column":65},"end":{"line":119,"column":null}},"line":47},"3":{"name":"createSummarizeHook","decl":{"start":{"line":125,"column":16},"end":{"line":125,"column":36}},"loc":{"start":{"line":125,"column":64},"end":{"line":128,"column":null}},"line":125}},"branchMap":{"0":{"loc":{"start":{"line":30,"column":42},"end":{"line":30,"column":63}},"type":"default-arg","locations":[{"start":{"line":30,"column":56},"end":{"line":30,"column":63}}],"line":30},"1":{"loc":{"start":{"line":39,"column":4},"end":{"line":41,"column":null}},"type":"if","locations":[{"start":{"line":39,"column":4},"end":{"line":41,"column":null}},{"start":{},"end":{}}],"line":39},"2":{"loc":{"start":{"line":54,"column":6},"end":{"line":60,"column":null}},"type":"if","locations":[{"start":{"line":54,"column":6},"end":{"line":60,"column":null}},{"start":{},"end":{}}],"line":54},"3":{"loc":{"start":{"line":74,"column":6},"end":{"line":76,"column":null}},"type":"if","locations":[{"start":{"line":74,"column":6},"end":{"line":76,"column":null}},{"start":{},"end":{}}],"line":74},"4":{"loc":{"start":{"line":79,"column":6},"end":{"line":93,"column":null}},"type":"if","locations":[{"start":{"line":79,"column":6},"end":{"line":93,"column":null}},{"start":{},"end":{}}],"line":79},"5":{"loc":{"start":{"line":79,"column":6},"end":{"line":79,"column":59}},"type":"binary-expr","locations":[{"start":{"line":79,"column":6},"end":{"line":79,"column":37}},{"start":{"line":79,"column":37},"end":{"line":79,"column":59}}],"line":79},"6":{"loc":{"start":{"line":116,"column":15},"end":{"line":116,"column":null}},"type":"cond-expr","locations":[{"start":{"line":116,"column":40},"end":{"line":116,"column":56}},{"start":{"line":116,"column":56},"end":{"line":116,"column":null}}],"line":116}},"s":{"0":5,"1":5,"2":4,"3":4,"4":5,"5":5,"6":4,"7":4,"8":1,"9":3,"10":3,"11":3,"12":3,"13":3,"14":3,"15":0,"16":3,"17":0,"18":0,"19":0,"20":0,"21":3,"22":3,"23":1,"24":1,"25":1,"26":1,"27":4,"28":4},"f":{"0":5,"1":4,"2":5,"3":4},"b":{"0":[5],"1":[4,0],"2":[1,3],"3":[0,3],"4":[0,3],"5":[3,0],"6":[1,0]},"meta":{"lastBranch":7,"lastFunction":4,"lastStatement":29,"seen":{"f:30:2:30:14":0,"b:30:56:30:63":0,"s:31:4:31:Infinity":0,"s:32:4:32:Infinity":1,"f:38:8:38:34":1,"b:39:4:41:Infinity:undefined:undefined:undefined:undefined":1,"s:39:4:41:Infinity":2,"s:40:6:40:Infinity":3,"f:47:8:47:16":2,"s:48:4:118:Infinity":4,"s:50:6:50:Infinity":5,"s:53:22:53:Infinity":6,"b:54:6:60:Infinity:undefined:undefined:undefined:undefined":2,"s:54:6:60:Infinity":7,"s:56:8:59:Infinity":8,"s:63:25:63:Infinity":9,"s:66:6:66:Infinity":10,"s:69:26:69:Infinity":11,"s:70:6:70:Infinity":12,"s:73:6:73:Infinity":13,"b:74:6:76:Infinity:undefined:undefined:undefined:undefined":3,"s:74:6:76:Infinity":14,"s:75:8:75:Infinity":15,"b:79:6:93:Infinity:undefined:undefined:undefined:undefined":4,"s:79:6:93:Infinity":16,"b:79:6:79:37:79:37:79:59":5,"s:80:8:92:Infinity":17,"s:81:26:81:Infinity":18,"s:82:16:88:Infinity":19,"s:89:10:89:Infinity":20,"s:96:6:96:Infinity":21,"s:98:6:101:Infinity":22,"s:104:6:104:Infinity":23,"s:107:6:111:Infinity":24,"s:108:8:108:Infinity":25,"s:113:6:117:Infinity":26,"b:116:40:116:56:116:56:116:Infinity":6,"f:125:16:125:36":3,"s:126:18:126:Infinity":27,"s:127:2:127:Infinity":28}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/types.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/types.ts","statementMap":{"0":{"start":{"line":323,"column":20},"end":{"line":323,"column":null}},"1":{"start":{"line":324,"column":17},"end":{"line":324,"column":null}},"2":{"start":{"line":325,"column":2},"end":{"line":325,"column":null}},"3":{"start":{"line":332,"column":16},"end":{"line":332,"column":null}},"4":{"start":{"line":333,"column":2},"end":{"line":333,"column":null}},"5":{"start":{"line":340,"column":20},"end":{"line":340,"column":null}},"6":{"start":{"line":341,"column":21},"end":{"line":341,"column":null}},"7":{"start":{"line":342,"column":23},"end":{"line":342,"column":null}},"8":{"start":{"line":343,"column":22},"end":{"line":343,"column":null}},"9":{"start":{"line":345,"column":2},"end":{"line":345,"column":null}},"10":{"start":{"line":345,"column":36},"end":{"line":345,"column":null}},"11":{"start":{"line":346,"column":2},"end":{"line":346,"column":null}},"12":{"start":{"line":346,"column":37},"end":{"line":346,"column":null}},"13":{"start":{"line":347,"column":2},"end":{"line":347,"column":null}},"14":{"start":{"line":347,"column":39},"end":{"line":347,"column":null}},"15":{"start":{"line":348,"column":2},"end":{"line":348,"column":null}},"16":{"start":{"line":348,"column":38},"end":{"line":348,"column":null}},"17":{"start":{"line":349,"column":2},"end":{"line":349,"column":null}},"18":{"start":{"line":356,"column":30},"end":{"line":356,"column":null}},"19":{"start":{"line":357,"column":34},"end":{"line":357,"column":null}},"20":{"start":{"line":359,"column":2},"end":{"line":373,"column":null}},"21":{"start":{"line":360,"column":18},"end":{"line":360,"column":null}},"22":{"start":{"line":361,"column":21},"end":{"line":361,"column":null}},"23":{"start":{"line":363,"column":4},"end":{"line":363,"column":null}},"24":{"start":{"line":363,"column":19},"end":{"line":363,"column":null}},"25":{"start":{"line":365,"column":17},"end":{"line":365,"column":null}},"26":{"start":{"line":366,"column":4},"end":{"line":370,"column":null}},"27":{"start":{"line":367,"column":6},"end":{"line":367,"column":null}},"28":{"start":{"line":368,"column":4},"end":{"line":370,"column":null}},"29":{"start":{"line":369,"column":6},"end":{"line":369,"column":null}},"30":{"start":{"line":375,"column":2},"end":{"line":375,"column":null}},"31":{"start":{"line":382,"column":2},"end":{"line":411,"column":null}},"32":{"start":{"line":383,"column":18},"end":{"line":383,"column":null}},"33":{"start":{"line":385,"column":4},"end":{"line":408,"column":null}},"34":{"start":{"line":387,"column":8},"end":{"line":387,"column":null}},"35":{"start":{"line":389,"column":8},"end":{"line":389,"column":null}},"36":{"start":{"line":392,"column":8},"end":{"line":392,"column":null}},"37":{"start":{"line":394,"column":20},"end":{"line":394,"column":null}},"38":{"start":{"line":395,"column":8},"end":{"line":395,"column":null}},"39":{"start":{"line":397,"column":8},"end":{"line":397,"column":null}},"40":{"start":{"line":399,"column":8},"end":{"line":399,"column":null}},"41":{"start":{"line":401,"column":8},"end":{"line":401,"column":null}},"42":{"start":{"line":403,"column":8},"end":{"line":403,"column":null}},"43":{"start":{"line":405,"column":8},"end":{"line":405,"column":null}},"44":{"start":{"line":407,"column":8},"end":{"line":407,"column":null}},"45":{"start":{"line":410,"column":4},"end":{"line":410,"column":null}},"46":{"start":{"line":418,"column":2},"end":{"line":456,"column":null}},"47":{"start":{"line":419,"column":18},"end":{"line":419,"column":null}},"48":{"start":{"line":420,"column":21},"end":{"line":420,"column":null}},"49":{"start":{"line":421,"column":21},"end":{"line":421,"column":null}},"50":{"start":{"line":423,"column":4},"end":{"line":453,"column":null}},"51":{"start":{"line":425,"column":8},"end":{"line":425,"column":null}},"52":{"start":{"line":427,"column":8},"end":{"line":427,"column":null}},"53":{"start":{"line":430,"column":8},"end":{"line":430,"column":null}},"54":{"start":{"line":432,"column":14},"end":{"line":432,"column":null}},"55":{"start":{"line":433,"column":47},"end":{"line":438,"column":null}},"56":{"start":{"line":439,"column":8},"end":{"line":439,"column":null}},"57":{"start":{"line":442,"column":8},"end":{"line":442,"column":null}},"58":{"start":{"line":444,"column":8},"end":{"line":444,"column":null}},"59":{"start":{"line":446,"column":8},"end":{"line":446,"column":null}},"60":{"start":{"line":448,"column":8},"end":{"line":448,"column":null}},"61":{"start":{"line":450,"column":8},"end":{"line":450,"column":null}},"62":{"start":{"line":452,"column":8},"end":{"line":452,"column":null}},"63":{"start":{"line":455,"column":4},"end":{"line":455,"column":null}},"64":{"start":{"line":465,"column":2},"end":{"line":504,"column":null}},"65":{"start":{"line":466,"column":18},"end":{"line":466,"column":null}},"66":{"start":{"line":467,"column":21},"end":{"line":467,"column":null}},"67":{"start":{"line":469,"column":4},"end":{"line":501,"column":null}},"68":{"start":{"line":471,"column":8},"end":{"line":471,"column":null}},"69":{"start":{"line":473,"column":8},"end":{"line":473,"column":null}},"70":{"start":{"line":476,"column":23},"end":{"line":476,"column":null}},"71":{"start":{"line":477,"column":8},"end":{"line":477,"column":null}},"72":{"start":{"line":480,"column":20},"end":{"line":480,"column":null}},"73":{"start":{"line":481,"column":8},"end":{"line":482,"column":null}},"74":{"start":{"line":482,"column":10},"end":{"line":482,"column":null}},"75":{"start":{"line":483,"column":8},"end":{"line":484,"column":null}},"76":{"start":{"line":484,"column":10},"end":{"line":484,"column":null}},"77":{"start":{"line":485,"column":8},"end":{"line":486,"column":null}},"78":{"start":{"line":486,"column":10},"end":{"line":486,"column":null}},"79":{"start":{"line":487,"column":8},"end":{"line":487,"column":null}},"80":{"start":{"line":490,"column":8},"end":{"line":490,"column":null}},"81":{"start":{"line":492,"column":8},"end":{"line":492,"column":null}},"82":{"start":{"line":494,"column":8},"end":{"line":494,"column":null}},"83":{"start":{"line":496,"column":8},"end":{"line":496,"column":null}},"84":{"start":{"line":498,"column":8},"end":{"line":498,"column":null}},"85":{"start":{"line":500,"column":8},"end":{"line":500,"column":null}},"86":{"start":{"line":503,"column":4},"end":{"line":503,"column":null}},"87":{"start":{"line":511,"column":26},"end":{"line":511,"column":null}},"88":{"start":{"line":513,"column":2},"end":{"line":562,"column":null}},"89":{"start":{"line":514,"column":18},"end":{"line":514,"column":null}},"90":{"start":{"line":515,"column":21},"end":{"line":515,"column":null}},"91":{"start":{"line":516,"column":21},"end":{"line":516,"column":null}},"92":{"start":{"line":518,"column":4},"end":{"line":559,"column":null}},"93":{"start":{"line":520,"column":8},"end":{"line":520,"column":null}},"94":{"start":{"line":520,"column":22},"end":{"line":520,"column":null}},"95":{"start":{"line":521,"column":8},"end":{"line":521,"column":null}},"96":{"start":{"line":523,"column":8},"end":{"line":523,"column":null}},"97":{"start":{"line":523,"column":22},"end":{"line":523,"column":null}},"98":{"start":{"line":524,"column":8},"end":{"line":524,"column":null}},"99":{"start":{"line":527,"column":8},"end":{"line":527,"column":null}},"100":{"start":{"line":527,"column":22},"end":{"line":527,"column":null}},"101":{"start":{"line":528,"column":8},"end":{"line":528,"column":null}},"102":{"start":{"line":528,"column":31},"end":{"line":528,"column":null}},"103":{"start":{"line":529,"column":8},"end":{"line":529,"column":null}},"104":{"start":{"line":531,"column":20},"end":{"line":531,"column":null}},"105":{"start":{"line":532,"column":8},"end":{"line":532,"column":null}},"106":{"start":{"line":534,"column":23},"end":{"line":534,"column":null}},"107":{"start":{"line":535,"column":8},"end":{"line":539,"column":null}},"108":{"start":{"line":536,"column":10},"end":{"line":536,"column":null}},"109":{"start":{"line":536,"column":65},"end":{"line":536,"column":null}},"110":{"start":{"line":537,"column":10},"end":{"line":537,"column":null}},"111":{"start":{"line":537,"column":65},"end":{"line":537,"column":null}},"112":{"start":{"line":538,"column":10},"end":{"line":538,"column":null}},"113":{"start":{"line":538,"column":68},"end":{"line":538,"column":null}},"114":{"start":{"line":540,"column":8},"end":{"line":540,"column":null}},"115":{"start":{"line":543,"column":8},"end":{"line":543,"column":null}},"116":{"start":{"line":543,"column":28},"end":{"line":543,"column":null}},"117":{"start":{"line":544,"column":8},"end":{"line":544,"column":null}},"118":{"start":{"line":546,"column":8},"end":{"line":546,"column":null}},"119":{"start":{"line":546,"column":28},"end":{"line":546,"column":null}},"120":{"start":{"line":547,"column":8},"end":{"line":547,"column":null}},"121":{"start":{"line":547,"column":25},"end":{"line":547,"column":null}},"122":{"start":{"line":548,"column":8},"end":{"line":548,"column":null}},"123":{"start":{"line":550,"column":8},"end":{"line":550,"column":null}},"124":{"start":{"line":550,"column":26},"end":{"line":550,"column":null}},"125":{"start":{"line":551,"column":8},"end":{"line":551,"column":null}},"126":{"start":{"line":553,"column":8},"end":{"line":553,"column":null}},"127":{"start":{"line":553,"column":24},"end":{"line":553,"column":null}},"128":{"start":{"line":554,"column":8},"end":{"line":554,"column":null}},"129":{"start":{"line":556,"column":8},"end":{"line":556,"column":null}},"130":{"start":{"line":556,"column":32},"end":{"line":556,"column":null}},"131":{"start":{"line":557,"column":8},"end":{"line":557,"column":null}},"132":{"start":{"line":557,"column":34},"end":{"line":557,"column":null}},"133":{"start":{"line":558,"column":8},"end":{"line":558,"column":null}},"134":{"start":{"line":564,"column":2},"end":{"line":564,"column":null}},"135":{"start":{"line":571,"column":32},"end":{"line":571,"column":null}},"136":{"start":{"line":573,"column":2},"end":{"line":631,"column":null}},"137":{"start":{"line":574,"column":18},"end":{"line":574,"column":null}},"138":{"start":{"line":575,"column":22},"end":{"line":575,"column":null}},"139":{"start":{"line":578,"column":4},"end":{"line":604,"column":null}},"140":{"start":{"line":580,"column":20},"end":{"line":580,"column":null}},"141":{"start":{"line":581,"column":6},"end":{"line":603,"column":null}},"142":{"start":{"line":582,"column":8},"end":{"line":582,"column":null}},"143":{"start":{"line":582,"column":78},"end":{"line":582,"column":null}},"144":{"start":{"line":583,"column":8},"end":{"line":593,"column":null}},"145":{"start":{"line":585,"column":22},"end":{"line":585,"column":null}},"146":{"start":{"line":586,"column":49},"end":{"line":591,"column":null}},"147":{"start":{"line":592,"column":10},"end":{"line":592,"column":null}},"148":{"start":{"line":592,"column":34},"end":{"line":592,"column":null}},"149":{"start":{"line":595,"column":47},"end":{"line":601,"column":null}},"150":{"start":{"line":602,"column":8},"end":{"line":602,"column":null}},"151":{"start":{"line":602,"column":26},"end":{"line":602,"column":null}},"152":{"start":{"line":607,"column":4},"end":{"line":628,"column":null}},"153":{"start":{"line":609,"column":21},"end":{"line":609,"column":null}},"154":{"start":{"line":610,"column":8},"end":{"line":610,"column":null}},"155":{"start":{"line":610,"column":84},"end":{"line":610,"column":null}},"156":{"start":{"line":611,"column":8},"end":{"line":611,"column":null}},"157":{"start":{"line":611,"column":58},"end":{"line":611,"column":null}},"158":{"start":{"line":612,"column":8},"end":{"line":612,"column":null}},"159":{"start":{"line":612,"column":33},"end":{"line":612,"column":null}},"160":{"start":{"line":613,"column":8},"end":{"line":613,"column":null}},"161":{"start":{"line":613,"column":81},"end":{"line":613,"column":null}},"162":{"start":{"line":614,"column":8},"end":{"line":614,"column":null}},"163":{"start":{"line":614,"column":36},"end":{"line":614,"column":null}},"164":{"start":{"line":615,"column":8},"end":{"line":615,"column":null}},"165":{"start":{"line":615,"column":60},"end":{"line":615,"column":null}},"166":{"start":{"line":616,"column":8},"end":{"line":616,"column":null}},"167":{"start":{"line":619,"column":8},"end":{"line":619,"column":null}},"168":{"start":{"line":620,"column":8},"end":{"line":620,"column":null}},"169":{"start":{"line":622,"column":8},"end":{"line":622,"column":null}},"170":{"start":{"line":623,"column":8},"end":{"line":623,"column":null}},"171":{"start":{"line":625,"column":8},"end":{"line":625,"column":null}},"172":{"start":{"line":626,"column":8},"end":{"line":626,"column":null}},"173":{"start":{"line":626,"column":34},"end":{"line":626,"column":null}},"174":{"start":{"line":627,"column":8},"end":{"line":627,"column":null}},"175":{"start":{"line":633,"column":2},"end":{"line":633,"column":null}},"176":{"start":{"line":640,"column":2},"end":{"line":640,"column":null}},"177":{"start":{"line":640,"column":31},"end":{"line":640,"column":null}},"178":{"start":{"line":641,"column":2},"end":{"line":641,"column":null}},"179":{"start":{"line":647,"column":57},"end":{"line":650,"column":null}},"180":{"start":{"line":656,"column":2},"end":{"line":663,"column":null}},"181":{"start":{"line":657,"column":4},"end":{"line":662,"column":null}},"182":{"start":{"line":665,"column":2},"end":{"line":665,"column":null}},"183":{"start":{"line":672,"column":2},"end":{"line":698,"column":null}},"184":{"start":{"line":673,"column":37},"end":{"line":673,"column":null}},"185":{"start":{"line":675,"column":16},"end":{"line":675,"column":null}},"186":{"start":{"line":677,"column":4},"end":{"line":688,"column":null}},"187":{"start":{"line":691,"column":16},"end":{"line":691,"column":null}},"188":{"start":{"line":692,"column":4},"end":{"line":697,"column":null}}},"fnMap":{"0":{"name":"generateObservationId","decl":{"start":{"line":322,"column":16},"end":{"line":322,"column":48}},"loc":{"start":{"line":322,"column":48},"end":{"line":326,"column":null}},"line":322},"1":{"name":"getProjectName","decl":{"start":{"line":331,"column":16},"end":{"line":331,"column":31}},"loc":{"start":{"line":331,"column":52},"end":{"line":334,"column":null}},"line":331},"2":{"name":"getObservationType","decl":{"start":{"line":339,"column":16},"end":{"line":339,"column":35}},"loc":{"start":{"line":339,"column":70},"end":{"line":350,"column":null}},"line":339},"3":{"name":"extractFilePaths","decl":{"start":{"line":355,"column":16},"end":{"line":355,"column":33}},"loc":{"start":{"line":355,"column":121},"end":{"line":376,"column":null}},"line":355},"4":{"name":"generateObservationTitle","decl":{"start":{"line":381,"column":16},"end":{"line":381,"column":41}},"loc":{"start":{"line":381,"column":87},"end":{"line":412,"column":null}},"line":381},"5":{"name":"generateObservationSubtitle","decl":{"start":{"line":417,"column":16},"end":{"line":417,"column":44}},"loc":{"start":{"line":417,"column":115},"end":{"line":457,"column":null}},"line":417},"6":{"name":"generateObservationNarrative","decl":{"start":{"line":462,"column":16},"end":{"line":462,"column":null}},"loc":{"start":{"line":464,"column":10},"end":{"line":505,"column":null}},"line":464},"7":{"name":"extractFacts","decl":{"start":{"line":510,"column":16},"end":{"line":510,"column":29}},"loc":{"start":{"line":510,"column":100},"end":{"line":565,"column":null}},"line":510},"8":{"name":"extractConcepts","decl":{"start":{"line":570,"column":16},"end":{"line":570,"column":32}},"loc":{"start":{"line":570,"column":105},"end":{"line":634,"column":null}},"line":570},"9":{"name":"truncate","decl":{"start":{"line":639,"column":16},"end":{"line":639,"column":25}},"loc":{"start":{"line":639,"column":72},"end":{"line":642,"column":null}},"line":639},"10":{"name":"formatResponse","decl":{"start":{"line":655,"column":16},"end":{"line":655,"column":31}},"loc":{"start":{"line":655,"column":59},"end":{"line":666,"column":null}},"line":655},"11":{"name":"parseHookInput","decl":{"start":{"line":671,"column":16},"end":{"line":671,"column":31}},"loc":{"start":{"line":671,"column":67},"end":{"line":699,"column":null}},"line":671}},"branchMap":{"0":{"loc":{"start":{"line":333,"column":9},"end":{"line":333,"column":null}},"type":"binary-expr","locations":[{"start":{"line":333,"column":9},"end":{"line":333,"column":36}},{"start":{"line":333,"column":36},"end":{"line":333,"column":null}}],"line":333},"1":{"loc":{"start":{"line":345,"column":2},"end":{"line":345,"column":null}},"type":"if","locations":[{"start":{"line":345,"column":2},"end":{"line":345,"column":null}},{"start":{},"end":{}}],"line":345},"2":{"loc":{"start":{"line":346,"column":2},"end":{"line":346,"column":null}},"type":"if","locations":[{"start":{"line":346,"column":2},"end":{"line":346,"column":null}},{"start":{},"end":{}}],"line":346},"3":{"loc":{"start":{"line":347,"column":2},"end":{"line":347,"column":null}},"type":"if","locations":[{"start":{"line":347,"column":2},"end":{"line":347,"column":null}},{"start":{},"end":{}}],"line":347},"4":{"loc":{"start":{"line":348,"column":2},"end":{"line":348,"column":null}},"type":"if","locations":[{"start":{"line":348,"column":2},"end":{"line":348,"column":null}},{"start":{},"end":{}}],"line":348},"5":{"loc":{"start":{"line":360,"column":18},"end":{"line":360,"column":null}},"type":"cond-expr","locations":[{"start":{"line":360,"column":50},"end":{"line":360,"column":74}},{"start":{"line":360,"column":74},"end":{"line":360,"column":null}}],"line":360},"6":{"loc":{"start":{"line":361,"column":21},"end":{"line":361,"column":null}},"type":"binary-expr","locations":[{"start":{"line":361,"column":21},"end":{"line":361,"column":41}},{"start":{"line":361,"column":41},"end":{"line":361,"column":56}},{"start":{"line":361,"column":56},"end":{"line":361,"column":null}}],"line":361},"7":{"loc":{"start":{"line":363,"column":4},"end":{"line":363,"column":null}},"type":"if","locations":[{"start":{"line":363,"column":4},"end":{"line":363,"column":null}},{"start":{},"end":{}}],"line":363},"8":{"loc":{"start":{"line":366,"column":4},"end":{"line":370,"column":null}},"type":"if","locations":[{"start":{"line":366,"column":4},"end":{"line":370,"column":null}},{"start":{"line":368,"column":4},"end":{"line":370,"column":null}}],"line":366},"9":{"loc":{"start":{"line":368,"column":4},"end":{"line":370,"column":null}},"type":"if","locations":[{"start":{"line":368,"column":4},"end":{"line":370,"column":null}},{"start":{},"end":{}}],"line":368},"10":{"loc":{"start":{"line":383,"column":18},"end":{"line":383,"column":null}},"type":"cond-expr","locations":[{"start":{"line":383,"column":50},"end":{"line":383,"column":74}},{"start":{"line":383,"column":74},"end":{"line":383,"column":null}}],"line":383},"11":{"loc":{"start":{"line":385,"column":4},"end":{"line":408,"column":null}},"type":"switch","locations":[{"start":{"line":386,"column":6},"end":{"line":387,"column":null}},{"start":{"line":388,"column":6},"end":{"line":389,"column":null}},{"start":{"line":390,"column":6},"end":{"line":390,"column":null}},{"start":{"line":391,"column":6},"end":{"line":392,"column":null}},{"start":{"line":393,"column":6},"end":{"line":395,"column":null}},{"start":{"line":396,"column":6},"end":{"line":397,"column":null}},{"start":{"line":398,"column":6},"end":{"line":399,"column":null}},{"start":{"line":400,"column":6},"end":{"line":401,"column":null}},{"start":{"line":402,"column":6},"end":{"line":403,"column":null}},{"start":{"line":404,"column":6},"end":{"line":405,"column":null}},{"start":{"line":406,"column":6},"end":{"line":407,"column":null}}],"line":385},"12":{"loc":{"start":{"line":387,"column":23},"end":{"line":387,"column":64}},"type":"binary-expr","locations":[{"start":{"line":387,"column":23},"end":{"line":387,"column":43}},{"start":{"line":387,"column":43},"end":{"line":387,"column":58}},{"start":{"line":387,"column":58},"end":{"line":387,"column":64}}],"line":387},"13":{"loc":{"start":{"line":389,"column":24},"end":{"line":389,"column":65}},"type":"binary-expr","locations":[{"start":{"line":389,"column":24},"end":{"line":389,"column":44}},{"start":{"line":389,"column":44},"end":{"line":389,"column":59}},{"start":{"line":389,"column":59},"end":{"line":389,"column":65}}],"line":389},"14":{"loc":{"start":{"line":392,"column":23},"end":{"line":392,"column":64}},"type":"binary-expr","locations":[{"start":{"line":392,"column":23},"end":{"line":392,"column":43}},{"start":{"line":392,"column":43},"end":{"line":392,"column":58}},{"start":{"line":392,"column":58},"end":{"line":392,"column":64}}],"line":392},"15":{"loc":{"start":{"line":394,"column":20},"end":{"line":394,"column":null}},"type":"binary-expr","locations":[{"start":{"line":394,"column":20},"end":{"line":394,"column":38}},{"start":{"line":394,"column":38},"end":{"line":394,"column":null}}],"line":394},"16":{"loc":{"start":{"line":395,"column":46},"end":{"line":395,"column":74}},"type":"cond-expr","locations":[{"start":{"line":395,"column":64},"end":{"line":395,"column":72}},{"start":{"line":395,"column":72},"end":{"line":395,"column":74}}],"line":395},"17":{"loc":{"start":{"line":397,"column":23},"end":{"line":397,"column":48}},"type":"binary-expr","locations":[{"start":{"line":397,"column":23},"end":{"line":397,"column":41}},{"start":{"line":397,"column":41},"end":{"line":397,"column":48}}],"line":397},"18":{"loc":{"start":{"line":399,"column":26},"end":{"line":399,"column":46}},"type":"binary-expr","locations":[{"start":{"line":399,"column":26},"end":{"line":399,"column":44}},{"start":{"line":399,"column":44},"end":{"line":399,"column":46}}],"line":399},"19":{"loc":{"start":{"line":401,"column":24},"end":{"line":401,"column":53}},"type":"binary-expr","locations":[{"start":{"line":401,"column":24},"end":{"line":401,"column":46}},{"start":{"line":401,"column":46},"end":{"line":401,"column":53}}],"line":401},"20":{"loc":{"start":{"line":403,"column":26},"end":{"line":403,"column":44}},"type":"binary-expr","locations":[{"start":{"line":403,"column":26},"end":{"line":403,"column":42}},{"start":{"line":403,"column":42},"end":{"line":403,"column":44}}],"line":403},"21":{"loc":{"start":{"line":405,"column":25},"end":{"line":405,"column":41}},"type":"binary-expr","locations":[{"start":{"line":405,"column":25},"end":{"line":405,"column":39}},{"start":{"line":405,"column":39},"end":{"line":405,"column":41}}],"line":405},"22":{"loc":{"start":{"line":419,"column":18},"end":{"line":419,"column":null}},"type":"cond-expr","locations":[{"start":{"line":419,"column":50},"end":{"line":419,"column":74}},{"start":{"line":419,"column":74},"end":{"line":419,"column":null}}],"line":419},"23":{"loc":{"start":{"line":420,"column":21},"end":{"line":420,"column":null}},"type":"binary-expr","locations":[{"start":{"line":420,"column":21},"end":{"line":420,"column":41}},{"start":{"line":420,"column":41},"end":{"line":420,"column":56}},{"start":{"line":420,"column":56},"end":{"line":420,"column":null}}],"line":420},"24":{"loc":{"start":{"line":421,"column":21},"end":{"line":421,"column":null}},"type":"cond-expr","locations":[{"start":{"line":421,"column":32},"end":{"line":421,"column":64}},{"start":{"line":421,"column":64},"end":{"line":421,"column":null}}],"line":421},"25":{"loc":{"start":{"line":423,"column":4},"end":{"line":453,"column":null}},"type":"switch","locations":[{"start":{"line":424,"column":6},"end":{"line":425,"column":null}},{"start":{"line":426,"column":6},"end":{"line":427,"column":null}},{"start":{"line":428,"column":6},"end":{"line":428,"column":null}},{"start":{"line":429,"column":6},"end":{"line":430,"column":null}},{"start":{"line":431,"column":6},"end":{"line":440,"column":null}},{"start":{"line":441,"column":6},"end":{"line":442,"column":null}},{"start":{"line":443,"column":6},"end":{"line":444,"column":null}},{"start":{"line":445,"column":6},"end":{"line":446,"column":null}},{"start":{"line":447,"column":6},"end":{"line":448,"column":null}},{"start":{"line":449,"column":6},"end":{"line":450,"column":null}},{"start":{"line":451,"column":6},"end":{"line":452,"column":null}}],"line":423},"26":{"loc":{"start":{"line":425,"column":15},"end":{"line":425,"column":null}},"type":"cond-expr","locations":[{"start":{"line":425,"column":26},"end":{"line":425,"column":52}},{"start":{"line":425,"column":52},"end":{"line":425,"column":null}}],"line":425},"27":{"loc":{"start":{"line":427,"column":15},"end":{"line":427,"column":null}},"type":"cond-expr","locations":[{"start":{"line":427,"column":26},"end":{"line":427,"column":60}},{"start":{"line":427,"column":60},"end":{"line":427,"column":null}}],"line":427},"28":{"loc":{"start":{"line":430,"column":15},"end":{"line":430,"column":null}},"type":"cond-expr","locations":[{"start":{"line":430,"column":26},"end":{"line":430,"column":52}},{"start":{"line":430,"column":52},"end":{"line":430,"column":null}}],"line":430},"29":{"loc":{"start":{"line":432,"column":21},"end":{"line":432,"column":43}},"type":"binary-expr","locations":[{"start":{"line":432,"column":21},"end":{"line":432,"column":39}},{"start":{"line":432,"column":39},"end":{"line":432,"column":43}}],"line":432},"30":{"loc":{"start":{"line":439,"column":15},"end":{"line":439,"column":null}},"type":"binary-expr","locations":[{"start":{"line":439,"column":15},"end":{"line":439,"column":30}},{"start":{"line":439,"column":30},"end":{"line":439,"column":null}}],"line":439},"31":{"loc":{"start":{"line":439,"column":43},"end":{"line":439,"column":59}},"type":"binary-expr","locations":[{"start":{"line":439,"column":43},"end":{"line":439,"column":50}},{"start":{"line":439,"column":50},"end":{"line":439,"column":59}}],"line":439},"32":{"loc":{"start":{"line":442,"column":32},"end":{"line":442,"column":57}},"type":"binary-expr","locations":[{"start":{"line":442,"column":32},"end":{"line":442,"column":50}},{"start":{"line":442,"column":50},"end":{"line":442,"column":57}}],"line":442},"33":{"loc":{"start":{"line":444,"column":38},"end":{"line":444,"column":65}},"type":"binary-expr","locations":[{"start":{"line":444,"column":38},"end":{"line":444,"column":56}},{"start":{"line":444,"column":56},"end":{"line":444,"column":65}}],"line":444},"34":{"loc":{"start":{"line":446,"column":32},"end":{"line":446,"column":67}},"type":"binary-expr","locations":[{"start":{"line":446,"column":32},"end":{"line":446,"column":56}},{"start":{"line":446,"column":56},"end":{"line":446,"column":67}}],"line":446},"35":{"loc":{"start":{"line":448,"column":32},"end":{"line":448,"column":52}},"type":"binary-expr","locations":[{"start":{"line":448,"column":32},"end":{"line":448,"column":48}},{"start":{"line":448,"column":48},"end":{"line":448,"column":52}}],"line":448},"36":{"loc":{"start":{"line":466,"column":18},"end":{"line":466,"column":null}},"type":"cond-expr","locations":[{"start":{"line":466,"column":50},"end":{"line":466,"column":74}},{"start":{"line":466,"column":74},"end":{"line":466,"column":null}}],"line":466},"37":{"loc":{"start":{"line":467,"column":21},"end":{"line":467,"column":null}},"type":"binary-expr","locations":[{"start":{"line":467,"column":21},"end":{"line":467,"column":41}},{"start":{"line":467,"column":41},"end":{"line":467,"column":56}},{"start":{"line":467,"column":56},"end":{"line":467,"column":null}}],"line":467},"38":{"loc":{"start":{"line":469,"column":4},"end":{"line":501,"column":null}},"type":"switch","locations":[{"start":{"line":470,"column":6},"end":{"line":471,"column":null}},{"start":{"line":472,"column":6},"end":{"line":473,"column":null}},{"start":{"line":474,"column":6},"end":{"line":474,"column":null}},{"start":{"line":475,"column":6},"end":{"line":478,"column":null}},{"start":{"line":479,"column":6},"end":{"line":488,"column":null}},{"start":{"line":489,"column":6},"end":{"line":490,"column":null}},{"start":{"line":491,"column":6},"end":{"line":492,"column":null}},{"start":{"line":493,"column":6},"end":{"line":494,"column":null}},{"start":{"line":495,"column":6},"end":{"line":496,"column":null}},{"start":{"line":497,"column":6},"end":{"line":498,"column":null}},{"start":{"line":499,"column":6},"end":{"line":500,"column":null}}],"line":469},"39":{"loc":{"start":{"line":471,"column":39},"end":{"line":471,"column":59}},"type":"binary-expr","locations":[{"start":{"line":471,"column":39},"end":{"line":471,"column":51}},{"start":{"line":471,"column":51},"end":{"line":471,"column":59}}],"line":471},"40":{"loc":{"start":{"line":473,"column":24},"end":{"line":473,"column":44}},"type":"binary-expr","locations":[{"start":{"line":473,"column":24},"end":{"line":473,"column":36}},{"start":{"line":473,"column":36},"end":{"line":473,"column":44}}],"line":473},"41":{"loc":{"start":{"line":476,"column":23},"end":{"line":476,"column":null}},"type":"cond-expr","locations":[{"start":{"line":476,"column":43},"end":{"line":476,"column":89}},{"start":{"line":476,"column":89},"end":{"line":476,"column":null}}],"line":476},"42":{"loc":{"start":{"line":477,"column":25},"end":{"line":477,"column":45}},"type":"binary-expr","locations":[{"start":{"line":477,"column":25},"end":{"line":477,"column":37}},{"start":{"line":477,"column":37},"end":{"line":477,"column":45}}],"line":477},"43":{"loc":{"start":{"line":480,"column":20},"end":{"line":480,"column":null}},"type":"binary-expr","locations":[{"start":{"line":480,"column":20},"end":{"line":480,"column":38}},{"start":{"line":480,"column":38},"end":{"line":480,"column":null}}],"line":480},"44":{"loc":{"start":{"line":481,"column":8},"end":{"line":482,"column":null}},"type":"if","locations":[{"start":{"line":481,"column":8},"end":{"line":482,"column":null}},{"start":{},"end":{}}],"line":481},"45":{"loc":{"start":{"line":481,"column":12},"end":{"line":481,"column":null}},"type":"binary-expr","locations":[{"start":{"line":481,"column":12},"end":{"line":481,"column":42}},{"start":{"line":481,"column":42},"end":{"line":481,"column":null}}],"line":481},"46":{"loc":{"start":{"line":483,"column":8},"end":{"line":484,"column":null}},"type":"if","locations":[{"start":{"line":483,"column":8},"end":{"line":484,"column":null}},{"start":{},"end":{}}],"line":483},"47":{"loc":{"start":{"line":483,"column":12},"end":{"line":483,"column":null}},"type":"binary-expr","locations":[{"start":{"line":483,"column":12},"end":{"line":483,"column":47}},{"start":{"line":483,"column":47},"end":{"line":483,"column":null}}],"line":483},"48":{"loc":{"start":{"line":485,"column":8},"end":{"line":486,"column":null}},"type":"if","locations":[{"start":{"line":485,"column":8},"end":{"line":486,"column":null}},{"start":{},"end":{}}],"line":485},"49":{"loc":{"start":{"line":490,"column":70},"end":{"line":490,"column":90}},"type":"binary-expr","locations":[{"start":{"line":490,"column":70},"end":{"line":490,"column":88}},{"start":{"line":490,"column":88},"end":{"line":490,"column":90}}],"line":490},"50":{"loc":{"start":{"line":492,"column":45},"end":{"line":492,"column":65}},"type":"binary-expr","locations":[{"start":{"line":492,"column":45},"end":{"line":492,"column":63}},{"start":{"line":492,"column":63},"end":{"line":492,"column":65}}],"line":492},"51":{"loc":{"start":{"line":492,"column":69},"end":{"line":492,"column":107}},"type":"cond-expr","locations":[{"start":{"line":492,"column":83},"end":{"line":492,"column":105}},{"start":{"line":492,"column":105},"end":{"line":492,"column":107}}],"line":492},"52":{"loc":{"start":{"line":494,"column":38},"end":{"line":494,"column":67}},"type":"binary-expr","locations":[{"start":{"line":494,"column":38},"end":{"line":494,"column":62}},{"start":{"line":494,"column":62},"end":{"line":494,"column":67}}],"line":494},"53":{"loc":{"start":{"line":494,"column":78},"end":{"line":494,"column":106}},"type":"binary-expr","locations":[{"start":{"line":494,"column":78},"end":{"line":494,"column":100}},{"start":{"line":494,"column":100},"end":{"line":494,"column":106}}],"line":494},"54":{"loc":{"start":{"line":496,"column":40},"end":{"line":496,"column":69}},"type":"binary-expr","locations":[{"start":{"line":496,"column":40},"end":{"line":496,"column":56}},{"start":{"line":496,"column":56},"end":{"line":496,"column":69}}],"line":496},"55":{"loc":{"start":{"line":498,"column":39},"end":{"line":498,"column":60}},"type":"binary-expr","locations":[{"start":{"line":498,"column":39},"end":{"line":498,"column":53}},{"start":{"line":498,"column":53},"end":{"line":498,"column":60}}],"line":498},"56":{"loc":{"start":{"line":514,"column":18},"end":{"line":514,"column":null}},"type":"cond-expr","locations":[{"start":{"line":514,"column":50},"end":{"line":514,"column":74}},{"start":{"line":514,"column":74},"end":{"line":514,"column":null}}],"line":514},"57":{"loc":{"start":{"line":515,"column":21},"end":{"line":515,"column":null}},"type":"cond-expr","locations":[{"start":{"line":515,"column":56},"end":{"line":515,"column":83}},{"start":{"line":515,"column":83},"end":{"line":515,"column":null}}],"line":515},"58":{"loc":{"start":{"line":516,"column":21},"end":{"line":516,"column":null}},"type":"binary-expr","locations":[{"start":{"line":516,"column":21},"end":{"line":516,"column":41}},{"start":{"line":516,"column":41},"end":{"line":516,"column":56}},{"start":{"line":516,"column":56},"end":{"line":516,"column":null}}],"line":516},"59":{"loc":{"start":{"line":518,"column":4},"end":{"line":559,"column":null}},"type":"switch","locations":[{"start":{"line":519,"column":6},"end":{"line":521,"column":null}},{"start":{"line":522,"column":6},"end":{"line":524,"column":null}},{"start":{"line":525,"column":6},"end":{"line":525,"column":null}},{"start":{"line":526,"column":6},"end":{"line":529,"column":null}},{"start":{"line":530,"column":6},"end":{"line":541,"column":null}},{"start":{"line":542,"column":6},"end":{"line":544,"column":null}},{"start":{"line":545,"column":6},"end":{"line":548,"column":null}},{"start":{"line":549,"column":6},"end":{"line":551,"column":null}},{"start":{"line":552,"column":6},"end":{"line":554,"column":null}},{"start":{"line":555,"column":6},"end":{"line":558,"column":null}}],"line":518},"60":{"loc":{"start":{"line":520,"column":8},"end":{"line":520,"column":null}},"type":"if","locations":[{"start":{"line":520,"column":8},"end":{"line":520,"column":null}},{"start":{},"end":{}}],"line":520},"61":{"loc":{"start":{"line":523,"column":8},"end":{"line":523,"column":null}},"type":"if","locations":[{"start":{"line":523,"column":8},"end":{"line":523,"column":null}},{"start":{},"end":{}}],"line":523},"62":{"loc":{"start":{"line":527,"column":8},"end":{"line":527,"column":null}},"type":"if","locations":[{"start":{"line":527,"column":8},"end":{"line":527,"column":null}},{"start":{},"end":{}}],"line":527},"63":{"loc":{"start":{"line":528,"column":8},"end":{"line":528,"column":null}},"type":"if","locations":[{"start":{"line":528,"column":8},"end":{"line":528,"column":null}},{"start":{},"end":{}}],"line":528},"64":{"loc":{"start":{"line":528,"column":62},"end":{"line":528,"column":101}},"type":"binary-expr","locations":[{"start":{"line":528,"column":62},"end":{"line":528,"column":95}},{"start":{"line":528,"column":95},"end":{"line":528,"column":101}}],"line":528},"65":{"loc":{"start":{"line":531,"column":20},"end":{"line":531,"column":null}},"type":"binary-expr","locations":[{"start":{"line":531,"column":20},"end":{"line":531,"column":38}},{"start":{"line":531,"column":38},"end":{"line":531,"column":null}}],"line":531},"66":{"loc":{"start":{"line":534,"column":23},"end":{"line":534,"column":null}},"type":"binary-expr","locations":[{"start":{"line":534,"column":23},"end":{"line":534,"column":43}},{"start":{"line":534,"column":43},"end":{"line":534,"column":63}},{"start":{"line":534,"column":63},"end":{"line":534,"column":null}}],"line":534},"67":{"loc":{"start":{"line":535,"column":8},"end":{"line":539,"column":null}},"type":"if","locations":[{"start":{"line":535,"column":8},"end":{"line":539,"column":null}},{"start":{},"end":{}}],"line":535},"68":{"loc":{"start":{"line":536,"column":10},"end":{"line":536,"column":null}},"type":"if","locations":[{"start":{"line":536,"column":10},"end":{"line":536,"column":null}},{"start":{},"end":{}}],"line":536},"69":{"loc":{"start":{"line":536,"column":14},"end":{"line":536,"column":65}},"type":"binary-expr","locations":[{"start":{"line":536,"column":14},"end":{"line":536,"column":43}},{"start":{"line":536,"column":43},"end":{"line":536,"column":65}}],"line":536},"70":{"loc":{"start":{"line":537,"column":10},"end":{"line":537,"column":null}},"type":"if","locations":[{"start":{"line":537,"column":10},"end":{"line":537,"column":null}},{"start":{},"end":{}}],"line":537},"71":{"loc":{"start":{"line":537,"column":14},"end":{"line":537,"column":65}},"type":"binary-expr","locations":[{"start":{"line":537,"column":14},"end":{"line":537,"column":43}},{"start":{"line":537,"column":43},"end":{"line":537,"column":65}}],"line":537},"72":{"loc":{"start":{"line":538,"column":10},"end":{"line":538,"column":null}},"type":"if","locations":[{"start":{"line":538,"column":10},"end":{"line":538,"column":null}},{"start":{},"end":{}}],"line":538},"73":{"loc":{"start":{"line":538,"column":14},"end":{"line":538,"column":68}},"type":"binary-expr","locations":[{"start":{"line":538,"column":14},"end":{"line":538,"column":42}},{"start":{"line":538,"column":42},"end":{"line":538,"column":68}}],"line":538},"74":{"loc":{"start":{"line":543,"column":8},"end":{"line":543,"column":null}},"type":"if","locations":[{"start":{"line":543,"column":8},"end":{"line":543,"column":null}},{"start":{},"end":{}}],"line":543},"75":{"loc":{"start":{"line":546,"column":8},"end":{"line":546,"column":null}},"type":"if","locations":[{"start":{"line":546,"column":8},"end":{"line":546,"column":null}},{"start":{},"end":{}}],"line":546},"76":{"loc":{"start":{"line":547,"column":8},"end":{"line":547,"column":null}},"type":"if","locations":[{"start":{"line":547,"column":8},"end":{"line":547,"column":null}},{"start":{},"end":{}}],"line":547},"77":{"loc":{"start":{"line":550,"column":8},"end":{"line":550,"column":null}},"type":"if","locations":[{"start":{"line":550,"column":8},"end":{"line":550,"column":null}},{"start":{},"end":{}}],"line":550},"78":{"loc":{"start":{"line":553,"column":8},"end":{"line":553,"column":null}},"type":"if","locations":[{"start":{"line":553,"column":8},"end":{"line":553,"column":null}},{"start":{},"end":{}}],"line":553},"79":{"loc":{"start":{"line":556,"column":8},"end":{"line":556,"column":null}},"type":"if","locations":[{"start":{"line":556,"column":8},"end":{"line":556,"column":null}},{"start":{},"end":{}}],"line":556},"80":{"loc":{"start":{"line":557,"column":8},"end":{"line":557,"column":null}},"type":"if","locations":[{"start":{"line":557,"column":8},"end":{"line":557,"column":null}},{"start":{},"end":{}}],"line":557},"81":{"loc":{"start":{"line":574,"column":18},"end":{"line":574,"column":null}},"type":"cond-expr","locations":[{"start":{"line":574,"column":50},"end":{"line":574,"column":74}},{"start":{"line":574,"column":74},"end":{"line":574,"column":null}}],"line":574},"82":{"loc":{"start":{"line":575,"column":22},"end":{"line":575,"column":null}},"type":"binary-expr","locations":[{"start":{"line":575,"column":22},"end":{"line":575,"column":42}},{"start":{"line":575,"column":42},"end":{"line":575,"column":57}},{"start":{"line":575,"column":57},"end":{"line":575,"column":null}}],"line":575},"83":{"loc":{"start":{"line":578,"column":4},"end":{"line":604,"column":null}},"type":"if","locations":[{"start":{"line":578,"column":4},"end":{"line":604,"column":null}},{"start":{},"end":{}}],"line":578},"84":{"loc":{"start":{"line":582,"column":8},"end":{"line":582,"column":null}},"type":"if","locations":[{"start":{"line":582,"column":8},"end":{"line":582,"column":null}},{"start":{},"end":{}}],"line":582},"85":{"loc":{"start":{"line":583,"column":8},"end":{"line":593,"column":null}},"type":"if","locations":[{"start":{"line":583,"column":8},"end":{"line":593,"column":null}},{"start":{},"end":{}}],"line":583},"86":{"loc":{"start":{"line":592,"column":10},"end":{"line":592,"column":null}},"type":"if","locations":[{"start":{"line":592,"column":10},"end":{"line":592,"column":null}},{"start":{},"end":{}}],"line":592},"87":{"loc":{"start":{"line":592,"column":14},"end":{"line":592,"column":34}},"type":"binary-expr","locations":[{"start":{"line":592,"column":14},"end":{"line":592,"column":21}},{"start":{"line":592,"column":21},"end":{"line":592,"column":34}}],"line":592},"88":{"loc":{"start":{"line":602,"column":8},"end":{"line":602,"column":null}},"type":"if","locations":[{"start":{"line":602,"column":8},"end":{"line":602,"column":null}},{"start":{},"end":{}}],"line":602},"89":{"loc":{"start":{"line":607,"column":4},"end":{"line":628,"column":null}},"type":"switch","locations":[{"start":{"line":608,"column":6},"end":{"line":617,"column":null}},{"start":{"line":618,"column":6},"end":{"line":620,"column":null}},{"start":{"line":621,"column":6},"end":{"line":623,"column":null}},{"start":{"line":624,"column":6},"end":{"line":627,"column":null}}],"line":607},"90":{"loc":{"start":{"line":609,"column":21},"end":{"line":609,"column":null}},"type":"binary-expr","locations":[{"start":{"line":609,"column":21},"end":{"line":609,"column":39}},{"start":{"line":609,"column":39},"end":{"line":609,"column":null}}],"line":609},"91":{"loc":{"start":{"line":610,"column":8},"end":{"line":610,"column":null}},"type":"if","locations":[{"start":{"line":610,"column":8},"end":{"line":610,"column":null}},{"start":{},"end":{}}],"line":610},"92":{"loc":{"start":{"line":610,"column":12},"end":{"line":610,"column":84}},"type":"binary-expr","locations":[{"start":{"line":610,"column":12},"end":{"line":610,"column":36}},{"start":{"line":610,"column":36},"end":{"line":610,"column":62}},{"start":{"line":610,"column":62},"end":{"line":610,"column":84}}],"line":610},"93":{"loc":{"start":{"line":611,"column":8},"end":{"line":611,"column":null}},"type":"if","locations":[{"start":{"line":611,"column":8},"end":{"line":611,"column":null}},{"start":{},"end":{}}],"line":611},"94":{"loc":{"start":{"line":611,"column":12},"end":{"line":611,"column":58}},"type":"binary-expr","locations":[{"start":{"line":611,"column":12},"end":{"line":611,"column":37}},{"start":{"line":611,"column":37},"end":{"line":611,"column":58}}],"line":611},"95":{"loc":{"start":{"line":612,"column":8},"end":{"line":612,"column":null}},"type":"if","locations":[{"start":{"line":612,"column":8},"end":{"line":612,"column":null}},{"start":{},"end":{}}],"line":612},"96":{"loc":{"start":{"line":613,"column":8},"end":{"line":613,"column":null}},"type":"if","locations":[{"start":{"line":613,"column":8},"end":{"line":613,"column":null}},{"start":{},"end":{}}],"line":613},"97":{"loc":{"start":{"line":613,"column":12},"end":{"line":613,"column":81}},"type":"binary-expr","locations":[{"start":{"line":613,"column":12},"end":{"line":613,"column":35}},{"start":{"line":613,"column":35},"end":{"line":613,"column":59}},{"start":{"line":613,"column":59},"end":{"line":613,"column":81}}],"line":613},"98":{"loc":{"start":{"line":614,"column":8},"end":{"line":614,"column":null}},"type":"if","locations":[{"start":{"line":614,"column":8},"end":{"line":614,"column":null}},{"start":{},"end":{}}],"line":614},"99":{"loc":{"start":{"line":615,"column":8},"end":{"line":615,"column":null}},"type":"if","locations":[{"start":{"line":615,"column":8},"end":{"line":615,"column":null}},{"start":{},"end":{}}],"line":615},"100":{"loc":{"start":{"line":615,"column":12},"end":{"line":615,"column":60}},"type":"binary-expr","locations":[{"start":{"line":615,"column":12},"end":{"line":615,"column":36}},{"start":{"line":615,"column":36},"end":{"line":615,"column":60}}],"line":615},"101":{"loc":{"start":{"line":626,"column":8},"end":{"line":626,"column":null}},"type":"if","locations":[{"start":{"line":626,"column":8},"end":{"line":626,"column":null}},{"start":{},"end":{}}],"line":626},"102":{"loc":{"start":{"line":639,"column":38},"end":{"line":639,"column":72}},"type":"default-arg","locations":[{"start":{"line":639,"column":58},"end":{"line":639,"column":72}}],"line":639},"103":{"loc":{"start":{"line":640,"column":2},"end":{"line":640,"column":null}},"type":"if","locations":[{"start":{"line":640,"column":2},"end":{"line":640,"column":null}},{"start":{},"end":{}}],"line":640},"104":{"loc":{"start":{"line":656,"column":2},"end":{"line":663,"column":null}},"type":"if","locations":[{"start":{"line":656,"column":2},"end":{"line":663,"column":null}},{"start":{},"end":{}}],"line":656},"105":{"loc":{"start":{"line":675,"column":16},"end":{"line":675,"column":null}},"type":"binary-expr","locations":[{"start":{"line":675,"column":16},"end":{"line":675,"column":27}},{"start":{"line":675,"column":27},"end":{"line":675,"column":null}}],"line":675},"106":{"loc":{"start":{"line":678,"column":17},"end":{"line":678,"column":null}},"type":"binary-expr","locations":[{"start":{"line":678,"column":17},"end":{"line":678,"column":35}},{"start":{"line":678,"column":35},"end":{"line":678,"column":null}}],"line":678}},"s":{"0":136,"1":136,"2":136,"3":12,"4":12,"5":249,"6":249,"7":249,"8":249,"9":249,"10":123,"11":126,"12":98,"13":28,"14":18,"15":10,"16":6,"17":4,"18":139,"19":139,"20":139,"21":139,"22":139,"23":139,"24":36,"25":103,"26":103,"27":47,"28":56,"29":56,"30":103,"31":154,"32":154,"33":154,"34":65,"35":49,"36":3,"37":18,"38":18,"39":2,"40":3,"41":2,"42":5,"43":3,"44":3,"45":1,"46":143,"47":143,"48":143,"49":143,"50":143,"51":63,"52":49,"53":1,"54":18,"55":18,"56":18,"57":1,"58":2,"59":1,"60":4,"61":1,"62":3,"63":0,"64":138,"65":138,"66":138,"67":138,"68":63,"69":49,"70":0,"71":0,"72":17,"73":17,"74":6,"75":11,"76":2,"77":9,"78":9,"79":9,"80":0,"81":2,"82":0,"83":3,"84":1,"85":3,"86":0,"87":139,"88":139,"89":139,"90":139,"91":139,"92":139,"93":64,"94":53,"95":64,"96":49,"97":46,"98":49,"99":1,"100":1,"101":1,"102":1,"103":1,"104":17,"105":17,"106":17,"107":17,"108":17,"109":2,"110":17,"111":0,"112":17,"113":1,"114":17,"115":0,"116":0,"117":0,"118":1,"119":1,"120":1,"121":1,"122":1,"123":4,"124":3,"125":4,"126":1,"127":1,"128":1,"129":0,"130":0,"131":0,"132":0,"133":0,"134":139,"135":140,"136":140,"137":140,"138":140,"139":140,"140":100,"141":100,"142":132,"143":9,"144":123,"145":99,"146":99,"147":99,"148":99,"149":123,"150":123,"151":7,"152":140,"153":18,"154":18,"155":7,"156":18,"157":1,"158":18,"159":1,"160":18,"161":8,"162":18,"163":0,"164":18,"165":0,"166":18,"167":4,"168":4,"169":1,"170":1,"171":1,"172":1,"173":1,"174":1,"175":140,"176":153,"177":149,"178":4,"179":5,"180":2,"181":1,"182":1,"183":7,"184":7,"185":7,"186":7,"187":2,"188":2},"f":{"0":136,"1":12,"2":249,"3":139,"4":154,"5":143,"6":138,"7":139,"8":140,"9":153,"10":2,"11":7},"b":{"0":[12,2],"1":[123,126],"2":[98,28],"3":[18,10],"4":[6,4],"5":[1,138],"6":[139,38,36],"7":[36,103],"8":[47,56],"9":[56,0],"10":[2,152],"11":[65,49,3,3,18,2,3,2,5,3,3],"12":[65,11,11],"13":[49,3,3],"14":[3,2,1],"15":[18,2],"16":[1,17],"17":[2,1],"18":[3,1],"19":[2,1],"20":[5,2],"21":[3,1],"22":[0,143],"23":[143,43,42],"24":[101,42],"25":[63,49,1,1,18,1,2,1,4,1,3],"26":[53,10],"27":[46,3],"28":[1,0],"29":[18,1],"30":[18,9],"31":[9,1],"32":[1,0],"33":[2,0],"34":[1,0],"35":[4,1],"36":[0,138],"37":[138,39,37],"38":[63,49,0,0,17,0,2,0,3,1,3],"39":[63,10],"40":[49,3],"41":[0,0],"42":[0,0],"43":[17,1],"44":[6,11],"45":[17,11],"46":[2,9],"47":[11,10],"48":[0,9],"49":[0,0],"50":[2,0],"51":[2,0],"52":[0,0],"53":[0,0],"54":[3,1],"55":[1,0],"56":[0,139],"57":[0,139],"58":[139,39,38],"59":[64,49,1,1,17,0,1,4,1,0],"60":[53,11],"61":[46,3],"62":[1,0],"63":[1,0],"64":[1,0],"65":[17,1],"66":[17,14,14],"67":[17,0],"68":[2,15],"69":[17,15],"70":[0,17],"71":[17,17],"72":[1,16],"73":[17,17],"74":[0,0],"75":[1,0],"76":[1,0],"77":[3,1],"78":[1,0],"79":[0,0],"80":[0,0],"81":[0,140],"82":[140,41,40],"83":[100,40],"84":[9,123],"85":[99,24],"86":[99,0],"87":[99,99],"88":[7,116],"89":[18,4,1,1],"90":[18,1],"91":[7,11],"92":[18,11,11],"93":[1,17],"94":[18,17],"95":[1,17],"96":[8,10],"97":[18,10,10],"98":[0,18],"99":[0,18],"100":[18,18],"101":[1,0],"102":[153],"103":[149,4],"104":[1,1],"105":[7,1],"106":[7,1]},"meta":{"lastBranch":107,"lastFunction":12,"lastStatement":189,"seen":{"f:322:16:322:48":0,"s:323:20:323:Infinity":0,"s:324:17:324:Infinity":1,"s:325:2:325:Infinity":2,"f:331:16:331:31":1,"s:332:16:332:Infinity":3,"s:333:2:333:Infinity":4,"b:333:9:333:36:333:36:333:Infinity":0,"f:339:16:339:35":2,"s:340:20:340:Infinity":5,"s:341:21:341:Infinity":6,"s:342:23:342:Infinity":7,"s:343:22:343:Infinity":8,"b:345:2:345:Infinity:undefined:undefined:undefined:undefined":1,"s:345:2:345:Infinity":9,"s:345:36:345:Infinity":10,"b:346:2:346:Infinity:undefined:undefined:undefined:undefined":2,"s:346:2:346:Infinity":11,"s:346:37:346:Infinity":12,"b:347:2:347:Infinity:undefined:undefined:undefined:undefined":3,"s:347:2:347:Infinity":13,"s:347:39:347:Infinity":14,"b:348:2:348:Infinity:undefined:undefined:undefined:undefined":4,"s:348:2:348:Infinity":15,"s:348:38:348:Infinity":16,"s:349:2:349:Infinity":17,"f:355:16:355:33":3,"s:356:30:356:Infinity":18,"s:357:34:357:Infinity":19,"s:359:2:373:Infinity":20,"s:360:18:360:Infinity":21,"b:360:50:360:74:360:74:360:Infinity":5,"s:361:21:361:Infinity":22,"b:361:21:361:41:361:41:361:56:361:56:361:Infinity":6,"b:363:4:363:Infinity:undefined:undefined:undefined:undefined":7,"s:363:4:363:Infinity":23,"s:363:19:363:Infinity":24,"s:365:17:365:Infinity":25,"b:366:4:370:Infinity:368:4:370:Infinity":8,"s:366:4:370:Infinity":26,"s:367:6:367:Infinity":27,"b:368:4:370:Infinity:undefined:undefined:undefined:undefined":9,"s:368:4:370:Infinity":28,"s:369:6:369:Infinity":29,"s:375:2:375:Infinity":30,"f:381:16:381:41":4,"s:382:2:411:Infinity":31,"s:383:18:383:Infinity":32,"b:383:50:383:74:383:74:383:Infinity":10,"b:386:6:387:Infinity:388:6:389:Infinity:390:6:390:Infinity:391:6:392:Infinity:393:6:395:Infinity:396:6:397:Infinity:398:6:399:Infinity:400:6:401:Infinity:402:6:403:Infinity:404:6:405:Infinity:406:6:407:Infinity":11,"s:385:4:408:Infinity":33,"s:387:8:387:Infinity":34,"b:387:23:387:43:387:43:387:58:387:58:387:64":12,"s:389:8:389:Infinity":35,"b:389:24:389:44:389:44:389:59:389:59:389:65":13,"s:392:8:392:Infinity":36,"b:392:23:392:43:392:43:392:58:392:58:392:64":14,"s:394:20:394:Infinity":37,"b:394:20:394:38:394:38:394:Infinity":15,"s:395:8:395:Infinity":38,"b:395:64:395:72:395:72:395:74":16,"s:397:8:397:Infinity":39,"b:397:23:397:41:397:41:397:48":17,"s:399:8:399:Infinity":40,"b:399:26:399:44:399:44:399:46":18,"s:401:8:401:Infinity":41,"b:401:24:401:46:401:46:401:53":19,"s:403:8:403:Infinity":42,"b:403:26:403:42:403:42:403:44":20,"s:405:8:405:Infinity":43,"b:405:25:405:39:405:39:405:41":21,"s:407:8:407:Infinity":44,"s:410:4:410:Infinity":45,"f:417:16:417:44":5,"s:418:2:456:Infinity":46,"s:419:18:419:Infinity":47,"b:419:50:419:74:419:74:419:Infinity":22,"s:420:21:420:Infinity":48,"b:420:21:420:41:420:41:420:56:420:56:420:Infinity":23,"s:421:21:421:Infinity":49,"b:421:32:421:64:421:64:421:Infinity":24,"b:424:6:425:Infinity:426:6:427:Infinity:428:6:428:Infinity:429:6:430:Infinity:431:6:440:Infinity:441:6:442:Infinity:443:6:444:Infinity:445:6:446:Infinity:447:6:448:Infinity:449:6:450:Infinity:451:6:452:Infinity":25,"s:423:4:453:Infinity":50,"s:425:8:425:Infinity":51,"b:425:26:425:52:425:52:425:Infinity":26,"s:427:8:427:Infinity":52,"b:427:26:427:60:427:60:427:Infinity":27,"s:430:8:430:Infinity":53,"b:430:26:430:52:430:52:430:Infinity":28,"s:432:14:432:Infinity":54,"b:432:21:432:39:432:39:432:43":29,"s:433:47:438:Infinity":55,"s:439:8:439:Infinity":56,"b:439:15:439:30:439:30:439:Infinity":30,"b:439:43:439:50:439:50:439:59":31,"s:442:8:442:Infinity":57,"b:442:32:442:50:442:50:442:57":32,"s:444:8:444:Infinity":58,"b:444:38:444:56:444:56:444:65":33,"s:446:8:446:Infinity":59,"b:446:32:446:56:446:56:446:67":34,"s:448:8:448:Infinity":60,"b:448:32:448:48:448:48:448:52":35,"s:450:8:450:Infinity":61,"s:452:8:452:Infinity":62,"s:455:4:455:Infinity":63,"f:462:16:462:Infinity":6,"s:465:2:504:Infinity":64,"s:466:18:466:Infinity":65,"b:466:50:466:74:466:74:466:Infinity":36,"s:467:21:467:Infinity":66,"b:467:21:467:41:467:41:467:56:467:56:467:Infinity":37,"b:470:6:471:Infinity:472:6:473:Infinity:474:6:474:Infinity:475:6:478:Infinity:479:6:488:Infinity:489:6:490:Infinity:491:6:492:Infinity:493:6:494:Infinity:495:6:496:Infinity:497:6:498:Infinity:499:6:500:Infinity":38,"s:469:4:501:Infinity":67,"s:471:8:471:Infinity":68,"b:471:39:471:51:471:51:471:59":39,"s:473:8:473:Infinity":69,"b:473:24:473:36:473:36:473:44":40,"s:476:23:476:Infinity":70,"b:476:43:476:89:476:89:476:Infinity":41,"s:477:8:477:Infinity":71,"b:477:25:477:37:477:37:477:45":42,"s:480:20:480:Infinity":72,"b:480:20:480:38:480:38:480:Infinity":43,"b:481:8:482:Infinity:undefined:undefined:undefined:undefined":44,"s:481:8:482:Infinity":73,"b:481:12:481:42:481:42:481:Infinity":45,"s:482:10:482:Infinity":74,"b:483:8:484:Infinity:undefined:undefined:undefined:undefined":46,"s:483:8:484:Infinity":75,"b:483:12:483:47:483:47:483:Infinity":47,"s:484:10:484:Infinity":76,"b:485:8:486:Infinity:undefined:undefined:undefined:undefined":48,"s:485:8:486:Infinity":77,"s:486:10:486:Infinity":78,"s:487:8:487:Infinity":79,"s:490:8:490:Infinity":80,"b:490:70:490:88:490:88:490:90":49,"s:492:8:492:Infinity":81,"b:492:45:492:63:492:63:492:65":50,"b:492:83:492:105:492:105:492:107":51,"s:494:8:494:Infinity":82,"b:494:38:494:62:494:62:494:67":52,"b:494:78:494:100:494:100:494:106":53,"s:496:8:496:Infinity":83,"b:496:40:496:56:496:56:496:69":54,"s:498:8:498:Infinity":84,"b:498:39:498:53:498:53:498:60":55,"s:500:8:500:Infinity":85,"s:503:4:503:Infinity":86,"f:510:16:510:29":7,"s:511:26:511:Infinity":87,"s:513:2:562:Infinity":88,"s:514:18:514:Infinity":89,"b:514:50:514:74:514:74:514:Infinity":56,"s:515:21:515:Infinity":90,"b:515:56:515:83:515:83:515:Infinity":57,"s:516:21:516:Infinity":91,"b:516:21:516:41:516:41:516:56:516:56:516:Infinity":58,"b:519:6:521:Infinity:522:6:524:Infinity:525:6:525:Infinity:526:6:529:Infinity:530:6:541:Infinity:542:6:544:Infinity:545:6:548:Infinity:549:6:551:Infinity:552:6:554:Infinity:555:6:558:Infinity":59,"s:518:4:559:Infinity":92,"b:520:8:520:Infinity:undefined:undefined:undefined:undefined":60,"s:520:8:520:Infinity":93,"s:520:22:520:Infinity":94,"s:521:8:521:Infinity":95,"b:523:8:523:Infinity:undefined:undefined:undefined:undefined":61,"s:523:8:523:Infinity":96,"s:523:22:523:Infinity":97,"s:524:8:524:Infinity":98,"b:527:8:527:Infinity:undefined:undefined:undefined:undefined":62,"s:527:8:527:Infinity":99,"s:527:22:527:Infinity":100,"b:528:8:528:Infinity:undefined:undefined:undefined:undefined":63,"s:528:8:528:Infinity":101,"s:528:31:528:Infinity":102,"b:528:62:528:95:528:95:528:101":64,"s:529:8:529:Infinity":103,"s:531:20:531:Infinity":104,"b:531:20:531:38:531:38:531:Infinity":65,"s:532:8:532:Infinity":105,"s:534:23:534:Infinity":106,"b:534:23:534:43:534:43:534:63:534:63:534:Infinity":66,"b:535:8:539:Infinity:undefined:undefined:undefined:undefined":67,"s:535:8:539:Infinity":107,"b:536:10:536:Infinity:undefined:undefined:undefined:undefined":68,"s:536:10:536:Infinity":108,"b:536:14:536:43:536:43:536:65":69,"s:536:65:536:Infinity":109,"b:537:10:537:Infinity:undefined:undefined:undefined:undefined":70,"s:537:10:537:Infinity":110,"b:537:14:537:43:537:43:537:65":71,"s:537:65:537:Infinity":111,"b:538:10:538:Infinity:undefined:undefined:undefined:undefined":72,"s:538:10:538:Infinity":112,"b:538:14:538:42:538:42:538:68":73,"s:538:68:538:Infinity":113,"s:540:8:540:Infinity":114,"b:543:8:543:Infinity:undefined:undefined:undefined:undefined":74,"s:543:8:543:Infinity":115,"s:543:28:543:Infinity":116,"s:544:8:544:Infinity":117,"b:546:8:546:Infinity:undefined:undefined:undefined:undefined":75,"s:546:8:546:Infinity":118,"s:546:28:546:Infinity":119,"b:547:8:547:Infinity:undefined:undefined:undefined:undefined":76,"s:547:8:547:Infinity":120,"s:547:25:547:Infinity":121,"s:548:8:548:Infinity":122,"b:550:8:550:Infinity:undefined:undefined:undefined:undefined":77,"s:550:8:550:Infinity":123,"s:550:26:550:Infinity":124,"s:551:8:551:Infinity":125,"b:553:8:553:Infinity:undefined:undefined:undefined:undefined":78,"s:553:8:553:Infinity":126,"s:553:24:553:Infinity":127,"s:554:8:554:Infinity":128,"b:556:8:556:Infinity:undefined:undefined:undefined:undefined":79,"s:556:8:556:Infinity":129,"s:556:32:556:Infinity":130,"b:557:8:557:Infinity:undefined:undefined:undefined:undefined":80,"s:557:8:557:Infinity":131,"s:557:34:557:Infinity":132,"s:558:8:558:Infinity":133,"s:564:2:564:Infinity":134,"f:570:16:570:32":8,"s:571:32:571:Infinity":135,"s:573:2:631:Infinity":136,"s:574:18:574:Infinity":137,"b:574:50:574:74:574:74:574:Infinity":81,"s:575:22:575:Infinity":138,"b:575:22:575:42:575:42:575:57:575:57:575:Infinity":82,"b:578:4:604:Infinity:undefined:undefined:undefined:undefined":83,"s:578:4:604:Infinity":139,"s:580:20:580:Infinity":140,"s:581:6:603:Infinity":141,"b:582:8:582:Infinity:undefined:undefined:undefined:undefined":84,"s:582:8:582:Infinity":142,"s:582:78:582:Infinity":143,"b:583:8:593:Infinity:undefined:undefined:undefined:undefined":85,"s:583:8:593:Infinity":144,"s:585:22:585:Infinity":145,"s:586:49:591:Infinity":146,"b:592:10:592:Infinity:undefined:undefined:undefined:undefined":86,"s:592:10:592:Infinity":147,"b:592:14:592:21:592:21:592:34":87,"s:592:34:592:Infinity":148,"s:595:47:601:Infinity":149,"b:602:8:602:Infinity:undefined:undefined:undefined:undefined":88,"s:602:8:602:Infinity":150,"s:602:26:602:Infinity":151,"b:608:6:617:Infinity:618:6:620:Infinity:621:6:623:Infinity:624:6:627:Infinity":89,"s:607:4:628:Infinity":152,"s:609:21:609:Infinity":153,"b:609:21:609:39:609:39:609:Infinity":90,"b:610:8:610:Infinity:undefined:undefined:undefined:undefined":91,"s:610:8:610:Infinity":154,"b:610:12:610:36:610:36:610:62:610:62:610:84":92,"s:610:84:610:Infinity":155,"b:611:8:611:Infinity:undefined:undefined:undefined:undefined":93,"s:611:8:611:Infinity":156,"b:611:12:611:37:611:37:611:58":94,"s:611:58:611:Infinity":157,"b:612:8:612:Infinity:undefined:undefined:undefined:undefined":95,"s:612:8:612:Infinity":158,"s:612:33:612:Infinity":159,"b:613:8:613:Infinity:undefined:undefined:undefined:undefined":96,"s:613:8:613:Infinity":160,"b:613:12:613:35:613:35:613:59:613:59:613:81":97,"s:613:81:613:Infinity":161,"b:614:8:614:Infinity:undefined:undefined:undefined:undefined":98,"s:614:8:614:Infinity":162,"s:614:36:614:Infinity":163,"b:615:8:615:Infinity:undefined:undefined:undefined:undefined":99,"s:615:8:615:Infinity":164,"b:615:12:615:36:615:36:615:60":100,"s:615:60:615:Infinity":165,"s:616:8:616:Infinity":166,"s:619:8:619:Infinity":167,"s:620:8:620:Infinity":168,"s:622:8:622:Infinity":169,"s:623:8:623:Infinity":170,"s:625:8:625:Infinity":171,"b:626:8:626:Infinity:undefined:undefined:undefined:undefined":101,"s:626:8:626:Infinity":172,"s:626:34:626:Infinity":173,"s:627:8:627:Infinity":174,"s:633:2:633:Infinity":175,"f:639:16:639:25":9,"b:639:58:639:72":102,"b:640:2:640:Infinity:undefined:undefined:undefined:undefined":103,"s:640:2:640:Infinity":176,"s:640:31:640:Infinity":177,"s:641:2:641:Infinity":178,"s:647:57:650:Infinity":179,"f:655:16:655:31":10,"b:656:2:663:Infinity:undefined:undefined:undefined:undefined":104,"s:656:2:663:Infinity":180,"s:657:4:662:Infinity":181,"s:665:2:665:Infinity":182,"f:671:16:671:31":11,"s:672:2:698:Infinity":183,"s:673:37:673:Infinity":184,"s:675:16:675:Infinity":185,"b:675:16:675:27:675:27:675:Infinity":105,"s:677:4:688:Infinity":186,"b:678:17:678:35:678:35:678:Infinity":106,"s:691:16:691:Infinity":187,"s:692:4:697:Infinity":188}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/user-message.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/user-message.ts","statementMap":{"0":{"start":{"line":30,"column":4},"end":{"line":30,"column":null}},"1":{"start":{"line":31,"column":4},"end":{"line":31,"column":null}},"2":{"start":{"line":38,"column":4},"end":{"line":40,"column":null}},"3":{"start":{"line":39,"column":6},"end":{"line":39,"column":null}},"4":{"start":{"line":47,"column":4},"end":{"line":91,"column":null}},"5":{"start":{"line":49,"column":6},"end":{"line":49,"column":null}},"6":{"start":{"line":52,"column":22},"end":{"line":52,"column":null}},"7":{"start":{"line":53,"column":23},"end":{"line":53,"column":null}},"8":{"start":{"line":54,"column":27},"end":{"line":54,"column":null}},"9":{"start":{"line":55,"column":26},"end":{"line":55,"column":null}},"10":{"start":{"line":58,"column":30},"end":{"line":58,"column":null}},"11":{"start":{"line":59,"column":6},"end":{"line":59,"column":null}},"12":{"start":{"line":60,"column":6},"end":{"line":60,"column":null}},"13":{"start":{"line":62,"column":6},"end":{"line":71,"column":null}},"14":{"start":{"line":63,"column":32},"end":{"line":63,"column":null}},"15":{"start":{"line":64,"column":8},"end":{"line":64,"column":null}},"16":{"start":{"line":64,"column":30},"end":{"line":64,"column":null}},"17":{"start":{"line":65,"column":8},"end":{"line":65,"column":null}},"18":{"start":{"line":65,"column":26},"end":{"line":65,"column":null}},"19":{"start":{"line":66,"column":8},"end":{"line":66,"column":null}},"20":{"start":{"line":66,"column":29},"end":{"line":66,"column":null}},"21":{"start":{"line":67,"column":8},"end":{"line":67,"column":null}},"22":{"start":{"line":68,"column":8},"end":{"line":68,"column":null}},"23":{"start":{"line":70,"column":8},"end":{"line":70,"column":null}},"24":{"start":{"line":73,"column":6},"end":{"line":73,"column":null}},"25":{"start":{"line":76,"column":6},"end":{"line":76,"column":null}},"26":{"start":{"line":78,"column":6},"end":{"line":81,"column":null}},"27":{"start":{"line":84,"column":6},"end":{"line":84,"column":null}},"28":{"start":{"line":86,"column":6},"end":{"line":90,"column":null}},"29":{"start":{"line":99,"column":18},"end":{"line":99,"column":null}},"30":{"start":{"line":100,"column":2},"end":{"line":100,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":29,"column":2},"end":{"line":29,"column":14}},"loc":{"start":{"line":29,"column":63},"end":{"line":32,"column":null}},"line":29},"1":{"name":"(anonymous_1)","decl":{"start":{"line":37,"column":8},"end":{"line":37,"column":34}},"loc":{"start":{"line":37,"column":34},"end":{"line":41,"column":null}},"line":37},"2":{"name":"(anonymous_2)","decl":{"start":{"line":46,"column":8},"end":{"line":46,"column":16}},"loc":{"start":{"line":46,"column":65},"end":{"line":92,"column":null}},"line":46},"3":{"name":"createUserMessageHook","decl":{"start":{"line":98,"column":16},"end":{"line":98,"column":38}},"loc":{"start":{"line":98,"column":68},"end":{"line":101,"column":null}},"line":98}},"branchMap":{"0":{"loc":{"start":{"line":29,"column":42},"end":{"line":29,"column":63}},"type":"default-arg","locations":[{"start":{"line":29,"column":56},"end":{"line":29,"column":63}}],"line":29},"1":{"loc":{"start":{"line":38,"column":4},"end":{"line":40,"column":null}},"type":"if","locations":[{"start":{"line":38,"column":4},"end":{"line":40,"column":null}},{"start":{},"end":{}}],"line":38},"2":{"loc":{"start":{"line":54,"column":27},"end":{"line":54,"column":null}},"type":"binary-expr","locations":[{"start":{"line":54,"column":27},"end":{"line":54,"column":62}},{"start":{"line":54,"column":62},"end":{"line":54,"column":null}}],"line":54},"3":{"loc":{"start":{"line":62,"column":6},"end":{"line":71,"column":null}},"type":"if","locations":[{"start":{"line":62,"column":6},"end":{"line":71,"column":null}},{"start":{"line":69,"column":13},"end":{"line":71,"column":null}}],"line":62},"4":{"loc":{"start":{"line":62,"column":10},"end":{"line":62,"column":63}},"type":"binary-expr","locations":[{"start":{"line":62,"column":10},"end":{"line":62,"column":26}},{"start":{"line":62,"column":26},"end":{"line":62,"column":46}},{"start":{"line":62,"column":46},"end":{"line":62,"column":63}}],"line":62},"5":{"loc":{"start":{"line":64,"column":8},"end":{"line":64,"column":null}},"type":"if","locations":[{"start":{"line":64,"column":8},"end":{"line":64,"column":null}},{"start":{},"end":{}}],"line":64},"6":{"loc":{"start":{"line":64,"column":67},"end":{"line":64,"column":94}},"type":"cond-expr","locations":[{"start":{"line":64,"column":86},"end":{"line":64,"column":92}},{"start":{"line":64,"column":92},"end":{"line":64,"column":94}}],"line":64},"7":{"loc":{"start":{"line":65,"column":8},"end":{"line":65,"column":null}},"type":"if","locations":[{"start":{"line":65,"column":8},"end":{"line":65,"column":null}},{"start":{},"end":{}}],"line":65},"8":{"loc":{"start":{"line":65,"column":63},"end":{"line":65,"column":86}},"type":"cond-expr","locations":[{"start":{"line":65,"column":78},"end":{"line":65,"column":84}},{"start":{"line":65,"column":84},"end":{"line":65,"column":86}}],"line":65},"9":{"loc":{"start":{"line":66,"column":8},"end":{"line":66,"column":null}},"type":"if","locations":[{"start":{"line":66,"column":8},"end":{"line":66,"column":null}},{"start":{},"end":{}}],"line":66},"10":{"loc":{"start":{"line":66,"column":64},"end":{"line":66,"column":90}},"type":"cond-expr","locations":[{"start":{"line":66,"column":82},"end":{"line":66,"column":88}},{"start":{"line":66,"column":88},"end":{"line":66,"column":90}}],"line":66},"11":{"loc":{"start":{"line":89,"column":15},"end":{"line":89,"column":null}},"type":"cond-expr","locations":[{"start":{"line":89,"column":40},"end":{"line":89,"column":56}},{"start":{"line":89,"column":56},"end":{"line":89,"column":null}}],"line":89}},"s":{"0":3,"1":3,"2":2,"3":2,"4":3,"5":3,"6":2,"7":2,"8":2,"9":3,"10":3,"11":3,"12":3,"13":3,"14":1,"15":1,"16":1,"17":1,"18":1,"19":1,"20":1,"21":1,"22":1,"23":1,"24":2,"25":2,"26":2,"27":1,"28":1,"29":2,"30":2},"f":{"0":3,"1":2,"2":3,"3":2},"b":{"0":[3],"1":[2,0],"2":[2,2],"3":[1,1],"4":[3,1,1],"5":[1,0],"6":[0,1],"7":[1,0],"8":[0,1],"9":[1,0],"10":[0,1],"11":[1,0]},"meta":{"lastBranch":12,"lastFunction":4,"lastStatement":31,"seen":{"f:29:2:29:14":0,"b:29:56:29:63":0,"s:30:4:30:Infinity":0,"s:31:4:31:Infinity":1,"f:37:8:37:34":1,"b:38:4:40:Infinity:undefined:undefined:undefined:undefined":1,"s:38:4:40:Infinity":2,"s:39:6:39:Infinity":3,"f:46:8:46:16":2,"s:47:4:91:Infinity":4,"s:49:6:49:Infinity":5,"s:52:22:52:Infinity":6,"s:53:23:53:Infinity":7,"s:54:27:54:Infinity":8,"b:54:27:54:62:54:62:54:Infinity":2,"s:55:26:55:Infinity":9,"s:58:30:58:Infinity":10,"s:59:6:59:Infinity":11,"s:60:6:60:Infinity":12,"b:62:6:71:Infinity:69:13:71:Infinity":3,"s:62:6:71:Infinity":13,"b:62:10:62:26:62:26:62:46:62:46:62:63":4,"s:63:32:63:Infinity":14,"b:64:8:64:Infinity:undefined:undefined:undefined:undefined":5,"s:64:8:64:Infinity":15,"s:64:30:64:Infinity":16,"b:64:86:64:92:64:92:64:94":6,"b:65:8:65:Infinity:undefined:undefined:undefined:undefined":7,"s:65:8:65:Infinity":17,"s:65:26:65:Infinity":18,"b:65:78:65:84:65:84:65:86":8,"b:66:8:66:Infinity:undefined:undefined:undefined:undefined":9,"s:66:8:66:Infinity":19,"s:66:29:66:Infinity":20,"b:66:82:66:88:66:88:66:90":10,"s:67:8:67:Infinity":21,"s:68:8:68:Infinity":22,"s:70:8:70:Infinity":23,"s:73:6:73:Infinity":24,"s:76:6:76:Infinity":25,"s:78:6:81:Infinity":26,"s:84:6:84:Infinity":27,"s:86:6:90:Infinity":28,"b:89:40:89:56:89:56:89:Infinity":11,"f:98:16:98:38":3,"s:99:18:99:Infinity":29,"s:100:2:100:Infinity":30}}} +} diff --git a/i18n/.translation-cache.json b/i18n/.translation-cache.json new file mode 100644 index 0000000..e3291a3 --- /dev/null +++ b/i18n/.translation-cache.json @@ -0,0 +1,56 @@ +{ + "sourceHash": "a7990776d64addb3", + "lastUpdated": "2026-02-04T06:36:58.800Z", + "translations": { + "pt-br": { + "hash": "a7990776d64addb3", + "translatedAt": "2026-02-04T06:36:58.800Z", + "costUsd": 0.17389320000000003 + }, + "fr": { + "hash": "a7990776d64addb3", + "translatedAt": "2026-02-04T06:36:58.800Z", + "costUsd": 0.17782320000000001 + }, + "de": { + "hash": "a7990776d64addb3", + "translatedAt": "2026-02-04T06:36:58.800Z", + "costUsd": 0.1789482 + }, + "es": { + "hash": "a7990776d64addb3", + "translatedAt": "2026-02-04T06:36:58.800Z", + "costUsd": 0.17545320000000003 + }, + "zh": { + "hash": "a7990776d64addb3", + "translatedAt": "2026-02-04T06:36:58.800Z", + "costUsd": 0.18046320000000002 + }, + "ru": { + "hash": "a7990776d64addb3", + "translatedAt": "2026-02-04T06:36:58.800Z", + "costUsd": 0.1934982 + }, + "ko": { + "hash": "a7990776d64addb3", + "translatedAt": "2026-02-04T06:36:58.800Z", + "costUsd": 0.20324820000000002 + }, + "vi": { + "hash": "a7990776d64addb3", + "translatedAt": "2026-02-04T06:36:58.800Z", + "costUsd": 0.20227320000000001 + }, + "ja": { + "hash": "a7990776d64addb3", + "translatedAt": "2026-02-04T06:36:58.800Z", + "costUsd": 0.19414320000000002 + }, + "ar": { + "hash": "a7990776d64addb3", + "translatedAt": "2026-02-04T06:36:58.800Z", + "costUsd": 0.21572820000000004 + } + } +} \ No newline at end of file diff --git a/i18n/README.ar.md b/i18n/README.ar.md new file mode 100644 index 0000000..7542425 --- /dev/null +++ b/i18n/README.ar.md @@ -0,0 +1,743 @@ +

+ AgentKits Logo +

+ +

AgentKits Memory

+ +

+ من AityTech +

+ +

+ npm + License + Claude Code + Cursor + Windsurf + Cline + OpenCode +
+ Tests + Coverage +

+ +

+ نظام ذاكرة دائمة لمساعدي البرمجة بالذكاء الاصطناعي +

+ +

+ مساعدك بالذكاء الاصطناعي ينسى كل شيء بين الجلسات. AgentKits Memory يحل هذه المشكلة.
+ القرارات والأنماط والأخطاء والسياق — كلها محفوظة محليًا عبر MCP. +

+ +

+ الموقع • + التوثيق • + البدء السريع • + كيف يعمل • + المنصات • + CLI • + عارض الويب +

+ +

+ English · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português · Tiếng Việt · Русский · العربية +

+ +--- + +## المميزات + +| الميزة | الفائدة | +|---------|---------| +| **محلي 100%** | جميع البيانات تبقى على جهازك. لا سحابة، لا مفاتيح API، لا حسابات | +| **سريع للغاية** | SQLite الأصلي (better-sqlite3) = استعلامات فورية، صفر تأخير | +| **بدون إعداد** | يعمل مباشرة. لا حاجة لإعداد قاعدة بيانات | +| **متعدد المنصات** | Claude Code، Cursor، Windsurf، Cline، OpenCode — أمر إعداد واحد | +| **خادم MCP** | 9 أدوات: save، search، timeline، details، recall، list، update، delete، status | +| **التقاط تلقائي** | الخطافات تلتقط سياق الجلسة واستخدام الأدوات والملخصات تلقائيًا | +| **إثراء بالذكاء الاصطناعي** | عمال الخلفية يثرون الملاحظات بملخصات مولدة بالذكاء الاصطناعي | +| **بحث متجهي** | تشابه دلالي HNSW مع تضمينات متعددة اللغات (أكثر من 100 لغة) | +| **عارض ويب** | واجهة متصفح لعرض وبحث وإضافة وتحرير وحذف الذكريات | +| **بحث ثلاثي الطبقات** | الكشف التدريجي يوفر ~87% من الرموز مقارنة بجلب كل شيء | +| **إدارة دورة الحياة** | ضغط تلقائي وأرشفة وتنظيف الجلسات القديمة | +| **تصدير/استيراد** | نسخ احتياطي واستعادة الذكريات كـ JSON | + +--- + +## كيف يعمل + +``` +الجلسة 1: "استخدم JWT للمصادقة" الجلسة 2: "أضف نقطة نهاية تسجيل الدخول" +┌──────────────────────────┐ ┌──────────────────────────┐ +│ تقوم بالبرمجة مع AI... │ │ AI يعرف بالفعل: │ +│ AI يتخذ القرارات │ │ ✓ قرار مصادقة JWT │ +│ AI يواجه أخطاء │ ───► │ ✓ حلول الأخطاء │ +│ AI يتعلم الأنماط │ محفوظ │ ✓ أنماط الكود │ +│ │ │ ✓ سياق الجلسة │ +└──────────────────────────┘ └──────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite، محلي 100%) +``` + +1. **إعداد لمرة واحدة** — `npx agentkits-memory-setup` يقوم بإعداد منصتك +2. **التقاط تلقائي** — الخطافات تسجل القرارات واستخدام الأدوات والملخصات أثناء العمل +3. **حقن السياق** — الجلسة التالية تبدأ بالتاريخ ذي الصلة من الجلسات السابقة +4. **معالجة الخلفية** — العمال يثرون الملاحظات بالذكاء الاصطناعي، ويولدون التضمينات، ويضغطون البيانات القديمة +5. **بحث في أي وقت** — AI يستخدم أدوات MCP (`memory_search` → `memory_details`) للعثور على السياق السابق + +جميع البيانات تبقى في `.claude/memory/memory.db` على جهازك. لا سحابة. لا حاجة لمفاتيح API. + +--- + +## قرارات التصميم المهمة + +معظم أدوات الذاكرة تنثر البيانات عبر ملفات markdown، تتطلب بيئات تشغيل Python، أو ترسل كودك إلى واجهات برمجة تطبيقات خارجية. AgentKits Memory يتخذ خيارات مختلفة جذريًا: + +| خيار التصميم | لماذا مهم | +|---------------|----------------| +| **قاعدة بيانات SQLite واحدة** | ملف واحد (`memory.db`) يحتوي على كل شيء — الذكريات والجلسات والملاحظات والتضمينات. لا ملفات منتشرة للمزامنة، لا تعارضات دمج، لا بيانات يتيمة. النسخ الاحتياطي = نسخ ملف واحد | +| **Node.js أصلي، صفر Python** | يعمل حيثما يعمل Node. لا conda، لا pip، لا virtualenv. نفس اللغة مثل خادم MCP الخاص بك — أمر `npx` واحد، انتهى | +| **بحث ثلاثي الطبقات موفر للرموز** | فهرس البحث أولاً (~50 رمزًا/نتيجة)، ثم سياق الجدول الزمني، ثم التفاصيل الكاملة. اجلب فقط ما تحتاجه. الأدوات الأخرى تفرغ ملفات الذاكرة بأكملها في السياق، محرقة الرموز على محتوى غير ذي صلة | +| **التقاط تلقائي عبر الخطافات** | القرارات والأنماط والأخطاء تُسجل أثناء حدوثها — ليس بعد أن تتذكر حفظها. حقن سياق الجلسة يحدث تلقائيًا عند بداية الجلسة التالية | +| **تضمينات محلية، بدون استدعاءات API** | البحث المتجهي يستخدم نموذج ONNX محلي (multilingual-e5-small). البحث الدلالي يعمل دون اتصال، لا يكلف شيئًا، ويدعم أكثر من 100 لغة | +| **عمال الخلفية** | إثراء الذكاء الاصطناعي وتوليد التضمينات والضغط يعملون بشكل غير متزامن. تدفق البرمجة الخاص بك لا يُحجب أبدًا | +| **متعدد المنصات منذ اليوم الأول** | علامة `--platform=all` واحدة تكون Claude Code و Cursor و Windsurf و Cline و OpenCode في وقت واحد. نفس قاعدة بيانات الذاكرة، محررات مختلفة | +| **بيانات ملاحظات منظمة** | استخدام الأداة يُلتقط مع تصنيف النوع (قراءة/كتابة/تنفيذ/بحث)، تتبع الملفات، كشف النية، وسرديات مولدة بالذكاء الاصطناعي — ليس تفريغات نصية خام | +| **بدون تسريب عمليات** | عمال الخلفية ينهون أنفسهم تلقائيًا بعد 5 دقائق، يستخدمون ملفات قفل قائمة على PID مع تنظيف قفل قديم، ويتعاملون مع SIGTERM/SIGINT بشكل سلس. لا عمليات زومبي، لا عمال يتامى | +| **بدون تسريب ذاكرة** | الخطافات تعمل كعمليات قصيرة العمر (ليست خدمات طويلة الأمد). اتصالات قاعدة البيانات تغلق عند الإيقاف. عملية فرعية للتضمين لها إعادة إنتاج محدودة (بحد أقصى 2)، مهلات طلب معلقة، وتنظيف سلس لجميع المؤقتات والطوابير | + +--- + +## عارض الويب + +اعرض وأدر ذكرياتك من خلال واجهة ويب حديثة. + +```bash +npx agentkits-memory-web +``` + +ثم افتح **http://localhost:1905** في متصفحك. + +### قائمة الجلسات + +تصفح جميع الجلسات مع عرض الجدول الزمني وتفاصيل النشاط. + +![Session List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-session-list_v2.png) + +### قائمة الذاكرة + +تصفح جميع الذكريات المخزنة مع البحث وتصفية مساحة الاسم. + +![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list_v2.png) + +### إضافة ذاكرة + +أنشئ ذكريات جديدة بمفتاح ومساحة اسم ونوع ومحتوى ووسوم. + +![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory_v2.png) + +### تفاصيل الذاكرة + +اعرض تفاصيل الذاكرة الكاملة مع خيارات التحرير والحذف. + +![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail_v2.png) + +### إدارة التضمينات + +ولّد وأدر تضمينات المتجهات للبحث الدلالي. + +![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding_v2.png) + +--- + +## البدء السريع + +### الخيار 1: سوق إضافات Claude Code (موصى به لـ Claude Code) + +تثبيت بأمر واحد — لا حاجة لإعداد يدوي: + +```bash +/plugin marketplace add aitytech/agentkits-memory +/plugin install agentkits-memory@aitytech +``` + +هذا يُثبّت الخطافات وخادم MCP ومهارة سير عمل الذاكرة تلقائيًا. أعد تشغيل Claude Code بعد التثبيت. + +### الخيار 2: إعداد تلقائي (جميع المنصات) + +```bash +npx agentkits-memory-setup +``` + +هذا يكتشف منصتك تلقائيًا ويكوّن كل شيء: خادم MCP، الخطافات (Claude Code/OpenCode)، ملفات القواعد (Cursor/Windsurf/Cline)، ويُنزل نموذج التضمين. + +**استهدف منصة معينة:** + +```bash +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all +``` + +### الخيار 3: إعداد MCP يدوي + +إذا كنت تفضل الإعداد اليدوي، أضف إلى إعداد MCP الخاص بك: + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "agentkits-memory-server"] + } + } +} +``` + +مواقع ملف الإعداد: +- **Claude Code**: `.claude/settings.json` (مضمن في مفتاح `mcpServers`) +- **Cursor**: `.cursor/mcp.json` +- **Windsurf**: `.windsurf/mcp.json` +- **Cline / OpenCode**: `.mcp.json` (جذر المشروع) + +### 3. أدوات MCP + +بمجرد الإعداد، يمكن لمساعد الذكاء الاصطناعي الخاص بك استخدام هذه الأدوات: + +| الأداة | الوصف | +|------|-------------| +| `memory_status` | التحقق من حالة نظام الذاكرة (اتصل أولاً!) | +| `memory_save` | حفظ القرارات والأنماط والأخطاء أو السياق | +| `memory_search` | **[الخطوة 1]** فهرس البحث — معرفات وعناوين خفيفة الوزن (~50 رمزًا/نتيجة) | +| `memory_timeline` | **[الخطوة 2]** احصل على السياق الزمني حول ذاكرة | +| `memory_details` | **[الخطوة 3]** احصل على المحتوى الكامل لمعرفات محددة | +| `memory_recall` | نظرة عامة سريعة على الموضوع — ملخص مجمع | +| `memory_list` | سرد الذكريات الحديثة | +| `memory_update` | تحديث محتوى أو وسوم ذاكرة موجودة | +| `memory_delete` | إزالة الذكريات القديمة | + +--- + +## الكشف التدريجي (بحث موفر للرموز) + +AgentKits Memory يستخدم **نمط بحث ثلاثي الطبقات** يوفر ~70% من الرموز مقارنة بجلب المحتوى الكامل مقدمًا. + +### كيف يعمل + +``` +┌─────────────────────────────────────────────────────────────┐ +│ الخطوة 1: memory_search │ +│ يعيد: المعرفات والعناوين والوسوم والدرجات (~50 رمزًا/عنصر) │ +│ → مراجعة الفهرس، اختر الذكريات ذات الصلة │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ الخطوة 2: memory_timeline (اختياري) │ +│ يعيد: السياق ±30 دقيقة حول الذاكرة │ +│ → فهم ما حدث قبل/بعد │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ الخطوة 3: memory_details │ +│ يعيد: المحتوى الكامل للمعرفات المحددة فقط │ +│ → اجلب فقط ما تحتاجه فعلاً │ +└─────────────────────────────────────────────────────────────┘ +``` + +### مثال سير العمل + +```typescript +// الخطوة 1: البحث - احصل على فهرس خفيف الوزن +memory_search({ query: "authentication" }) +// → يعيد: [{ id: "abc", title: "JWT pattern...", score: 85% }] + +// الخطوة 2: (اختياري) شاهد السياق الزمني +memory_timeline({ anchor: "abc" }) +// → يعيد: ما حدث قبل/بعد هذه الذاكرة + +// الخطوة 3: احصل على المحتوى الكامل فقط لما تحتاجه +memory_details({ ids: ["abc"] }) +// → يعيد: المحتوى الكامل للذاكرة المحددة +``` + +### توفير الرموز + +| المنهج | الرموز المستخدمة | +|----------|-------------| +| **القديم:** جلب كل المحتوى | ~500 رمز × 10 نتائج = 5000 رمز | +| **الجديد:** الكشف التدريجي | 50 × 10 + 500 × 2 = 1500 رمز | +| **التوفير** | **تقليل 70%** | + +--- + +## أوامر CLI + +```bash +# إعداد بأمر واحد (يكتشف المنصة تلقائيًا) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # منصة محددة +npx agentkits-memory-setup --platform=all # جميع المنصات +npx agentkits-memory-setup --force # إعادة التثبيت/التحديث + +# بدء خادم MCP +npx agentkits-memory-server + +# عارض الويب (منفذ 1905) +npx agentkits-memory-web + +# عارض الطرفية +npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # إحصائيات قاعدة البيانات +npx agentkits-memory-viewer --json # إخراج JSON + +# حفظ من CLI +npx agentkits-memory-save "Use JWT with refresh tokens" --category pattern --tags auth,security + +# الإعدادات +npx agentkits-memory-hook settings . # عرض الإعدادات الحالية +npx agentkits-memory-hook settings . --reset # إعادة التعيين إلى الافتراضيات +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# تصدير / استيراد +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# إدارة دورة الحياة +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . +``` + +--- + +## الاستخدام البرمجي + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService({ + baseDir: '.claude/memory', + dbFilename: 'memory.db', +}); +await memory.initialize(); + +// تخزين ذاكرة +await memory.storeEntry({ + key: 'auth-pattern', + content: 'Use JWT with refresh tokens for authentication', + namespace: 'patterns', + tags: ['auth', 'security'], +}); + +// الاستعلام عن الذكريات +const results = await memory.query({ + type: 'hybrid', + namespace: 'patterns', + content: 'authentication', + limit: 10, +}); + +// الحصول بالمفتاح +const entry = await memory.getByKey('patterns', 'auth-pattern'); +``` + +--- + +## خطافات الالتقاط التلقائي + +الخطافات تلتقط جلسات البرمجة بالذكاء الاصطناعي تلقائيًا (Claude Code و OpenCode فقط): + +| الخطاف | المحفز | الإجراء | +|------|---------|--------| +| `context` | بداية الجلسة | يحقن سياق الجلسة السابقة + حالة الذاكرة | +| `session-init` | مطالبة المستخدم | يبدأ/يستأنف الجلسة، يسجل المطالبات | +| `observation` | بعد استخدام الأداة | يلتقط استخدام الأداة مع كشف النية | +| `summarize` | نهاية الجلسة | يولد ملخص جلسة منظم | +| `user-message` | بداية الجلسة | يعرض حالة الذاكرة للمستخدم (stderr) | + +إعداد الخطافات: +```bash +npx agentkits-memory-setup +``` + +**ما يُلتقط تلقائيًا:** +- قراءات/كتابات الملفات مع المسارات +- تغييرات الكود كاختلافات منظمة (قبل → بعد) +- نية المطور (إصلاح خطأ، ميزة، إعادة هيكلة، تحقيق، إلخ) +- ملخصات الجلسات مع القرارات والأخطاء والخطوات التالية +- تتبع متعدد المطالبات داخل الجلسات + +--- + +## دعم منصات متعددة + +| المنصة | MCP | الخطافات | ملف القواعد | الإعداد | +|----------|-----|-------|------------|-------| +| **Claude Code** | `.claude/settings.json` | ✅ كامل | CLAUDE.md (مهارة) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ كامل | — | `--platform=opencode` | + +- **خادم MCP** يعمل مع جميع المنصات (أدوات الذاكرة عبر بروتوكول MCP) +- **الخطافات** توفر التقاط تلقائي على Claude Code و OpenCode +- **ملفات القواعد** تعلم Cursor/Windsurf/Cline سير عمل الذاكرة +- **بيانات الذاكرة** تُخزن دائمًا في `.claude/memory/` (مصدر واحد للحقيقة) + +--- + +## عمال الخلفية + +بعد كل جلسة، يعالج عمال الخلفية المهام في قائمة الانتظار: + +| العامل | المهمة | الوصف | +|--------|------|-------------| +| `embed-session` | التضمينات | توليد تضمينات المتجهات للبحث الدلالي | +| `enrich-session` | إثراء الذكاء الاصطناعي | إثراء الملاحظات بملخصات وحقائق ومفاهيم مولدة بالذكاء الاصطناعي | +| `compress-session` | الضغط | ضغط الملاحظات القديمة (10:1–25:1) وتوليد ملخصات الجلسات (20:1–100:1) | + +العمال يعملون تلقائيًا بعد انتهاء الجلسة. كل عامل: +- يعالج حتى 200 عنصر لكل تشغيل +- يستخدم ملفات القفل لمنع التنفيذ المتزامن +- ينهي نفسه تلقائيًا بعد 5 دقائق (يمنع الزومبي) +- يعيد المحاولة للمهام الفاشلة حتى 3 مرات + +--- + +## إعداد موفر الذكاء الاصطناعي + +إثراء الذكاء الاصطناعي يستخدم موفرين قابلين للتوصيل. الافتراضي هو `claude-cli` (لا حاجة لمفتاح API). + +| الموفر | النوع | النموذج الافتراضي | ملاحظات | +|----------|------|---------------|-------| +| **Claude CLI** | `claude-cli` | `haiku` | يستخدم `claude --print`، لا حاجة لمفتاح API | +| **OpenAI** | `openai` | `gpt-4o-mini` | أي نموذج OpenAI | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | مفتاح Google AI Studio | +| **OpenRouter** | `openai` | أي | عيّن `baseUrl` إلى `https://openrouter.ai/api/v1` | +| **GLM (Zhipu)** | `openai` | أي | عيّن `baseUrl` إلى `https://open.bigmodel.cn/api/paas/v4` | +| **Ollama** | `openai` | أي | عيّن `baseUrl` إلى `http://localhost:11434/v1` | + +### الخيار 1: متغيرات البيئة + +```bash +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter (يستخدم تنسيق متوافق مع OpenAI) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# Ollama المحلي (لا حاجة لمفتاح API) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# تعطيل إثراء الذكاء الاصطناعي تمامًا +export AGENTKITS_AI_ENRICHMENT=false +``` + +### الخيار 2: إعدادات دائمة + +```bash +# محفوظ في .claude/memory/settings.json — يستمر عبر الجلسات +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# عرض الإعدادات الحالية +npx agentkits-memory-hook settings . + +# إعادة التعيين إلى الافتراضيات +npx agentkits-memory-hook settings . --reset +``` + +> **الأولوية:** متغيرات البيئة تتجاوز settings.json. settings.json يتجاوز الافتراضيات. + +--- + +## إدارة دورة الحياة + +إدارة نمو الذاكرة مع مرور الوقت: + +```bash +# ضغط الملاحظات الأقدم من 7 أيام، أرشفة الجلسات الأقدم من 30 يومًا +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# أيضًا حذف تلقائي للجلسات المؤرشفة الأقدم من 90 يومًا +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# عرض إحصائيات دورة الحياة +npx agentkits-memory-hook lifecycle-stats . +``` + +| المرحلة | ما يحدث | +|-------|-------------| +| **الضغط** | ضغط الملاحظات بالذكاء الاصطناعي، توليد ملخصات الجلسات | +| **الأرشفة** | وضع علامة على الجلسات القديمة كمؤرشفة (مستبعدة من السياق) | +| **الحذف** | إزالة الجلسات المؤرشفة (اختياري، يتطلب `--delete`) | + +--- + +## تصدير / استيراد + +نسخ احتياطي واستعادة ذكريات مشروعك: + +```bash +# تصدير جميع الجلسات لمشروع +npx agentkits-memory-hook export . my-project ./backup.json + +# استيراد من النسخة الاحتياطية (يزيل التكرار تلقائيًا) +npx agentkits-memory-hook import . ./backup.json +``` + +تنسيق التصدير يتضمن الجلسات والملاحظات والمطالبات والملخصات. + +--- + +## فئات الذاكرة + +| الفئة | حالة الاستخدام | +|----------|----------| +| `decision` | قرارات البنية، اختيارات مكدس التقنية، المقايضات | +| `pattern` | اتفاقيات البرمجة، أنماط المشروع، المناهج المتكررة | +| `error` | إصلاحات الأخطاء، حلول الأخطاء، رؤى تصحيح الأخطاء | +| `context` | خلفية المشروع، اتفاقيات الفريق، إعداد البيئة | +| `observation` | ملاحظات الجلسة الملتقطة تلقائيًا | + +--- + +## التخزين + +الذكريات تُخزن في `.claude/memory/` داخل دليل مشروعك. + +``` +.claude/memory/ +├── memory.db # قاعدة بيانات SQLite (جميع البيانات) +├── memory.db-wal # سجل الكتابة المسبقة (مؤقت) +├── settings.json # إعدادات دائمة (موفر AI، إعداد السياق) +└── embeddings-cache/ # تضمينات المتجهات المخزنة مؤقتًا +``` + +--- + +## دعم اللغات CJK + +AgentKits Memory لديه **دعم CJK تلقائي** للبحث في النصوص الصينية واليابانية والكورية. + +### بدون إعداد + +عندما يتم تثبيت `better-sqlite3` (افتراضي)، البحث CJK يعمل تلقائيًا: + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService('.claude/memory'); +await memory.initialize(); + +// تخزين محتوى CJK +await memory.storeEntry({ + key: 'auth-pattern', + content: '認証機能の実装パターン - JWT with refresh tokens', + namespace: 'patterns', +}); + +// البحث باليابانية أو الصينية أو الكورية - يعمل فقط! +const results = await memory.query({ + type: 'hybrid', + content: '認証機能', +}); +``` + +### كيف يعمل + +- **SQLite الأصلي**: يستخدم `better-sqlite3` لأقصى أداء +- **محلل Trigram**: FTS5 مع trigram ينشئ تسلسلات من 3 أحرف لمطابقة CJK +- **رجوع ذكي**: استعلامات CJK القصيرة (< 3 أحرف) تستخدم تلقائيًا بحث LIKE +- **ترتيب BM25**: تسجيل الصلة لنتائج البحث + +### متقدم: تجزئة الكلمات اليابانية + +لليابانية المتقدمة مع تجزئة كلمات مناسبة، استخدم اختياريًا lindera: + +```typescript +import { createJapaneseOptimizedBackend } from '@aitytech/agentkits-memory'; + +const backend = createJapaneseOptimizedBackend({ + databasePath: '.claude/memory/memory.db', + linderaPath: './path/to/liblindera_sqlite.dylib', +}); +``` + +يتطلب بناء [lindera-sqlite](https://github.com/lindera/lindera-sqlite). + +--- + +## مرجع API + +### ProjectMemoryService + +```typescript +interface ProjectMemoryConfig { + baseDir: string; // الافتراضي: '.claude/memory' + dbFilename: string; // الافتراضي: 'memory.db' + enableVectorIndex: boolean; // الافتراضي: false + dimensions: number; // الافتراضي: 384 + embeddingGenerator?: EmbeddingGenerator; + cacheEnabled: boolean; // الافتراضي: true + cacheSize: number; // الافتراضي: 1000 + cacheTtl: number; // الافتراضي: 300000 (5 دقائق) +} +``` + +### الطرق + +| الطريقة | الوصف | +|--------|-------------| +| `initialize()` | تهيئة خدمة الذاكرة | +| `shutdown()` | إيقاف وحفظ التغييرات | +| `storeEntry(input)` | تخزين إدخال ذاكرة | +| `get(id)` | الحصول على إدخال بالمعرف | +| `getByKey(namespace, key)` | الحصول على إدخال بمساحة الاسم والمفتاح | +| `update(id, update)` | تحديث إدخال | +| `delete(id)` | حذف إدخال | +| `query(query)` | الاستعلام عن الإدخالات بالمرشحات | +| `semanticSearch(content, k)` | بحث تشابه دلالي | +| `count(namespace?)` | عد الإدخالات | +| `listNamespaces()` | سرد جميع مساحات الأسماء | +| `getStats()` | الحصول على الإحصائيات | + +--- + +## جودة الكود + +تم اختبار AgentKits Memory بشكل شامل مع **970 اختبار وحدة** عبر 21 مجموعة اختبار. + +| المقياس | التغطية | +|---------|---------| +| **التعليمات** | 90.29% | +| **الفروع** | 80.85% | +| **الدوال** | 90.54% | +| **الأسطر** | 91.74% | + +### فئات الاختبار + +| الفئة | الاختبارات | ما يتم تغطيته | +|-------|-----------|--------------| +| خدمة الذاكرة الأساسية | 56 | CRUD، البحث، التقسيم، الفئات، الوسوم، الاستيراد/التصدير | +| واجهة SQLite الخلفية | 65 | المخطط، الترحيل، FTS5، المعاملات، معالجة الأخطاء | +| فهرس HNSW المتجهي | 47 | الإدراج، البحث، الحذف، الاستمرارية، الحالات الحدية | +| البحث الهجين | 44 | FTS + دمج المتجهات، التسجيل، الترتيب، المرشحات | +| اقتصاديات التوكنات | 27 | ميزانيات البحث ثلاثية الطبقات، الاقتطاع، التحسين | +| نظام التضمين | 63 | التخزين المؤقت، العمليات الفرعية، النماذج المحلية، دعم CJK | +| نظام الخطافات | 502 | السياق، تهيئة الجلسة، المراقبة، التلخيص، إثراء الذكاء الاصطناعي، دورة حياة الخدمة، عمال قائمة الانتظار، المحولات، الأنواع | +| خادم MCP | 48 | جميع أدوات MCP التسعة، التحقق، استجابات الأخطاء | +| CLI | 34 | اكتشاف المنصة، توليد القواعد | +| التكامل | 84 | التدفقات الشاملة، تكامل التضمين، الجلسات المتعددة | + +```bash +# تشغيل الاختبارات +npm test + +# تشغيل مع التغطية +npm run test:coverage +``` + +--- + +## المتطلبات + +- **Node.js LTS**: 18.x أو 20.x أو 22.x (موصى به) +- مساعد برمجة ذكاء اصطناعي متوافق مع MCP + +### ملاحظات إصدار Node.js + +هذه الحزمة تستخدم `better-sqlite3` الذي يتطلب ملفات ثنائية أصلية. **الملفات الثنائية المبنية مسبقًا متوفرة لإصدارات LTS فقط**. + +| إصدار Node | الحالة | ملاحظات | +|--------------|--------|-------| +| 18.x LTS | ✅ يعمل | ملفات ثنائية مبنية مسبقًا | +| 20.x LTS | ✅ يعمل | ملفات ثنائية مبنية مسبقًا | +| 22.x LTS | ✅ يعمل | ملفات ثنائية مبنية مسبقًا | +| 19.x, 21.x, 23.x | ⚠️ يتطلب أدوات البناء | لا ملفات ثنائية مبنية مسبقًا | + +### استخدام إصدارات غير LTS (Windows) + +إذا كان عليك استخدام إصدار غير LTS (19، 21، 23)، ثبّت أدوات البناء أولاً: + +**الخيار 1: Visual Studio Build Tools** +```powershell +# قم بالتنزيل والتثبيت من: +# https://visualstudio.microsoft.com/visual-cpp-build-tools/ +# اختر حمل عمل "تطوير سطح المكتب مع C++" +``` + +**الخيار 2: windows-build-tools (npm)** +```powershell +npm install --global windows-build-tools +``` + +**الخيار 3: Chocolatey** +```powershell +choco install visualstudio2022-workload-vctools +``` + +انظر [دليل node-gyp Windows](https://github.com/nodejs/node-gyp#on-windows) لمزيد من التفاصيل. + +--- + +## نظام AgentKits البيئي + +**AgentKits Memory** جزء من نظام AgentKits البيئي من AityTech - أدوات تجعل مساعدي البرمجة بالذكاء الاصطناعي أذكى. + +| المنتج | الوصف | الرابط | +|---------|-------------|------| +| **AgentKits Engineer** | 28 وكيلًا متخصصًا، أكثر من 100 مهارة، أنماط مؤسسية | [GitHub](https://github.com/aitytech/agentkits-engineer) | +| **AgentKits Marketing** | توليد محتوى تسويقي مدعوم بالذكاء الاصطناعي | [GitHub](https://github.com/aitytech/agentkits-marketing) | +| **AgentKits Memory** | ذاكرة دائمة لمساعدي الذكاء الاصطناعي (هذه الحزمة) | [npm](https://www.npmjs.com/package/@aitytech/agentkits-memory) | + +

+ + agentkits.net + +

+ +--- + +## تاريخ النجوم + + + + + + Star History Chart + + + +--- + +## الترخيص + +MIT + +--- + +

+ امنح مساعد الذكاء الاصطناعي الخاص بك ذاكرة تستمر. +

+ +

+ AgentKits Memory من AityTech +

+ +

+ ضع نجمة على هذا المستودع إذا ساعد ذكاءك الاصطناعي على التذكر. +

\ No newline at end of file diff --git a/i18n/README.de.md b/i18n/README.de.md new file mode 100644 index 0000000..f8f252c --- /dev/null +++ b/i18n/README.de.md @@ -0,0 +1,743 @@ +

+ AgentKits Logo +

+ +

AgentKits Memory

+ +

+ von AityTech +

+ +

+ npm + License + Claude Code + Cursor + Windsurf + Cline + OpenCode +
+ Tests + Coverage +

+ +

+ Persistentes Speichersystem für KI-Coding-Assistenten +

+ +

+ Ihr KI-Assistent vergisst zwischen Sessions alles. AgentKits Memory behebt das.
+ Entscheidungen, Muster, Fehler und Kontext — alles lokal über MCP gespeichert. +

+ +

+ Webseite • + Dokumentation • + Schnellstart • + So funktioniert es • + Plattformen • + CLI • + Web Viewer +

+ +

+ English · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português · Tiếng Việt · Русский · العربية +

+ +--- + +## Features + +| Feature | Vorteil | +|---------|---------| +| **100% Lokal** | Alle Daten bleiben auf Ihrem Rechner. Keine Cloud, keine API-Keys, keine Accounts | +| **Blitzschnell** | Native SQLite (better-sqlite3) = sofortige Abfragen, null Latenz | +| **Null Konfiguration** | Funktioniert out of the box. Keine Datenbank-Einrichtung erforderlich | +| **Multi-Plattform** | Claude Code, Cursor, Windsurf, Cline, OpenCode — ein Setup-Befehl | +| **MCP Server** | 9 Tools: save, search, timeline, details, recall, list, update, delete, status | +| **Auto-Capture** | Hooks erfassen Session-Kontext, Tool-Nutzung, Zusammenfassungen automatisch | +| **KI-Anreicherung** | Background-Worker reichern Beobachtungen mit KI-generierten Zusammenfassungen an | +| **Vektorsuche** | HNSW semantische Ähnlichkeit mit mehrsprachigen Embeddings (100+ Sprachen) | +| **Web Viewer** | Browser-UI zum Anzeigen, Suchen, Hinzufügen, Bearbeiten, Löschen von Erinnerungen | +| **3-Schicht-Suche** | Progressive Disclosure spart ~87% Tokens vs. alles abrufen | +| **Lifecycle-Mgmt** | Auto-Komprimierung, Archivierung und Bereinigung alter Sessions | +| **Export/Import** | Backup und Wiederherstellung von Erinnerungen als JSON | + +--- + +## So funktioniert es + +``` +Session 1: "Use JWT for auth" Session 2: "Add login endpoint" +┌──────────────────────────┐ ┌──────────────────────────┐ +│ Sie coden mit KI... │ │ KI weiß bereits: │ +│ KI trifft Entscheidungen│ │ ✓ JWT-Auth-Entscheidung │ +│ KI begegnet Fehlern │ ───► │ ✓ Fehlerlösungen │ +│ KI lernt Muster │ gespeich.│ ✓ Code-Muster │ +│ │ │ ✓ Session-Kontext │ +└──────────────────────────┘ └──────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite, 100% lokal) +``` + +1. **Einmal einrichten** — `npx agentkits-memory-setup` konfiguriert Ihre Plattform +2. **Auto-Capture** — Hooks zeichnen Entscheidungen, Tool-Nutzung und Zusammenfassungen während der Arbeit auf +3. **Kontext-Injektion** — Nächste Session startet mit relevantem Verlauf aus vergangenen Sessions +4. **Hintergrundverarbeitung** — Worker reichern Beobachtungen mit KI an, generieren Embeddings, komprimieren alte Daten +5. **Jederzeit suchen** — KI verwendet MCP-Tools (`memory_search` → `memory_details`), um vergangenen Kontext zu finden + +Alle Daten bleiben in `.claude/memory/memory.db` auf Ihrem Rechner. Keine Cloud. Keine API-Keys erforderlich. + +--- + +## Design-Entscheidungen, die zählen + +Die meisten Memory-Tools verstreuen Daten über Markdown-Dateien, benötigen Python-Laufzeiten oder senden Ihren Code an externe APIs. AgentKits Memory trifft grundlegend andere Entscheidungen: + +| Design-Entscheidung | Warum es wichtig ist | +|---------------------|----------------------| +| **Einzelne SQLite-Datenbank** | Eine Datei (`memory.db`) enthält alles — Erinnerungen, Sessions, Beobachtungen, Embeddings. Keine verstreuten Dateien zum Synchronisieren, keine Merge-Konflikte, keine verwaisten Daten. Backup = eine Datei kopieren | +| **Native Node.js, null Python** | Läuft überall, wo Node läuft. Kein conda, kein pip, kein virtualenv. Gleiche Sprache wie Ihr MCP-Server — ein `npx`-Befehl, fertig | +| **Token-effiziente 3-Schicht-Suche** | Erst Suchindex (~50 Tokens/Ergebnis), dann Timeline-Kontext, dann vollständige Details. Nur abrufen, was Sie brauchen. Andere Tools werfen ganze Memory-Dateien in den Kontext und verschwenden Tokens für irrelevante Inhalte | +| **Auto-Capture über Hooks** | Entscheidungen, Muster und Fehler werden aufgezeichnet, während sie passieren — nicht nachdem Sie sich daran erinnert haben, sie zu speichern. Session-Kontext-Injektion erfolgt automatisch beim nächsten Session-Start | +| **Lokale Embeddings, keine API-Aufrufe** | Vektorsuche verwendet ein lokales ONNX-Modell (multilingual-e5-small). Semantische Suche funktioniert offline, kostet nichts und unterstützt 100+ Sprachen | +| **Background-Worker** | KI-Anreicherung, Embedding-Generierung und Komprimierung laufen asynchron. Ihr Coding-Flow wird nie blockiert | +| **Multi-Plattform von Tag eins** | Ein `--platform=all`-Flag konfiguriert Claude Code, Cursor, Windsurf, Cline und OpenCode gleichzeitig. Gleiche Memory-Datenbank, verschiedene Editoren | +| **Strukturierte Beobachtungsdaten** | Tool-Nutzung wird mit Typ-Klassifizierung (read/write/execute/search), Datei-Tracking, Intent-Erkennung und KI-generierten Narrativen erfasst — keine rohen Text-Dumps | +| **Keine Process-Leaks** | Background-Worker beenden sich nach 5 Minuten selbst, verwenden PID-basierte Lock-Dateien mit Stale-Lock-Cleanup und handhaben SIGTERM/SIGINT ordentlich. Keine Zombie-Prozesse, keine verwaisten Worker | +| **Keine Memory-Leaks** | Hooks laufen als kurzlebige Prozesse (nicht lang laufende Daemons). Datenbankverbindungen schließen beim Shutdown. Embedding-Subprocess hat begrenztes Respawn (max 2), Pending-Request-Timeouts und ordentliches Cleanup aller Timer und Queues | + +--- + +## Web Viewer + +Zeigen Sie Ihre Erinnerungen über eine moderne Web-Oberfläche an und verwalten Sie sie. + +```bash +npx agentkits-memory-web +``` + +Öffnen Sie dann **http://localhost:1905** in Ihrem Browser. + +### Sitzungsliste + +Durchsuchen Sie alle Sitzungen mit Zeitleistenansicht und Aktivitätsdetails. + +![Session List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-session-list_v2.png) + +### Memory-Liste + +Durchsuchen Sie alle gespeicherten Erinnerungen mit Such- und Namespace-Filterung. + +![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list_v2.png) + +### Erinnerung hinzufügen + +Erstellen Sie neue Erinnerungen mit Key, Namespace, Typ, Inhalt und Tags. + +![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory_v2.png) + +### Memory-Details + +Zeigen Sie vollständige Memory-Details mit Bearbeitungs- und Löschoptionen an. + +![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail_v2.png) + +### Embeddings verwalten + +Generieren und verwalten Sie Vektor-Embeddings für semantische Suche. + +![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding_v2.png) + +--- + +## Schnellstart + +### Option 1: Claude Code Plugin-Marketplace (Empfohlen für Claude Code) + +Mit einem Befehl installieren — keine manuelle Konfiguration nötig: + +```bash +/plugin marketplace add aitytech/agentkits-memory +/plugin install agentkits-memory@aitytech +``` + +Dies installiert Hooks, MCP-Server und Memory-Workflow-Skill automatisch. Starten Sie Claude Code nach der Installation neu. + +### Option 2: Automatisches Setup (Alle Plattformen) + +```bash +npx agentkits-memory-setup +``` + +Dies erkennt Ihre Plattform automatisch und konfiguriert alles: MCP-Server, Hooks (Claude Code/OpenCode), Rules-Dateien (Cursor/Windsurf/Cline) und lädt das Embedding-Modell herunter. + +**Spezifische Plattform auswählen:** + +```bash +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all +``` + +### Option 3: Manuelle MCP-Konfiguration + +Wenn Sie manuelle Einrichtung bevorzugen, fügen Sie zu Ihrer MCP-Konfiguration hinzu: + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "agentkits-memory-server"] + } + } +} +``` + +Speicherorte der Config-Dateien: +- **Claude Code**: `.claude/settings.json` (eingebettet im `mcpServers`-Key) +- **Cursor**: `.cursor/mcp.json` +- **Windsurf**: `.windsurf/mcp.json` +- **Cline / OpenCode**: `.mcp.json` (Projektstamm) + +### 3. MCP-Tools + +Sobald konfiguriert, kann Ihr KI-Assistent diese Tools verwenden: + +| Tool | Beschreibung | +|------|--------------| +| `memory_status` | Memory-System-Status prüfen (zuerst aufrufen!) | +| `memory_save` | Entscheidungen, Muster, Fehler oder Kontext speichern | +| `memory_search` | **[Schritt 1]** Suchindex durchsuchen — leichtgewichtige IDs + Titel (~50 Tokens/Ergebnis) | +| `memory_timeline` | **[Schritt 2]** Temporalen Kontext um eine Erinnerung abrufen | +| `memory_details` | **[Schritt 3]** Vollständigen Inhalt für bestimmte IDs abrufen | +| `memory_recall` | Schnelle Themenübersicht — gruppierte Zusammenfassung | +| `memory_list` | Aktuelle Erinnerungen auflisten | +| `memory_update` | Vorhandenen Memory-Inhalt oder Tags aktualisieren | +| `memory_delete` | Veraltete Erinnerungen entfernen | + +--- + +## Progressive Disclosure (Token-effiziente Suche) + +AgentKits Memory verwendet ein **3-Schicht-Suchmuster**, das ~70% Tokens spart im Vergleich zum sofortigen Abrufen vollständiger Inhalte. + +### So funktioniert es + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Schritt 1: memory_search │ +│ Gibt zurück: IDs, Titel, Tags, Scores (~50 Tokens/Element) │ +│ → Index durchsehen, relevante Erinnerungen auswählen │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Schritt 2: memory_timeline (optional) │ +│ Gibt zurück: Kontext ±30 Minuten um Erinnerung │ +│ → Verstehen, was vorher/nachher passiert ist │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Schritt 3: memory_details │ +│ Gibt zurück: Vollständiger Inhalt nur für ausgewählte IDs │ +│ → Nur abrufen, was Sie tatsächlich brauchen │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Beispiel-Workflow + +```typescript +// Schritt 1: Suchen - leichtgewichtigen Index abrufen +memory_search({ query: "authentication" }) +// → Gibt zurück: [{ id: "abc", title: "JWT pattern...", score: 85% }] + +// Schritt 2: (Optional) Temporalen Kontext sehen +memory_timeline({ anchor: "abc" }) +// → Gibt zurück: Was vor/nach dieser Erinnerung passiert ist + +// Schritt 3: Vollständigen Inhalt nur für das Benötigte abrufen +memory_details({ ids: ["abc"] }) +// → Gibt zurück: Vollständiger Inhalt für ausgewählte Erinnerung +``` + +### Token-Einsparungen + +| Ansatz | Verwendete Tokens | +|--------|-------------------| +| **Alt:** Alle Inhalte abrufen | ~500 Tokens × 10 Ergebnisse = 5000 Tokens | +| **Neu:** Progressive Disclosure | 50 × 10 + 500 × 2 = 1500 Tokens | +| **Einsparung** | **70% Reduzierung** | + +--- + +## CLI-Befehle + +```bash +# Ein-Befehl-Setup (erkennt Plattform automatisch) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # spezifische Plattform +npx agentkits-memory-setup --platform=all # alle Plattformen +npx agentkits-memory-setup --force # neu installieren/aktualisieren + +# MCP-Server starten +npx agentkits-memory-server + +# Web Viewer (Port 1905) +npx agentkits-memory-web + +# Terminal-Viewer +npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # Datenbankstatistiken +npx agentkits-memory-viewer --json # JSON-Ausgabe + +# Aus CLI speichern +npx agentkits-memory-save "Use JWT with refresh tokens" --category pattern --tags auth,security + +# Einstellungen +npx agentkits-memory-hook settings . # aktuelle Einstellungen anzeigen +npx agentkits-memory-hook settings . --reset # auf Standard zurücksetzen +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# Export / Import +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# Lifecycle-Management +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . +``` + +--- + +## Programmatische Verwendung + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService({ + baseDir: '.claude/memory', + dbFilename: 'memory.db', +}); +await memory.initialize(); + +// Eine Erinnerung speichern +await memory.storeEntry({ + key: 'auth-pattern', + content: 'Use JWT with refresh tokens for authentication', + namespace: 'patterns', + tags: ['auth', 'security'], +}); + +// Erinnerungen abfragen +const results = await memory.query({ + type: 'hybrid', + namespace: 'patterns', + content: 'authentication', + limit: 10, +}); + +// Nach Key abrufen +const entry = await memory.getByKey('patterns', 'auth-pattern'); +``` + +--- + +## Auto-Capture-Hooks + +Hooks erfassen Ihre KI-Coding-Sessions automatisch (nur Claude Code und OpenCode): + +| Hook | Auslöser | Aktion | +|------|----------|--------| +| `context` | Session-Start | Injiziert vorherigen Session-Kontext + Memory-Status | +| `session-init` | User Prompt | Initialisiert/setzt Session fort, zeichnet Prompts auf | +| `observation` | Nach Tool-Nutzung | Erfasst Tool-Nutzung mit Intent-Erkennung | +| `summarize` | Session-Ende | Generiert strukturierte Session-Zusammenfassung | +| `user-message` | Session-Start | Zeigt Memory-Status dem Benutzer an (stderr) | + +Hooks einrichten: +```bash +npx agentkits-memory-setup +``` + +**Was automatisch erfasst wird:** +- Datei-Reads/Writes mit Pfaden +- Code-Änderungen als strukturierte Diffs (vorher → nachher) +- Entwickler-Intent (Bugfix, Feature, Refactoring, Investigation, etc.) +- Session-Zusammenfassungen mit Entscheidungen, Fehlern und nächsten Schritten +- Multi-Prompt-Tracking innerhalb von Sessions + +--- + +## Multi-Plattform-Unterstützung + +| Plattform | MCP | Hooks | Rules-Datei | Setup | +|-----------|-----|-------|-------------|-------| +| **Claude Code** | `.claude/settings.json` | ✅ Voll | CLAUDE.md (skill) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ Voll | — | `--platform=opencode` | + +- **MCP Server** funktioniert mit allen Plattformen (Memory-Tools über MCP-Protokoll) +- **Hooks** bieten Auto-Capture auf Claude Code und OpenCode +- **Rules-Dateien** lehren Cursor/Windsurf/Cline den Memory-Workflow +- **Memory-Daten** immer in `.claude/memory/` gespeichert (Single Source of Truth) + +--- + +## Background-Worker + +Nach jeder Session verarbeiten Background-Worker Aufgaben in der Warteschlange: + +| Worker | Aufgabe | Beschreibung | +|--------|---------|--------------| +| `embed-session` | Embeddings | Generiert Vektor-Embeddings für semantische Suche | +| `enrich-session` | KI-Anreicherung | Reichert Beobachtungen mit KI-generierten Zusammenfassungen, Fakten, Konzepten an | +| `compress-session` | Komprimierung | Komprimiert alte Beobachtungen (10:1–25:1) und generiert Session-Digests (20:1–100:1) | + +Worker laufen automatisch nach Session-Ende. Jeder Worker: +- Verarbeitet bis zu 200 Elemente pro Durchlauf +- Verwendet Lock-Dateien, um gleichzeitige Ausführung zu verhindern +- Beendet sich nach 5 Minuten automatisch (verhindert Zombies) +- Wiederholt fehlgeschlagene Aufgaben bis zu 3 Mal + +--- + +## KI-Provider-Konfiguration + +KI-Anreicherung verwendet austauschbare Provider. Standard ist `claude-cli` (kein API-Key benötigt). + +| Provider | Typ | Standard-Modell | Hinweise | +|----------|-----|-----------------|----------| +| **Claude CLI** | `claude-cli` | `haiku` | Verwendet `claude --print`, kein API-Key benötigt | +| **OpenAI** | `openai` | `gpt-4o-mini` | Jedes OpenAI-Modell | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | Google AI Studio Key | +| **OpenRouter** | `openai` | beliebig | `baseUrl` auf `https://openrouter.ai/api/v1` setzen | +| **GLM (Zhipu)** | `openai` | beliebig | `baseUrl` auf `https://open.bigmodel.cn/api/paas/v4` setzen | +| **Ollama** | `openai` | beliebig | `baseUrl` auf `http://localhost:11434/v1` setzen | + +### Option 1: Umgebungsvariablen + +```bash +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter (verwendet OpenAI-kompatibles Format) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# Lokales Ollama (kein API-Key benötigt) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# KI-Anreicherung komplett deaktivieren +export AGENTKITS_AI_ENRICHMENT=false +``` + +### Option 2: Persistente Einstellungen + +```bash +# In .claude/memory/settings.json gespeichert — bleibt über Sessions bestehen +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# Aktuelle Einstellungen anzeigen +npx agentkits-memory-hook settings . + +# Auf Standard zurücksetzen +npx agentkits-memory-hook settings . --reset +``` + +> **Priorität:** Umgebungsvariablen überschreiben settings.json. Settings.json überschreibt Standardwerte. + +--- + +## Lifecycle-Management + +Memory-Wachstum im Laufe der Zeit verwalten: + +```bash +# Beobachtungen älter als 7 Tage komprimieren, Sessions älter als 30 Tage archivieren +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# Zusätzlich archivierte Sessions älter als 90 Tage automatisch löschen +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# Lifecycle-Statistiken anzeigen +npx agentkits-memory-hook lifecycle-stats . +``` + +| Phase | Was passiert | +|-------|-------------| +| **Komprimierung** | KI-komprimiert Beobachtungen, generiert Session-Digests | +| **Archivierung** | Markiert alte Sessions als archiviert (aus Kontext ausgeschlossen) | +| **Löschung** | Entfernt archivierte Sessions (Opt-in, erfordert `--delete`) | + +--- + +## Export / Import + +Backup und Wiederherstellung Ihrer Projekt-Erinnerungen: + +```bash +# Alle Sessions für ein Projekt exportieren +npx agentkits-memory-hook export . my-project ./backup.json + +# Aus Backup importieren (dedupliziert automatisch) +npx agentkits-memory-hook import . ./backup.json +``` + +Export-Format enthält Sessions, Beobachtungen, Prompts und Zusammenfassungen. + +--- + +## Memory-Kategorien + +| Kategorie | Anwendungsfall | +|-----------|----------------| +| `decision` | Architektur-Entscheidungen, Tech-Stack-Auswahl, Trade-offs | +| `pattern` | Coding-Konventionen, Projekt-Muster, wiederkehrende Ansätze | +| `error` | Bugfixes, Fehlerlösungen, Debugging-Einblicke | +| `context` | Projekt-Hintergrund, Team-Konventionen, Umgebungs-Setup | +| `observation` | Automatisch erfasste Session-Beobachtungen | + +--- + +## Speicherung + +Erinnerungen werden in `.claude/memory/` innerhalb Ihres Projektverzeichnisses gespeichert. + +``` +.claude/memory/ +├── memory.db # SQLite-Datenbank (alle Daten) +├── memory.db-wal # Write-ahead Log (temp) +├── settings.json # Persistente Einstellungen (KI-Provider, Kontext-Config) +└── embeddings-cache/ # Gecachte Vektor-Embeddings +``` + +--- + +## CJK-Sprachunterstützung + +AgentKits Memory hat **automatische CJK-Unterstützung** für chinesische, japanische und koreanische Textsuche. + +### Null Konfiguration + +Wenn `better-sqlite3` installiert ist (Standard), funktioniert CJK-Suche automatisch: + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService('.claude/memory'); +await memory.initialize(); + +// CJK-Inhalt speichern +await memory.storeEntry({ + key: 'auth-pattern', + content: '認証機能の実装パターン - JWT with refresh tokens', + namespace: 'patterns', +}); + +// Auf Japanisch, Chinesisch oder Koreanisch suchen - es funktioniert einfach! +const results = await memory.query({ + type: 'hybrid', + content: '認証機能', +}); +``` + +### So funktioniert es + +- **Native SQLite**: Verwendet `better-sqlite3` für maximale Performance +- **Trigram-Tokenizer**: FTS5 mit Trigram erstellt 3-Zeichen-Sequenzen für CJK-Matching +- **Smart Fallback**: Kurze CJK-Abfragen (< 3 Zeichen) verwenden automatisch LIKE-Suche +- **BM25-Ranking**: Relevanz-Scoring für Suchergebnisse + +### Erweitert: Japanische Wortsegmentierung + +Für erweitertes Japanisch mit ordentlicher Wortsegmentierung optional lindera verwenden: + +```typescript +import { createJapaneseOptimizedBackend } from '@aitytech/agentkits-memory'; + +const backend = createJapaneseOptimizedBackend({ + databasePath: '.claude/memory/memory.db', + linderaPath: './path/to/liblindera_sqlite.dylib', +}); +``` + +Erfordert [lindera-sqlite](https://github.com/lindera/lindera-sqlite) Build. + +--- + +## API-Referenz + +### ProjectMemoryService + +```typescript +interface ProjectMemoryConfig { + baseDir: string; // Standard: '.claude/memory' + dbFilename: string; // Standard: 'memory.db' + enableVectorIndex: boolean; // Standard: false + dimensions: number; // Standard: 384 + embeddingGenerator?: EmbeddingGenerator; + cacheEnabled: boolean; // Standard: true + cacheSize: number; // Standard: 1000 + cacheTtl: number; // Standard: 300000 (5 Min.) +} +``` + +### Methoden + +| Methode | Beschreibung | +|---------|--------------| +| `initialize()` | Memory-Service initialisieren | +| `shutdown()` | Herunterfahren und Änderungen speichern | +| `storeEntry(input)` | Memory-Eintrag speichern | +| `get(id)` | Eintrag nach ID abrufen | +| `getByKey(namespace, key)` | Eintrag nach Namespace und Key abrufen | +| `update(id, update)` | Eintrag aktualisieren | +| `delete(id)` | Eintrag löschen | +| `query(query)` | Einträge mit Filtern abfragen | +| `semanticSearch(content, k)` | Semantische Ähnlichkeitssuche | +| `count(namespace?)` | Einträge zählen | +| `listNamespaces()` | Alle Namespaces auflisten | +| `getStats()` | Statistiken abrufen | + +--- + +## Codequalität + +AgentKits Memory ist gründlich getestet mit **970 Unit-Tests** in 21 Test-Suites. + +| Metrik | Abdeckung | +|--------|-----------| +| **Anweisungen** | 90.29% | +| **Verzweigungen** | 80.85% | +| **Funktionen** | 90.54% | +| **Zeilen** | 91.74% | + +### Testkategorien + +| Kategorie | Tests | Abgedeckt | +|-----------|-------|-----------| +| Kern-Speicherdienst | 56 | CRUD, Suche, Paginierung, Kategorien, Tags, Import/Export | +| SQLite-Backend | 65 | Schema, Migrationen, FTS5, Transaktionen, Fehlerbehandlung | +| HNSW-Vektorindex | 47 | Einfügen, Suche, Löschen, Persistenz, Grenzfälle | +| Hybride Suche | 44 | FTS + Vektor-Fusion, Bewertung, Ranking, Filter | +| Token-Ökonomie | 27 | 3-Schicht-Suchbudgets, Kürzung, Optimierung | +| Embedding-System | 63 | Cache, Subprozess, lokale Modelle, CJK-Unterstützung | +| Hook-System | 502 | Kontext, Session-Init, Beobachtung, Zusammenfassung, KI-Anreicherung, Service-Lebenszyklus, Queue-Worker, Adapter, Typen | +| MCP-Server | 48 | Alle 9 MCP-Tools, Validierung, Fehlerantworten | +| CLI | 34 | Plattformerkennung, Regelgenerierung | +| Integration | 84 | End-to-End-Flows, Embedding-Integration, Multi-Session | + +```bash +# Tests ausführen +npm test + +# Mit Abdeckung ausführen +npm run test:coverage +``` + +--- + +## Anforderungen + +- **Node.js LTS**: 18.x, 20.x oder 22.x (empfohlen) +- MCP-kompatibler KI-Coding-Assistent + +### Node.js-Versions-Hinweise + +Dieses Paket verwendet `better-sqlite3`, das native Binaries benötigt. **Vorgefertigte Binaries sind nur für LTS-Versionen verfügbar**. + +| Node-Version | Status | Hinweise | +|--------------|--------|----------| +| 18.x LTS | ✅ Funktioniert | Vorgefertigte Binaries | +| 20.x LTS | ✅ Funktioniert | Vorgefertigte Binaries | +| 22.x LTS | ✅ Funktioniert | Vorgefertigte Binaries | +| 19.x, 21.x, 23.x | ⚠️ Benötigt Build-Tools | Keine vorgefertigten Binaries | + +### Nicht-LTS-Versionen verwenden (Windows) + +Wenn Sie eine Nicht-LTS-Version (19, 21, 23) verwenden müssen, installieren Sie zuerst Build-Tools: + +**Option 1: Visual Studio Build Tools** +```powershell +# Herunterladen und installieren von: +# https://visualstudio.microsoft.com/visual-cpp-build-tools/ +# Wählen Sie "Desktopentwicklung mit C++"-Workload +``` + +**Option 2: windows-build-tools (npm)** +```powershell +npm install --global windows-build-tools +``` + +**Option 3: Chocolatey** +```powershell +choco install visualstudio2022-workload-vctools +``` + +Siehe [node-gyp Windows-Leitfaden](https://github.com/nodejs/node-gyp#on-windows) für weitere Details. + +--- + +## AgentKits-Ökosystem + +**AgentKits Memory** ist Teil des AgentKits-Ökosystems von AityTech - Tools, die KI-Coding-Assistenten intelligenter machen. + +| Produkt | Beschreibung | Link | +|---------|--------------|------| +| **AgentKits Engineer** | 28 spezialisierte Agents, 100+ Skills, Enterprise-Muster | [GitHub](https://github.com/aitytech/agentkits-engineer) | +| **AgentKits Marketing** | KI-gestützte Marketing-Content-Generierung | [GitHub](https://github.com/aitytech/agentkits-marketing) | +| **AgentKits Memory** | Persistenter Speicher für KI-Assistenten (dieses Paket) | [npm](https://www.npmjs.com/package/@aitytech/agentkits-memory) | + +

+ + agentkits.net + +

+ +--- + +## Star History + + + + + + Star History Chart + + + +--- + +## Lizenz + +MIT + +--- + +

+ Geben Sie Ihrem KI-Assistenten Speicher, der bleibt. +

+ +

+ AgentKits Memory von AityTech +

+ +

+ Sternchen Sie dieses Repo, wenn es Ihrer KI hilft, sich zu erinnern. +

\ No newline at end of file diff --git a/i18n/README.es.md b/i18n/README.es.md new file mode 100644 index 0000000..6c5cfdb --- /dev/null +++ b/i18n/README.es.md @@ -0,0 +1,743 @@ +

+ AgentKits Logo +

+ +

AgentKits Memory

+ +

+ por AityTech +

+ +

+ npm + License + Claude Code + Cursor + Windsurf + Cline + OpenCode +
+ Tests + Coverage +

+ +

+ Sistema de Memoria Persistente para Asistentes de IA en Programación +

+ +

+ Tu asistente de IA olvida todo entre sesiones. AgentKits Memory soluciona eso.
+ Decisiones, patrones, errores y contexto — todo persistido localmente vía MCP. +

+ +

+ Sitio Web • + Documentación • + Inicio Rápido • + Cómo Funciona • + Plataformas • + CLI • + Visor Web +

+ +

+ English · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português · Tiếng Việt · Русский · العربية +

+ +--- + +## Características + +| Característica | Beneficio | +|---------|---------| +| **100% Local** | Todos los datos permanecen en tu máquina. Sin nube, sin claves API, sin cuentas | +| **Extremadamente Rápido** | SQLite nativo (better-sqlite3) = consultas instantáneas, cero latencia | +| **Configuración Cero** | Funciona desde el primer momento. No requiere configuración de base de datos | +| **Multiplataforma** | Claude Code, Cursor, Windsurf, Cline, OpenCode — un solo comando de configuración | +| **Servidor MCP** | 9 herramientas: guardar, buscar, línea de tiempo, detalles, recordar, listar, actualizar, eliminar, estado | +| **Captura Automática** | Los hooks capturan contexto de sesión, uso de herramientas y resúmenes automáticamente | +| **Enriquecimiento con IA** | Los workers en segundo plano enriquecen observaciones con resúmenes generados por IA | +| **Búsqueda Vectorial** | Similitud semántica HNSW con embeddings multilingües (más de 100 idiomas) | +| **Visor Web** | Interfaz de navegador para ver, buscar, agregar, editar y eliminar memorias | +| **Búsqueda de 3 Capas** | La divulgación progresiva ahorra ~87% de tokens vs obtener todo | +| **Gestión del Ciclo de Vida** | Comprime, archiva y limpia automáticamente sesiones antiguas | +| **Exportar/Importar** | Respalda y restaura memorias como JSON | + +--- + +## Cómo Funciona + +``` +Sesión 1: "Usar JWT para auth" Sesión 2: "Agregar endpoint de login" +┌──────────────────────────┐ ┌──────────────────────────┐ +│ Programas con IA... │ │ La IA ya conoce: │ +│ IA toma decisiones │ │ ✓ Decisión de JWT auth │ +│ IA encuentra errores │ ───► │ ✓ Soluciones de errores │ +│ IA aprende patrones │ guardado│ ✓ Patrones de código │ +│ │ │ ✓ Contexto de sesión │ +└──────────────────────────┘ └──────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite, 100% local) +``` + +1. **Configura una vez** — `npx agentkits-memory-setup` configura tu plataforma +2. **Captura automática** — Los hooks registran decisiones, uso de herramientas y resúmenes mientras trabajas +3. **Inyección de contexto** — La siguiente sesión comienza con historial relevante de sesiones pasadas +4. **Procesamiento en segundo plano** — Los workers enriquecen observaciones con IA, generan embeddings, comprimen datos antiguos +5. **Busca en cualquier momento** — La IA usa herramientas MCP (`memory_search` → `memory_details`) para encontrar contexto pasado + +Todos los datos permanecen en `.claude/memory/memory.db` en tu máquina. Sin nube. Sin claves API requeridas. + +--- + +## Decisiones de Diseño que Importan + +La mayoría de las herramientas de memoria dispersan datos en archivos markdown, requieren entornos de ejecución de Python o envían tu código a APIs externas. AgentKits Memory toma decisiones fundamentalmente diferentes: + +| Decisión de Diseño | Por Qué Importa | +|---------------|----------------| +| **Base de datos SQLite única** | Un archivo (`memory.db`) contiene todo — memorias, sesiones, observaciones, embeddings. Sin archivos dispersos que sincronizar, sin conflictos de fusión, sin datos huérfanos. Respaldo = copiar un archivo | +| **Node.js nativo, cero Python** | Se ejecuta donde sea que se ejecute Node. Sin conda, sin pip, sin virtualenv. El mismo lenguaje que tu servidor MCP — un comando `npx`, listo | +| **Búsqueda de 3 capas eficiente en tokens** | Primero índice de búsqueda (~50 tokens/resultado), luego contexto de línea de tiempo, luego detalles completos. Solo obtén lo que necesitas. Otras herramientas vuelcan archivos de memoria completos en el contexto, quemando tokens en contenido irrelevante | +| **Captura automática vía hooks** | Las decisiones, patrones y errores se registran a medida que ocurren — no después de que recuerdes guardarlos. La inyección de contexto de sesión ocurre automáticamente al inicio de la siguiente sesión | +| **Embeddings locales, sin llamadas API** | La búsqueda vectorial usa un modelo ONNX local (multilingual-e5-small). La búsqueda semántica funciona sin conexión, no cuesta nada y soporta más de 100 idiomas | +| **Workers en segundo plano** | El enriquecimiento con IA, la generación de embeddings y la compresión se ejecutan de forma asíncrona. Tu flujo de codificación nunca se bloquea | +| **Multiplataforma desde el día uno** | Una bandera `--platform=all` configura Claude Code, Cursor, Windsurf, Cline y OpenCode simultáneamente. Misma base de datos de memoria, diferentes editores | +| **Datos de observación estructurados** | El uso de herramientas se captura con clasificación de tipos (lectura/escritura/ejecución/búsqueda), seguimiento de archivos, detección de intención y narrativas generadas por IA — no volcados de texto sin procesar | +| **Sin fugas de procesos** | Los workers en segundo plano se autodestruyen después de 5 minutos, usan archivos de bloqueo basados en PID con limpieza de bloqueos obsoletos y manejan SIGTERM/SIGINT con gracia. Sin procesos zombis, sin workers huérfanos | +| **Sin fugas de memoria** | Los hooks se ejecutan como procesos de corta duración (no demonios de larga ejecución). Las conexiones de base de datos se cierran al apagar. El subproceso de embedding tiene respawn limitado (máx 2), tiempos de espera de solicitudes pendientes y limpieza elegante de todos los temporizadores y colas | + +--- + +## Visor Web + +Ver y gestionar tus memorias a través de una interfaz web moderna. + +```bash +npx agentkits-memory-web +``` + +Luego abre **http://localhost:1905** en tu navegador. + +### Lista de Sesiones + +Explora todas las sesiones con vista de línea de tiempo y detalles de actividad. + +![Session List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-session-list_v2.png) + +### Lista de Memorias + +Explora todas las memorias almacenadas con búsqueda y filtrado por espacio de nombres. + +![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list_v2.png) + +### Agregar Memoria + +Crea nuevas memorias con clave, espacio de nombres, tipo, contenido y etiquetas. + +![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory_v2.png) + +### Detalles de Memoria + +Ver detalles completos de la memoria con opciones de edición y eliminación. + +![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail_v2.png) + +### Gestionar Embeddings + +Genera y gestiona embeddings vectoriales para búsqueda semántica. + +![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding_v2.png) + +--- + +## Inicio Rápido + +### Opción 1: Marketplace de Plugins de Claude Code (Recomendado para Claude Code) + +Instala con un solo comando — sin configuración manual: + +```bash +/plugin marketplace add aitytech/agentkits-memory +/plugin install agentkits-memory@aitytech +``` + +Esto instala hooks, servidor MCP y skill de flujo de trabajo de memoria automáticamente. Reinicia Claude Code después de la instalación. + +### Opción 2: Configuración Automática (Todas las Plataformas) + +```bash +npx agentkits-memory-setup +``` + +Esto detecta automáticamente tu plataforma y configura todo: servidor MCP, hooks (Claude Code/OpenCode), archivos de reglas (Cursor/Windsurf/Cline), y descarga el modelo de embedding. + +**Apuntar a una plataforma específica:** + +```bash +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all +``` + +### Opción 3: Configuración Manual de MCP + +Si prefieres la configuración manual, agrega a tu configuración MCP: + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "agentkits-memory-server"] + } + } +} +``` + +Ubicaciones de archivos de configuración: +- **Claude Code**: `.claude/settings.json` (embebido en la clave `mcpServers`) +- **Cursor**: `.cursor/mcp.json` +- **Windsurf**: `.windsurf/mcp.json` +- **Cline / OpenCode**: `.mcp.json` (raíz del proyecto) + +### 3. Herramientas MCP + +Una vez configurado, tu asistente de IA puede usar estas herramientas: + +| Herramienta | Descripción | +|------|-------------| +| `memory_status` | Verifica el estado del sistema de memoria (¡llama primero!) | +| `memory_save` | Guarda decisiones, patrones, errores o contexto | +| `memory_search` | **[Paso 1]** Índice de búsqueda — IDs + títulos ligeros (~50 tokens/resultado) | +| `memory_timeline` | **[Paso 2]** Obtén contexto temporal alrededor de una memoria | +| `memory_details` | **[Paso 3]** Obtén contenido completo para IDs específicos | +| `memory_recall` | Vista rápida del tema — resumen agrupado | +| `memory_list` | Lista memorias recientes | +| `memory_update` | Actualiza contenido o etiquetas de memoria existente | +| `memory_delete` | Elimina memorias obsoletas | + +--- + +## Divulgación Progresiva (Búsqueda Eficiente en Tokens) + +AgentKits Memory usa un **patrón de búsqueda de 3 capas** que ahorra ~70% de tokens comparado con obtener contenido completo por adelantado. + +### Cómo Funciona + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Paso 1: memory_search │ +│ Devuelve: IDs, títulos, etiquetas, puntuaciones (~50 tokens/elemento) │ +│ → Revisa el índice, elige memorias relevantes │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Paso 2: memory_timeline (opcional) │ +│ Devuelve: Contexto ±30 minutos alrededor de la memoria │ +│ → Comprende qué sucedió antes/después │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Paso 3: memory_details │ +│ Devuelve: Contenido completo solo para IDs seleccionados │ +│ → Obtén solo lo que realmente necesitas │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Flujo de Trabajo de Ejemplo + +```typescript +// Paso 1: Buscar - obtener índice ligero +memory_search({ query: "authentication" }) +// → Devuelve: [{ id: "abc", title: "JWT pattern...", score: 85% }] + +// Paso 2: (Opcional) Ver contexto temporal +memory_timeline({ anchor: "abc" }) +// → Devuelve: Qué sucedió antes/después de esta memoria + +// Paso 3: Obtener contenido completo solo para lo que necesitas +memory_details({ ids: ["abc"] }) +// → Devuelve: Contenido completo para la memoria seleccionada +``` + +### Ahorro de Tokens + +| Enfoque | Tokens Usados | +|----------|-------------| +| **Antiguo:** Obtener todo el contenido | ~500 tokens × 10 resultados = 5000 tokens | +| **Nuevo:** Divulgación progresiva | 50 × 10 + 500 × 2 = 1500 tokens | +| **Ahorro** | **Reducción del 70%** | + +--- + +## Comandos CLI + +```bash +# Configuración con un comando (detecta automáticamente la plataforma) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # plataforma específica +npx agentkits-memory-setup --platform=all # todas las plataformas +npx agentkits-memory-setup --force # reinstalar/actualizar + +# Iniciar servidor MCP +npx agentkits-memory-server + +# Visor web (puerto 1905) +npx agentkits-memory-web + +# Visor de terminal +npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # estadísticas de base de datos +npx agentkits-memory-viewer --json # salida JSON + +# Guardar desde CLI +npx agentkits-memory-save "Use JWT with refresh tokens" --category pattern --tags auth,security + +# Configuración +npx agentkits-memory-hook settings . # ver configuración actual +npx agentkits-memory-hook settings . --reset # restablecer a valores predeterminados +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# Exportar / Importar +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# Gestión del ciclo de vida +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . +``` + +--- + +## Uso Programático + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService({ + baseDir: '.claude/memory', + dbFilename: 'memory.db', +}); +await memory.initialize(); + +// Almacenar una memoria +await memory.storeEntry({ + key: 'auth-pattern', + content: 'Use JWT with refresh tokens for authentication', + namespace: 'patterns', + tags: ['auth', 'security'], +}); + +// Consultar memorias +const results = await memory.query({ + type: 'hybrid', + namespace: 'patterns', + content: 'authentication', + limit: 10, +}); + +// Obtener por clave +const entry = await memory.getByKey('patterns', 'auth-pattern'); +``` + +--- + +## Hooks de Captura Automática + +Los hooks capturan automáticamente tus sesiones de codificación con IA (solo Claude Code y OpenCode): + +| Hook | Disparador | Acción | +|------|---------|--------| +| `context` | Inicio de Sesión | Inyecta contexto de sesión anterior + estado de memoria | +| `session-init` | Prompt del Usuario | Inicializa/reanuda sesión, registra prompts | +| `observation` | Después del Uso de Herramienta | Captura uso de herramienta con detección de intención | +| `summarize` | Fin de Sesión | Genera resumen estructurado de sesión | +| `user-message` | Inicio de Sesión | Muestra estado de memoria al usuario (stderr) | + +Configurar hooks: +```bash +npx agentkits-memory-setup +``` + +**Lo que se captura automáticamente:** +- Lecturas/escrituras de archivos con rutas +- Cambios de código como diffs estructurados (antes → después) +- Intención del desarrollador (corrección de errores, característica, refactorización, investigación, etc.) +- Resúmenes de sesión con decisiones, errores y próximos pasos +- Seguimiento de múltiples prompts dentro de sesiones + +--- + +## Compatibilidad Multiplataforma + +| Plataforma | MCP | Hooks | Archivo de Reglas | Configuración | +|----------|-----|-------|------------|-------| +| **Claude Code** | `.claude/settings.json` | ✅ Completo | CLAUDE.md (skill) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ Completo | — | `--platform=opencode` | + +- **Servidor MCP** funciona con todas las plataformas (herramientas de memoria vía protocolo MCP) +- **Hooks** proporcionan captura automática en Claude Code y OpenCode +- **Archivos de reglas** enseñan a Cursor/Windsurf/Cline el flujo de trabajo de memoria +- **Datos de memoria** siempre almacenados en `.claude/memory/` (única fuente de verdad) + +--- + +## Workers en Segundo Plano + +Después de cada sesión, los workers en segundo plano procesan tareas en cola: + +| Worker | Tarea | Descripción | +|--------|------|-------------| +| `embed-session` | Embeddings | Genera embeddings vectoriales para búsqueda semántica | +| `enrich-session` | Enriquecimiento con IA | Enriquece observaciones con resúmenes, hechos y conceptos generados por IA | +| `compress-session` | Compresión | Comprime observaciones antiguas (10:1–25:1) y genera resúmenes de sesión (20:1–100:1) | + +Los workers se ejecutan automáticamente después del fin de la sesión. Cada worker: +- Procesa hasta 200 elementos por ejecución +- Usa archivos de bloqueo para prevenir ejecución concurrente +- Se autodestruye después de 5 minutos (previene zombis) +- Reintenta tareas fallidas hasta 3 veces + +--- + +## Configuración del Proveedor de IA + +El enriquecimiento con IA usa proveedores conectables. El predeterminado es `claude-cli` (no se necesita clave API). + +| Proveedor | Tipo | Modelo Predeterminado | Notas | +|----------|------|---------------|-------| +| **Claude CLI** | `claude-cli` | `haiku` | Usa `claude --print`, no se necesita clave API | +| **OpenAI** | `openai` | `gpt-4o-mini` | Cualquier modelo OpenAI | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | Clave de Google AI Studio | +| **OpenRouter** | `openai` | cualquiera | Establece `baseUrl` a `https://openrouter.ai/api/v1` | +| **GLM (Zhipu)** | `openai` | cualquiera | Establece `baseUrl` a `https://open.bigmodel.cn/api/paas/v4` | +| **Ollama** | `openai` | cualquiera | Establece `baseUrl` a `http://localhost:11434/v1` | + +### Opción 1: Variables de Entorno + +```bash +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter (usa formato compatible con OpenAI) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# Ollama local (no se necesita clave API) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# Deshabilitar enriquecimiento con IA por completo +export AGENTKITS_AI_ENRICHMENT=false +``` + +### Opción 2: Configuración Persistente + +```bash +# Guardado en .claude/memory/settings.json — persiste entre sesiones +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# Ver configuración actual +npx agentkits-memory-hook settings . + +# Restablecer a valores predeterminados +npx agentkits-memory-hook settings . --reset +``` + +> **Prioridad:** Las variables de entorno anulan settings.json. Settings.json anula los valores predeterminados. + +--- + +## Gestión del Ciclo de Vida + +Gestiona el crecimiento de la memoria a lo largo del tiempo: + +```bash +# Comprimir observaciones más antiguas de 7 días, archivar sesiones más antiguas de 30 días +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# También eliminar automáticamente sesiones archivadas más antiguas de 90 días +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# Ver estadísticas del ciclo de vida +npx agentkits-memory-hook lifecycle-stats . +``` + +| Etapa | Qué Sucede | +|-------|-------------| +| **Comprimir** | Comprime con IA las observaciones, genera resúmenes de sesión | +| **Archivar** | Marca sesiones antiguas como archivadas (excluidas del contexto) | +| **Eliminar** | Elimina sesiones archivadas (opt-in, requiere `--delete`) | + +--- + +## Exportar / Importar + +Respalda y restaura las memorias de tu proyecto: + +```bash +# Exportar todas las sesiones para un proyecto +npx agentkits-memory-hook export . my-project ./backup.json + +# Importar desde respaldo (deduplica automáticamente) +npx agentkits-memory-hook import . ./backup.json +``` + +El formato de exportación incluye sesiones, observaciones, prompts y resúmenes. + +--- + +## Categorías de Memoria + +| Categoría | Caso de Uso | +|----------|----------| +| `decision` | Decisiones de arquitectura, elecciones de stack tecnológico, compromisos | +| `pattern` | Convenciones de codificación, patrones de proyecto, enfoques recurrentes | +| `error` | Correcciones de errores, soluciones de errores, insights de depuración | +| `context` | Antecedentes del proyecto, convenciones del equipo, configuración del entorno | +| `observation` | Observaciones de sesión capturadas automáticamente | + +--- + +## Almacenamiento + +Las memorias se almacenan en `.claude/memory/` dentro del directorio de tu proyecto. + +``` +.claude/memory/ +├── memory.db # Base de datos SQLite (todos los datos) +├── memory.db-wal # Registro write-ahead (temporal) +├── settings.json # Configuración persistente (proveedor de IA, config de contexto) +└── embeddings-cache/ # Embeddings vectoriales en caché +``` + +--- + +## Soporte para Idiomas CJK + +AgentKits Memory tiene **soporte automático para CJK** para búsqueda de texto en chino, japonés y coreano. + +### Configuración Cero + +Cuando `better-sqlite3` está instalado (predeterminado), la búsqueda CJK funciona automáticamente: + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService('.claude/memory'); +await memory.initialize(); + +// Almacenar contenido CJK +await memory.storeEntry({ + key: 'auth-pattern', + content: '認証機能の実装パターン - JWT with refresh tokens', + namespace: 'patterns', +}); + +// Buscar en japonés, chino o coreano - ¡simplemente funciona! +const results = await memory.query({ + type: 'hybrid', + content: '認証機能', +}); +``` + +### Cómo Funciona + +- **SQLite nativo**: Usa `better-sqlite3` para máximo rendimiento +- **Tokenizador de trigramas**: FTS5 con trigramas crea secuencias de 3 caracteres para coincidencia CJK +- **Respaldo inteligente**: Consultas CJK cortas (< 3 caracteres) usan automáticamente búsqueda LIKE +- **Clasificación BM25**: Puntuación de relevancia para resultados de búsqueda + +### Avanzado: Segmentación de Palabras en Japonés + +Para japonés avanzado con segmentación de palabras adecuada, opcionalmente usa lindera: + +```typescript +import { createJapaneseOptimizedBackend } from '@aitytech/agentkits-memory'; + +const backend = createJapaneseOptimizedBackend({ + databasePath: '.claude/memory/memory.db', + linderaPath: './path/to/liblindera_sqlite.dylib', +}); +``` + +Requiere compilación de [lindera-sqlite](https://github.com/lindera/lindera-sqlite). + +--- + +## Referencia de API + +### ProjectMemoryService + +```typescript +interface ProjectMemoryConfig { + baseDir: string; // Predeterminado: '.claude/memory' + dbFilename: string; // Predeterminado: 'memory.db' + enableVectorIndex: boolean; // Predeterminado: false + dimensions: number; // Predeterminado: 384 + embeddingGenerator?: EmbeddingGenerator; + cacheEnabled: boolean; // Predeterminado: true + cacheSize: number; // Predeterminado: 1000 + cacheTtl: number; // Predeterminado: 300000 (5 min) +} +``` + +### Métodos + +| Método | Descripción | +|--------|-------------| +| `initialize()` | Inicializar el servicio de memoria | +| `shutdown()` | Apagar y persistir cambios | +| `storeEntry(input)` | Almacenar una entrada de memoria | +| `get(id)` | Obtener entrada por ID | +| `getByKey(namespace, key)` | Obtener entrada por espacio de nombres y clave | +| `update(id, update)` | Actualizar una entrada | +| `delete(id)` | Eliminar una entrada | +| `query(query)` | Consultar entradas con filtros | +| `semanticSearch(content, k)` | Búsqueda de similitud semántica | +| `count(namespace?)` | Contar entradas | +| `listNamespaces()` | Listar todos los espacios de nombres | +| `getStats()` | Obtener estadísticas | + +--- + +## Calidad del Código + +AgentKits Memory está exhaustivamente probado con **970 tests unitarios** en 21 suites de test. + +| Métrica | Cobertura | +|---------|-----------| +| **Sentencias** | 90.29% | +| **Ramas** | 80.85% | +| **Funciones** | 90.54% | +| **Líneas** | 91.74% | + +### Categorías de Tests + +| Categoría | Tests | Cobertura | +|-----------|-------|-----------| +| Servicio de Memoria Core | 56 | CRUD, búsqueda, paginación, categorías, tags, importar/exportar | +| Backend SQLite | 65 | Schema, migraciones, FTS5, transacciones, manejo de errores | +| Índice Vectorial HNSW | 47 | Inserción, búsqueda, eliminación, persistencia, casos límite | +| Búsqueda Híbrida | 44 | FTS + fusión vectorial, puntuación, ranking, filtros | +| Economía de Tokens | 27 | Presupuestos de búsqueda 3 capas, truncamiento, optimización | +| Sistema de Embeddings | 63 | Caché, subprocesos, modelos locales, soporte CJK | +| Sistema de Hooks | 502 | Contexto, init de sesión, observación, resumen, enriquecimiento IA, ciclo de vida, workers, adaptadores, tipos | +| Servidor MCP | 48 | 9 herramientas MCP, validación, respuestas de error | +| CLI | 34 | Detección de plataforma, generación de reglas | +| Integración | 84 | Flujos end-to-end, integración de embeddings, multi-sesión | + +```bash +# Ejecutar tests +npm test + +# Ejecutar con cobertura +npm run test:coverage +``` + +--- + +## Requisitos + +- **Node.js LTS**: 18.x, 20.x o 22.x (recomendado) +- Asistente de codificación con IA compatible con MCP + +### Notas sobre la Versión de Node.js + +Este paquete usa `better-sqlite3` que requiere binarios nativos. **Los binarios precompilados están disponibles solo para versiones LTS**. + +| Versión de Node | Estado | Notas | +|--------------|--------|-------| +| 18.x LTS | ✅ Funciona | Binarios precompilados | +| 20.x LTS | ✅ Funciona | Binarios precompilados | +| 22.x LTS | ✅ Funciona | Binarios precompilados | +| 19.x, 21.x, 23.x | ⚠️ Requiere herramientas de compilación | Sin binarios precompilados | + +### Uso de Versiones No-LTS (Windows) + +Si debes usar una versión no-LTS (19, 21, 23), instala primero las herramientas de compilación: + +**Opción 1: Visual Studio Build Tools** +```powershell +# Descarga e instala desde: +# https://visualstudio.microsoft.com/visual-cpp-build-tools/ +# Selecciona la carga de trabajo "Desarrollo de escritorio con C++" +``` + +**Opción 2: windows-build-tools (npm)** +```powershell +npm install --global windows-build-tools +``` + +**Opción 3: Chocolatey** +```powershell +choco install visualstudio2022-workload-vctools +``` + +Consulta la [guía de node-gyp para Windows](https://github.com/nodejs/node-gyp#on-windows) para más detalles. + +--- + +## Ecosistema AgentKits + +**AgentKits Memory** es parte del ecosistema AgentKits de AityTech - herramientas que hacen más inteligentes a los asistentes de codificación con IA. + +| Producto | Descripción | Enlace | +|---------|-------------|------| +| **AgentKits Engineer** | 28 agentes especializados, más de 100 skills, patrones empresariales | [GitHub](https://github.com/aitytech/agentkits-engineer) | +| **AgentKits Marketing** | Generación de contenido de marketing impulsada por IA | [GitHub](https://github.com/aitytech/agentkits-marketing) | +| **AgentKits Memory** | Memoria persistente para asistentes de IA (este paquete) | [npm](https://www.npmjs.com/package/@aitytech/agentkits-memory) | + +

+ + agentkits.net + +

+ +--- + +## Historial de Estrellas + + + + + + Star History Chart + + + +--- + +## Licencia + +MIT + +--- + +

+ Dale a tu asistente de IA memoria que persiste. +

+ +

+ AgentKits Memory por AityTech +

+ +

+ Marca con estrella este repositorio si te ayuda a que tu IA recuerde. +

\ No newline at end of file diff --git a/i18n/README.fr.md b/i18n/README.fr.md new file mode 100644 index 0000000..d6d15df --- /dev/null +++ b/i18n/README.fr.md @@ -0,0 +1,743 @@ +

+ AgentKits Logo +

+ +

AgentKits Memory

+ +

+ par AityTech +

+ +

+ npm + License + Claude Code + Cursor + Windsurf + Cline + OpenCode +
+ Tests + Coverage +

+ +

+ Système de Mémoire Persistante pour Assistants de Codage IA +

+ +

+ Votre assistant IA oublie tout entre les sessions. AgentKits Memory résout ce problème.
+ Décisions, motifs, erreurs et contexte — tout est persisté localement via MCP. +

+ +

+ Site Web • + Documentation • + Démarrage Rapide • + Comment Ça Fonctionne • + Plateformes • + CLI • + Interface Web +

+ +

+ English · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português · Tiếng Việt · Русский · العربية +

+ +--- + +## Fonctionnalités + +| Fonctionnalité | Avantage | +|---------|---------| +| **100% Local** | Toutes les données restent sur votre machine. Pas de cloud, pas de clés API, pas de comptes | +| **Ultra Rapide** | SQLite natif (better-sqlite3) = requêtes instantanées, latence zéro | +| **Zéro Configuration** | Fonctionne immédiatement. Aucune configuration de base de données requise | +| **Multi-Plateforme** | Claude Code, Cursor, Windsurf, Cline, OpenCode — une seule commande d'installation | +| **Serveur MCP** | 9 outils : save, search, timeline, details, recall, list, update, delete, status | +| **Capture Automatique** | Les hooks capturent automatiquement le contexte de session, l'utilisation des outils et les résumés | +| **Enrichissement IA** | Les workers en arrière-plan enrichissent les observations avec des résumés générés par IA | +| **Recherche Vectorielle** | Similarité sémantique HNSW avec embeddings multilingues (100+ langues) | +| **Interface Web** | Interface navigateur pour visualiser, rechercher, ajouter, modifier et supprimer des mémoires | +| **Recherche en 3 Couches** | La divulgation progressive économise ~87% de tokens vs tout récupérer | +| **Gestion du Cycle de Vie** | Compression, archivage et nettoyage automatiques des anciennes sessions | +| **Export/Import** | Sauvegarde et restauration des mémoires au format JSON | + +--- + +## Comment Ça Fonctionne + +``` +Session 1: "Use JWT for auth" Session 2: "Add login endpoint" +┌──────────────────────────┐ ┌──────────────────────────┐ +│ Vous codez avec l'IA... │ │ L'IA sait déjà : │ +│ L'IA prend des décisions│ │ ✓ Décision auth JWT │ +│ L'IA rencontre erreurs │ ───► │ ✓ Solutions d'erreurs │ +│ L'IA apprend motifs │ saved │ ✓ Motifs de code │ +│ │ │ ✓ Contexte de session │ +└──────────────────────────┘ └──────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite, 100% local) +``` + +1. **Configuration unique** — `npx agentkits-memory-setup` configure votre plateforme +2. **Capture automatique** — Les hooks enregistrent les décisions, l'utilisation des outils et les résumés pendant votre travail +3. **Injection de contexte** — La prochaine session démarre avec l'historique pertinent des sessions passées +4. **Traitement en arrière-plan** — Les workers enrichissent les observations avec l'IA, génèrent des embeddings, compressent les anciennes données +5. **Recherche à tout moment** — L'IA utilise les outils MCP (`memory_search` → `memory_details`) pour trouver le contexte passé + +Toutes les données restent dans `.claude/memory/memory.db` sur votre machine. Pas de cloud. Pas de clés API requises. + +--- + +## Décisions de Conception Importantes + +La plupart des outils de mémoire dispersent les données dans des fichiers markdown, nécessitent des environnements Python ou envoient votre code vers des API externes. AgentKits Memory fait des choix fondamentalement différents : + +| Choix de Conception | Pourquoi C'est Important | +|---------------|----------------| +| **Base de données SQLite unique** | Un seul fichier (`memory.db`) contient tout — mémoires, sessions, observations, embeddings. Pas de fichiers dispersés à synchroniser, pas de conflits de fusion, pas de données orphelines. Sauvegarde = copier un seul fichier | +| **Node.js natif, zéro Python** | Fonctionne partout où Node fonctionne. Pas de conda, pas de pip, pas de virtualenv. Même langage que votre serveur MCP — une commande `npx`, c'est fait | +| **Recherche en 3 couches économe en tokens** | Index de recherche d'abord (~50 tokens/résultat), puis contexte chronologique, puis détails complets. Récupérez seulement ce dont vous avez besoin. Les autres outils déversent des fichiers de mémoire entiers dans le contexte, brûlant des tokens sur du contenu non pertinent | +| **Capture automatique via hooks** | Les décisions, motifs et erreurs sont enregistrés au moment où ils se produisent — pas après que vous vous rappeliez de les sauvegarder. L'injection de contexte de session se fait automatiquement au démarrage de la session suivante | +| **Embeddings locaux, pas d'appels API** | La recherche vectorielle utilise un modèle ONNX local (multilingual-e5-small). La recherche sémantique fonctionne hors ligne, ne coûte rien et prend en charge 100+ langues | +| **Workers en arrière-plan** | L'enrichissement IA, la génération d'embeddings et la compression s'exécutent de manière asynchrone. Votre flux de codage n'est jamais bloqué | +| **Multi-plateforme dès le départ** | Un seul flag `--platform=all` configure Claude Code, Cursor, Windsurf, Cline et OpenCode simultanément. Même base de données de mémoire, différents éditeurs | +| **Données d'observation structurées** | L'utilisation des outils est capturée avec classification de type (read/write/execute/search), suivi de fichiers, détection d'intention et narratifs générés par IA — pas de dumps de texte brut | +| **Pas de fuites de processus** | Les workers en arrière-plan s'auto-terminent après 5 minutes, utilisent des fichiers de verrouillage basés sur PID avec nettoyage des verrous périmés, et gèrent SIGTERM/SIGINT gracieusement. Pas de processus zombies, pas de workers orphelins | +| **Pas de fuites de mémoire** | Les hooks s'exécutent comme processus de courte durée (pas de démons longue durée). Les connexions à la base de données se ferment à l'arrêt. Le sous-processus d'embedding a un respawn borné (max 2), des timeouts de requêtes en attente et un nettoyage gracieux de tous les timers et files d'attente | + +--- + +## Interface Web + +Visualisez et gérez vos mémoires via une interface web moderne. + +```bash +npx agentkits-memory-web +``` + +Puis ouvrez **http://localhost:1905** dans votre navigateur. + +### Liste des Sessions + +Parcourez toutes les sessions avec vue chronologique et détails d'activité. + +![Session List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-session-list_v2.png) + +### Liste des Mémoires + +Parcourez toutes les mémoires stockées avec recherche et filtrage par namespace. + +![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list_v2.png) + +### Ajouter une Mémoire + +Créez de nouvelles mémoires avec clé, namespace, type, contenu et tags. + +![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory_v2.png) + +### Détails de la Mémoire + +Visualisez les détails complets d'une mémoire avec options d'édition et de suppression. + +![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail_v2.png) + +### Gérer les Embeddings + +Générez et gérez les embeddings vectoriels pour la recherche sémantique. + +![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding_v2.png) + +--- + +## Démarrage Rapide + +### Option 1 : Marketplace de Plugins Claude Code (Recommandé pour Claude Code) + +Installation en une seule commande — aucune configuration manuelle nécessaire : + +```bash +/plugin marketplace add aitytech/agentkits-memory +/plugin install agentkits-memory@aitytech +``` + +Cela installe automatiquement les hooks, le serveur MCP et le skill de workflow mémoire. Redémarrez Claude Code après l'installation. + +### Option 2 : Installation Automatique (Toutes les Plateformes) + +```bash +npx agentkits-memory-setup +``` + +Cela détecte automatiquement votre plateforme et configure tout : serveur MCP, hooks (Claude Code/OpenCode), fichiers de règles (Cursor/Windsurf/Cline) et télécharge le modèle d'embedding. + +**Cibler une plateforme spécifique :** + +```bash +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all +``` + +### Option 3 : Configuration Manuelle MCP + +Si vous préférez la configuration manuelle, ajoutez à votre configuration MCP : + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "agentkits-memory-server"] + } + } +} +``` + +Emplacements des fichiers de configuration : +- **Claude Code** : `.claude/settings.json` (intégré dans la clé `mcpServers`) +- **Cursor** : `.cursor/mcp.json` +- **Windsurf** : `.windsurf/mcp.json` +- **Cline / OpenCode** : `.mcp.json` (racine du projet) + +### 3. Outils MCP + +Une fois configuré, votre assistant IA peut utiliser ces outils : + +| Outil | Description | +|------|-------------| +| `memory_status` | Vérifier le statut du système de mémoire (appelez d'abord !) | +| `memory_save` | Sauvegarder des décisions, motifs, erreurs ou contexte | +| `memory_search` | **[Étape 1]** Index de recherche — IDs + titres légers (~50 tokens/résultat) | +| `memory_timeline` | **[Étape 2]** Obtenir le contexte temporel autour d'une mémoire | +| `memory_details` | **[Étape 3]** Obtenir le contenu complet pour des IDs spécifiques | +| `memory_recall` | Aperçu rapide d'un sujet — résumé groupé | +| `memory_list` | Lister les mémoires récentes | +| `memory_update` | Mettre à jour le contenu ou les tags d'une mémoire existante | +| `memory_delete` | Supprimer des mémoires obsolètes | + +--- + +## Divulgation Progressive (Recherche Économe en Tokens) + +AgentKits Memory utilise un **modèle de recherche en 3 couches** qui économise ~70% de tokens par rapport à la récupération du contenu complet d'emblée. + +### Comment Ça Fonctionne + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Étape 1 : memory_search │ +│ Retourne : IDs, titres, tags, scores (~50 tokens/élément) │ +│ → Examinez l'index, choisissez les mémoires pertinentes │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Étape 2 : memory_timeline (optionnel) │ +│ Retourne : Contexte ±30 minutes autour de la mémoire │ +│ → Comprenez ce qui s'est passé avant/après │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Étape 3 : memory_details │ +│ Retourne : Contenu complet pour les IDs sélectionnés seuls │ +│ → Récupérez seulement ce dont vous avez réellement besoin │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Exemple de Workflow + +```typescript +// Étape 1 : Recherche - obtenir un index léger +memory_search({ query: "authentication" }) +// → Retourne : [{ id: "abc", title: "JWT pattern...", score: 85% }] + +// Étape 2 : (Optionnel) Voir le contexte temporel +memory_timeline({ anchor: "abc" }) +// → Retourne : Ce qui s'est passé avant/après cette mémoire + +// Étape 3 : Obtenir le contenu complet seulement pour ce dont vous avez besoin +memory_details({ ids: ["abc"] }) +// → Retourne : Contenu complet pour la mémoire sélectionnée +``` + +### Économies de Tokens + +| Approche | Tokens Utilisés | +|----------|-------------| +| **Ancienne :** Récupérer tout le contenu | ~500 tokens × 10 résultats = 5000 tokens | +| **Nouvelle :** Divulgation progressive | 50 × 10 + 500 × 2 = 1500 tokens | +| **Économies** | **Réduction de 70%** | + +--- + +## Commandes CLI + +```bash +# Installation en une commande (détecte automatiquement la plateforme) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # plateforme spécifique +npx agentkits-memory-setup --platform=all # toutes les plateformes +npx agentkits-memory-setup --force # réinstaller/mettre à jour + +# Démarrer le serveur MCP +npx agentkits-memory-server + +# Interface web (port 1905) +npx agentkits-memory-web + +# Visualiseur terminal +npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # statistiques de la base de données +npx agentkits-memory-viewer --json # sortie JSON + +# Sauvegarder depuis CLI +npx agentkits-memory-save "Use JWT with refresh tokens" --category pattern --tags auth,security + +# Paramètres +npx agentkits-memory-hook settings . # voir les paramètres actuels +npx agentkits-memory-hook settings . --reset # réinitialiser aux valeurs par défaut +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# Export / Import +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# Gestion du cycle de vie +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . +``` + +--- + +## Utilisation Programmatique + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService({ + baseDir: '.claude/memory', + dbFilename: 'memory.db', +}); +await memory.initialize(); + +// Stocker une mémoire +await memory.storeEntry({ + key: 'auth-pattern', + content: 'Use JWT with refresh tokens for authentication', + namespace: 'patterns', + tags: ['auth', 'security'], +}); + +// Interroger les mémoires +const results = await memory.query({ + type: 'hybrid', + namespace: 'patterns', + content: 'authentication', + limit: 10, +}); + +// Obtenir par clé +const entry = await memory.getByKey('patterns', 'auth-pattern'); +``` + +--- + +## Hooks de Capture Automatique + +Les hooks capturent automatiquement vos sessions de codage IA (Claude Code et OpenCode uniquement) : + +| Hook | Déclencheur | Action | +|------|---------|--------| +| `context` | Démarrage de Session | Injecte le contexte de session précédente + statut de la mémoire | +| `session-init` | Prompt Utilisateur | Initialise/reprend la session, enregistre les prompts | +| `observation` | Après Utilisation d'Outil | Capture l'utilisation de l'outil avec détection d'intention | +| `summarize` | Fin de Session | Génère un résumé structuré de session | +| `user-message` | Démarrage de Session | Affiche le statut de la mémoire à l'utilisateur (stderr) | + +Installer les hooks : +```bash +npx agentkits-memory-setup +``` + +**Ce qui est capturé automatiquement :** +- Lectures/écritures de fichiers avec chemins +- Modifications de code sous forme de diffs structurés (avant → après) +- Intention du développeur (bugfix, feature, refactor, investigation, etc.) +- Résumés de session avec décisions, erreurs et prochaines étapes +- Suivi multi-prompts au sein des sessions + +--- + +## Support Multi-Plateforme + +| Plateforme | MCP | Hooks | Fichier de Règles | Installation | +|----------|-----|-------|------------|-------| +| **Claude Code** | `.claude/settings.json` | ✅ Complet | CLAUDE.md (skill) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ Complet | — | `--platform=opencode` | + +- **Serveur MCP** fonctionne avec toutes les plateformes (outils de mémoire via protocole MCP) +- **Hooks** fournissent la capture automatique sur Claude Code et OpenCode +- **Fichiers de règles** enseignent le workflow de mémoire à Cursor/Windsurf/Cline +- **Données de mémoire** toujours stockées dans `.claude/memory/` (source unique de vérité) + +--- + +## Workers en Arrière-Plan + +Après chaque session, les workers en arrière-plan traitent les tâches en file d'attente : + +| Worker | Tâche | Description | +|--------|------|-------------| +| `embed-session` | Embeddings | Génère les embeddings vectoriels pour la recherche sémantique | +| `enrich-session` | Enrichissement IA | Enrichit les observations avec résumés, faits, concepts générés par IA | +| `compress-session` | Compression | Compresse les anciennes observations (10:1–25:1) et génère des résumés de session (20:1–100:1) | + +Les workers s'exécutent automatiquement après la fin de session. Chaque worker : +- Traite jusqu'à 200 éléments par exécution +- Utilise des fichiers de verrouillage pour empêcher l'exécution concurrente +- S'auto-termine après 5 minutes (empêche les zombies) +- Réessaie les tâches échouées jusqu'à 3 fois + +--- + +## Configuration du Fournisseur IA + +L'enrichissement IA utilise des fournisseurs modulaires. Par défaut c'est `claude-cli` (pas de clé API nécessaire). + +| Fournisseur | Type | Modèle par Défaut | Notes | +|----------|------|---------------|-------| +| **Claude CLI** | `claude-cli` | `haiku` | Utilise `claude --print`, pas de clé API nécessaire | +| **OpenAI** | `openai` | `gpt-4o-mini` | N'importe quel modèle OpenAI | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | Clé Google AI Studio | +| **OpenRouter** | `openai` | any | Définir `baseUrl` sur `https://openrouter.ai/api/v1` | +| **GLM (Zhipu)** | `openai` | any | Définir `baseUrl` sur `https://open.bigmodel.cn/api/paas/v4` | +| **Ollama** | `openai` | any | Définir `baseUrl` sur `http://localhost:11434/v1` | + +### Option 1 : Variables d'Environnement + +```bash +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter (utilise le format compatible OpenAI) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# Ollama local (pas de clé API nécessaire) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# Désactiver complètement l'enrichissement IA +export AGENTKITS_AI_ENRICHMENT=false +``` + +### Option 2 : Paramètres Persistants + +```bash +# Sauvegardé dans .claude/memory/settings.json — persiste entre les sessions +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# Voir les paramètres actuels +npx agentkits-memory-hook settings . + +# Réinitialiser aux valeurs par défaut +npx agentkits-memory-hook settings . --reset +``` + +> **Priorité :** Les variables d'environnement remplacent settings.json. Settings.json remplace les valeurs par défaut. + +--- + +## Gestion du Cycle de Vie + +Gérez la croissance de la mémoire au fil du temps : + +```bash +# Compresser les observations de plus de 7 jours, archiver les sessions de plus de 30 jours +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# Aussi auto-supprimer les sessions archivées de plus de 90 jours +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# Voir les statistiques du cycle de vie +npx agentkits-memory-hook lifecycle-stats . +``` + +| Étape | Que Se Passe-t-il | +|-------|-------------| +| **Compresser** | L'IA compresse les observations, génère des résumés de session | +| **Archiver** | Marque les anciennes sessions comme archivées (exclues du contexte) | +| **Supprimer** | Supprime les sessions archivées (opt-in, nécessite `--delete`) | + +--- + +## Export / Import + +Sauvegardez et restaurez vos mémoires de projet : + +```bash +# Exporter toutes les sessions d'un projet +npx agentkits-memory-hook export . my-project ./backup.json + +# Importer depuis une sauvegarde (déduplique automatiquement) +npx agentkits-memory-hook import . ./backup.json +``` + +Le format d'export inclut les sessions, observations, prompts et résumés. + +--- + +## Catégories de Mémoire + +| Catégorie | Cas d'Usage | +|----------|----------| +| `decision` | Décisions d'architecture, choix de pile technique, compromis | +| `pattern` | Conventions de codage, motifs de projet, approches récurrentes | +| `error` | Corrections de bugs, solutions d'erreurs, insights de débogage | +| `context` | Contexte du projet, conventions d'équipe, configuration d'environnement | +| `observation` | Observations de session auto-capturées | + +--- + +## Stockage + +Les mémoires sont stockées dans `.claude/memory/` dans le répertoire de votre projet. + +``` +.claude/memory/ +├── memory.db # Base de données SQLite (toutes les données) +├── memory.db-wal # Write-ahead log (temporaire) +├── settings.json # Paramètres persistants (fournisseur IA, config contexte) +└── embeddings-cache/ # Embeddings vectoriels en cache +``` + +--- + +## Support des Langues CJK + +AgentKits Memory a un **support CJK automatique** pour la recherche de texte en chinois, japonais et coréen. + +### Zéro Configuration + +Quand `better-sqlite3` est installé (par défaut), la recherche CJK fonctionne automatiquement : + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService('.claude/memory'); +await memory.initialize(); + +// Stocker du contenu CJK +await memory.storeEntry({ + key: 'auth-pattern', + content: '認証機能の実装パターン - JWT with refresh tokens', + namespace: 'patterns', +}); + +// Rechercher en japonais, chinois ou coréen - ça marche simplement ! +const results = await memory.query({ + type: 'hybrid', + content: '認証機能', +}); +``` + +### Comment Ça Fonctionne + +- **SQLite natif** : Utilise `better-sqlite3` pour des performances maximales +- **Tokenizer trigramme** : FTS5 avec trigramme crée des séquences de 3 caractères pour la correspondance CJK +- **Fallback intelligent** : Les requêtes CJK courtes (< 3 caractères) utilisent automatiquement la recherche LIKE +- **Classement BM25** : Scoring de pertinence pour les résultats de recherche + +### Avancé : Segmentation des Mots Japonais + +Pour le japonais avancé avec segmentation de mots appropriée, utilisez optionnellement lindera : + +```typescript +import { createJapaneseOptimizedBackend } from '@aitytech/agentkits-memory'; + +const backend = createJapaneseOptimizedBackend({ + databasePath: '.claude/memory/memory.db', + linderaPath: './path/to/liblindera_sqlite.dylib', +}); +``` + +Nécessite une build [lindera-sqlite](https://github.com/lindera/lindera-sqlite). + +--- + +## Référence API + +### ProjectMemoryService + +```typescript +interface ProjectMemoryConfig { + baseDir: string; // Par défaut : '.claude/memory' + dbFilename: string; // Par défaut : 'memory.db' + enableVectorIndex: boolean; // Par défaut : false + dimensions: number; // Par défaut : 384 + embeddingGenerator?: EmbeddingGenerator; + cacheEnabled: boolean; // Par défaut : true + cacheSize: number; // Par défaut : 1000 + cacheTtl: number; // Par défaut : 300000 (5 min) +} +``` + +### Méthodes + +| Méthode | Description | +|--------|-------------| +| `initialize()` | Initialiser le service de mémoire | +| `shutdown()` | Arrêter et persister les changements | +| `storeEntry(input)` | Stocker une entrée de mémoire | +| `get(id)` | Obtenir une entrée par ID | +| `getByKey(namespace, key)` | Obtenir une entrée par namespace et clé | +| `update(id, update)` | Mettre à jour une entrée | +| `delete(id)` | Supprimer une entrée | +| `query(query)` | Interroger les entrées avec filtres | +| `semanticSearch(content, k)` | Recherche de similarité sémantique | +| `count(namespace?)` | Compter les entrées | +| `listNamespaces()` | Lister tous les namespaces | +| `getStats()` | Obtenir les statistiques | + +--- + +## Qualité du Code + +AgentKits Memory est rigoureusement testé avec **970 tests unitaires** répartis sur 21 suites de tests. + +| Métrique | Couverture | +|----------|-----------| +| **Instructions** | 90.29% | +| **Branches** | 80.85% | +| **Fonctions** | 90.54% | +| **Lignes** | 91.74% | + +### Catégories de Tests + +| Catégorie | Tests | Couverture | +|-----------|-------|-----------| +| Service Mémoire Core | 56 | CRUD, recherche, pagination, catégories, tags, import/export | +| Backend SQLite | 65 | Schéma, migrations, FTS5, transactions, gestion d'erreurs | +| Index Vectoriel HNSW | 47 | Insertion, recherche, suppression, persistance, cas limites | +| Recherche Hybride | 44 | FTS + fusion vectorielle, scoring, classement, filtres | +| Économie de Tokens | 27 | Budgets de recherche 3 couches, troncature, optimisation | +| Système d'Embeddings | 63 | Cache, sous-processus, modèles locaux, support CJK | +| Système de Hooks | 502 | Contexte, init session, observation, résumé, enrichissement IA, cycle de vie, workers, adaptateurs, types | +| Serveur MCP | 48 | 9 outils MCP, validation, réponses d'erreur | +| CLI | 34 | Détection de plateforme, génération de règles | +| Intégration | 84 | Flux end-to-end, intégration embeddings, multi-session | + +```bash +# Exécuter les tests +npm test + +# Exécuter avec couverture +npm run test:coverage +``` + +--- + +## Prérequis + +- **Node.js LTS** : 18.x, 20.x ou 22.x (recommandé) +- Assistant de codage IA compatible MCP + +### Notes sur la Version Node.js + +Ce package utilise `better-sqlite3` qui nécessite des binaires natifs. **Les binaires précompilés sont disponibles uniquement pour les versions LTS**. + +| Version Node | Statut | Notes | +|--------------|--------|-------| +| 18.x LTS | ✅ Fonctionne | Binaires précompilés | +| 20.x LTS | ✅ Fonctionne | Binaires précompilés | +| 22.x LTS | ✅ Fonctionne | Binaires précompilés | +| 19.x, 21.x, 23.x | ⚠️ Nécessite outils de build | Pas de binaires précompilés | + +### Utilisation de Versions Non-LTS (Windows) + +Si vous devez utiliser une version non-LTS (19, 21, 23), installez d'abord les outils de build : + +**Option 1 : Visual Studio Build Tools** +```powershell +# Téléchargez et installez depuis : +# https://visualstudio.microsoft.com/visual-cpp-build-tools/ +# Sélectionnez la charge de travail "Desktop development with C++" +``` + +**Option 2 : windows-build-tools (npm)** +```powershell +npm install --global windows-build-tools +``` + +**Option 3 : Chocolatey** +```powershell +choco install visualstudio2022-workload-vctools +``` + +Voir le [guide Windows node-gyp](https://github.com/nodejs/node-gyp#on-windows) pour plus de détails. + +--- + +## Écosystème AgentKits + +**AgentKits Memory** fait partie de l'écosystème AgentKits par AityTech - des outils qui rendent les assistants de codage IA plus intelligents. + +| Produit | Description | Lien | +|---------|-------------|------| +| **AgentKits Engineer** | 28 agents spécialisés, 100+ compétences, patterns d'entreprise | [GitHub](https://github.com/aitytech/agentkits-engineer) | +| **AgentKits Marketing** | Génération de contenu marketing par IA | [GitHub](https://github.com/aitytech/agentkits-marketing) | +| **AgentKits Memory** | Mémoire persistante pour assistants IA (ce package) | [npm](https://www.npmjs.com/package/@aitytech/agentkits-memory) | + +

+ + agentkits.net + +

+ +--- + +## Historique des Stars + + + + + + Star History Chart + + + +--- + +## Licence + +MIT + +--- + +

+ Donnez à votre assistant IA une mémoire qui persiste. +

+ +

+ AgentKits Memory par AityTech +

+ +

+ Ajoutez une étoile à ce repo s'il aide votre IA à se souvenir. +

\ No newline at end of file diff --git a/i18n/README.ja.md b/i18n/README.ja.md new file mode 100644 index 0000000..92e0015 --- /dev/null +++ b/i18n/README.ja.md @@ -0,0 +1,743 @@ +

+ AgentKits Logo +

+ +

AgentKits Memory

+ +

+ by AityTech +

+ +

+ npm + License + Claude Code + Cursor + Windsurf + Cline + OpenCode +
+ Tests + Coverage +

+ +

+ AIコーディングアシスタント向けの永続的メモリシステム +

+ +

+ AIアシスタントはセッション間ですべてを忘れてしまいます。AgentKits Memoryがそれを解決します。
+ 決定事項、パターン、エラー、コンテキスト — すべてMCPを通じてローカルに永続化されます。 +

+ +

+ ウェブサイト • + ドキュメント • + クイックスタート • + 仕組み • + プラットフォーム • + CLI • + Webビューア +

+ +

+ English · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português · Tiếng Việt · Русский · العربية +

+ +--- + +## 機能 + +| 機能 | メリット | +|---------|---------| +| **100%ローカル** | すべてのデータがあなたのマシンに保存されます。クラウド不要、APIキー不要、アカウント不要 | +| **超高速** | ネイティブSQLite (better-sqlite3) = 瞬時のクエリ、レイテンシゼロ | +| **設定不要** | すぐに使えます。データベースのセットアップは不要 | +| **マルチプラットフォーム** | Claude Code、Cursor、Windsurf、Cline、OpenCode — 1つのセットアップコマンドで対応 | +| **MCPサーバー** | 9つのツール: save、search、timeline、details、recall、list、update、delete、status | +| **自動キャプチャ** | フックがセッションコンテキスト、ツール使用状況、サマリーを自動的にキャプチャ | +| **AIエンリッチメント** | バックグラウンドワーカーがAI生成サマリーで観測データをエンリッチ | +| **ベクトル検索** | 多言語埋め込み(100以上の言語)によるHNSWセマンティック類似性検索 | +| **Webビューア** | ブラウザUIでメモリの表示、検索、追加、編集、削除が可能 | +| **3層検索** | 段階的開示により、すべてを取得する場合と比較して約87%のトークンを節約 | +| **ライフサイクル管理** | 古いセッションの自動圧縮、アーカイブ、クリーンアップ | +| **エクスポート/インポート** | メモリをJSONとしてバックアップおよび復元 | + +--- + +## 仕組み + +``` +セッション1: "認証にJWTを使用" セッション2: "ログインエンドポイントを追加" +┌──────────────────────────┐ ┌──────────────────────────┐ +│ AIとコーディング... │ │ AIはすでに知っている: │ +│ AIが決定を下す │ │ ✓ JWT認証の決定 │ +│ AIがエラーに遭遇 │ ───► │ ✓ エラーの解決策 │ +│ AIがパターンを学習 │ 保存 │ ✓ コードパターン │ +│ │ │ ✓ セッションコンテキスト │ +└──────────────────────────┘ └──────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite、100%ローカル) +``` + +1. **1回セットアップ** — `npx agentkits-memory-setup`でプラットフォームを設定 +2. **自動キャプチャ** — 作業中に決定事項、ツール使用状況、サマリーをフックが記録 +3. **コンテキスト注入** — 次のセッションは過去のセッションの関連履歴から始まります +4. **バックグラウンド処理** — ワーカーがAIで観測データをエンリッチし、埋め込みを生成し、古いデータを圧縮 +5. **いつでも検索** — AIはMCPツール(`memory_search` → `memory_details`)を使って過去のコンテキストを検索 + +すべてのデータは、あなたのマシン上の`.claude/memory/memory.db`に保存されます。クラウドなし。APIキー不要。 + +--- + +## 重要な設計上の決定 + +ほとんどのメモリツールは、データをMarkdownファイルに分散させたり、Pythonランタイムを必要としたり、コードを外部APIに送信したりします。AgentKits Memoryは根本的に異なる選択をしています: + +| 設計上の選択 | 重要な理由 | +|---------------|----------------| +| **単一のSQLiteデータベース** | 1つのファイル(`memory.db`)がすべてを保持 — メモリ、セッション、観測、埋め込み。同期すべき分散ファイルなし、マージ競合なし、孤立データなし。バックアップ = 1ファイルのコピー | +| **ネイティブNode.js、Python不要** | Nodeが動作するところならどこでも動作。conda不要、pip不要、virtualenv不要。MCPサーバーと同じ言語 — 1つの`npx`コマンドで完了 | +| **トークン効率的な3層検索** | まず検索インデックス(~50トークン/結果)、次にタイムラインコンテキスト、その後完全な詳細。必要なものだけを取得。他のツールはメモリファイル全体をコンテキストにダンプし、無関係なコンテンツでトークンを消費 | +| **フックによる自動キャプチャ** | 決定事項、パターン、エラーは発生時に記録される — 保存を思い出した後ではありません。セッションコンテキスト注入は次のセッション開始時に自動的に発生 | +| **ローカル埋め込み、API呼び出し不要** | ベクトル検索はローカルONNXモデル(multilingual-e5-small)を使用。セマンティック検索はオフラインで動作し、コストゼロ、100以上の言語をサポート | +| **バックグラウンドワーカー** | AIエンリッチメント、埋め込み生成、圧縮は非同期で実行。コーディングフローは決してブロックされません | +| **初日からマルチプラットフォーム** | 1つの`--platform=all`フラグで、Claude Code、Cursor、Windsurf、Cline、OpenCodeを同時に設定。同じメモリデータベース、異なるエディター | +| **構造化された観測データ** | ツール使用状況は、タイプ分類(read/write/execute/search)、ファイル追跡、インテント検出、AI生成ナラティブとともにキャプチャ — 生のテキストダンプではありません | +| **プロセスリークなし** | バックグラウンドワーカーは5分後に自己終了し、PIDベースのロックファイルと古いロックのクリーンアップを使用し、SIGTERM/SIGINTを適切に処理。ゾンビプロセスなし、孤立ワーカーなし | +| **メモリリークなし** | フックは短命プロセスとして実行(長時間実行デーモンではない)。データベース接続はシャットダウン時にクローズ。埋め込みサブプロセスには制限付き再起動(最大2回)、保留中のリクエストタイムアウト、すべてのタイマーとキューの適切なクリーンアップがあります | + +--- + +## Webビューア + +モダンなWebインターフェースでメモリを表示および管理します。 + +```bash +npx agentkits-memory-web +``` + +その後、ブラウザで**http://localhost:1905**を開きます。 + +### セッションリスト + +タイムラインビューとアクティビティ詳細で全セッションを閲覧します。 + +![Session List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-session-list_v2.png) + +### メモリリスト + +検索と名前空間フィルタリングで保存されたすべてのメモリを閲覧します。 + +![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list_v2.png) + +### メモリを追加 + +キー、名前空間、タイプ、コンテンツ、タグで新しいメモリを作成します。 + +![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory_v2.png) + +### メモリの詳細 + +編集および削除オプション付きでメモリの完全な詳細を表示します。 + +![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail_v2.png) + +### 埋め込み管理 + +セマンティック検索用のベクトル埋め込みを生成および管理します。 + +![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding_v2.png) + +--- + +## クイックスタート + +### オプション1: Claude Codeプラグインマーケットプレイス (Claude Code推奨) + +1つのコマンドでインストール — 手動設定不要: + +```bash +/plugin marketplace add aitytech/agentkits-memory +/plugin install agentkits-memory@aitytech +``` + +フック、MCPサーバー、メモリワークフロースキルが自動的にインストールされます。インストール後にClaude Codeを再起動してください。 + +### オプション2: 自動セットアップ (全プラットフォーム) + +```bash +npx agentkits-memory-setup +``` + +これにより、プラットフォームを自動検出し、すべてを設定します: MCPサーバー、フック(Claude Code/OpenCode)、rulesファイル(Cursor/Windsurf/Cline)、および埋め込みモデルのダウンロード。 + +**特定のプラットフォームをターゲットにする:** + +```bash +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all +``` + +### オプション3: 手動MCP設定 + +手動セットアップを希望する場合は、MCP設定に追加します: + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "agentkits-memory-server"] + } + } +} +``` + +設定ファイルの場所: +- **Claude Code**: `.claude/settings.json` (`mcpServers`キーに埋め込み) +- **Cursor**: `.cursor/mcp.json` +- **Windsurf**: `.windsurf/mcp.json` +- **Cline / OpenCode**: `.mcp.json` (プロジェクトルート) + +### 3. MCPツール + +設定が完了すると、AIアシスタントは次のツールを使用できます: + +| ツール | 説明 | +|------|-------------| +| `memory_status` | メモリシステムのステータスを確認(最初に呼び出す!) | +| `memory_save` | 決定事項、パターン、エラー、またはコンテキストを保存 | +| `memory_search` | **[ステップ1]** インデックスを検索 — 軽量なID+タイトル(~50トークン/結果) | +| `memory_timeline` | **[ステップ2]** メモリ周辺の時系列コンテキストを取得 | +| `memory_details` | **[ステップ3]** 特定のIDの完全なコンテンツを取得 | +| `memory_recall` | クイックトピック概要 — グループ化されたサマリー | +| `memory_list` | 最近のメモリをリスト表示 | +| `memory_update` | 既存のメモリコンテンツまたはタグを更新 | +| `memory_delete` | 古いメモリを削除 | + +--- + +## 段階的開示(トークン効率的な検索) + +AgentKits Memoryは、完全なコンテンツを前もって取得する場合と比較して約70%のトークンを節約する**3層検索パターン**を使用します。 + +### 仕組み + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ステップ1: memory_search │ +│ 返却: ID、タイトル、タグ、スコア(~50トークン/項目) │ +│ → インデックスをレビューし、関連メモリを選択 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ ステップ2: memory_timeline(オプション) │ +│ 返却: メモリ前後±30分のコンテキスト │ +│ → 前後に何が起こったかを理解 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ ステップ3: memory_details │ +│ 返却: 選択したIDのみの完全なコンテンツ │ +│ → 実際に必要なものだけを取得 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### ワークフローの例 + +```typescript +// ステップ1: 検索 - 軽量インデックスを取得 +memory_search({ query: "authentication" }) +// → 返却: [{ id: "abc", title: "JWTパターン...", score: 85% }] + +// ステップ2: (オプション)時系列コンテキストを確認 +memory_timeline({ anchor: "abc" }) +// → 返却: このメモリの前後に何が起こったか + +// ステップ3: 必要なもののみの完全なコンテンツを取得 +memory_details({ ids: ["abc"] }) +// → 返却: 選択したメモリの完全なコンテンツ +``` + +### トークン節約 + +| アプローチ | 使用トークン | +|----------|-------------| +| **旧:** すべてのコンテンツを取得 | ~500トークン × 10結果 = 5000トークン | +| **新:** 段階的開示 | 50 × 10 + 500 × 2 = 1500トークン | +| **節約** | **70%削減** | + +--- + +## CLIコマンド + +```bash +# 1コマンドセットアップ(プラットフォームを自動検出) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # 特定のプラットフォーム +npx agentkits-memory-setup --platform=all # すべてのプラットフォーム +npx agentkits-memory-setup --force # 再インストール/更新 + +# MCPサーバーを起動 +npx agentkits-memory-server + +# Webビューア(ポート1905) +npx agentkits-memory-web + +# ターミナルビューア +npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # データベース統計 +npx agentkits-memory-viewer --json # JSON出力 + +# CLIから保存 +npx agentkits-memory-save "リフレッシュトークン付きJWTを使用" --category pattern --tags auth,security + +# 設定 +npx agentkits-memory-hook settings . # 現在の設定を表示 +npx agentkits-memory-hook settings . --reset # デフォルトにリセット +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# エクスポート/インポート +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# ライフサイクル管理 +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . +``` + +--- + +## プログラマティック使用 + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService({ + baseDir: '.claude/memory', + dbFilename: 'memory.db', +}); +await memory.initialize(); + +// メモリを保存 +await memory.storeEntry({ + key: 'auth-pattern', + content: '認証にリフレッシュトークン付きJWTを使用', + namespace: 'patterns', + tags: ['auth', 'security'], +}); + +// メモリをクエリ +const results = await memory.query({ + type: 'hybrid', + namespace: 'patterns', + content: 'authentication', + limit: 10, +}); + +// キーで取得 +const entry = await memory.getByKey('patterns', 'auth-pattern'); +``` + +--- + +## 自動キャプチャフック + +フックは自動的にAIコーディングセッションをキャプチャします(Claude CodeとOpenCodeのみ): + +| フック | トリガー | アクション | +|------|---------|--------| +| `context` | セッション開始 | 前のセッションコンテキスト+メモリステータスを注入 | +| `session-init` | ユーザープロンプト | セッションを初期化/再開、プロンプトを記録 | +| `observation` | ツール使用後 | インテント検出でツール使用状況をキャプチャ | +| `summarize` | セッション終了 | 構造化されたセッションサマリーを生成 | +| `user-message` | セッション開始 | ユーザーにメモリステータスを表示(stderr) | + +フックのセットアップ: +```bash +npx agentkits-memory-setup +``` + +**自動的にキャプチャされるもの:** +- パス付きファイルの読み取り/書き込み +- 構造化された差分としてのコード変更(変更前→変更後) +- 開発者のインテント(バグ修正、機能、リファクタリング、調査など) +- 決定事項、エラー、次のステップを含むセッションサマリー +- セッション内のマルチプロンプト追跡 + +--- + +## マルチプラットフォーム対応 + +| プラットフォーム | MCP | フック | Rulesファイル | セットアップ | +|----------|-----|-------|------------|-------| +| **Claude Code** | `.claude/settings.json` | ✅ フル | CLAUDE.md (skill) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ フル | — | `--platform=opencode` | + +- **MCPサーバー**はすべてのプラットフォームで動作(MCPプロトコル経由のメモリツール) +- **フック**はClaude CodeとOpenCodeで自動キャプチャを提供 +- **Rulesファイル**はCursor/Windsurf/Clineにメモリワークフローを教える +- **メモリデータ**は常に`.claude/memory/`に保存(単一の信頼できる情報源) + +--- + +## バックグラウンドワーカー + +各セッションの後、バックグラウンドワーカーがキューに入れられたタスクを処理します: + +| ワーカー | タスク | 説明 | +|--------|------|-------------| +| `embed-session` | 埋め込み | セマンティック検索用のベクトル埋め込みを生成 | +| `enrich-session` | AIエンリッチメント | AI生成のサマリー、事実、概念で観測データをエンリッチ | +| `compress-session` | 圧縮 | 古い観測データを圧縮(10:1–25:1)し、セッションダイジェストを生成(20:1–100:1) | + +ワーカーはセッション終了後に自動的に実行されます。各ワーカーは: +- 1回の実行で最大200項目を処理 +- ロックファイルを使用して同時実行を防止 +- 5分後に自動終了(ゾンビを防止) +- 失敗したタスクを最大3回再試行 + +--- + +## AIプロバイダー設定 + +AIエンリッチメントはプラグ可能なプロバイダーを使用します。デフォルトは`claude-cli`(APIキー不要)です。 + +| プロバイダー | タイプ | デフォルトモデル | 備考 | +|----------|------|---------------|-------| +| **Claude CLI** | `claude-cli` | `haiku` | `claude --print`を使用、APIキー不要 | +| **OpenAI** | `openai` | `gpt-4o-mini` | 任意のOpenAIモデル | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | Google AI Studioキー | +| **OpenRouter** | `openai` | 任意 | `baseUrl`を`https://openrouter.ai/api/v1`に設定 | +| **GLM (Zhipu)** | `openai` | 任意 | `baseUrl`を`https://open.bigmodel.cn/api/paas/v4`に設定 | +| **Ollama** | `openai` | 任意 | `baseUrl`を`http://localhost:11434/v1`に設定 | + +### オプション1: 環境変数 + +```bash +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter(OpenAI互換フォーマットを使用) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# ローカルOllama(APIキー不要) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# AIエンリッチメントを完全に無効化 +export AGENTKITS_AI_ENRICHMENT=false +``` + +### オプション2: 永続的な設定 + +```bash +# .claude/memory/settings.jsonに保存 — セッション間で永続化 +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# 現在の設定を表示 +npx agentkits-memory-hook settings . + +# デフォルトにリセット +npx agentkits-memory-hook settings . --reset +``` + +> **優先順位:** 環境変数がsettings.jsonをオーバーライドします。settings.jsonがデフォルトをオーバーライドします。 + +--- + +## ライフサイクル管理 + +時間の経過に伴うメモリの増加を管理します: + +```bash +# 7日以上前の観測データを圧縮し、30日以上前のセッションをアーカイブ +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# 90日以上前のアーカイブされたセッションも自動削除 +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# ライフサイクル統計を表示 +npx agentkits-memory-hook lifecycle-stats . +``` + +| ステージ | 何が起こるか | +|-------|-------------| +| **圧縮** | 観測データをAI圧縮し、セッションダイジェストを生成 | +| **アーカイブ** | 古いセッションをアーカイブ済みとしてマーク(コンテキストから除外) | +| **削除** | アーカイブされたセッションを削除(オプトイン、`--delete`が必要) | + +--- + +## エクスポート/インポート + +プロジェクトメモリをバックアップおよび復元します: + +```bash +# プロジェクトのすべてのセッションをエクスポート +npx agentkits-memory-hook export . my-project ./backup.json + +# バックアップからインポート(自動的に重複排除) +npx agentkits-memory-hook import . ./backup.json +``` + +エクスポート形式にはセッション、観測、プロンプト、サマリーが含まれます。 + +--- + +## メモリカテゴリー + +| カテゴリー | ユースケース | +|----------|----------| +| `decision` | アーキテクチャの決定、技術スタックの選択、トレードオフ | +| `pattern` | コーディング規約、プロジェクトパターン、繰り返しアプローチ | +| `error` | バグ修正、エラーソリューション、デバッグインサイト | +| `context` | プロジェクトの背景、チーム規約、環境セットアップ | +| `observation` | 自動キャプチャされたセッション観測 | + +--- + +## ストレージ + +メモリは、プロジェクトディレクトリ内の`.claude/memory/`に保存されます。 + +``` +.claude/memory/ +├── memory.db # SQLiteデータベース(すべてのデータ) +├── memory.db-wal # 先行書き込みログ(一時) +├── settings.json # 永続的な設定(AIプロバイダー、コンテキスト設定) +└── embeddings-cache/ # キャッシュされたベクトル埋め込み +``` + +--- + +## CJK言語サポート + +AgentKits Memoryは、中国語、日本語、韓国語のテキスト検索に対する**自動CJKサポート**を備えています。 + +### 設定不要 + +`better-sqlite3`がインストールされている場合(デフォルト)、CJK検索は自動的に機能します: + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService('.claude/memory'); +await memory.initialize(); + +// CJKコンテンツを保存 +await memory.storeEntry({ + key: 'auth-pattern', + content: '認証機能の実装パターン - JWT with refresh tokens', + namespace: 'patterns', +}); + +// 日本語、中国語、韓国語で検索 - そのまま動作します! +const results = await memory.query({ + type: 'hybrid', + content: '認証機能', +}); +``` + +### 仕組み + +- **ネイティブSQLite**: 最大のパフォーマンスのために`better-sqlite3`を使用 +- **トライグラムトークナイザー**: FTS5とトライグラムがCJKマッチング用に3文字シーケンスを作成 +- **スマートフォールバック**: 短いCJKクエリ(3文字未満)は自動的にLIKE検索を使用 +- **BM25ランキング**: 検索結果の関連性スコアリング + +### 高度: 日本語単語分割 + +適切な単語分割を伴う高度な日本語の場合、オプションでlinderaを使用できます: + +```typescript +import { createJapaneseOptimizedBackend } from '@aitytech/agentkits-memory'; + +const backend = createJapaneseOptimizedBackend({ + databasePath: '.claude/memory/memory.db', + linderaPath: './path/to/liblindera_sqlite.dylib', +}); +``` + +[lindera-sqlite](https://github.com/lindera/lindera-sqlite)のビルドが必要です。 + +--- + +## APIリファレンス + +### ProjectMemoryService + +```typescript +interface ProjectMemoryConfig { + baseDir: string; // デフォルト: '.claude/memory' + dbFilename: string; // デフォルト: 'memory.db' + enableVectorIndex: boolean; // デフォルト: false + dimensions: number; // デフォルト: 384 + embeddingGenerator?: EmbeddingGenerator; + cacheEnabled: boolean; // デフォルト: true + cacheSize: number; // デフォルト: 1000 + cacheTtl: number; // デフォルト: 300000 (5分) +} +``` + +### メソッド + +| メソッド | 説明 | +|--------|-------------| +| `initialize()` | メモリサービスを初期化 | +| `shutdown()` | シャットダウンして変更を永続化 | +| `storeEntry(input)` | メモリエントリーを保存 | +| `get(id)` | IDでエントリーを取得 | +| `getByKey(namespace, key)` | 名前空間とキーでエントリーを取得 | +| `update(id, update)` | エントリーを更新 | +| `delete(id)` | エントリーを削除 | +| `query(query)` | フィルターでエントリーをクエリ | +| `semanticSearch(content, k)` | セマンティック類似性検索 | +| `count(namespace?)` | エントリーをカウント | +| `listNamespaces()` | すべての名前空間をリスト表示 | +| `getStats()` | 統計を取得 | + +--- + +## コード品質 + +AgentKits Memoryは21のテストスイートにわたる**970の単体テスト**で徹底的にテストされています。 + +| 指標 | カバレッジ | +|------|-----------| +| **ステートメント** | 90.29% | +| **ブランチ** | 80.85% | +| **関数** | 90.54% | +| **行** | 91.74% | + +### テストカテゴリ + +| カテゴリ | テスト数 | カバー内容 | +|----------|---------|-----------| +| コアメモリサービス | 56 | CRUD、検索、ページネーション、カテゴリ、タグ、インポート/エクスポート | +| SQLiteバックエンド | 65 | スキーマ、マイグレーション、FTS5、トランザクション、エラーハンドリング | +| HNSWベクトルインデックス | 47 | 挿入、検索、削除、永続化、エッジケース | +| ハイブリッド検索 | 44 | FTS + ベクトル融合、スコアリング、ランキング、フィルター | +| トークンエコノミクス | 27 | 3層検索バジェット、トランケーション、最適化 | +| 埋め込みシステム | 63 | キャッシュ、サブプロセス、ローカルモデル、CJKサポート | +| フックシステム | 502 | コンテキスト、セッション初期化、オブザベーション、サマライズ、AI拡張、サービスライフサイクル、キューワーカー、アダプター、型 | +| MCPサーバー | 48 | 全9つのMCPツール、バリデーション、エラーレスポンス | +| CLI | 34 | プラットフォーム検出、ルール生成 | +| 統合テスト | 84 | エンドツーエンドフロー、埋め込み統合、マルチセッション | + +```bash +# テスト実行 +npm test + +# カバレッジ付きテスト +npm run test:coverage +``` + +--- + +## 要件 + +- **Node.js LTS**: 18.x、20.x、または22.x(推奨) +- MCP互換AIコーディングアシスタント + +### Node.jsバージョンに関する注意 + +このパッケージは、ネイティブバイナリを必要とする`better-sqlite3`を使用します。**ビルド済みバイナリはLTSバージョンのみで利用可能です**。 + +| Nodeバージョン | ステータス | 備考 | +|--------------|--------|-------| +| 18.x LTS | ✅ 動作 | ビルド済みバイナリ | +| 20.x LTS | ✅ 動作 | ビルド済みバイナリ | +| 22.x LTS | ✅ 動作 | ビルド済みバイナリ | +| 19.x, 21.x, 23.x | ⚠️ ビルドツールが必要 | ビルド済みバイナリなし | + +### 非LTSバージョンの使用(Windows) + +非LTSバージョン(19、21、23)を使用する必要がある場合は、まずビルドツールをインストールします: + +**オプション1: Visual Studio Build Tools** +```powershell +# 以下からダウンロードしてインストール: +# https://visualstudio.microsoft.com/visual-cpp-build-tools/ +# 「C++によるデスクトップ開発」ワークロードを選択 +``` + +**オプション2: windows-build-tools (npm)** +```powershell +npm install --global windows-build-tools +``` + +**オプション3: Chocolatey** +```powershell +choco install visualstudio2022-workload-vctools +``` + +詳細については[node-gyp Windowsガイド](https://github.com/nodejs/node-gyp#on-windows)を参照してください。 + +--- + +## AgentKitsエコシステム + +**AgentKits Memory**は、AityTechによるAgentKitsエコシステムの一部です - AIコーディングアシスタントをよりスマートにするツール。 + +| プロダクト | 説明 | リンク | +|---------|-------------|------| +| **AgentKits Engineer** | 28の専門エージェント、100以上のスキル、エンタープライズパターン | [GitHub](https://github.com/aitytech/agentkits-engineer) | +| **AgentKits Marketing** | AI駆動のマーケティングコンテンツ生成 | [GitHub](https://github.com/aitytech/agentkits-marketing) | +| **AgentKits Memory** | AIアシスタント用の永続的メモリ(このパッケージ) | [npm](https://www.npmjs.com/package/@aitytech/agentkits-memory) | + +

+ + agentkits.net + +

+ +--- + +## Star履歴 + + + + + + Star History Chart + + + +--- + +## ライセンス + +MIT + +--- + +

+ AIアシスタントに永続化するメモリを与えましょう。 +

+ +

+ AgentKits Memory by AityTech +

+ +

+ 役に立ったらこのリポジトリにスターをつけてください。 +

\ No newline at end of file diff --git a/i18n/README.ko.md b/i18n/README.ko.md new file mode 100644 index 0000000..0187dc3 --- /dev/null +++ b/i18n/README.ko.md @@ -0,0 +1,743 @@ +

+ AgentKits Logo +

+ +

AgentKits Memory

+ +

+ by AityTech +

+ +

+ npm + License + Claude Code + Cursor + Windsurf + Cline + OpenCode +
+ Tests + Coverage +

+ +

+ AI 코딩 어시스턴트를 위한 영구 메모리 시스템 +

+ +

+ AI 어시스턴트는 세션 간에 모든 것을 잊어버립니다. AgentKits Memory가 이를 해결합니다.
+ 결정사항, 패턴, 오류, 컨텍스트 — MCP를 통해 로컬에 영구 저장됩니다. +

+ +

+ 웹사이트 • + 문서 • + 빠른 시작 • + 작동 방식 • + 플랫폼 • + CLI • + 웹 뷰어 +

+ +

+ English · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português · Tiếng Việt · Русский · العربية +

+ +--- + +## 기능 + +| 기능 | 이점 | +|---------|---------| +| **100% 로컬** | 모든 데이터가 사용자 컴퓨터에 저장됩니다. 클라우드 없음, API 키 없음, 계정 없음 | +| **초고속** | 네이티브 SQLite (better-sqlite3) = 즉각적인 쿼리, 지연시간 제로 | +| **설정 불필요** | 별도 설정 없이 바로 작동합니다. 데이터베이스 설정 불필요 | +| **멀티 플랫폼** | Claude Code, Cursor, Windsurf, Cline, OpenCode — 한 번의 설정 명령 | +| **MCP 서버** | 9가지 도구: save, search, timeline, details, recall, list, update, delete, status | +| **자동 캡처** | 훅이 세션 컨텍스트, 도구 사용, 요약을 자동으로 캡처 | +| **AI 강화** | 백그라운드 워커가 AI 생성 요약으로 관찰 데이터 강화 | +| **벡터 검색** | 다국어 임베딩을 사용한 HNSW 의미적 유사성 (100개 이상 언어) | +| **웹 뷰어** | 브라우저 UI로 메모리 보기, 검색, 추가, 편집, 삭제 | +| **3계층 검색** | 점진적 노출로 모든 것을 가져오는 것보다 ~87% 토큰 절약 | +| **라이프사이클 관리** | 이전 세션 자동 압축, 아카이브, 정리 | +| **내보내기/가져오기** | JSON으로 메모리 백업 및 복원 | + +--- + +## 작동 방식 + +``` +세션 1: "인증에 JWT 사용" 세션 2: "로그인 엔드포인트 추가" +┌──────────────────────────┐ ┌──────────────────────────┐ +│ AI와 코딩... │ │ AI가 이미 알고 있음: │ +│ AI가 결정 │ │ ✓ JWT 인증 결정 │ +│ AI가 오류 발견 │ ───► │ ✓ 오류 해결책 │ +│ AI가 패턴 학습 │ 저장됨 │ ✓ 코드 패턴 │ +│ │ │ ✓ 세션 컨텍스트 │ +└──────────────────────────┘ └──────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite, 100% 로컬) +``` + +1. **한 번만 설정** — `npx agentkits-memory-setup`이 플랫폼을 구성합니다 +2. **자동 캡처** — 작업하는 동안 훅이 결정사항, 도구 사용, 요약을 기록합니다 +3. **컨텍스트 주입** — 다음 세션이 이전 세션의 관련 이력으로 시작됩니다 +4. **백그라운드 처리** — 워커가 AI로 관찰 데이터를 강화하고, 임베딩을 생성하며, 이전 데이터를 압축합니다 +5. **언제든 검색** — AI가 MCP 도구(`memory_search` → `memory_details`)를 사용하여 과거 컨텍스트를 찾습니다 + +모든 데이터는 사용자 컴퓨터의 `.claude/memory/memory.db`에 저장됩니다. 클라우드 없음. API 키 불필요. + +--- + +## 중요한 설계 결정 + +대부분의 메모리 도구는 데이터를 마크다운 파일에 분산 저장하거나, Python 런타임이 필요하거나, 코드를 외부 API로 전송합니다. AgentKits Memory는 근본적으로 다른 선택을 합니다: + +| 설계 선택 | 중요한 이유 | +|---------------|----------------| +| **단일 SQLite 데이터베이스** | 하나의 파일(`memory.db`)이 모든 것을 보관 — 메모리, 세션, 관찰, 임베딩. 동기화할 분산 파일 없음, 병합 충돌 없음, 고아 데이터 없음. 백업 = 파일 하나만 복사 | +| **네이티브 Node.js, Python 제로** | Node가 실행되는 곳이면 어디서나 실행됩니다. conda 없음, pip 없음, virtualenv 없음. MCP 서버와 같은 언어 — `npx` 명령 하나로 완료 | +| **토큰 효율적인 3계층 검색** | 먼저 검색 인덱스(~50 토큰/결과), 그 다음 타임라인 컨텍스트, 마지막으로 전체 세부사항. 필요한 것만 가져옵니다. 다른 도구는 전체 메모리 파일을 컨텍스트에 덤프하여 무관한 콘텐츠에 토큰을 낭비합니다 | +| **훅을 통한 자동 캡처** | 결정사항, 패턴, 오류가 발생하는 즉시 기록됩니다 — 저장을 기억한 후가 아닙니다. 세션 컨텍스트 주입은 다음 세션 시작 시 자동으로 발생합니다 | +| **로컬 임베딩, API 호출 없음** | 벡터 검색은 로컬 ONNX 모델(multilingual-e5-small)을 사용합니다. 의미 검색이 오프라인에서 작동하고, 비용이 없으며, 100개 이상의 언어를 지원합니다 | +| **백그라운드 워커** | AI 강화, 임베딩 생성, 압축이 비동기적으로 실행됩니다. 코딩 흐름이 결코 차단되지 않습니다 | +| **처음부터 멀티 플랫폼** | `--platform=all` 플래그 하나로 Claude Code, Cursor, Windsurf, Cline, OpenCode를 동시에 구성합니다. 동일한 메모리 데이터베이스, 다른 에디터 | +| **구조화된 관찰 데이터** | 도구 사용이 유형 분류(read/write/execute/search), 파일 추적, 의도 감지, AI 생성 내러티브와 함께 캡처됩니다 — 원시 텍스트 덤프가 아닙니다 | +| **프로세스 누수 없음** | 백그라운드 워커는 5분 후 자동 종료되며, PID 기반 잠금 파일을 사용하고 오래된 잠금 정리와 SIGTERM/SIGINT를 우아하게 처리합니다. 좀비 프로세스 없음, 고아 워커 없음 | +| **메모리 누수 없음** | 훅은 단기 프로세스로 실행됩니다(장기 실행 데몬이 아님). 데이터베이스 연결은 종료 시 닫힙니다. 임베딩 서브프로세스는 제한된 재시작(최대 2회), 대기 중인 요청 타임아웃, 모든 타이머와 큐의 우아한 정리를 갖습니다 | + +--- + +## 웹 뷰어 + +최신 웹 인터페이스를 통해 메모리를 보고 관리하세요. + +```bash +npx agentkits-memory-web +``` + +그런 다음 브라우저에서 **http://localhost:1905**를 엽니다. + +### 세션 목록 + +타임라인 뷰와 활동 세부사항으로 모든 세션을 탐색합니다. + +![Session List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-session-list_v2.png) + +### 메모리 목록 + +검색 및 네임스페이스 필터링으로 저장된 모든 메모리를 탐색합니다. + +![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list_v2.png) + +### 메모리 추가 + +키, 네임스페이스, 유형, 콘텐츠, 태그로 새 메모리를 생성합니다. + +![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory_v2.png) + +### 메모리 세부사항 + +편집 및 삭제 옵션과 함께 전체 메모리 세부사항을 봅니다. + +![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail_v2.png) + +### 임베딩 관리 + +의미 검색을 위한 벡터 임베딩을 생성하고 관리합니다. + +![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding_v2.png) + +--- + +## 빠른 시작 + +### 옵션 1: Claude Code 플러그인 마켓플레이스 (Claude Code 권장) + +하나의 명령으로 설치 — 수동 구성 불필요: + +```bash +/plugin marketplace add aitytech/agentkits-memory +/plugin install agentkits-memory@aitytech +``` + +훅, MCP 서버, 메모리 워크플로 스킬이 자동으로 설치됩니다. 설치 후 Claude Code를 재시작하세요. + +### 옵션 2: 자동 설정 (모든 플랫폼) + +```bash +npx agentkits-memory-setup +``` + +플랫폼을 자동 감지하고 모든 것을 구성합니다: MCP 서버, 훅(Claude Code/OpenCode), 규칙 파일(Cursor/Windsurf/Cline), 임베딩 모델 다운로드. + +**특정 플랫폼 대상 지정:** + +```bash +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all +``` + +### 옵션 3: 수동 MCP 구성 + +수동 설정을 선호하는 경우, MCP 구성에 추가하세요: + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "agentkits-memory-server"] + } + } +} +``` + +구성 파일 위치: +- **Claude Code**: `.claude/settings.json` (`mcpServers` 키에 포함) +- **Cursor**: `.cursor/mcp.json` +- **Windsurf**: `.windsurf/mcp.json` +- **Cline / OpenCode**: `.mcp.json` (프로젝트 루트) + +### 3. MCP 도구 + +구성 후, AI 어시스턴트가 다음 도구를 사용할 수 있습니다: + +| 도구 | 설명 | +|------|-------------| +| `memory_status` | 메모리 시스템 상태 확인 (먼저 호출!) | +| `memory_save` | 결정사항, 패턴, 오류 또는 컨텍스트 저장 | +| `memory_search` | **[1단계]** 검색 인덱스 — 경량 ID + 제목 (~50 토큰/결과) | +| `memory_timeline` | **[2단계]** 메모리 주변의 시간적 컨텍스트 가져오기 | +| `memory_details` | **[3단계]** 특정 ID의 전체 콘텐츠 가져오기 | +| `memory_recall` | 빠른 주제 개요 — 그룹화된 요약 | +| `memory_list` | 최근 메모리 나열 | +| `memory_update` | 기존 메모리 콘텐츠 또는 태그 업데이트 | +| `memory_delete` | 오래된 메모리 제거 | + +--- + +## 점진적 노출 (토큰 효율적 검색) + +AgentKits Memory는 전체 콘텐츠를 미리 가져오는 것보다 ~70% 토큰을 절약하는 **3계층 검색 패턴**을 사용합니다. + +### 작동 방식 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 1단계: memory_search │ +│ 반환: ID, 제목, 태그, 점수 (~50 토큰/항목) │ +│ → 인덱스 검토, 관련 메모리 선택 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 2단계: memory_timeline (선택사항) │ +│ 반환: 메모리 주변 ±30분 컨텍스트 │ +│ → 전후에 무슨 일이 일어났는지 이해 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 3단계: memory_details │ +│ 반환: 선택한 ID의 전체 콘텐츠만 │ +│ → 실제로 필요한 것만 가져오기 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 예제 워크플로 + +```typescript +// 1단계: 검색 - 경량 인덱스 가져오기 +memory_search({ query: "authentication" }) +// → 반환: [{ id: "abc", title: "JWT pattern...", score: 85% }] + +// 2단계: (선택사항) 시간적 컨텍스트 보기 +memory_timeline({ anchor: "abc" }) +// → 반환: 이 메모리 전후에 무슨 일이 일어났는지 + +// 3단계: 필요한 것만 전체 콘텐츠 가져오기 +memory_details({ ids: ["abc"] }) +// → 반환: 선택한 메모리의 전체 콘텐츠 +``` + +### 토큰 절약 + +| 접근 방식 | 사용된 토큰 | +|----------|-------------| +| **기존:** 모든 콘텐츠 가져오기 | ~500 토큰 × 10 결과 = 5000 토큰 | +| **신규:** 점진적 노출 | 50 × 10 + 500 × 2 = 1500 토큰 | +| **절약** | **70% 감소** | + +--- + +## CLI 명령어 + +```bash +# 한 번의 명령으로 설정 (플랫폼 자동 감지) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # 특정 플랫폼 +npx agentkits-memory-setup --platform=all # 모든 플랫폼 +npx agentkits-memory-setup --force # 재설치/업데이트 + +# MCP 서버 시작 +npx agentkits-memory-server + +# 웹 뷰어 (포트 1905) +npx agentkits-memory-web + +# 터미널 뷰어 +npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # 데이터베이스 통계 +npx agentkits-memory-viewer --json # JSON 출력 + +# CLI에서 저장 +npx agentkits-memory-save "Use JWT with refresh tokens" --category pattern --tags auth,security + +# 설정 +npx agentkits-memory-hook settings . # 현재 설정 보기 +npx agentkits-memory-hook settings . --reset # 기본값으로 재설정 +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# 내보내기 / 가져오기 +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# 라이프사이클 관리 +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . +``` + +--- + +## 프로그래밍 방식 사용 + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService({ + baseDir: '.claude/memory', + dbFilename: 'memory.db', +}); +await memory.initialize(); + +// 메모리 저장 +await memory.storeEntry({ + key: 'auth-pattern', + content: 'Use JWT with refresh tokens for authentication', + namespace: 'patterns', + tags: ['auth', 'security'], +}); + +// 메모리 쿼리 +const results = await memory.query({ + type: 'hybrid', + namespace: 'patterns', + content: 'authentication', + limit: 10, +}); + +// 키로 가져오기 +const entry = await memory.getByKey('patterns', 'auth-pattern'); +``` + +--- + +## 자동 캡처 훅 + +훅이 AI 코딩 세션을 자동으로 캡처합니다(Claude Code 및 OpenCode만): + +| 훅 | 트리거 | 동작 | +|------|---------|--------| +| `context` | 세션 시작 | 이전 세션 컨텍스트 + 메모리 상태 주입 | +| `session-init` | 사용자 프롬프트 | 세션 초기화/재개, 프롬프트 기록 | +| `observation` | 도구 사용 후 | 의도 감지와 함께 도구 사용 캡처 | +| `summarize` | 세션 종료 | 구조화된 세션 요약 생성 | +| `user-message` | 세션 시작 | 사용자에게 메모리 상태 표시 (stderr) | + +훅 설정: +```bash +npx agentkits-memory-setup +``` + +**자동으로 캡처되는 내용:** +- 경로가 있는 파일 읽기/쓰기 +- 구조화된 차이로서의 코드 변경 (이전 → 이후) +- 개발자 의도 (bugfix, feature, refactor, investigation 등) +- 결정사항, 오류, 다음 단계가 있는 세션 요약 +- 세션 내 다중 프롬프트 추적 + +--- + +## 멀티 플랫폼 지원 + +| 플랫폼 | MCP | 훅 | 규칙 파일 | 설정 | +|----------|-----|-------|------------|-------| +| **Claude Code** | `.claude/settings.json` | ✅ 전체 | CLAUDE.md (skill) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ 전체 | — | `--platform=opencode` | + +- **MCP 서버**는 모든 플랫폼에서 작동 (MCP 프로토콜을 통한 메모리 도구) +- **훅**은 Claude Code 및 OpenCode에서 자동 캡처 제공 +- **규칙 파일**은 Cursor/Windsurf/Cline에게 메모리 워크플로를 가르침 +- **메모리 데이터**는 항상 `.claude/memory/`에 저장 (단일 진실의 원천) + +--- + +## 백그라운드 워커 + +각 세션 후, 백그라운드 워커가 대기 중인 작업을 처리합니다: + +| 워커 | 작업 | 설명 | +|--------|------|-------------| +| `embed-session` | 임베딩 | 의미 검색을 위한 벡터 임베딩 생성 | +| `enrich-session` | AI 강화 | AI 생성 요약, 사실, 개념으로 관찰 데이터 강화 | +| `compress-session` | 압축 | 이전 관찰 압축 (10:1–25:1) 및 세션 다이제스트 생성 (20:1–100:1) | + +워커는 세션 종료 후 자동으로 실행됩니다. 각 워커는: +- 실행당 최대 200개 항목 처리 +- 동시 실행을 방지하기 위해 잠금 파일 사용 +- 5분 후 자동 종료 (좀비 방지) +- 실패한 작업을 최대 3회 재시도 + +--- + +## AI 제공자 구성 + +AI 강화는 플러그형 제공자를 사용합니다. 기본값은 `claude-cli` (API 키 불필요). + +| 제공자 | 유형 | 기본 모델 | 참고 | +|----------|------|---------------|-------| +| **Claude CLI** | `claude-cli` | `haiku` | `claude --print` 사용, API 키 불필요 | +| **OpenAI** | `openai` | `gpt-4o-mini` | 모든 OpenAI 모델 | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | Google AI Studio 키 | +| **OpenRouter** | `openai` | any | `baseUrl`을 `https://openrouter.ai/api/v1`로 설정 | +| **GLM (Zhipu)** | `openai` | any | `baseUrl`을 `https://open.bigmodel.cn/api/paas/v4`로 설정 | +| **Ollama** | `openai` | any | `baseUrl`을 `http://localhost:11434/v1`로 설정 | + +### 옵션 1: 환경 변수 + +```bash +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter (OpenAI 호환 형식 사용) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# 로컬 Ollama (API 키 불필요) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# AI 강화 완전히 비활성화 +export AGENTKITS_AI_ENRICHMENT=false +``` + +### 옵션 2: 영구 설정 + +```bash +# .claude/memory/settings.json에 저장됨 — 세션 간 유지 +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# 현재 설정 보기 +npx agentkits-memory-hook settings . + +# 기본값으로 재설정 +npx agentkits-memory-hook settings . --reset +``` + +> **우선순위:** 환경 변수가 settings.json을 재정의합니다. settings.json이 기본값을 재정의합니다. + +--- + +## 라이프사이클 관리 + +시간 경과에 따른 메모리 증가 관리: + +```bash +# 7일 이상 된 관찰 압축, 30일 이상 된 세션 아카이브 +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# 90일 이상 된 아카이브된 세션도 자동 삭제 +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# 라이프사이클 통계 보기 +npx agentkits-memory-hook lifecycle-stats . +``` + +| 단계 | 수행되는 작업 | +|-------|-------------| +| **압축** | AI가 관찰을 압축하고, 세션 다이제스트 생성 | +| **아카이브** | 이전 세션을 아카이브로 표시 (컨텍스트에서 제외) | +| **삭제** | 아카이브된 세션 제거 (옵트인, `--delete` 필요) | + +--- + +## 내보내기 / 가져오기 + +프로젝트 메모리 백업 및 복원: + +```bash +# 프로젝트의 모든 세션 내보내기 +npx agentkits-memory-hook export . my-project ./backup.json + +# 백업에서 가져오기 (자동 중복 제거) +npx agentkits-memory-hook import . ./backup.json +``` + +내보내기 형식에는 세션, 관찰, 프롬프트, 요약이 포함됩니다. + +--- + +## 메모리 카테고리 + +| 카테고리 | 사용 사례 | +|----------|----------| +| `decision` | 아키텍처 결정, 기술 스택 선택, 트레이드오프 | +| `pattern` | 코딩 규칙, 프로젝트 패턴, 반복되는 접근 방식 | +| `error` | 버그 수정, 오류 해결책, 디버깅 인사이트 | +| `context` | 프로젝트 배경, 팀 규칙, 환경 설정 | +| `observation` | 자동 캡처된 세션 관찰 | + +--- + +## 저장소 + +메모리는 프로젝트 디렉토리 내 `.claude/memory/`에 저장됩니다. + +``` +.claude/memory/ +├── memory.db # SQLite 데이터베이스 (모든 데이터) +├── memory.db-wal # Write-ahead log (임시) +├── settings.json # 영구 설정 (AI 제공자, 컨텍스트 구성) +└── embeddings-cache/ # 캐시된 벡터 임베딩 +``` + +--- + +## CJK 언어 지원 + +AgentKits Memory는 중국어, 일본어, 한국어 텍스트 검색을 위한 **자동 CJK 지원**을 제공합니다. + +### 설정 불필요 + +`better-sqlite3`이 설치되면 (기본값), CJK 검색이 자동으로 작동합니다: + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService('.claude/memory'); +await memory.initialize(); + +// CJK 콘텐츠 저장 +await memory.storeEntry({ + key: 'auth-pattern', + content: '認証機能の実装パターン - JWT with refresh tokens', + namespace: 'patterns', +}); + +// 일본어, 중국어, 한국어로 검색 - 바로 작동합니다! +const results = await memory.query({ + type: 'hybrid', + content: '認証機能', +}); +``` + +### 작동 방식 + +- **네이티브 SQLite**: 최대 성능을 위해 `better-sqlite3` 사용 +- **트라이그램 토크나이저**: FTS5가 트라이그램으로 CJK 매칭을 위한 3문자 시퀀스 생성 +- **스마트 폴백**: 짧은 CJK 쿼리(< 3자)는 자동으로 LIKE 검색 사용 +- **BM25 순위**: 검색 결과에 대한 관련성 점수 + +### 고급: 일본어 단어 분할 + +적절한 단어 분할을 사용한 고급 일본어의 경우, 선택적으로 lindera를 사용하세요: + +```typescript +import { createJapaneseOptimizedBackend } from '@aitytech/agentkits-memory'; + +const backend = createJapaneseOptimizedBackend({ + databasePath: '.claude/memory/memory.db', + linderaPath: './path/to/liblindera_sqlite.dylib', +}); +``` + +[lindera-sqlite](https://github.com/lindera/lindera-sqlite) 빌드가 필요합니다. + +--- + +## API 참조 + +### ProjectMemoryService + +```typescript +interface ProjectMemoryConfig { + baseDir: string; // 기본값: '.claude/memory' + dbFilename: string; // 기본값: 'memory.db' + enableVectorIndex: boolean; // 기본값: false + dimensions: number; // 기본값: 384 + embeddingGenerator?: EmbeddingGenerator; + cacheEnabled: boolean; // 기본값: true + cacheSize: number; // 기본값: 1000 + cacheTtl: number; // 기본값: 300000 (5분) +} +``` + +### 메서드 + +| 메서드 | 설명 | +|--------|-------------| +| `initialize()` | 메모리 서비스 초기화 | +| `shutdown()` | 종료 및 변경사항 유지 | +| `storeEntry(input)` | 메모리 항목 저장 | +| `get(id)` | ID로 항목 가져오기 | +| `getByKey(namespace, key)` | 네임스페이스와 키로 항목 가져오기 | +| `update(id, update)` | 항목 업데이트 | +| `delete(id)` | 항목 삭제 | +| `query(query)` | 필터로 항목 쿼리 | +| `semanticSearch(content, k)` | 의미적 유사성 검색 | +| `count(namespace?)` | 항목 수 세기 | +| `listNamespaces()` | 모든 네임스페이스 나열 | +| `getStats()` | 통계 가져오기 | + +--- + +## 코드 품질 + +AgentKits Memory는 21개의 테스트 스위트에 걸쳐 **970개의 단위 테스트**로 철저하게 테스트되었습니다. + +| 지표 | 커버리지 | +|------|---------| +| **구문** | 90.29% | +| **분기** | 80.85% | +| **함수** | 90.54% | +| **라인** | 91.74% | + +### 테스트 카테고리 + +| 카테고리 | 테스트 수 | 커버 내용 | +|----------|---------|----------| +| 코어 메모리 서비스 | 56 | CRUD, 검색, 페이지네이션, 카테고리, 태그, 가져오기/내보내기 | +| SQLite 백엔드 | 65 | 스키마, 마이그레이션, FTS5, 트랜잭션, 오류 처리 | +| HNSW 벡터 인덱스 | 47 | 삽입, 검색, 삭제, 영속성, 엣지 케이스 | +| 하이브리드 검색 | 44 | FTS + 벡터 융합, 스코어링, 랭킹, 필터 | +| 토큰 이코노믹스 | 27 | 3계층 검색 예산, 절삭, 최적화 | +| 임베딩 시스템 | 63 | 캐시, 서브프로세스, 로컬 모델, CJK 지원 | +| 훅 시스템 | 502 | 컨텍스트, 세션 초기화, 관찰, 요약, AI 강화, 서비스 라이프사이클, 큐 워커, 어댑터, 타입 | +| MCP 서버 | 48 | 전체 9개 MCP 도구, 유효성 검사, 오류 응답 | +| CLI | 34 | 플랫폼 감지, 규칙 생성 | +| 통합 테스트 | 84 | 엔드투엔드 플로우, 임베딩 통합, 멀티 세션 | + +```bash +# 테스트 실행 +npm test + +# 커버리지 포함 테스트 +npm run test:coverage +``` + +--- + +## 요구 사항 + +- **Node.js LTS**: 18.x, 20.x, 또는 22.x (권장) +- MCP 호환 AI 코딩 어시스턴트 + +### Node.js 버전 참고사항 + +이 패키지는 네이티브 바이너리가 필요한 `better-sqlite3`을 사용합니다. **사전 빌드된 바이너리는 LTS 버전에서만 사용 가능합니다**. + +| Node 버전 | 상태 | 참고 | +|--------------|--------|-------| +| 18.x LTS | ✅ 작동 | 사전 빌드된 바이너리 | +| 20.x LTS | ✅ 작동 | 사전 빌드된 바이너리 | +| 22.x LTS | ✅ 작동 | 사전 빌드된 바이너리 | +| 19.x, 21.x, 23.x | ⚠️ 빌드 도구 필요 | 사전 빌드된 바이너리 없음 | + +### 비-LTS 버전 사용 (Windows) + +비-LTS 버전(19, 21, 23)을 사용해야 하는 경우, 먼저 빌드 도구를 설치하세요: + +**옵션 1: Visual Studio Build Tools** +```powershell +# 다음에서 다운로드 및 설치: +# https://visualstudio.microsoft.com/visual-cpp-build-tools/ +# "Desktop development with C++" 워크로드 선택 +``` + +**옵션 2: windows-build-tools (npm)** +```powershell +npm install --global windows-build-tools +``` + +**옵션 3: Chocolatey** +```powershell +choco install visualstudio2022-workload-vctools +``` + +자세한 내용은 [node-gyp Windows 가이드](https://github.com/nodejs/node-gyp#on-windows)를 참조하세요. + +--- + +## AgentKits 생태계 + +**AgentKits Memory**는 AityTech의 AgentKits 생태계의 일부입니다 - AI 코딩 어시스턴트를 더 스마트하게 만드는 도구입니다. + +| 제품 | 설명 | 링크 | +|---------|-------------|------| +| **AgentKits Engineer** | 28개의 특화 에이전트, 100개 이상의 스킬, 엔터프라이즈 패턴 | [GitHub](https://github.com/aitytech/agentkits-engineer) | +| **AgentKits Marketing** | AI 기반 마케팅 콘텐츠 생성 | [GitHub](https://github.com/aitytech/agentkits-marketing) | +| **AgentKits Memory** | AI 어시스턴트를 위한 영구 메모리 (이 패키지) | [npm](https://www.npmjs.com/package/@aitytech/agentkits-memory) | + +

+ + agentkits.net + +

+ +--- + +## Star History + + + + + + Star History Chart + + + +--- + +## 라이선스 + +MIT + +--- + +

+ AI 어시스턴트에게 지속되는 메모리를 부여하세요. +

+ +

+ AgentKits Memory by AityTech +

+ +

+ 도움이 되셨다면 이 저장소에 스타를 주세요. +

\ No newline at end of file diff --git a/i18n/README.pt-br.md b/i18n/README.pt-br.md new file mode 100644 index 0000000..fc3b8c0 --- /dev/null +++ b/i18n/README.pt-br.md @@ -0,0 +1,743 @@ +

+ AgentKits Logo +

+ +

AgentKits Memory

+ +

+ por AityTech +

+ +

+ npm + License + Claude Code + Cursor + Windsurf + Cline + OpenCode +
+ Tests + Coverage +

+ +

+ Sistema de Memória Persistente para Assistentes de Codificação com IA +

+ +

+ Seu assistente de IA esquece tudo entre as sessões. AgentKits Memory resolve isso.
+ Decisões, padrões, erros e contexto — tudo persistido localmente via MCP. +

+ +

+ Site • + Documentação • + Início Rápido • + Como Funciona • + Plataformas • + CLI • + Visualizador Web +

+ +

+ English · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português · Tiếng Việt · Русский · العربية +

+ +--- + +## Recursos + +| Recurso | Benefício | +|---------|-----------| +| **100% Local** | Todos os dados ficam na sua máquina. Sem nuvem, sem chaves de API, sem contas | +| **Extremamente Rápido** | SQLite nativo (better-sqlite3) = consultas instantâneas, latência zero | +| **Zero Configuração** | Funciona imediatamente. Sem necessidade de configurar banco de dados | +| **Multiplataforma** | Claude Code, Cursor, Windsurf, Cline, OpenCode — um único comando de configuração | +| **Servidor MCP** | 9 ferramentas: save, search, timeline, details, recall, list, update, delete, status | +| **Captura Automática** | Hooks capturam contexto da sessão, uso de ferramentas e resumos automaticamente | +| **Enriquecimento com IA** | Workers em segundo plano enriquecem observações com resumos gerados por IA | +| **Busca Vetorial** | Similaridade semântica HNSW com embeddings multilíngues (mais de 100 idiomas) | +| **Visualizador Web** | Interface no navegador para visualizar, buscar, adicionar, editar e excluir memórias | +| **Busca em 3 Camadas** | Divulgação progressiva economiza ~87% de tokens vs buscar tudo | +| **Gerenciamento de Ciclo de Vida** | Compacta, arquiva e limpa automaticamente sessões antigas | +| **Exportar/Importar** | Backup e restauração de memórias em formato JSON | + +--- + +## Como Funciona + +``` +Sessão 1: "Usar JWT para autenticação" Sessão 2: "Adicionar endpoint de login" +┌──────────────────────────────┐ ┌──────────────────────────────┐ +│ Você codifica com IA... │ │ IA já sabe: │ +│ IA toma decisões │ │ ✓ Decisão de auth JWT │ +│ IA encontra erros │ ───► │ ✓ Soluções de erros │ +│ IA aprende padrões │ salvo │ ✓ Padrões de código │ +│ │ │ ✓ Contexto da sessão │ +└──────────────────────────────┘ └──────────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite, 100% local) +``` + +1. **Configure uma vez** — `npx agentkits-memory-setup` configura sua plataforma +2. **Captura automática** — Hooks registram decisões, uso de ferramentas e resumos enquanto você trabalha +3. **Injeção de contexto** — Próxima sessão começa com histórico relevante de sessões passadas +4. **Processamento em segundo plano** — Workers enriquecem observações com IA, geram embeddings, compactam dados antigos +5. **Busca a qualquer momento** — IA usa ferramentas MCP (`memory_search` → `memory_details`) para encontrar contexto passado + +Todos os dados ficam em `.claude/memory/memory.db` na sua máquina. Sem nuvem. Sem necessidade de chaves de API. + +--- + +## Decisões de Design que Importam + +A maioria das ferramentas de memória espalha dados em arquivos markdown, requer runtimes Python ou envia seu código para APIs externas. AgentKits Memory faz escolhas fundamentalmente diferentes: + +| Escolha de Design | Por que Importa | +|-------------------|-----------------| +| **Banco de dados SQLite único** | Um arquivo (`memory.db`) contém tudo — memórias, sessões, observações, embeddings. Sem arquivos espalhados para sincronizar, sem conflitos de merge, sem dados órfãos. Backup = copiar um arquivo | +| **Node.js nativo, zero Python** | Roda onde o Node roda. Sem conda, sem pip, sem virtualenv. Mesma linguagem que seu servidor MCP — um comando `npx`, pronto | +| **Busca em 3 camadas eficiente em tokens** | Busca primeiro no índice (~50 tokens/resultado), depois contexto da timeline, depois detalhes completos. Busque apenas o que você precisa. Outras ferramentas despejam arquivos de memória inteiros no contexto, queimando tokens em conteúdo irrelevante | +| **Captura automática via hooks** | Decisões, padrões e erros são registrados conforme acontecem — não depois que você se lembra de salvá-los. Injeção de contexto da sessão acontece automaticamente no início da próxima sessão | +| **Embeddings locais, sem chamadas de API** | Busca vetorial usa um modelo ONNX local (multilingual-e5-small). Busca semântica funciona offline, não custa nada e suporta mais de 100 idiomas | +| **Workers em segundo plano** | Enriquecimento com IA, geração de embeddings e compactação rodam de forma assíncrona. Seu fluxo de codificação nunca é bloqueado | +| **Multiplataforma desde o início** | Um único comando `--platform=all` configura Claude Code, Cursor, Windsurf, Cline e OpenCode simultaneamente. Mesmo banco de dados de memória, editores diferentes | +| **Dados de observação estruturados** | Uso de ferramentas é capturado com classificação de tipo (read/write/execute/search), rastreamento de arquivos, detecção de intenção e narrativas geradas por IA — não dumps de texto bruto | +| **Sem vazamento de processos** | Workers em segundo plano se auto-encerram após 5 minutos, usam arquivos de bloqueio baseados em PID com limpeza de bloqueios obsoletos e lidam graciosamente com SIGTERM/SIGINT. Sem processos zumbis, sem workers órfãos | +| **Sem vazamento de memória** | Hooks rodam como processos de curta duração (não daemons de longa duração). Conexões de banco de dados fecham no desligamento. Subprocesso de embedding tem respawn limitado (máx 2), timeouts de requisições pendentes e limpeza graciosa de todos os timers e filas | + +--- + +## Visualizador Web + +Visualize e gerencie suas memórias através de uma interface web moderna. + +```bash +npx agentkits-memory-web +``` + +Depois abra **http://localhost:1905** no seu navegador. + +### Lista de Sessões + +Navegue por todas as sessões com visualização de linha do tempo e detalhes de atividade. + +![Session List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-session-list_v2.png) + +### Lista de Memórias + +Navegue por todas as memórias armazenadas com busca e filtragem por namespace. + +![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list_v2.png) + +### Adicionar Memória + +Crie novas memórias com chave, namespace, tipo, conteúdo e tags. + +![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory_v2.png) + +### Detalhes da Memória + +Visualize detalhes completos da memória com opções de edição e exclusão. + +![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail_v2.png) + +### Gerenciar Embeddings + +Gere e gerencie embeddings vetoriais para busca semântica. + +![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding_v2.png) + +--- + +## Início Rápido + +### Opção 1: Marketplace de Plugins do Claude Code (Recomendado para Claude Code) + +Instale com um único comando — sem configuração manual: + +```bash +/plugin marketplace add aitytech/agentkits-memory +/plugin install agentkits-memory@aitytech +``` + +Isso instala hooks, servidor MCP e skill de workflow de memória automaticamente. Reinicie o Claude Code após a instalação. + +### Opção 2: Configuração Automática (Todas as Plataformas) + +```bash +npx agentkits-memory-setup +``` + +Isso detecta automaticamente sua plataforma e configura tudo: servidor MCP, hooks (Claude Code/OpenCode), arquivos de regras (Cursor/Windsurf/Cline) e baixa o modelo de embedding. + +**Direcionar para uma plataforma específica:** + +```bash +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all +``` + +### Opção 3: Configuração Manual do MCP + +Se preferir configuração manual, adicione ao seu arquivo de configuração MCP: + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "agentkits-memory-server"] + } + } +} +``` + +Localizações dos arquivos de configuração: +- **Claude Code**: `.claude/settings.json` (embutido na chave `mcpServers`) +- **Cursor**: `.cursor/mcp.json` +- **Windsurf**: `.windsurf/mcp.json` +- **Cline / OpenCode**: `.mcp.json` (raiz do projeto) + +### 3. Ferramentas MCP + +Uma vez configurado, seu assistente de IA pode usar estas ferramentas: + +| Ferramenta | Descrição | +|------------|-----------| +| `memory_status` | Verificar status do sistema de memória (chame primeiro!) | +| `memory_save` | Salvar decisões, padrões, erros ou contexto | +| `memory_search` | **[Passo 1]** Buscar índice — IDs e títulos leves (~50 tokens/resultado) | +| `memory_timeline` | **[Passo 2]** Obter contexto temporal ao redor de uma memória | +| `memory_details` | **[Passo 3]** Obter conteúdo completo para IDs específicos | +| `memory_recall` | Visão geral rápida de tópico — resumo agrupado | +| `memory_list` | Listar memórias recentes | +| `memory_update` | Atualizar conteúdo ou tags de memória existente | +| `memory_delete` | Remover memórias desatualizadas | + +--- + +## Divulgação Progressiva (Busca Eficiente em Tokens) + +AgentKits Memory usa um **padrão de busca em 3 camadas** que economiza ~70% de tokens comparado a buscar conteúdo completo antecipadamente. + +### Como Funciona + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Passo 1: memory_search │ +│ Retorna: IDs, títulos, tags, pontuações (~50 tokens/item) │ +│ → Revisar índice, escolher memórias relevantes │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Passo 2: memory_timeline (opcional) │ +│ Retorna: Contexto ±30 minutos ao redor da memória │ +│ → Entender o que aconteceu antes/depois │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Passo 3: memory_details │ +│ Retorna: Conteúdo completo apenas para IDs selecionados │ +│ → Buscar apenas o que você realmente precisa │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Exemplo de Fluxo de Trabalho + +```typescript +// Passo 1: Buscar - obter índice leve +memory_search({ query: "authentication" }) +// → Retorna: [{ id: "abc", title: "JWT pattern...", score: 85% }] + +// Passo 2: (Opcional) Ver contexto temporal +memory_timeline({ anchor: "abc" }) +// → Retorna: O que aconteceu antes/depois desta memória + +// Passo 3: Obter conteúdo completo apenas do que você precisa +memory_details({ ids: ["abc"] }) +// → Retorna: Conteúdo completo da memória selecionada +``` + +### Economia de Tokens + +| Abordagem | Tokens Usados | +|-----------|---------------| +| **Antiga:** Buscar todo o conteúdo | ~500 tokens × 10 resultados = 5000 tokens | +| **Nova:** Divulgação progressiva | 50 × 10 + 500 × 2 = 1500 tokens | +| **Economia** | **70% de redução** | + +--- + +## Comandos CLI + +```bash +# Configuração com um comando (detecta plataforma automaticamente) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # plataforma específica +npx agentkits-memory-setup --platform=all # todas as plataformas +npx agentkits-memory-setup --force # reinstalar/atualizar + +# Iniciar servidor MCP +npx agentkits-memory-server + +# Visualizador web (porta 1905) +npx agentkits-memory-web + +# Visualizador de terminal +npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # estatísticas do banco de dados +npx agentkits-memory-viewer --json # saída JSON + +# Salvar via CLI +npx agentkits-memory-save "Use JWT with refresh tokens" --category pattern --tags auth,security + +# Configurações +npx agentkits-memory-hook settings . # ver configurações atuais +npx agentkits-memory-hook settings . --reset # resetar para padrões +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# Exportar / Importar +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# Gerenciamento de ciclo de vida +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . +``` + +--- + +## Uso Programático + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService({ + baseDir: '.claude/memory', + dbFilename: 'memory.db', +}); +await memory.initialize(); + +// Armazenar uma memória +await memory.storeEntry({ + key: 'auth-pattern', + content: 'Use JWT with refresh tokens for authentication', + namespace: 'patterns', + tags: ['auth', 'security'], +}); + +// Consultar memórias +const results = await memory.query({ + type: 'hybrid', + namespace: 'patterns', + content: 'authentication', + limit: 10, +}); + +// Obter por chave +const entry = await memory.getByKey('patterns', 'auth-pattern'); +``` + +--- + +## Hooks de Captura Automática + +Hooks capturam automaticamente suas sessões de codificação com IA (Claude Code e OpenCode apenas): + +| Hook | Gatilho | Ação | +|------|---------|------| +| `context` | Início da Sessão | Injeta contexto da sessão anterior + status da memória | +| `session-init` | Prompt do Usuário | Inicializa/retoma sessão, registra prompts | +| `observation` | Após Uso de Ferramenta | Captura uso de ferramenta com detecção de intenção | +| `summarize` | Fim da Sessão | Gera resumo estruturado da sessão | +| `user-message` | Início da Sessão | Exibe status da memória para o usuário (stderr) | + +Configurar hooks: +```bash +npx agentkits-memory-setup +``` + +**O que é capturado automaticamente:** +- Leituras/escritas de arquivos com caminhos +- Mudanças de código como diffs estruturados (antes → depois) +- Intenção do desenvolvedor (bugfix, feature, refactor, investigation, etc.) +- Resumos de sessão com decisões, erros e próximos passos +- Rastreamento de múltiplos prompts dentro de sessões + +--- + +## Suporte Multiplataforma + +| Plataforma | MCP | Hooks | Arquivo de Regras | Configuração | +|------------|-----|-------|-------------------|--------------| +| **Claude Code** | `.claude/settings.json` | ✅ Completo | CLAUDE.md (skill) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ Completo | — | `--platform=opencode` | + +- **Servidor MCP** funciona com todas as plataformas (ferramentas de memória via protocolo MCP) +- **Hooks** fornecem captura automática no Claude Code e OpenCode +- **Arquivos de regras** ensinam ao Cursor/Windsurf/Cline o fluxo de trabalho de memória +- **Dados de memória** sempre armazenados em `.claude/memory/` (fonte única da verdade) + +--- + +## Workers em Segundo Plano + +Após cada sessão, workers em segundo plano processam tarefas enfileiradas: + +| Worker | Tarefa | Descrição | +|--------|--------|-----------| +| `embed-session` | Embeddings | Gera embeddings vetoriais para busca semântica | +| `enrich-session` | Enriquecimento com IA | Enriquece observações com resumos, fatos e conceitos gerados por IA | +| `compress-session` | Compactação | Compacta observações antigas (10:1–25:1) e gera resumos de sessão (20:1–100:1) | + +Workers rodam automaticamente após o fim da sessão. Cada worker: +- Processa até 200 itens por execução +- Usa arquivos de bloqueio para prevenir execução concorrente +- Auto-encerra após 5 minutos (previne zumbis) +- Retentar tarefas falhadas até 3 vezes + +--- + +## Configuração de Provedor de IA + +Enriquecimento com IA usa provedores plugáveis. Padrão é `claude-cli` (sem necessidade de chave de API). + +| Provedor | Tipo | Modelo Padrão | Notas | +|----------|------|---------------|-------| +| **Claude CLI** | `claude-cli` | `haiku` | Usa `claude --print`, sem necessidade de chave de API | +| **OpenAI** | `openai` | `gpt-4o-mini` | Qualquer modelo OpenAI | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | Chave do Google AI Studio | +| **OpenRouter** | `openai` | qualquer | Defina `baseUrl` para `https://openrouter.ai/api/v1` | +| **GLM (Zhipu)** | `openai` | qualquer | Defina `baseUrl` para `https://open.bigmodel.cn/api/paas/v4` | +| **Ollama** | `openai` | qualquer | Defina `baseUrl` para `http://localhost:11434/v1` | + +### Opção 1: Variáveis de Ambiente + +```bash +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter (usa formato compatível com OpenAI) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# Ollama local (sem necessidade de chave de API) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# Desabilitar enriquecimento com IA completamente +export AGENTKITS_AI_ENRICHMENT=false +``` + +### Opção 2: Configurações Persistentes + +```bash +# Salvo em .claude/memory/settings.json — persiste entre sessões +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# Ver configurações atuais +npx agentkits-memory-hook settings . + +# Resetar para padrões +npx agentkits-memory-hook settings . --reset +``` + +> **Prioridade:** Variáveis de ambiente sobrepõem settings.json. Settings.json sobrepõe padrões. + +--- + +## Gerenciamento de Ciclo de Vida + +Gerencie o crescimento da memória ao longo do tempo: + +```bash +# Compactar observações com mais de 7 dias, arquivar sessões com mais de 30 dias +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# Também auto-deletar sessões arquivadas com mais de 90 dias +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# Ver estatísticas de ciclo de vida +npx agentkits-memory-hook lifecycle-stats . +``` + +| Estágio | O que Acontece | +|---------|----------------| +| **Compactar** | IA compacta observações, gera resumos de sessão | +| **Arquivar** | Marca sessões antigas como arquivadas (excluídas do contexto) | +| **Deletar** | Remove sessões arquivadas (opt-in, requer `--delete`) | + +--- + +## Exportar / Importar + +Faça backup e restaure suas memórias de projeto: + +```bash +# Exportar todas as sessões de um projeto +npx agentkits-memory-hook export . my-project ./backup.json + +# Importar do backup (deduplica automaticamente) +npx agentkits-memory-hook import . ./backup.json +``` + +Formato de exportação inclui sessões, observações, prompts e resumos. + +--- + +## Categorias de Memória + +| Categoria | Caso de Uso | +|-----------|-------------| +| `decision` | Decisões de arquitetura, escolhas de stack tecnológico, trade-offs | +| `pattern` | Convenções de codificação, padrões de projeto, abordagens recorrentes | +| `error` | Correções de bugs, soluções de erros, insights de debugging | +| `context` | Contexto do projeto, convenções de equipe, configuração de ambiente | +| `observation` | Observações de sessão capturadas automaticamente | + +--- + +## Armazenamento + +Memórias são armazenadas em `.claude/memory/` dentro do diretório do seu projeto. + +``` +.claude/memory/ +├── memory.db # Banco de dados SQLite (todos os dados) +├── memory.db-wal # Write-ahead log (temporário) +├── settings.json # Configurações persistentes (provedor de IA, config de contexto) +└── embeddings-cache/ # Cache de embeddings vetoriais +``` + +--- + +## Suporte a Idiomas CJK + +AgentKits Memory tem **suporte automático a CJK** para busca de texto em chinês, japonês e coreano. + +### Zero Configuração + +Quando `better-sqlite3` está instalado (padrão), busca CJK funciona automaticamente: + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService('.claude/memory'); +await memory.initialize(); + +// Armazenar conteúdo CJK +await memory.storeEntry({ + key: 'auth-pattern', + content: '認証機能の実装パターン - JWT with refresh tokens', + namespace: 'patterns', +}); + +// Buscar em japonês, chinês ou coreano - simplesmente funciona! +const results = await memory.query({ + type: 'hybrid', + content: '認証機能', +}); +``` + +### Como Funciona + +- **SQLite Nativo**: Usa `better-sqlite3` para máxima performance +- **Tokenizador trigram**: FTS5 com trigram cria sequências de 3 caracteres para correspondência CJK +- **Fallback inteligente**: Consultas CJK curtas (< 3 caracteres) automaticamente usam busca LIKE +- **Ranking BM25**: Pontuação de relevância para resultados de busca + +### Avançado: Segmentação de Palavras em Japonês + +Para japonês avançado com segmentação de palavras adequada, opcionalmente use lindera: + +```typescript +import { createJapaneseOptimizedBackend } from '@aitytech/agentkits-memory'; + +const backend = createJapaneseOptimizedBackend({ + databasePath: '.claude/memory/memory.db', + linderaPath: './path/to/liblindera_sqlite.dylib', +}); +``` + +Requer build do [lindera-sqlite](https://github.com/lindera/lindera-sqlite). + +--- + +## Referência da API + +### ProjectMemoryService + +```typescript +interface ProjectMemoryConfig { + baseDir: string; // Padrão: '.claude/memory' + dbFilename: string; // Padrão: 'memory.db' + enableVectorIndex: boolean; // Padrão: false + dimensions: number; // Padrão: 384 + embeddingGenerator?: EmbeddingGenerator; + cacheEnabled: boolean; // Padrão: true + cacheSize: number; // Padrão: 1000 + cacheTtl: number; // Padrão: 300000 (5 min) +} +``` + +### Métodos + +| Método | Descrição | +|--------|-----------| +| `initialize()` | Inicializar o serviço de memória | +| `shutdown()` | Desligar e persistir mudanças | +| `storeEntry(input)` | Armazenar uma entrada de memória | +| `get(id)` | Obter entrada por ID | +| `getByKey(namespace, key)` | Obter entrada por namespace e chave | +| `update(id, update)` | Atualizar uma entrada | +| `delete(id)` | Deletar uma entrada | +| `query(query)` | Consultar entradas com filtros | +| `semanticSearch(content, k)` | Busca por similaridade semântica | +| `count(namespace?)` | Contar entradas | +| `listNamespaces()` | Listar todos os namespaces | +| `getStats()` | Obter estatísticas | + +--- + +## Qualidade do Código + +AgentKits Memory é rigorosamente testado com **970 testes unitários** em 21 suítes de teste. + +| Métrica | Cobertura | +|---------|-----------| +| **Declarações** | 90.29% | +| **Branches** | 80.85% | +| **Funções** | 90.54% | +| **Linhas** | 91.74% | + +### Categorias de Testes + +| Categoria | Testes | Cobertura | +|-----------|--------|-----------| +| Serviço de Memória Core | 56 | CRUD, busca, paginação, categorias, tags, importar/exportar | +| Backend SQLite | 65 | Schema, migrações, FTS5, transações, tratamento de erros | +| Índice Vetorial HNSW | 47 | Inserção, busca, exclusão, persistência, casos limite | +| Busca Híbrida | 44 | FTS + fusão vetorial, pontuação, ranking, filtros | +| Economia de Tokens | 27 | Orçamentos de busca 3 camadas, truncamento, otimização | +| Sistema de Embeddings | 63 | Cache, subprocesso, modelos locais, suporte CJK | +| Sistema de Hooks | 502 | Contexto, init de sessão, observação, resumo, enriquecimento IA, ciclo de vida, workers, adaptadores, tipos | +| Servidor MCP | 48 | 9 ferramentas MCP, validação, respostas de erro | +| CLI | 34 | Detecção de plataforma, geração de regras | +| Integração | 84 | Fluxos end-to-end, integração de embeddings, multi-sessão | + +```bash +# Executar testes +npm test + +# Executar com cobertura +npm run test:coverage +``` + +--- + +## Requisitos + +- **Node.js LTS**: 18.x, 20.x ou 22.x (recomendado) +- Assistente de codificação com IA compatível com MCP + +### Notas sobre Versão do Node.js + +Este pacote usa `better-sqlite3` que requer binários nativos. **Binários pré-compilados estão disponíveis apenas para versões LTS**. + +| Versão do Node | Status | Notas | +|----------------|--------|-------| +| 18.x LTS | ✅ Funciona | Binários pré-compilados | +| 20.x LTS | ✅ Funciona | Binários pré-compilados | +| 22.x LTS | ✅ Funciona | Binários pré-compilados | +| 19.x, 21.x, 23.x | ⚠️ Requer ferramentas de build | Sem binários pré-compilados | + +### Usando Versões Não-LTS (Windows) + +Se você precisa usar uma versão não-LTS (19, 21, 23), instale ferramentas de build primeiro: + +**Opção 1: Visual Studio Build Tools** +```powershell +# Baixe e instale de: +# https://visualstudio.microsoft.com/visual-cpp-build-tools/ +# Selecione a carga de trabalho "Desktop development with C++" +``` + +**Opção 2: windows-build-tools (npm)** +```powershell +npm install --global windows-build-tools +``` + +**Opção 3: Chocolatey** +```powershell +choco install visualstudio2022-workload-vctools +``` + +Veja [guia do node-gyp para Windows](https://github.com/nodejs/node-gyp#on-windows) para mais detalhes. + +--- + +## Ecossistema AgentKits + +**AgentKits Memory** faz parte do ecossistema AgentKits da AityTech - ferramentas que tornam assistentes de codificação com IA mais inteligentes. + +| Produto | Descrição | Link | +|---------|-----------|------| +| **AgentKits Engineer** | 28 agentes especializados, mais de 100 skills, padrões empresariais | [GitHub](https://github.com/aitytech/agentkits-engineer) | +| **AgentKits Marketing** | Geração de conteúdo de marketing com IA | [GitHub](https://github.com/aitytech/agentkits-marketing) | +| **AgentKits Memory** | Memória persistente para assistentes de IA (este pacote) | [npm](https://www.npmjs.com/package/@aitytech/agentkits-memory) | + +

+ + agentkits.net + +

+ +--- + +## Histórico de Estrelas + + + + + + Star History Chart + + + +--- + +## Licença + +MIT + +--- + +

+ Dê ao seu assistente de IA uma memória que persiste. +

+ +

+ AgentKits Memory por AityTech +

+ +

+ Dê uma estrela neste repositório se ele ajuda sua IA a lembrar. +

\ No newline at end of file diff --git a/i18n/README.ru.md b/i18n/README.ru.md new file mode 100644 index 0000000..e953b25 --- /dev/null +++ b/i18n/README.ru.md @@ -0,0 +1,743 @@ +

+ AgentKits Logo +

+ +

AgentKits Memory

+ +

+ от AityTech +

+ +

+ npm + License + Claude Code + Cursor + Windsurf + Cline + OpenCode +
+ Tests + Coverage +

+ +

+ Система постоянной памяти для AI-ассистентов программирования +

+ +

+ Ваш AI-ассистент забывает всё между сеансами. AgentKits Memory решает эту проблему.
+ Решения, паттерны, ошибки и контекст — всё сохраняется локально через MCP. +

+ +

+ Сайт • + Документация • + Быстрый старт • + Как это работает • + Платформы • + CLI • + Веб-интерфейс +

+ +

+ English · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português · Tiếng Việt · Русский · العربية +

+ +--- + +## Возможности + +| Возможность | Преимущество | +|---------|---------| +| **100% локально** | Все данные остаются на вашей машине. Без облака, без API-ключей, без аккаунтов | +| **Молниеносная скорость** | Нативный SQLite (better-sqlite3) = мгновенные запросы, нулевая задержка | +| **Без настройки** | Работает из коробки. Не требуется настройка базы данных | +| **Мультиплатформенность** | Claude Code, Cursor, Windsurf, Cline, OpenCode — одна команда установки | +| **MCP-сервер** | 9 инструментов: сохранение, поиск, временная шкала, детали, извлечение, список, обновление, удаление, статус | +| **Автозахват** | Хуки автоматически фиксируют контекст сеанса, использование инструментов, сводки | +| **AI-обогащение** | Фоновые процессы обогащают наблюдения сводками, сгенерированными AI | +| **Векторный поиск** | HNSW семантическое сходство с многоязычными эмбеддингами (100+ языков) | +| **Веб-интерфейс** | Браузерный UI для просмотра, поиска, добавления, редактирования, удаления воспоминаний | +| **3-уровневый поиск** | Прогрессивное раскрытие экономит ~87% токенов по сравнению с загрузкой всего | +| **Управление жизненным циклом** | Автосжатие, архивация и очистка старых сеансов | +| **Экспорт/импорт** | Резервное копирование и восстановление воспоминаний в формате JSON | + +--- + +## Как это работает + +``` +Сеанс 1: "Использовать JWT для auth" Сеанс 2: "Добавить endpoint входа" +┌──────────────────────────┐ ┌──────────────────────────┐ +│ Вы кодите с AI... │ │ AI уже знает: │ +│ AI принимает решения │ │ ✓ Решение о JWT auth │ +│ AI сталкивается с │ ───► │ ✓ Решения ошибок │ +│ ошибками │ сохран. │ ✓ Паттерны кода │ +│ AI изучает паттерны │ │ ✓ Контекст сеанса │ +└──────────────────────────┘ └──────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite, 100% локально) +``` + +1. **Настройка один раз** — `npx agentkits-memory-setup` настраивает вашу платформу +2. **Автозахват** — Хуки записывают решения, использование инструментов и сводки во время работы +3. **Внедрение контекста** — Следующий сеанс начинается с релевантной историей из прошлых сеансов +4. **Фоновая обработка** — Процессы обогащают наблюдения с помощью AI, генерируют эмбеддинги, сжимают старые данные +5. **Поиск в любое время** — AI использует MCP-инструменты (`memory_search` → `memory_details`) для поиска прошлого контекста + +Все данные остаются в `.claude/memory/memory.db` на вашей машине. Без облака. API-ключи не требуются. + +--- + +## Проектные решения, которые имеют значение + +Большинство инструментов памяти рассеивают данные по markdown-файлам, требуют Python runtime или отправляют ваш код внешним API. AgentKits Memory делает принципиально другой выбор: + +| Проектное решение | Почему это важно | +|---------------|----------------| +| **Единая база данных SQLite** | Один файл (`memory.db`) содержит всё — воспоминания, сеансы, наблюдения, эмбеддинги. Никаких рассеянных файлов для синхронизации, конфликтов слияния, потерянных данных. Резервная копия = копирование одного файла | +| **Нативный Node.js, без Python** | Работает везде, где работает Node. Без conda, без pip, без virtualenv. Тот же язык, что и ваш MCP-сервер — одна команда `npx`, готово | +| **Токен-эффективный 3-уровневый поиск** | Сначала индекс поиска (~50 токенов/результат), затем контекст временной шкалы, затем полные детали. Загружайте только то, что нужно. Другие инструменты сбрасывают целые файлы памяти в контекст, сжигая токены на нерелевантном контенте | +| **Автозахват через хуки** | Решения, паттерны и ошибки записываются по мере их появления — а не после того, как вы вспомните их сохранить. Внедрение контекста сеанса происходит автоматически при следующем старте сеанса | +| **Локальные эмбеддинги, без API-вызовов** | Векторный поиск использует локальную ONNX-модель (multilingual-e5-small). Семантический поиск работает офлайн, ничего не стоит и поддерживает 100+ языков | +| **Фоновые процессы** | AI-обогащение, генерация эмбеддингов и сжатие выполняются асинхронно. Ваш процесс кодирования никогда не блокируется | +| **Мультиплатформенность с первого дня** | Один флаг `--platform=all` настраивает Claude Code, Cursor, Windsurf, Cline и OpenCode одновременно. Та же база данных памяти, разные редакторы | +| **Структурированные данные наблюдений** | Использование инструментов фиксируется с классификацией типов (read/write/execute/search), отслеживанием файлов, определением намерений и AI-генерируемыми описаниями — а не сырыми текстовыми дампами | +| **Без утечек процессов** | Фоновые процессы самостоятельно завершаются через 5 минут, используют PID-файлы блокировок с очисткой устаревших блокировок и корректно обрабатывают SIGTERM/SIGINT. Никаких процессов-зомби, никаких осиротевших процессов | +| **Без утечек памяти** | Хуки работают как короткоживущие процессы (не долгоживущие демоны). Соединения с базой данных закрываются при выключении. Подпроцесс эмбеддингов имеет ограниченный перезапуск (макс. 2), таймауты ожидающих запросов и корректную очистку всех таймеров и очередей | + +--- + +## Веб-интерфейс + +Просматривайте и управляйте своими воспоминаниями через современный веб-интерфейс. + +```bash +npx agentkits-memory-web +``` + +Затем откройте **http://localhost:1905** в браузере. + +### Список сеансов + +Просмотр всех сеансов с хронологией и деталями активности. + +![Session List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-session-list_v2.png) + +### Список воспоминаний + +Просмотр всех сохранённых воспоминаний с поиском и фильтрацией по пространству имён. + +![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list_v2.png) + +### Добавление воспоминания + +Создание новых воспоминаний с ключом, пространством имён, типом, содержимым и тегами. + +![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory_v2.png) + +### Детали воспоминания + +Просмотр полных деталей воспоминания с возможностью редактирования и удаления. + +![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail_v2.png) + +### Управление эмбеддингами + +Генерация и управление векторными эмбеддингами для семантического поиска. + +![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding_v2.png) + +--- + +## Быстрый старт + +### Вариант 1: Маркетплейс плагинов Claude Code (рекомендуется для Claude Code) + +Установка одной командой — без ручной настройки: + +```bash +/plugin marketplace add aitytech/agentkits-memory +/plugin install agentkits-memory@aitytech +``` + +Это автоматически устанавливает хуки, MCP-сервер и навык рабочего процесса памяти. Перезапустите Claude Code после установки. + +### Вариант 2: Автоматическая установка (все платформы) + +```bash +npx agentkits-memory-setup +``` + +Это автоматически определяет вашу платформу и настраивает всё: MCP-сервер, хуки (Claude Code/OpenCode), файлы правил (Cursor/Windsurf/Cline) и загружает модель эмбеддингов. + +**Настройка для конкретной платформы:** + +```bash +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all +``` + +### Вариант 3: Ручная настройка MCP + +Если вы предпочитаете ручную настройку, добавьте в ваш MCP-конфиг: + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "agentkits-memory-server"] + } + } +} +``` + +Расположение конфигурационных файлов: +- **Claude Code**: `.claude/settings.json` (встроен в ключ `mcpServers`) +- **Cursor**: `.cursor/mcp.json` +- **Windsurf**: `.windsurf/mcp.json` +- **Cline / OpenCode**: `.mcp.json` (корень проекта) + +### 3. MCP-инструменты + +После настройки ваш AI-ассистент может использовать эти инструменты: + +| Инструмент | Описание | +|------|-------------| +| `memory_status` | Проверка статуса системы памяти (вызывайте первым!) | +| `memory_save` | Сохранение решений, паттернов, ошибок или контекста | +| `memory_search` | **[Шаг 1]** Поиск по индексу — легковесные ID + заголовки (~50 токенов/результат) | +| `memory_timeline` | **[Шаг 2]** Получение временного контекста вокруг воспоминания | +| `memory_details` | **[Шаг 3]** Получение полного содержимого для конкретных ID | +| `memory_recall` | Быстрый обзор темы — сгруппированная сводка | +| `memory_list` | Список недавних воспоминаний | +| `memory_update` | Обновление существующего содержимого воспоминания или тегов | +| `memory_delete` | Удаление устаревших воспоминаний | + +--- + +## Прогрессивное раскрытие (токен-эффективный поиск) + +AgentKits Memory использует **шаблон 3-уровневого поиска**, который экономит ~70% токенов по сравнению с предварительной загрузкой полного содержимого. + +### Как это работает + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Шаг 1: memory_search │ +│ Возвращает: ID, заголовки, теги, оценки (~50 токенов/элемент) │ +│ → Просмотр индекса, выбор релевантных воспоминаний │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Шаг 2: memory_timeline (опционально) │ +│ Возвращает: Контекст ±30 минут вокруг воспоминания │ +│ → Понимание того, что произошло до/после │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Шаг 3: memory_details │ +│ Возвращает: Полное содержимое только для выбранных ID │ +│ → Загрузка только того, что действительно нужно │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Пример рабочего процесса + +```typescript +// Шаг 1: Поиск - получение легковесного индекса +memory_search({ query: "authentication" }) +// → Возвращает: [{ id: "abc", title: "JWT pattern...", score: 85% }] + +// Шаг 2: (Опционально) Просмотр временного контекста +memory_timeline({ anchor: "abc" }) +// → Возвращает: Что произошло до/после этого воспоминания + +// Шаг 3: Получение полного содержимого только для того, что нужно +memory_details({ ids: ["abc"] }) +// → Возвращает: Полное содержимое для выбранного воспоминания +``` + +### Экономия токенов + +| Подход | Использовано токенов | +|----------|-------------| +| **Старый:** Загрузка всего содержимого | ~500 токенов × 10 результатов = 5000 токенов | +| **Новый:** Прогрессивное раскрытие | 50 × 10 + 500 × 2 = 1500 токенов | +| **Экономия** | **Снижение на 70%** | + +--- + +## CLI-команды + +```bash +# Установка одной командой (автоопределение платформы) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # конкретная платформа +npx agentkits-memory-setup --platform=all # все платформы +npx agentkits-memory-setup --force # переустановка/обновление + +# Запуск MCP-сервера +npx agentkits-memory-server + +# Веб-интерфейс (порт 1905) +npx agentkits-memory-web + +# Терминальный просмотрщик +npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # статистика базы данных +npx agentkits-memory-viewer --json # вывод в JSON + +# Сохранение из CLI +npx agentkits-memory-save "Use JWT with refresh tokens" --category pattern --tags auth,security + +# Настройки +npx agentkits-memory-hook settings . # просмотр текущих настроек +npx agentkits-memory-hook settings . --reset # сброс до значений по умолчанию +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# Экспорт / Импорт +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# Управление жизненным циклом +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . +``` + +--- + +## Программное использование + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService({ + baseDir: '.claude/memory', + dbFilename: 'memory.db', +}); +await memory.initialize(); + +// Сохранение воспоминания +await memory.storeEntry({ + key: 'auth-pattern', + content: 'Use JWT with refresh tokens for authentication', + namespace: 'patterns', + tags: ['auth', 'security'], +}); + +// Запрос воспоминаний +const results = await memory.query({ + type: 'hybrid', + namespace: 'patterns', + content: 'authentication', + limit: 10, +}); + +// Получение по ключу +const entry = await memory.getByKey('patterns', 'auth-pattern'); +``` + +--- + +## Хуки автозахвата + +Хуки автоматически фиксируют ваши AI-сеансы программирования (только Claude Code и OpenCode): + +| Хук | Триггер | Действие | +|------|---------|--------| +| `context` | Начало сеанса | Внедряет контекст предыдущего сеанса + статус памяти | +| `session-init` | Запрос пользователя | Инициализирует/возобновляет сеанс, записывает запросы | +| `observation` | После использования инструмента | Фиксирует использование инструмента с определением намерения | +| `summarize` | Конец сеанса | Генерирует структурированную сводку сеанса | +| `user-message` | Начало сеанса | Отображает статус памяти пользователю (stderr) | + +Установка хуков: +```bash +npx agentkits-memory-setup +``` + +**Что фиксируется автоматически:** +- Чтение/запись файлов с путями +- Изменения кода в виде структурированных diff (до → после) +- Намерение разработчика (исправление ошибок, функция, рефакторинг, исследование и т.д.) +- Сводки сеансов с решениями, ошибками и следующими шагами +- Отслеживание нескольких запросов в рамках сеансов + +--- + +## Мультиплатформенная поддержка + +| Платформа | MCP | Хуки | Файл правил | Установка | +|----------|-----|-------|------------|-------| +| **Claude Code** | `.claude/settings.json` | ✅ Полная | CLAUDE.md (skill) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ Полная | — | `--platform=opencode` | + +- **MCP-сервер** работает со всеми платформами (инструменты памяти через MCP-протокол) +- **Хуки** обеспечивают автозахват в Claude Code и OpenCode +- **Файлы правил** обучают Cursor/Windsurf/Cline рабочему процессу памяти +- **Данные памяти** всегда хранятся в `.claude/memory/` (единый источник истины) + +--- + +## Фоновые процессы + +После каждого сеанса фоновые процессы обрабатывают задачи в очереди: + +| Процесс | Задача | Описание | +|--------|------|-------------| +| `embed-session` | Эмбеддинги | Генерация векторных эмбеддингов для семантического поиска | +| `enrich-session` | AI-обогащение | Обогащение наблюдений AI-генерируемыми сводками, фактами, концепциями | +| `compress-session` | Сжатие | Сжатие старых наблюдений (10:1–25:1) и генерация дайджестов сеансов (20:1–100:1) | + +Процессы запускаются автоматически после окончания сеанса. Каждый процесс: +- Обрабатывает до 200 элементов за запуск +- Использует файлы блокировок для предотвращения параллельного выполнения +- Автозавершается через 5 минут (предотвращает зомби) +- Повторяет неудачные задачи до 3 раз + +--- + +## Настройка AI-провайдера + +AI-обогащение использует подключаемые провайдеры. По умолчанию используется `claude-cli` (API-ключ не нужен). + +| Провайдер | Тип | Модель по умолчанию | Примечания | +|----------|------|---------------|-------| +| **Claude CLI** | `claude-cli` | `haiku` | Использует `claude --print`, API-ключ не нужен | +| **OpenAI** | `openai` | `gpt-4o-mini` | Любая модель OpenAI | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | Ключ Google AI Studio | +| **OpenRouter** | `openai` | любая | Установите `baseUrl` в `https://openrouter.ai/api/v1` | +| **GLM (Zhipu)** | `openai` | любая | Установите `baseUrl` в `https://open.bigmodel.cn/api/paas/v4` | +| **Ollama** | `openai` | любая | Установите `baseUrl` в `http://localhost:11434/v1` | + +### Вариант 1: Переменные окружения + +```bash +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter (использует OpenAI-совместимый формат) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# Локальный Ollama (API-ключ не нужен) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# Полностью отключить AI-обогащение +export AGENTKITS_AI_ENRICHMENT=false +``` + +### Вариант 2: Постоянные настройки + +```bash +# Сохраняется в .claude/memory/settings.json — сохраняется между сеансами +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# Просмотр текущих настроек +npx agentkits-memory-hook settings . + +# Сброс до значений по умолчанию +npx agentkits-memory-hook settings . --reset +``` + +> **Приоритет:** Переменные окружения переопределяют settings.json. Settings.json переопределяет значения по умолчанию. + +--- + +## Управление жизненным циклом + +Управление ростом памяти с течением времени: + +```bash +# Сжать наблюдения старше 7 дней, архивировать сеансы старше 30 дней +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# Также автоудаление архивированных сеансов старше 90 дней +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# Просмотр статистики жизненного цикла +npx agentkits-memory-hook lifecycle-stats . +``` + +| Стадия | Что происходит | +|-------|-------------| +| **Сжатие** | AI-сжатие наблюдений, генерация дайджестов сеансов | +| **Архивация** | Отметка старых сеансов как архивированных (исключены из контекста) | +| **Удаление** | Удаление архивированных сеансов (opt-in, требует `--delete`) | + +--- + +## Экспорт / Импорт + +Резервное копирование и восстановление воспоминаний вашего проекта: + +```bash +# Экспорт всех сеансов для проекта +npx agentkits-memory-hook export . my-project ./backup.json + +# Импорт из резервной копии (автоматическая дедупликация) +npx agentkits-memory-hook import . ./backup.json +``` + +Формат экспорта включает сеансы, наблюдения, запросы и сводки. + +--- + +## Категории воспоминаний + +| Категория | Случай использования | +|----------|----------| +| `decision` | Архитектурные решения, выбор технологий, компромиссы | +| `pattern` | Соглашения о кодировании, паттерны проекта, повторяющиеся подходы | +| `error` | Исправления ошибок, решения ошибок, инсайты отладки | +| `context` | Фон проекта, командные соглашения, настройка окружения | +| `observation` | Автозахваченные наблюдения сеанса | + +--- + +## Хранение + +Воспоминания хранятся в `.claude/memory/` внутри директории вашего проекта. + +``` +.claude/memory/ +├── memory.db # База данных SQLite (все данные) +├── memory.db-wal # Write-ahead log (временный) +├── settings.json # Постоянные настройки (AI-провайдер, конфиг контекста) +└── embeddings-cache/ # Кешированные векторные эмбеддинги +``` + +--- + +## Поддержка CJK-языков + +AgentKits Memory имеет **автоматическую поддержку CJK** для поиска текста на китайском, японском и корейском языках. + +### Без настройки + +Когда установлен `better-sqlite3` (по умолчанию), поиск CJK работает автоматически: + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService('.claude/memory'); +await memory.initialize(); + +// Сохранение CJK-содержимого +await memory.storeEntry({ + key: 'auth-pattern', + content: '認証機能の実装パターン - JWT with refresh tokens', + namespace: 'patterns', +}); + +// Поиск на японском, китайском или корейском - просто работает! +const results = await memory.query({ + type: 'hybrid', + content: '認証機能', +}); +``` + +### Как это работает + +- **Нативный SQLite**: Использует `better-sqlite3` для максимальной производительности +- **Триграммный токенизатор**: FTS5 с триграммами создаёт 3-символьные последовательности для CJK-сопоставления +- **Умный откат**: Короткие CJK-запросы (< 3 символов) автоматически используют LIKE-поиск +- **BM25-ранжирование**: Оценка релевантности для результатов поиска + +### Дополнительно: Сегментация японских слов + +Для продвинутого японского с правильной сегментацией слов, опционально используйте lindera: + +```typescript +import { createJapaneseOptimizedBackend } from '@aitytech/agentkits-memory'; + +const backend = createJapaneseOptimizedBackend({ + databasePath: '.claude/memory/memory.db', + linderaPath: './path/to/liblindera_sqlite.dylib', +}); +``` + +Требуется сборка [lindera-sqlite](https://github.com/lindera/lindera-sqlite). + +--- + +## Справочник API + +### ProjectMemoryService + +```typescript +interface ProjectMemoryConfig { + baseDir: string; // По умолчанию: '.claude/memory' + dbFilename: string; // По умолчанию: 'memory.db' + enableVectorIndex: boolean; // По умолчанию: false + dimensions: number; // По умолчанию: 384 + embeddingGenerator?: EmbeddingGenerator; + cacheEnabled: boolean; // По умолчанию: true + cacheSize: number; // По умолчанию: 1000 + cacheTtl: number; // По умолчанию: 300000 (5 мин) +} +``` + +### Методы + +| Метод | Описание | +|--------|-------------| +| `initialize()` | Инициализация сервиса памяти | +| `shutdown()` | Выключение и сохранение изменений | +| `storeEntry(input)` | Сохранение записи воспоминания | +| `get(id)` | Получение записи по ID | +| `getByKey(namespace, key)` | Получение записи по пространству имён и ключу | +| `update(id, update)` | Обновление записи | +| `delete(id)` | Удаление записи | +| `query(query)` | Запрос записей с фильтрами | +| `semanticSearch(content, k)` | Поиск по семантическому сходству | +| `count(namespace?)` | Подсчёт записей | +| `listNamespaces()` | Список всех пространств имён | +| `getStats()` | Получение статистики | + +--- + +## Качество кода + +AgentKits Memory тщательно протестирован — **970 модульных тестов** в 21 тестовом наборе. + +| Метрика | Покрытие | +|---------|----------| +| **Операторы** | 90.29% | +| **Ветви** | 80.85% | +| **Функции** | 90.54% | +| **Строки** | 91.74% | + +### Категории тестов + +| Категория | Тестов | Что покрывает | +|-----------|--------|---------------| +| Основной сервис памяти | 56 | CRUD, поиск, пагинация, категории, теги, импорт/экспорт | +| Backend SQLite | 65 | Схема, миграции, FTS5, транзакции, обработка ошибок | +| Векторный индекс HNSW | 47 | Вставка, поиск, удаление, персистентность, граничные случаи | +| Гибридный поиск | 44 | FTS + векторное слияние, скоринг, ранжирование, фильтры | +| Экономика токенов | 27 | Бюджеты 3-уровневого поиска, усечение, оптимизация | +| Система эмбеддингов | 63 | Кеш, подпроцесс, локальные модели, поддержка CJK | +| Система хуков | 502 | Контекст, инициализация сессии, наблюдение, резюме, AI-обогащение, жизненный цикл, воркеры очередей, адаптеры, типы | +| MCP-сервер | 48 | Все 9 MCP-инструментов, валидация, ответы об ошибках | +| CLI | 34 | Определение платформы, генерация правил | +| Интеграция | 84 | Сквозные потоки, интеграция эмбеддингов, мультисессии | + +```bash +# Запуск тестов +npm test + +# Запуск с покрытием +npm run test:coverage +``` + +--- + +## Требования + +- **Node.js LTS**: 18.x, 20.x или 22.x (рекомендуется) +- MCP-совместимый AI-ассистент программирования + +### Примечания к версиям Node.js + +Этот пакет использует `better-sqlite3`, который требует нативных бинарных файлов. **Предсобранные бинарные файлы доступны только для LTS-версий**. + +| Версия Node | Статус | Примечания | +|--------------|--------|-------| +| 18.x LTS | ✅ Работает | Предсобранные бинарные файлы | +| 20.x LTS | ✅ Работает | Предсобранные бинарные файлы | +| 22.x LTS | ✅ Работает | Предсобранные бинарные файлы | +| 19.x, 21.x, 23.x | ⚠️ Требуются инструменты сборки | Нет предсобранных бинарных файлов | + +### Использование не-LTS версий (Windows) + +Если вы должны использовать не-LTS версию (19, 21, 23), сначала установите инструменты сборки: + +**Вариант 1: Visual Studio Build Tools** +```powershell +# Скачайте и установите с: +# https://visualstudio.microsoft.com/visual-cpp-build-tools/ +# Выберите рабочую нагрузку "Desktop development with C++" +``` + +**Вариант 2: windows-build-tools (npm)** +```powershell +npm install --global windows-build-tools +``` + +**Вариант 3: Chocolatey** +```powershell +choco install visualstudio2022-workload-vctools +``` + +См. [руководство node-gyp для Windows](https://github.com/nodejs/node-gyp#on-windows) для более подробной информации. + +--- + +## Экосистема AgentKits + +**AgentKits Memory** является частью экосистемы AgentKits от AityTech — инструменты, которые делают AI-ассистентов программирования умнее. + +| Продукт | Описание | Ссылка | +|---------|-------------|------| +| **AgentKits Engineer** | 28 специализированных агентов, 100+ навыков, корпоративные паттерны | [GitHub](https://github.com/aitytech/agentkits-engineer) | +| **AgentKits Marketing** | AI-генерация маркетингового контента | [GitHub](https://github.com/aitytech/agentkits-marketing) | +| **AgentKits Memory** | Постоянная память для AI-ассистентов (этот пакет) | [npm](https://www.npmjs.com/package/@aitytech/agentkits-memory) | + +

+ + agentkits.net + +

+ +--- + +## История звёзд + + + + + + Star History Chart + + + +--- + +## Лицензия + +MIT + +--- + +

+ Дайте вашему AI-ассистенту память, которая сохраняется. +

+ +

+ AgentKits Memory от AityTech +

+ +

+ Поставьте звезду этому репозиторию, если он помогает вашему AI запоминать. +

\ No newline at end of file diff --git a/i18n/README.vi.md b/i18n/README.vi.md new file mode 100644 index 0000000..9d63a68 --- /dev/null +++ b/i18n/README.vi.md @@ -0,0 +1,743 @@ +

+ AgentKits Logo +

+ +

AgentKits Memory

+ +

+ bởi AityTech +

+ +

+ npm + License + Claude Code + Cursor + Windsurf + Cline + OpenCode +
+ Tests + Coverage +

+ +

+ Hệ thống Bộ nhớ Lâu dài cho Trợ lý Lập trình AI +

+ +

+ Trợ lý AI của bạn quên mọi thứ giữa các phiên làm việc. AgentKits Memory khắc phục điều đó.
+ Các quyết định, mẫu, lỗi và ngữ cảnh — tất cả được lưu trữ cục bộ qua MCP. +

+ +

+ Trang chủ • + Tài liệu • + Bắt đầu nhanh • + Cách hoạt động • + Nền tảng • + CLI • + Giao diện Web +

+ +

+ English · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português · Tiếng Việt · Русский · العربية +

+ +--- + +## Tính năng + +| Tính năng | Lợi ích | +|---------|---------| +| **100% Cục bộ** | Tất cả dữ liệu được lưu trên máy của bạn. Không có cloud, không cần API key, không cần tài khoản | +| **Cực kỳ Nhanh** | SQLite gốc (better-sqlite3) = truy vấn tức thì, độ trễ bằng 0 | +| **Không cần Cấu hình** | Hoạt động ngay sau khi cài đặt. Không cần thiết lập database | +| **Đa Nền tảng** | Claude Code, Cursor, Windsurf, Cline, OpenCode — chỉ một lệnh thiết lập | +| **MCP Server** | 9 công cụ: save, search, timeline, details, recall, list, update, delete, status | +| **Tự động Thu thập** | Hooks tự động ghi lại ngữ cảnh phiên, sử dụng công cụ, tóm tắt | +| **Làm giàu bằng AI** | Workers chạy nền làm giàu quan sát với tóm tắt do AI tạo ra | +| **Tìm kiếm Vector** | Độ tương đồng ngữ nghĩa HNSW với embeddings đa ngôn ngữ (100+ ngôn ngữ) | +| **Giao diện Web** | Giao diện trình duyệt để xem, tìm kiếm, thêm, sửa, xóa bộ nhớ | +| **Tìm kiếm 3 Lớp** | Tiết lộ tiến bộ tiết kiệm ~87% tokens so với tải toàn bộ | +| **Quản lý Vòng đời** | Tự động nén, lưu trữ và dọn dẹp các phiên cũ | +| **Export/Import** | Sao lưu và khôi phục bộ nhớ dưới dạng JSON | + +--- + +## Cách hoạt động + +``` +Phiên 1: "Dùng JWT cho auth" Phiên 2: "Thêm login endpoint" +┌──────────────────────────┐ ┌──────────────────────────┐ +│ Bạn code với AI... │ │ AI đã biết: │ +│ AI đưa ra quyết định │ │ ✓ Quyết định JWT auth │ +│ AI gặp lỗi │ ───► │ ✓ Giải pháp lỗi │ +│ AI học các mẫu │ saved │ ✓ Mẫu code │ +│ │ │ ✓ Ngữ cảnh phiên │ +└──────────────────────────┘ └──────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite, 100% cục bộ) +``` + +1. **Thiết lập một lần** — `npx agentkits-memory-setup` cấu hình nền tảng của bạn +2. **Tự động thu thập** — Hooks ghi lại quyết định, sử dụng công cụ và tóm tắt khi bạn làm việc +3. **Chèn ngữ cảnh** — Phiên tiếp theo bắt đầu với lịch sử liên quan từ các phiên trước +4. **Xử lý nền** — Workers làm giàu quan sát bằng AI, tạo embeddings, nén dữ liệu cũ +5. **Tìm kiếm mọi lúc** — AI sử dụng công cụ MCP (`memory_search` → `memory_details`) để tìm ngữ cảnh quá khứ + +Tất cả dữ liệu được lưu trong `.claude/memory/memory.db` trên máy của bạn. Không có cloud. Không cần API key. + +--- + +## Quyết định Thiết kế Quan trọng + +Hầu hết các công cụ bộ nhớ phân tán dữ liệu qua các file markdown, yêu cầu Python runtime, hoặc gửi code của bạn đến API bên ngoài. AgentKits Memory đưa ra những lựa chọn khác biệt cơ bản: + +| Lựa chọn Thiết kế | Tại sao Quan trọng | +|---------------|----------------| +| **Database SQLite đơn** | Một file (`memory.db`) chứa mọi thứ — bộ nhớ, phiên, quan sát, embeddings. Không có file phân tán cần đồng bộ, không có xung đột merge, không có dữ liệu mồ côi. Sao lưu = copy một file | +| **Node.js gốc, không cần Python** | Chạy ở bất cứ đâu Node chạy được. Không cần conda, không cần pip, không cần virtualenv. Cùng ngôn ngữ với MCP server — một lệnh `npx`, xong | +| **Tìm kiếm 3 lớp tiết kiệm tokens** | Tìm index trước (~50 tokens/kết quả), sau đó ngữ cảnh timeline, rồi chi tiết đầy đủ. Chỉ tải những gì cần. Các công cụ khác dump toàn bộ file bộ nhớ vào ngữ cảnh, tốn tokens cho nội dung không liên quan | +| **Tự động thu thập qua hooks** | Quyết định, mẫu và lỗi được ghi lại khi chúng xảy ra — không phải sau khi bạn nhớ lưu chúng. Chèn ngữ cảnh phiên diễn ra tự động khi phiên tiếp theo bắt đầu | +| **Embeddings cục bộ, không gọi API** | Tìm kiếm vector sử dụng mô hình ONNX cục bộ (multilingual-e5-small). Tìm kiếm ngữ nghĩa hoạt động offline, không tốn phí và hỗ trợ 100+ ngôn ngữ | +| **Workers chạy nền** | Làm giàu bằng AI, tạo embedding và nén chạy bất đồng bộ. Luồng code của bạn không bao giờ bị chặn | +| **Đa nền tảng ngay từ đầu** | Một flag `--platform=all` cấu hình Claude Code, Cursor, Windsurf, Cline và OpenCode cùng lúc. Cùng database bộ nhớ, các editor khác nhau | +| **Dữ liệu quan sát có cấu trúc** | Sử dụng công cụ được ghi lại với phân loại kiểu (read/write/execute/search), theo dõi file, phát hiện ý định và câu chuyện do AI tạo — không phải dump văn bản thô | +| **Không rò rỉ process** | Workers nền tự kết thúc sau 5 phút, sử dụng file khóa dựa trên PID với dọn dẹp khóa cũ, và xử lý SIGTERM/SIGINT một cách an toàn. Không có process zombie, không có worker mồ côi | +| **Không rò rỉ bộ nhớ** | Hooks chạy như các process ngắn hạn (không phải daemon chạy lâu). Kết nối database đóng khi shutdown. Subprocess embedding có respawn giới hạn (tối đa 2), timeout request đang chờ và dọn dẹp tất cả timers và queues một cách an toàn | + +--- + +## Giao diện Web + +Xem và quản lý bộ nhớ của bạn qua giao diện web hiện đại. + +```bash +npx agentkits-memory-web +``` + +Sau đó mở **http://localhost:1905** trong trình duyệt. + +### Danh sách Phiên + +Duyệt tất cả phiên làm việc với chế độ xem dòng thời gian và chi tiết hoạt động. + +![Session List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-session-list_v2.png) + +### Danh sách Bộ nhớ + +Duyệt tất cả bộ nhớ được lưu trữ với tìm kiếm và lọc namespace. + +![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list_v2.png) + +### Thêm Bộ nhớ + +Tạo bộ nhớ mới với key, namespace, type, content và tags. + +![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory_v2.png) + +### Chi tiết Bộ nhớ + +Xem chi tiết bộ nhớ đầy đủ với tùy chọn sửa và xóa. + +![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail_v2.png) + +### Quản lý Embeddings + +Tạo và quản lý vector embeddings cho tìm kiếm ngữ nghĩa. + +![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding_v2.png) + +--- + +## Bắt đầu nhanh + +### Cách 1: Chợ Plugin Claude Code (Khuyến nghị cho Claude Code) + +Cài đặt bằng một lệnh duy nhất — không cần cấu hình thủ công: + +```bash +/plugin marketplace add aitytech/agentkits-memory +/plugin install agentkits-memory@aitytech +``` + +Lệnh này tự động cài đặt hooks, MCP server và skill quy trình bộ nhớ. Khởi động lại Claude Code sau khi cài đặt. + +### Cách 2: Thiết lập Tự động (Tất cả Nền tảng) + +```bash +npx agentkits-memory-setup +``` + +Lệnh này tự động phát hiện nền tảng của bạn và cấu hình mọi thứ: MCP server, hooks (Claude Code/OpenCode), rules files (Cursor/Windsurf/Cline), và tải xuống mô hình embedding. + +**Chỉ định nền tảng cụ thể:** + +```bash +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all +``` + +### Cách 3: Cấu hình MCP Thủ công + +Nếu bạn muốn thiết lập thủ công, thêm vào cấu hình MCP của bạn: + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "agentkits-memory-server"] + } + } +} +``` + +Vị trí file cấu hình: +- **Claude Code**: `.claude/settings.json` (nhúng trong key `mcpServers`) +- **Cursor**: `.cursor/mcp.json` +- **Windsurf**: `.windsurf/mcp.json` +- **Cline / OpenCode**: `.mcp.json` (thư mục gốc project) + +### 3. Công cụ MCP + +Sau khi cấu hình, trợ lý AI của bạn có thể sử dụng các công cụ này: + +| Công cụ | Mô tả | +|------|-------------| +| `memory_status` | Kiểm tra trạng thái hệ thống bộ nhớ (gọi đầu tiên!) | +| `memory_save` | Lưu quyết định, mẫu, lỗi hoặc ngữ cảnh | +| `memory_search` | **[Bước 1]** Tìm kiếm index — IDs + tiêu đề nhẹ (~50 tokens/kết quả) | +| `memory_timeline` | **[Bước 2]** Lấy ngữ cảnh thời gian xung quanh một bộ nhớ | +| `memory_details` | **[Bước 3]** Lấy nội dung đầy đủ cho các IDs cụ thể | +| `memory_recall` | Tổng quan chủ đề nhanh — tóm tắt theo nhóm | +| `memory_list` | Liệt kê bộ nhớ gần đây | +| `memory_update` | Cập nhật nội dung hoặc tags của bộ nhớ hiện có | +| `memory_delete` | Xóa bộ nhớ đã lỗi thời | + +--- + +## Tiết lộ Tiến bộ (Tìm kiếm Tiết kiệm Tokens) + +AgentKits Memory sử dụng **mẫu tìm kiếm 3 lớp** tiết kiệm ~70% tokens so với tải nội dung đầy đủ ngay từ đầu. + +### Cách hoạt động + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Bước 1: memory_search │ +│ Trả về: IDs, tiêu đề, tags, điểm (~50 tokens/item) │ +│ → Xem xét index, chọn bộ nhớ liên quan │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Bước 2: memory_timeline (tùy chọn) │ +│ Trả về: Ngữ cảnh ±30 phút xung quanh bộ nhớ │ +│ → Hiểu điều gì đã xảy ra trước/sau │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Bước 3: memory_details │ +│ Trả về: Nội dung đầy đủ chỉ cho các IDs đã chọn │ +│ → Chỉ tải những gì bạn thực sự cần │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Ví dụ Quy trình + +```typescript +// Bước 1: Tìm kiếm - lấy index nhẹ +memory_search({ query: "authentication" }) +// → Trả về: [{ id: "abc", title: "JWT pattern...", score: 85% }] + +// Bước 2: (Tùy chọn) Xem ngữ cảnh thời gian +memory_timeline({ anchor: "abc" }) +// → Trả về: Điều gì đã xảy ra trước/sau bộ nhớ này + +// Bước 3: Lấy nội dung đầy đủ chỉ cho những gì bạn cần +memory_details({ ids: ["abc"] }) +// → Trả về: Nội dung đầy đủ cho bộ nhớ đã chọn +``` + +### Tiết kiệm Tokens + +| Cách tiếp cận | Tokens Sử dụng | +|----------|-------------| +| **Cũ:** Tải tất cả nội dung | ~500 tokens × 10 kết quả = 5000 tokens | +| **Mới:** Tiết lộ tiến bộ | 50 × 10 + 500 × 2 = 1500 tokens | +| **Tiết kiệm** | **Giảm 70%** | + +--- + +## Lệnh CLI + +```bash +# Thiết lập một lệnh (tự động phát hiện nền tảng) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # nền tảng cụ thể +npx agentkits-memory-setup --platform=all # tất cả nền tảng +npx agentkits-memory-setup --force # cài đặt lại/cập nhật + +# Khởi động MCP server +npx agentkits-memory-server + +# Giao diện web (cổng 1905) +npx agentkits-memory-web + +# Giao diện terminal +npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # thống kê database +npx agentkits-memory-viewer --json # đầu ra JSON + +# Lưu từ CLI +npx agentkits-memory-save "Use JWT with refresh tokens" --category pattern --tags auth,security + +# Cài đặt +npx agentkits-memory-hook settings . # xem cài đặt hiện tại +npx agentkits-memory-hook settings . --reset # đặt lại về mặc định +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# Export / Import +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# Quản lý vòng đời +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . +``` + +--- + +## Sử dụng Lập trình + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService({ + baseDir: '.claude/memory', + dbFilename: 'memory.db', +}); +await memory.initialize(); + +// Lưu trữ bộ nhớ +await memory.storeEntry({ + key: 'auth-pattern', + content: 'Use JWT with refresh tokens for authentication', + namespace: 'patterns', + tags: ['auth', 'security'], +}); + +// Truy vấn bộ nhớ +const results = await memory.query({ + type: 'hybrid', + namespace: 'patterns', + content: 'authentication', + limit: 10, +}); + +// Lấy theo key +const entry = await memory.getByKey('patterns', 'auth-pattern'); +``` + +--- + +## Hooks Tự động Thu thập + +Hooks tự động ghi lại các phiên code AI của bạn (chỉ Claude Code và OpenCode): + +| Hook | Kích hoạt | Hành động | +|------|---------|--------| +| `context` | Bắt đầu Phiên | Chèn ngữ cảnh phiên trước + trạng thái bộ nhớ | +| `session-init` | User Prompt | Khởi tạo/tiếp tục phiên, ghi lại prompts | +| `observation` | Sau Sử dụng Công cụ | Ghi lại sử dụng công cụ với phát hiện ý định | +| `summarize` | Kết thúc Phiên | Tạo tóm tắt phiên có cấu trúc | +| `user-message` | Bắt đầu Phiên | Hiển thị trạng thái bộ nhớ cho người dùng (stderr) | + +Thiết lập hooks: +```bash +npx agentkits-memory-setup +``` + +**Những gì được tự động ghi lại:** +- Đọc/ghi file với đường dẫn +- Thay đổi code dưới dạng diffs có cấu trúc (trước → sau) +- Ý định developer (bugfix, feature, refactor, investigation, v.v.) +- Tóm tắt phiên với quyết định, lỗi và bước tiếp theo +- Theo dõi nhiều prompt trong phiên + +--- + +## Hỗ trợ Đa Nền tảng + +| Nền tảng | MCP | Hooks | Rules File | Thiết lập | +|----------|-----|-------|------------|-------| +| **Claude Code** | `.claude/settings.json` | ✅ Đầy đủ | CLAUDE.md (skill) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ Đầy đủ | — | `--platform=opencode` | + +- **MCP Server** hoạt động với tất cả nền tảng (công cụ bộ nhớ qua giao thức MCP) +- **Hooks** cung cấp tự động thu thập trên Claude Code và OpenCode +- **Rules files** dạy Cursor/Windsurf/Cline quy trình làm việc bộ nhớ +- **Dữ liệu bộ nhớ** luôn được lưu trong `.claude/memory/` (nguồn sự thật duy nhất) + +--- + +## Workers Chạy Nền + +Sau mỗi phiên, workers nền xử lý các tác vụ trong hàng đợi: + +| Worker | Tác vụ | Mô tả | +|--------|------|-------------| +| `embed-session` | Embeddings | Tạo vector embeddings cho tìm kiếm ngữ nghĩa | +| `enrich-session` | Làm giàu AI | Làm giàu quan sát với tóm tắt, sự kiện, khái niệm do AI tạo | +| `compress-session` | Nén | Nén quan sát cũ (10:1–25:1) và tạo digest phiên (20:1–100:1) | + +Workers chạy tự động sau khi kết thúc phiên. Mỗi worker: +- Xử lý tối đa 200 items mỗi lần chạy +- Sử dụng lock files để ngăn thực thi đồng thời +- Tự kết thúc sau 5 phút (ngăn zombie) +- Thử lại các tác vụ thất bại tối đa 3 lần + +--- + +## Cấu hình AI Provider + +Làm giàu AI sử dụng providers có thể thay thế. Mặc định là `claude-cli` (không cần API key). + +| Provider | Kiểu | Mô hình Mặc định | Ghi chú | +|----------|------|---------------|-------| +| **Claude CLI** | `claude-cli` | `haiku` | Sử dụng `claude --print`, không cần API key | +| **OpenAI** | `openai` | `gpt-4o-mini` | Bất kỳ mô hình OpenAI nào | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | Google AI Studio key | +| **OpenRouter** | `openai` | bất kỳ | Đặt `baseUrl` thành `https://openrouter.ai/api/v1` | +| **GLM (Zhipu)** | `openai` | bất kỳ | Đặt `baseUrl` thành `https://open.bigmodel.cn/api/paas/v4` | +| **Ollama** | `openai` | bất kỳ | Đặt `baseUrl` thành `http://localhost:11434/v1` | + +### Tùy chọn 1: Biến Môi trường + +```bash +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter (sử dụng định dạng tương thích OpenAI) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# Local Ollama (không cần API key) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# Tắt hoàn toàn làm giàu AI +export AGENTKITS_AI_ENRICHMENT=false +``` + +### Tùy chọn 2: Cài đặt Lâu dài + +```bash +# Lưu vào .claude/memory/settings.json — tồn tại qua các phiên +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# Xem cài đặt hiện tại +npx agentkits-memory-hook settings . + +# Đặt lại về mặc định +npx agentkits-memory-hook settings . --reset +``` + +> **Ưu tiên:** Biến môi trường ghi đè settings.json. Settings.json ghi đè mặc định. + +--- + +## Quản lý Vòng đời + +Quản lý tăng trưởng bộ nhớ theo thời gian: + +```bash +# Nén quan sát cũ hơn 7 ngày, lưu trữ phiên cũ hơn 30 ngày +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# Cũng tự động xóa các phiên đã lưu trữ cũ hơn 90 ngày +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# Xem thống kê vòng đời +npx agentkits-memory-hook lifecycle-stats . +``` + +| Giai đoạn | Điều gì Xảy ra | +|-------|-------------| +| **Nén** | AI nén quan sát, tạo digest phiên | +| **Lưu trữ** | Đánh dấu các phiên cũ là đã lưu trữ (loại khỏi ngữ cảnh) | +| **Xóa** | Xóa các phiên đã lưu trữ (opt-in, yêu cầu `--delete`) | + +--- + +## Export / Import + +Sao lưu và khôi phục bộ nhớ project của bạn: + +```bash +# Export tất cả phiên cho một project +npx agentkits-memory-hook export . my-project ./backup.json + +# Import từ backup (tự động khử trùng) +npx agentkits-memory-hook import . ./backup.json +``` + +Định dạng export bao gồm phiên, quan sát, prompts và tóm tắt. + +--- + +## Danh mục Bộ nhớ + +| Danh mục | Trường hợp Sử dụng | +|----------|----------| +| `decision` | Quyết định kiến trúc, lựa chọn tech stack, đánh đổi | +| `pattern` | Quy ước code, mẫu project, cách tiếp cận lặp lại | +| `error` | Sửa lỗi, giải pháp lỗi, insight debug | +| `context` | Bối cảnh project, quy ước nhóm, thiết lập môi trường | +| `observation` | Quan sát phiên tự động ghi lại | + +--- + +## Lưu trữ + +Bộ nhớ được lưu trữ trong `.claude/memory/` bên trong thư mục project của bạn. + +``` +.claude/memory/ +├── memory.db # Database SQLite (tất cả dữ liệu) +├── memory.db-wal # Write-ahead log (tạm thời) +├── settings.json # Cài đặt lâu dài (AI provider, cấu hình ngữ cảnh) +└── embeddings-cache/ # Vector embeddings đã cache +``` + +--- + +## Hỗ trợ Ngôn ngữ CJK + +AgentKits Memory có **hỗ trợ CJK tự động** cho tìm kiếm văn bản tiếng Trung, Nhật và Hàn. + +### Không cần Cấu hình + +Khi `better-sqlite3` được cài đặt (mặc định), tìm kiếm CJK hoạt động tự động: + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService('.claude/memory'); +await memory.initialize(); + +// Lưu trữ nội dung CJK +await memory.storeEntry({ + key: 'auth-pattern', + content: '認証機能の実装パターン - JWT with refresh tokens', + namespace: 'patterns', +}); + +// Tìm kiếm bằng tiếng Nhật, Trung hoặc Hàn - nó chỉ hoạt động! +const results = await memory.query({ + type: 'hybrid', + content: '認証機能', +}); +``` + +### Cách hoạt động + +- **SQLite gốc**: Sử dụng `better-sqlite3` cho hiệu suất tối đa +- **Trigram tokenizer**: FTS5 với trigram tạo chuỗi 3 ký tự cho khớp CJK +- **Fallback thông minh**: Truy vấn CJK ngắn (< 3 ký tự) tự động sử dụng tìm kiếm LIKE +- **BM25 ranking**: Chấm điểm mức độ liên quan cho kết quả tìm kiếm + +### Nâng cao: Phân đoạn Từ Tiếng Nhật + +Đối với tiếng Nhật nâng cao với phân đoạn từ đúng, tùy chọn sử dụng lindera: + +```typescript +import { createJapaneseOptimizedBackend } from '@aitytech/agentkits-memory'; + +const backend = createJapaneseOptimizedBackend({ + databasePath: '.claude/memory/memory.db', + linderaPath: './path/to/liblindera_sqlite.dylib', +}); +``` + +Yêu cầu build [lindera-sqlite](https://github.com/lindera/lindera-sqlite). + +--- + +## Tham chiếu API + +### ProjectMemoryService + +```typescript +interface ProjectMemoryConfig { + baseDir: string; // Mặc định: '.claude/memory' + dbFilename: string; // Mặc định: 'memory.db' + enableVectorIndex: boolean; // Mặc định: false + dimensions: number; // Mặc định: 384 + embeddingGenerator?: EmbeddingGenerator; + cacheEnabled: boolean; // Mặc định: true + cacheSize: number; // Mặc định: 1000 + cacheTtl: number; // Mặc định: 300000 (5 phút) +} +``` + +### Phương thức + +| Phương thức | Mô tả | +|--------|-------------| +| `initialize()` | Khởi tạo dịch vụ bộ nhớ | +| `shutdown()` | Tắt và lưu trữ thay đổi | +| `storeEntry(input)` | Lưu trữ một entry bộ nhớ | +| `get(id)` | Lấy entry theo ID | +| `getByKey(namespace, key)` | Lấy entry theo namespace và key | +| `update(id, update)` | Cập nhật một entry | +| `delete(id)` | Xóa một entry | +| `query(query)` | Truy vấn entries với bộ lọc | +| `semanticSearch(content, k)` | Tìm kiếm độ tương đồng ngữ nghĩa | +| `count(namespace?)` | Đếm entries | +| `listNamespaces()` | Liệt kê tất cả namespaces | +| `getStats()` | Lấy thống kê | + +--- + +## Chất lượng Mã nguồn + +AgentKits Memory được kiểm thử kỹ lưỡng với **970 unit test** trên 21 test suite. + +| Chỉ số | Độ phủ | +|--------|--------| +| **Câu lệnh** | 90.29% | +| **Nhánh** | 80.85% | +| **Hàm** | 90.54% | +| **Dòng** | 91.74% | + +### Danh mục Test + +| Danh mục | Số test | Nội dung kiểm thử | +|----------|---------|-------------------| +| Dịch vụ Bộ nhớ Core | 56 | CRUD, tìm kiếm, phân trang, danh mục, thẻ, nhập/xuất | +| Backend SQLite | 65 | Schema, migration, FTS5, transaction, xử lý lỗi | +| Chỉ mục Vector HNSW | 47 | Chèn, tìm kiếm, xóa, lưu trữ, trường hợp biên | +| Tìm kiếm Hybrid | 44 | FTS + kết hợp vector, chấm điểm, xếp hạng, bộ lọc | +| Kinh tế Token | 27 | Ngân sách tìm kiếm 3 lớp, cắt ngắn, tối ưu hóa | +| Hệ thống Embedding | 63 | Bộ nhớ đệm, subprocess, mô hình cục bộ, hỗ trợ CJK | +| Hệ thống Hook | 502 | Context, khởi tạo session, observation, tóm tắt, làm giàu AI, vòng đời service, queue worker, adapter, type | +| Máy chủ MCP | 48 | 9 công cụ MCP, xác thực, phản hồi lỗi | +| CLI | 34 | Phát hiện nền tảng, tạo quy tắc | +| Tích hợp | 84 | Luồng end-to-end, tích hợp embedding, đa session | + +```bash +# Chạy test +npm test + +# Chạy với độ phủ +npm run test:coverage +``` + +--- + +## Yêu cầu + +- **Node.js LTS**: 18.x, 20.x, hoặc 22.x (khuyến nghị) +- Trợ lý code AI tương thích MCP + +### Ghi chú về Phiên bản Node.js + +Package này sử dụng `better-sqlite3` yêu cầu binaries gốc. **Prebuilt binaries chỉ có sẵn cho các phiên bản LTS**. + +| Phiên bản Node | Trạng thái | Ghi chú | +|--------------|--------|-------| +| 18.x LTS | ✅ Hoạt động | Prebuilt binaries | +| 20.x LTS | ✅ Hoạt động | Prebuilt binaries | +| 22.x LTS | ✅ Hoạt động | Prebuilt binaries | +| 19.x, 21.x, 23.x | ⚠️ Yêu cầu build tools | Không có prebuilt binaries | + +### Sử dụng Phiên bản Không phải LTS (Windows) + +Nếu bạn phải sử dụng phiên bản không phải LTS (19, 21, 23), cài đặt build tools trước: + +**Tùy chọn 1: Visual Studio Build Tools** +```powershell +# Tải xuống và cài đặt từ: +# https://visualstudio.microsoft.com/visual-cpp-build-tools/ +# Chọn workload "Desktop development with C++" +``` + +**Tùy chọn 2: windows-build-tools (npm)** +```powershell +npm install --global windows-build-tools +``` + +**Tùy chọn 3: Chocolatey** +```powershell +choco install visualstudio2022-workload-vctools +``` + +Xem [node-gyp Windows guide](https://github.com/nodejs/node-gyp#on-windows) để biết thêm chi tiết. + +--- + +## Hệ sinh thái AgentKits + +**AgentKits Memory** là một phần của hệ sinh thái AgentKits của AityTech - các công cụ làm cho trợ lý code AI thông minh hơn. + +| Sản phẩm | Mô tả | Link | +|---------|-------------|------| +| **AgentKits Engineer** | 28 agents chuyên biệt, 100+ skills, mẫu doanh nghiệp | [GitHub](https://github.com/aitytech/agentkits-engineer) | +| **AgentKits Marketing** | Tạo nội dung marketing được hỗ trợ bởi AI | [GitHub](https://github.com/aitytech/agentkits-marketing) | +| **AgentKits Memory** | Bộ nhớ lâu dài cho trợ lý AI (package này) | [npm](https://www.npmjs.com/package/@aitytech/agentkits-memory) | + +

+ + agentkits.net + +

+ +--- + +## Lịch sử Star + + + + + + Star History Chart + + + +--- + +## Giấy phép + +MIT + +--- + +

+ Trao cho trợ lý AI của bạn bộ nhớ tồn tại lâu dài. +

+ +

+ AgentKits Memory bởi AityTech +

+ +

+ Star repo này nếu nó giúp AI của bạn ghi nhớ. +

\ No newline at end of file diff --git a/i18n/README.zh.md b/i18n/README.zh.md new file mode 100644 index 0000000..75c22c9 --- /dev/null +++ b/i18n/README.zh.md @@ -0,0 +1,743 @@ +

+ AgentKits Logo +

+ +

AgentKits Memory

+ +

+ AityTech 出品 +

+ +

+ npm + License + Claude Code + Cursor + Windsurf + Cline + OpenCode +
+ Tests + Coverage +

+ +

+ AI 编程助手的持久化记忆系统 +

+ +

+ 你的 AI 助手在会话之间会忘记所有内容。AgentKits Memory 解决了这个问题。
+ 决策、模式、错误和上下文 — 全部通过 MCP 在本地持久化保存。 +

+ +

+ 网站 • + 文档 • + 快速开始 • + 工作原理 • + 平台 • + CLI • + Web 查看器 +

+ +

+ English · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português · Tiếng Việt · Русский · العربية +

+ +--- + +## 功能特性 + +| 功能 | 优势 | +|---------|---------| +| **100% 本地化** | 所有数据保存在你的机器上。无云端、无 API 密钥、无需注册账户 | +| **极速响应** | 原生 SQLite (better-sqlite3) = 即时查询、零延迟 | +| **零配置** | 开箱即用。无需数据库设置 | +| **多平台支持** | Claude Code、Cursor、Windsurf、Cline、OpenCode — 一条命令完成设置 | +| **MCP 服务器** | 9 个工具:保存、搜索、时间线、详情、回忆、列表、更新、删除、状态 | +| **自动捕获** | 钩子自动捕获会话上下文、工具使用情况、摘要 | +| **AI 增强** | 后台工作进程使用 AI 生成的摘要来增强观察记录 | +| **向量搜索** | HNSW 语义相似度搜索,支持多语言嵌入(100+ 种语言)| +| **Web 查看器** | 浏览器 UI 用于查看、搜索、添加、编辑、删除记忆 | +| **3 层搜索** | 渐进式披露相比获取所有内容节省约 87% 的 token | +| **生命周期管理** | 自动压缩、归档和清理旧会话 | +| **导出/导入** | 以 JSON 格式备份和恢复记忆 | + +--- + +## 工作原理 + +``` +会话 1: "使用 JWT 进行认证" 会话 2: "添加登录端点" +┌──────────────────────────┐ ┌──────────────────────────┐ +│ 你与 AI 编程... │ │ AI 已经知道: │ +│ AI 做出决策 │ │ ✓ JWT 认证决策 │ +│ AI 遇到错误 │ ───► │ ✓ 错误解决方案 │ +│ AI 学习模式 │ 已保存 │ ✓ 代码模式 │ +│ │ │ ✓ 会话上下文 │ +└──────────────────────────┘ └──────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite, 100% 本地) +``` + +1. **一次设置** — `npx agentkits-memory-setup` 配置你的平台 +2. **自动捕获** — 钩子在你工作时记录决策、工具使用和摘要 +3. **上下文注入** — 下一个会话开始时包含过去会话的相关历史记录 +4. **后台处理** — 工作进程使用 AI 增强观察记录、生成嵌入、压缩旧数据 +5. **随时搜索** — AI 使用 MCP 工具(`memory_search` → `memory_details`)查找过去的上下文 + +所有数据都保存在你机器上的 `.claude/memory/memory.db` 中。无云端。无需 API 密钥。 + +--- + +## 重要的设计决策 + +大多数记忆工具将数据分散在 markdown 文件中,需要 Python 运行时,或将你的代码发送到外部 API。AgentKits Memory 做出了根本不同的选择: + +| 设计选择 | 重要性 | +|---------------|----------------| +| **单一 SQLite 数据库** | 一个文件(`memory.db`)包含所有内容 — 记忆、会话、观察记录、嵌入。无分散文件需要同步、无合并冲突、无孤立数据。备份 = 复制一个文件 | +| **原生 Node.js,零 Python 依赖** | 可在任何支持 Node 的地方运行。无需 conda、pip 或 virtualenv。与你的 MCP 服务器使用相同语言 — 一条 `npx` 命令,完成 | +| **节省 token 的 3 层搜索** | 首先搜索索引(约 50 token/结果),然后是时间线上下文,最后是完整详情。只获取你需要的内容。其他工具将整个记忆文件倾倒到上下文中,在不相关内容上浪费 token | +| **通过钩子自动捕获** | 决策、模式和错误在发生时被记录 — 而不是在你记得保存它们之后。会话上下文注入在下次会话开始时自动发生 | +| **本地嵌入,无 API 调用** | 向量搜索使用本地 ONNX 模型(multilingual-e5-small)。语义搜索离线工作、零成本,并支持 100+ 种语言 | +| **后台工作进程** | AI 增强、嵌入生成和压缩异步运行。你的编码流程永不被阻塞 | +| **从第一天起就支持多平台** | 一个 `--platform=all` 标志同时配置 Claude Code、Cursor、Windsurf、Cline 和 OpenCode。相同的记忆数据库,不同的编辑器 | +| **结构化观察数据** | 工具使用通过类型分类(读/写/执行/搜索)、文件跟踪、意图检测和 AI 生成的叙述被捕获 — 而不是原始文本转储 | +| **无进程泄漏** | 后台工作进程在 5 分钟后自动终止,使用基于 PID 的锁文件并清理过期锁,优雅处理 SIGTERM/SIGINT。无僵尸进程、无孤立工作进程 | +| **无内存泄漏** | 钩子作为短期进程运行(而非长期守护进程)。数据库连接在关闭时关闭。嵌入子进程具有有限重生次数(最多 2 次)、待处理请求超时,以及所有计时器和队列的优雅清理 | + +--- + +## Web 查看器 + +通过现代 Web 界面查看和管理你的记忆。 + +```bash +npx agentkits-memory-web +``` + +然后在浏览器中打开 **http://localhost:1905**。 + +### 会话列表 + +浏览所有会话,支持时间线视图和活动详情。 + +![Session List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-session-list_v2.png) + +### 记忆列表 + +浏览所有已存储的记忆,支持搜索和命名空间过滤。 + +![Memory List](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-list_v2.png) + +### 添加记忆 + +创建新记忆,包含键、命名空间、类型、内容和标签。 + +![Add Memory](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-add-memory_v2.png) + +### 记忆详情 + +查看完整的记忆详情,包含编辑和删除选项。 + +![Memory Detail](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-memory-detail_v2.png) + +### 管理嵌入 + +生成和管理用于语义搜索的向量嵌入。 + +![Manage Embeddings](https://raw.githubusercontent.com/aitytech/agentkits-memory/main/assets/agentkits-memory-embedding_v2.png) + +--- + +## 快速开始 + +### 方式一:Claude Code 插件市场(推荐用于 Claude Code) + +一条命令安装即可——无需手动配置: + +```bash +/plugin marketplace add aitytech/agentkits-memory +/plugin install agentkits-memory@aitytech +``` + +这会自动安装钩子、MCP 服务器和记忆工作流技能。安装后请重启 Claude Code。 + +### 方式二:自动设置(所有平台) + +```bash +npx agentkits-memory-setup +``` + +这会自动检测你的平台并配置所有内容:MCP 服务器、钩子(Claude Code/OpenCode)、规则文件(Cursor/Windsurf/Cline),并下载嵌入模型。 + +**针对特定平台:** + +```bash +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all +``` + +### 方式三:手动 MCP 配置 + +如果你喜欢手动设置,请将以下内容添加到你的 MCP 配置中: + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "agentkits-memory-server"] + } + } +} +``` + +配置文件位置: +- **Claude Code**: `.claude/settings.json`(嵌入在 `mcpServers` 键中) +- **Cursor**: `.cursor/mcp.json` +- **Windsurf**: `.windsurf/mcp.json` +- **Cline / OpenCode**: `.mcp.json`(项目根目录) + +### 3. MCP 工具 + +配置完成后,你的 AI 助手可以使用这些工具: + +| 工具 | 描述 | +|------|-------------| +| `memory_status` | 检查记忆系统状态(首先调用!)| +| `memory_save` | 保存决策、模式、错误或上下文 | +| `memory_search` | **[步骤 1]** 搜索索引 — 轻量级 ID + 标题(约 50 token/结果)| +| `memory_timeline` | **[步骤 2]** 获取记忆周围的时间上下文 | +| `memory_details` | **[步骤 3]** 获取特定 ID 的完整内容 | +| `memory_recall` | 快速主题概览 — 分组摘要 | +| `memory_list` | 列出最近的记忆 | +| `memory_update` | 更新现有记忆内容或标签 | +| `memory_delete` | 删除过时的记忆 | + +--- + +## 渐进式披露(节省 Token 的搜索) + +AgentKits Memory 使用 **3 层搜索模式**,相比提前获取完整内容节省约 70% 的 token。 + +### 工作原理 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 步骤 1: memory_search │ +│ 返回:ID、标题、标签、分数(约 50 token/项) │ +│ → 查看索引,选择相关记忆 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 步骤 2: memory_timeline(可选) │ +│ 返回:记忆前后 ±30 分钟的上下文 │ +│ → 了解之前/之后发生的事情 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 步骤 3: memory_details │ +│ 返回:仅选定 ID 的完整内容 │ +│ → 只获取你真正需要的内容 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 示例工作流 + +```typescript +// 步骤 1:搜索 - 获取轻量级索引 +memory_search({ query: "authentication" }) +// → 返回:[{ id: "abc", title: "JWT pattern...", score: 85% }] + +// 步骤 2:(可选)查看时间上下文 +memory_timeline({ anchor: "abc" }) +// → 返回:此记忆前后发生的事情 + +// 步骤 3:仅获取你需要的完整内容 +memory_details({ ids: ["abc"] }) +// → 返回:选定记忆的完整内容 +``` + +### Token 节省 + +| 方法 | 使用的 Token | +|----------|-------------| +| **旧方法:** 获取所有内容 | 约 500 token × 10 个结果 = 5000 token | +| **新方法:** 渐进式披露 | 50 × 10 + 500 × 2 = 1500 token | +| **节省** | **减少 70%** | + +--- + +## CLI 命令 + +```bash +# 一键设置(自动检测平台) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # 特定平台 +npx agentkits-memory-setup --platform=all # 所有平台 +npx agentkits-memory-setup --force # 重新安装/更新 + +# 启动 MCP 服务器 +npx agentkits-memory-server + +# Web 查看器(端口 1905) +npx agentkits-memory-web + +# 终端查看器 +npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # 数据库统计 +npx agentkits-memory-viewer --json # JSON 输出 + +# 从 CLI 保存 +npx agentkits-memory-save "Use JWT with refresh tokens" --category pattern --tags auth,security + +# 设置 +npx agentkits-memory-hook settings . # 查看当前设置 +npx agentkits-memory-hook settings . --reset # 重置为默认值 +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# 导出 / 导入 +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# 生命周期管理 +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . +``` + +--- + +## 编程方式使用 + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService({ + baseDir: '.claude/memory', + dbFilename: 'memory.db', +}); +await memory.initialize(); + +// 存储记忆 +await memory.storeEntry({ + key: 'auth-pattern', + content: 'Use JWT with refresh tokens for authentication', + namespace: 'patterns', + tags: ['auth', 'security'], +}); + +// 查询记忆 +const results = await memory.query({ + type: 'hybrid', + namespace: 'patterns', + content: 'authentication', + limit: 10, +}); + +// 通过键获取 +const entry = await memory.getByKey('patterns', 'auth-pattern'); +``` + +--- + +## 自动捕获钩子 + +钩子自动捕获你的 AI 编程会话(仅限 Claude Code 和 OpenCode): + +| 钩子 | 触发器 | 操作 | +|------|---------|--------| +| `context` | 会话开始 | 注入上一个会话的上下文 + 记忆状态 | +| `session-init` | 用户提示 | 初始化/恢复会话,记录提示 | +| `observation` | 工具使用后 | 捕获工具使用情况并进行意图检测 | +| `summarize` | 会话结束 | 生成结构化会话摘要 | +| `user-message` | 会话开始 | 向用户显示记忆状态(stderr)| + +设置钩子: +```bash +npx agentkits-memory-setup +``` + +**自动捕获的内容:** +- 文件读/写及路径 +- 结构化差异代码更改(之前 → 之后) +- 开发者意图(bug 修复、功能、重构、调查等) +- 包含决策、错误和后续步骤的会话摘要 +- 会话内的多提示跟踪 + +--- + +## 多平台支持 + +| 平台 | MCP | 钩子 | 规则文件 | 设置 | +|----------|-----|-------|------------|-------| +| **Claude Code** | `.claude/settings.json` | ✅ 完整 | CLAUDE.md (skill) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ 完整 | — | `--platform=opencode` | + +- **MCP 服务器** 适用于所有平台(通过 MCP 协议的记忆工具) +- **钩子** 在 Claude Code 和 OpenCode 上提供自动捕获 +- **规则文件** 教 Cursor/Windsurf/Cline 记忆工作流程 +- **记忆数据** 始终存储在 `.claude/memory/` 中(单一真实来源) + +--- + +## 后台工作进程 + +每次会话后,后台工作进程处理排队的任务: + +| 工作进程 | 任务 | 描述 | +|--------|------|-------------| +| `embed-session` | 嵌入 | 为语义搜索生成向量嵌入 | +| `enrich-session` | AI 增强 | 使用 AI 生成的摘要、事实、概念来增强观察记录 | +| `compress-session` | 压缩 | 压缩旧观察记录(10:1–25:1)并生成会话摘要(20:1–100:1)| + +工作进程在会话结束后自动运行。每个工作进程: +- 每次运行最多处理 200 项 +- 使用锁文件防止并发执行 +- 5 分钟后自动终止(防止僵尸进程) +- 失败任务最多重试 3 次 + +--- + +## AI 提供商配置 + +AI 增强使用可插拔的提供商。默认为 `claude-cli`(无需 API 密钥)。 + +| 提供商 | 类型 | 默认模型 | 备注 | +|----------|------|---------------|-------| +| **Claude CLI** | `claude-cli` | `haiku` | 使用 `claude --print`,无需 API 密钥 | +| **OpenAI** | `openai` | `gpt-4o-mini` | 任何 OpenAI 模型 | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | Google AI Studio 密钥 | +| **OpenRouter** | `openai` | 任意 | 将 `baseUrl` 设置为 `https://openrouter.ai/api/v1` | +| **GLM (智谱)** | `openai` | 任意 | 将 `baseUrl` 设置为 `https://open.bigmodel.cn/api/paas/v4` | +| **Ollama** | `openai` | 任意 | 将 `baseUrl` 设置为 `http://localhost:11434/v1` | + +### 选项 1:环境变量 + +```bash +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter(使用 OpenAI 兼容格式) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# 本地 Ollama(无需 API 密钥) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# 完全禁用 AI 增强 +export AGENTKITS_AI_ENRICHMENT=false +``` + +### 选项 2:持久化设置 + +```bash +# 保存到 .claude/memory/settings.json — 跨会话持久化 +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# 查看当前设置 +npx agentkits-memory-hook settings . + +# 重置为默认值 +npx agentkits-memory-hook settings . --reset +``` + +> **优先级:** 环境变量覆盖 settings.json。settings.json 覆盖默认值。 + +--- + +## 生命周期管理 + +管理随时间推移的记忆增长: + +```bash +# 压缩 7 天前的观察记录,归档 30 天前的会话 +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# 同时自动删除 90 天前的已归档会话 +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# 查看生命周期统计 +npx agentkits-memory-hook lifecycle-stats . +``` + +| 阶段 | 发生的事情 | +|-------|-------------| +| **压缩** | AI 压缩观察记录,生成会话摘要 | +| **归档** | 将旧会话标记为已归档(从上下文中排除)| +| **删除** | 删除已归档的会话(选择性加入,需要 `--delete`)| + +--- + +## 导出 / 导入 + +备份和恢复你的项目记忆: + +```bash +# 导出项目的所有会话 +npx agentkits-memory-hook export . my-project ./backup.json + +# 从备份导入(自动去重) +npx agentkits-memory-hook import . ./backup.json +``` + +导出格式包括会话、观察记录、提示和摘要。 + +--- + +## 记忆类别 + +| 类别 | 使用场景 | +|----------|----------| +| `decision` | 架构决策、技术栈选择、权衡 | +| `pattern` | 编码约定、项目模式、重复出现的方法 | +| `error` | Bug 修复、错误解决方案、调试见解 | +| `context` | 项目背景、团队约定、环境设置 | +| `observation` | 自动捕获的会话观察记录 | + +--- + +## 存储 + +记忆存储在项目目录下的 `.claude/memory/` 中。 + +``` +.claude/memory/ +├── memory.db # SQLite 数据库(所有数据) +├── memory.db-wal # 预写日志(临时) +├── settings.json # 持久化设置(AI 提供商、上下文配置) +└── embeddings-cache/ # 缓存的向量嵌入 +``` + +--- + +## CJK 语言支持 + +AgentKits Memory 对中文、日文和韩文文本搜索具有 **自动 CJK 支持**。 + +### 零配置 + +当 `better-sqlite3` 安装后(默认),CJK 搜索自动工作: + +```typescript +import { ProjectMemoryService } from '@aitytech/agentkits-memory'; + +const memory = new ProjectMemoryService('.claude/memory'); +await memory.initialize(); + +// 存储 CJK 内容 +await memory.storeEntry({ + key: 'auth-pattern', + content: '認証機能の実装パターン - JWT with refresh tokens', + namespace: 'patterns', +}); + +// 使用日语、中文或韩语搜索 - 开箱即用! +const results = await memory.query({ + type: 'hybrid', + content: '認証機能', +}); +``` + +### 工作原理 + +- **原生 SQLite**:使用 `better-sqlite3` 以获得最大性能 +- **Trigram 分词器**:带有 trigram 的 FTS5 为 CJK 匹配创建 3 字符序列 +- **智能降级**:短 CJK 查询(< 3 个字符)自动使用 LIKE 搜索 +- **BM25 排名**:搜索结果的相关性评分 + +### 高级:日语分词 + +对于具有正确分词的高级日语处理,可选择使用 lindera: + +```typescript +import { createJapaneseOptimizedBackend } from '@aitytech/agentkits-memory'; + +const backend = createJapaneseOptimizedBackend({ + databasePath: '.claude/memory/memory.db', + linderaPath: './path/to/liblindera_sqlite.dylib', +}); +``` + +需要 [lindera-sqlite](https://github.com/lindera/lindera-sqlite) 构建。 + +--- + +## API 参考 + +### ProjectMemoryService + +```typescript +interface ProjectMemoryConfig { + baseDir: string; // 默认:'.claude/memory' + dbFilename: string; // 默认:'memory.db' + enableVectorIndex: boolean; // 默认:false + dimensions: number; // 默认:384 + embeddingGenerator?: EmbeddingGenerator; + cacheEnabled: boolean; // 默认:true + cacheSize: number; // 默认:1000 + cacheTtl: number; // 默认:300000(5 分钟) +} +``` + +### 方法 + +| 方法 | 描述 | +|--------|-------------| +| `initialize()` | 初始化记忆服务 | +| `shutdown()` | 关闭并持久化更改 | +| `storeEntry(input)` | 存储记忆条目 | +| `get(id)` | 通过 ID 获取条目 | +| `getByKey(namespace, key)` | 通过命名空间和键获取条目 | +| `update(id, update)` | 更新条目 | +| `delete(id)` | 删除条目 | +| `query(query)` | 使用过滤器查询条目 | +| `semanticSearch(content, k)` | 语义相似度搜索 | +| `count(namespace?)` | 计数条目 | +| `listNamespaces()` | 列出所有命名空间 | +| `getStats()` | 获取统计信息 | + +--- + +## 代码质量 + +AgentKits Memory 经过全面测试,包含 **970 个单元测试**,覆盖 21 个测试套件。 + +| 指标 | 覆盖率 | +|------|--------| +| **语句** | 90.29% | +| **分支** | 80.85% | +| **函数** | 90.54% | +| **行** | 91.74% | + +### 测试分类 + +| 分类 | 测试数 | 覆盖内容 | +|------|--------|----------| +| 核心内存服务 | 56 | CRUD、搜索、分页、分类、标签、导入/导出 | +| SQLite 后端 | 65 | Schema、迁移、FTS5、事务、错误处理 | +| HNSW 向量索引 | 47 | 插入、搜索、删除、持久化、边界情况 | +| 混合搜索 | 44 | FTS + 向量融合、评分、排名、过滤 | +| Token 经济学 | 27 | 三层搜索预算、截断、优化 | +| 嵌入系统 | 63 | 缓存、子进程、本地模型、CJK 支持 | +| Hook 系统 | 502 | 上下文、会话初始化、观察、摘要、AI 增强、服务生命周期、队列工作器、适配器、类型 | +| MCP 服务器 | 48 | 全部 9 个 MCP 工具、验证、错误响应 | +| CLI | 34 | 平台检测、规则生成 | +| 集成测试 | 84 | 端到端流程、嵌入集成、多会话 | + +```bash +# 运行测试 +npm test + +# 运行覆盖率测试 +npm run test:coverage +``` + +--- + +## 要求 + +- **Node.js LTS**:18.x、20.x 或 22.x(推荐) +- 兼容 MCP 的 AI 编程助手 + +### Node.js 版本说明 + +此包使用需要原生二进制文件的 `better-sqlite3`。**仅 LTS 版本提供预构建二进制文件**。 + +| Node 版本 | 状态 | 备注 | +|--------------|--------|-------| +| 18.x LTS | ✅ 可用 | 预构建二进制文件 | +| 20.x LTS | ✅ 可用 | 预构建二进制文件 | +| 22.x LTS | ✅ 可用 | 预构建二进制文件 | +| 19.x, 21.x, 23.x | ⚠️ 需要构建工具 | 无预构建二进制文件 | + +### 使用非 LTS 版本(Windows) + +如果你必须使用非 LTS 版本(19、21、23),请先安装构建工具: + +**选项 1:Visual Studio Build Tools** +```powershell +# 从以下网址下载并安装: +# https://visualstudio.microsoft.com/visual-cpp-build-tools/ +# 选择"使用 C++ 的桌面开发"工作负载 +``` + +**选项 2:windows-build-tools (npm)** +```powershell +npm install --global windows-build-tools +``` + +**选项 3:Chocolatey** +```powershell +choco install visualstudio2022-workload-vctools +``` + +有关更多详情,请参阅 [node-gyp Windows 指南](https://github.com/nodejs/node-gyp#on-windows)。 + +--- + +## AgentKits 生态系统 + +**AgentKits Memory** 是 AityTech 的 AgentKits 生态系统的一部分 - 让 AI 编程助手更智能的工具。 + +| 产品 | 描述 | 链接 | +|---------|-------------|------| +| **AgentKits Engineer** | 28 个专门的代理、100+ 项技能、企业模式 | [GitHub](https://github.com/aitytech/agentkits-engineer) | +| **AgentKits Marketing** | AI 驱动的营销内容生成 | [GitHub](https://github.com/aitytech/agentkits-marketing) | +| **AgentKits Memory** | AI 助手的持久化记忆(本包)| [npm](https://www.npmjs.com/package/@aitytech/agentkits-memory) | + +

+ + agentkits.net + +

+ +--- + +## Star 历史 + + + + + + Star History Chart + + + +--- + +## 许可证 + +MIT + +--- + +

+ 给你的 AI 助手持久化的记忆。 +

+ +

+ AgentKits Memory 由 AityTech 出品 +

+ +

+ 如果这个项目对你的 AI 记忆有帮助,请给仓库点个 Star。 +

\ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d928f04..7cb45e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@aitytech/agentkits-memory", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aitytech/agentkits-memory", - "version": "2.1.0", + "version": "2.2.0", "license": "MIT", "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.29", "better-sqlite3": "^11.0.0" }, "bin": { @@ -33,6 +34,28 @@ "@xenova/transformers": "^2.17.0" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.29", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.29.tgz", + "integrity": "sha512-b+655n4ZqqAiMQEL3P44e9UurkI7WWanWTQQQTEcKngL5YCjjXExEPEJRxrmqp8mQXs0kLErZhObx0ZuwibOhA==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-linuxmusl-arm64": "^0.33.5", + "@img/sharp-linuxmusl-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -545,6 +568,291 @@ "node": ">=18" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2673,6 +2981,16 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 31cf880..1eedb38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aitytech/agentkits-memory", - "version": "2.1.0", + "version": "2.2.0", "type": "module", "description": "Persistent memory system for AI coding assistants via MCP. Works with Claude Code, Cursor, Copilot, Windsurf, Cline.", "main": "dist/index.js", @@ -22,7 +22,7 @@ "agentkits-memory-setup": "./dist/cli/setup.js" }, "scripts": { - "build": "tsc", + "build": "tsc && chmod +x dist/hooks/cli.js dist/mcp/server.js dist/cli/viewer.js dist/cli/web-viewer.js dist/cli/save.js dist/cli/setup.js", "test": "vitest run", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", @@ -40,6 +40,7 @@ "access": "public" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.29", "better-sqlite3": "^11.0.0" }, "optionalDependencies": { @@ -56,6 +57,7 @@ "dist", "src", "assets", + "skills", "hooks.json", "LICENSE", "README.md" diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..d6c267e --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "agentkits-memory", + "version": "2.2.0", + "description": "Persistent memory system for AI coding assistants — saves decisions, patterns, errors, and context across sessions via MCP", + "author": { + "name": "AityTech" + }, + "homepage": "https://www.agentkits.net/memory", + "repository": "https://github.com/aitytech/agentkits-memory", + "license": "MIT", + "keywords": ["memory", "context", "persistence", "mcp", "sessions"] +} diff --git a/plugin/.mcp.json b/plugin/.mcp.json new file mode 100644 index 0000000..c8307ad --- /dev/null +++ b/plugin/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "memory": { + "type": "stdio", + "command": "npx", + "args": ["-y", "-p", "@aitytech/agentkits-memory", "agentkits-memory-server"] + } + } +} diff --git a/plugin/CLAUDE.md b/plugin/CLAUDE.md new file mode 100644 index 0000000..c391290 --- /dev/null +++ b/plugin/CLAUDE.md @@ -0,0 +1,26 @@ +# AgentKits Memory + +Persistent memory system is active. Your decisions, patterns, errors, and context are saved across sessions. + +## Available MCP Tools + +| Tool | Purpose | +|------|---------| +| `memory_save` | Save decisions, patterns, errors, context | +| `memory_search` | Search index (Step 1/3 — lightweight) | +| `memory_timeline` | Get context around a result (Step 2/3) | +| `memory_details` | Fetch full content (Step 3/3) | +| `memory_recall` | Quick topic summary | +| `memory_list` | List recent memories | +| `memory_update` | Update existing memory | +| `memory_delete` | Delete memories | +| `memory_status` | Database health check | + +## Workflow + +1. `memory_status()` — Check if memories exist before searching +2. `memory_search(query)` — Get index with IDs (~50 tokens/result) +3. `memory_timeline(anchor="ID")` — Get context around interesting results +4. `memory_details(ids=["ID1","ID2"])` — Fetch full content ONLY for filtered IDs + +Save important context naturally as you work — decisions made, patterns discovered, errors solved. diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json new file mode 100644 index 0000000..d433b37 --- /dev/null +++ b/plugin/hooks/hooks.json @@ -0,0 +1,56 @@ +{ + "description": "AgentKits Memory - persistent memory system hooks", + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-installed.cjs\"", + "timeout": 120 + }, + { + "type": "command", + "command": "npx -y -p @aitytech/agentkits-memory agentkits-memory-hook context", + "timeout": 10 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "npx -y -p @aitytech/agentkits-memory agentkits-memory-hook session-init", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Read|Write|Edit|MultiEdit|Bash|Glob|Grep|LS|WebSearch|WebFetch|Task|Skill|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "npx -y -p @aitytech/agentkits-memory agentkits-memory-hook observation", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "npx -y -p @aitytech/agentkits-memory agentkits-memory-hook summarize", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/plugin/package.json b/plugin/package.json new file mode 100644 index 0000000..919228c --- /dev/null +++ b/plugin/package.json @@ -0,0 +1,9 @@ +{ + "name": "agentkits-memory-plugin", + "version": "2.2.0", + "private": true, + "description": "Runtime configuration for AgentKits Memory plugin", + "engines": { + "node": ">=18.0.0" + } +} diff --git a/plugin/scripts/ensure-installed.cjs b/plugin/scripts/ensure-installed.cjs new file mode 100644 index 0000000..8522e14 --- /dev/null +++ b/plugin/scripts/ensure-installed.cjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Ensures @aitytech/agentkits-memory is installed before hooks run. + * Runs on SessionStart — uses a version marker to avoid re-checking every session. + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const PACKAGE = '@aitytech/agentkits-memory'; +const MARKER_DIR = path.join(os.homedir(), '.agentkits-memory'); +const MARKER_FILE = path.join(MARKER_DIR, '.install-version'); + +function getInstalledVersion() { + try { + const result = execSync(`npm list -g ${PACKAGE} --json 2>/dev/null`, { + encoding: 'utf-8', + timeout: 10000, + }); + const parsed = JSON.parse(result); + const deps = parsed.dependencies || {}; + const pkg = deps[PACKAGE] || deps['agentkits-memory']; + return pkg ? pkg.version : null; + } catch { + return null; + } +} + +function getMarkerVersion() { + try { + return fs.readFileSync(MARKER_FILE, 'utf-8').trim(); + } catch { + return null; + } +} + +function writeMarker(version) { + try { + fs.mkdirSync(MARKER_DIR, { recursive: true }); + fs.writeFileSync(MARKER_FILE, version); + } catch { + // Non-critical — just skip marker + } +} + +function main() { + const installed = getInstalledVersion(); + const marker = getMarkerVersion(); + + if (installed && marker === installed) { + console.log(JSON.stringify({ result: 'already-installed', version: installed })); + return; + } + + if (installed) { + writeMarker(installed); + console.log(JSON.stringify({ result: 'already-installed', version: installed })); + return; + } + + // Not installed — install globally + try { + execSync(`npm install -g ${PACKAGE}`, { + encoding: 'utf-8', + timeout: 120000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const newVersion = getInstalledVersion() || 'unknown'; + writeMarker(newVersion); + console.log(JSON.stringify({ result: 'installed', version: newVersion })); + } catch (err) { + console.log(JSON.stringify({ result: 'install-failed', error: err.message })); + process.exit(1); + } +} + +main(); diff --git a/plugin/skills/memory-workflow/SKILL.md b/plugin/skills/memory-workflow/SKILL.md new file mode 100644 index 0000000..b2d1940 --- /dev/null +++ b/plugin/skills/memory-workflow/SKILL.md @@ -0,0 +1,77 @@ +--- +name: memory-workflow +description: Use when you need to recall past work, previous decisions, error solutions, or project history. Activates the 3-layer memory search workflow for token-efficient retrieval. +--- + +# AgentKits Memory Workflow + +## When to Activate + +Use this skill when: +- User asks about past work, previous sessions, or what was done before +- User references a decision, pattern, or error you don't have context for +- You need project history, conventions, or architectural decisions +- User asks "what did we do about X?" or "how did we handle Y?" +- You're missing context that should exist from earlier sessions +- Starting work on a feature that may have prior decisions recorded + +## Prerequisites + +Before searching, check if memories exist: +``` +memory_status() +``` +If the database is empty, skip recall and inform the user. + +## 3-Layer Search Workflow + +### Layer 1: Search Index (lightweight, ~50 tokens/result) +``` +memory_search(query="your search term") +``` +- Returns IDs, titles, categories, dates, and relevance scores +- Filter by category: `decision`, `pattern`, `error`, `context`, `observation` +- Filter by date: `dateStart="2025-01-01"`, `dateEnd="2025-12-31"` +- Sort: `orderBy="relevance"` (default), `"date_asc"`, `"date_desc"` + +### Layer 2: Timeline Context (understand what happened around a result) +``` +memory_timeline(anchor="MEMORY_ID") +``` +- Shows what happened before/after a specific memory +- Helps understand the sequence of events +- Use when you need temporal context + +### Layer 3: Full Details (only for filtered IDs) +``` +memory_details(ids=["ID1", "ID2"]) +``` +- Returns complete content for selected memories +- Limit to 3-5 IDs at a time to conserve tokens +- NEVER fetch details without filtering through Layer 1 first + +## Quick Topic Recall + +For a fast overview of everything known about a topic: +``` +memory_recall(topic="authentication") +``` +This returns a grouped summary. Follow up with `memory_details` for specifics. + +## Saving Memories + +Save important information for future sessions: +``` +memory_save(content="...", category="decision", tags="auth,security", importance="high") +``` + +Categories: `decision`, `pattern`, `error`, `context`, `observation` +Importance: `low`, `medium`, `high`, `critical` + +## Token Efficiency Rules + +1. ALWAYS start with `memory_search` (Layer 1), never jump to `memory_details` +2. Review search results and select only relevant IDs before fetching details +3. Use filters (category, date range) to narrow results +4. Limit `memory_details` to 3-5 IDs per call +5. This workflow saves ~87% tokens vs fetching everything at once diff --git a/scripts/translate-readme/README.md b/scripts/translate-readme/README.md new file mode 100644 index 0000000..2071fd0 --- /dev/null +++ b/scripts/translate-readme/README.md @@ -0,0 +1,235 @@ +# README Translator + +Translate README.md files to multiple languages using the Claude Agent SDK. Perfect for build scripts and CI/CD pipelines. + +## Installation + +```bash +npm install readme-translator +# or +npm install -g readme-translator # for CLI usage +``` + +## Requirements + +- Node.js 18+ +- **Authentication** (one of the following): + - Claude Code installed and authenticated (Pro/Max subscription) - **no API key needed** + - `ANTHROPIC_API_KEY` environment variable set (for API-based usage) + - AWS Bedrock (`CLAUDE_CODE_USE_BEDROCK=1` + AWS credentials) + - Google Vertex AI (`CLAUDE_CODE_USE_VERTEX=1` + GCP credentials) + +If you have Claude Code installed and logged in with your Pro/Max subscription, the SDK will automatically use that authentication. + +## CLI Usage + +```bash +# Basic usage +translate-readme README.md es fr de + +# With options +translate-readme -v -o ./i18n --pattern docs.{lang}.md README.md es fr de ja zh + +# List supported languages +translate-readme --list-languages +``` + +### CLI Options + +| Option | Description | +|--------|-------------| +| `-o, --output ` | Output directory (default: same as source) | +| `-p, --pattern ` | Output filename pattern (default: `README.{lang}.md`) | +| `--no-preserve-code` | Translate code blocks too (not recommended) | +| `-m, --model ` | Claude model to use (default: `sonnet`) | +| `--max-budget ` | Maximum budget in USD | +| `-v, --verbose` | Show detailed progress | +| `-h, --help` | Show help message | +| `--list-languages` | List all supported language codes | + +## Programmatic Usage + +```typescript +import { translateReadme } from "readme-translator"; + +const result = await translateReadme({ + source: "./README.md", + languages: ["es", "fr", "de", "ja", "zh"], + verbose: true, +}); + +console.log(`Translated ${result.successful} files`); +console.log(`Total cost: $${result.totalCostUsd.toFixed(4)}`); +``` + +### API Options + +```typescript +interface TranslationOptions { + /** Source README file path */ + source: string; + + /** Target language codes */ + languages: string[]; + + /** Output directory (defaults to same directory as source) */ + outputDir?: string; + + /** Output filename pattern (use {lang} placeholder) */ + pattern?: string; // default: "README.{lang}.md" + + /** Preserve code blocks without translation */ + preserveCode?: boolean; // default: true + + /** Claude model to use */ + model?: string; // default: "sonnet" + + /** Maximum budget in USD */ + maxBudgetUsd?: number; + + /** Verbose output */ + verbose?: boolean; +} +``` + +### Return Value + +```typescript +interface TranslationJobResult { + results: TranslationResult[]; + totalCostUsd: number; + successful: number; + failed: number; +} + +interface TranslationResult { + language: string; + outputPath: string; + success: boolean; + error?: string; + costUsd?: number; +} +``` + +## Build Script Integration + +### package.json + +```json +{ + "scripts": { + "translate": "translate-readme README.md es fr de ja zh", + "translate:all": "translate-readme -v -o ./i18n README.md es fr de it pt ja ko zh ru ar", + "prebuild": "npm run translate" + } +} +``` + +### GitHub Actions + +Note: CI/CD environments require an API key since Claude Code won't be authenticated there. + +```yaml +name: Translate README +on: + push: + branches: [main] + paths: [README.md] + +jobs: + translate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - run: npm install -g readme-translator + + - name: Translate README + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + translate-readme -v -o ./i18n README.md es fr de ja zh + + - name: Commit translations + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add i18n/ + git diff --staged --quiet || git commit -m "chore: update README translations" + git push +``` + +### Programmatic Build Script + +```typescript +// scripts/translate.ts +import { translateReadme } from "readme-translator"; + +async function main() { + const result = await translateReadme({ + source: "./README.md", + languages: (process.env.TRANSLATE_LANGS || "es,fr,de").split(","), + outputDir: "./docs/i18n", + maxBudgetUsd: 5.0, + verbose: !process.env.CI, + }); + + if (result.failed > 0) { + console.error("Some translations failed"); + process.exit(1); + } +} + +main(); +``` + +## Supported Languages + +| Code | Language | Code | Language | +|------|----------|------|----------| +| `ar` | Arabic | `ko` | Korean | +| `bg` | Bulgarian | `lt` | Lithuanian | +| `cs` | Czech | `lv` | Latvian | +| `da` | Danish | `nl` | Dutch | +| `de` | German | `no` | Norwegian | +| `el` | Greek | `pl` | Polish | +| `es` | Spanish | `pt` | Portuguese | +| `et` | Estonian | `pt-br` | Brazilian Portuguese | +| `fi` | Finnish | `ro` | Romanian | +| `fr` | French | `ru` | Russian | +| `he` | Hebrew | `sk` | Slovak | +| `hi` | Hindi | `sl` | Slovenian | +| `hu` | Hungarian | `sv` | Swedish | +| `id` | Indonesian | `th` | Thai | +| `it` | Italian | `tr` | Turkish | +| `ja` | Japanese | `uk` | Ukrainian | +| | | `vi` | Vietnamese | +| | | `zh` | Chinese (Simplified) | +| | | `zh-tw` | Chinese (Traditional) | + +## Best Practices + +1. **Preserve Code Blocks**: Keep `preserveCode: true` (default) to avoid breaking code examples + +2. **Set Budget Limits**: Use `maxBudgetUsd` to prevent runaway costs + +3. **Run on Releases Only**: In CI/CD, trigger translations only on main branch or releases + +4. **Review Translations**: Automated translations are good but not perfect - consider human review for critical docs + +5. **Cache Results**: Don't re-translate unchanged content - check if README changed before running + +## Cost Estimation + +Typical costs per language (varies by README length): +- Short README (~500 words): ~$0.01-0.02 +- Medium README (~2000 words): ~$0.05-0.10 +- Long README (~5000 words): ~$0.15-0.25 + +## License + +MIT diff --git a/scripts/translate-readme/cli.cjs b/scripts/translate-readme/cli.cjs new file mode 100644 index 0000000..03a1dd8 --- /dev/null +++ b/scripts/translate-readme/cli.cjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node + +/** + * README Translation CLI + * Translate README.md files using Claude Agent SDK + * + * Usage: + * node scripts/translate-readme/cli.js README.md vi ja ko + * node scripts/translate-readme/cli.js --help + */ + +const { translateReadme, SUPPORTED_LANGUAGES, LANGUAGE_NAMES } = require("./index.cjs"); + +function printHelp() { + console.log(` +readme-translator - Translate README.md files using Claude Agent SDK + +AUTHENTICATION: + If Claude Code is installed and authenticated (Pro/Max subscription), + no API key is needed. Otherwise, set ANTHROPIC_API_KEY environment variable. + +USAGE: + node scripts/translate-readme/cli.js [options] + node scripts/translate-readme/cli.js --help + node scripts/translate-readme/cli.js --list-languages + +ARGUMENTS: + source Path to the source README.md file + languages Target language codes (e.g., es fr de ja zh) + +OPTIONS: + -o, --output Output directory (default: same as source) + -p, --pattern Output filename pattern (default: README.{lang}.md) + --no-preserve-code Translate code blocks too (not recommended) + -m, --model Claude model to use (default: sonnet) + --max-budget Maximum budget in USD + -v, --verbose Show detailed progress + -f, --force Force re-translation ignoring cache + -h, --help Show this help message + --list-languages List all supported language codes + +EXAMPLES: + # Translate to Spanish and French + node scripts/translate-readme/cli.js README.md es fr + + # Translate to multiple languages with custom output + node scripts/translate-readme/cli.js -v -o ./i18n README.md de ja ko zh + + # Translate to all major languages + node scripts/translate-readme/cli.js -v README.md zh ja ko es de fr pt-br vi ru ar + +PERFORMANCE: + All translations run in parallel automatically (up to 10 concurrent). + Cache prevents re-translating unchanged files. + +SUPPORTED LANGUAGES: + Run with --list-languages to see all supported language codes +`); +} + +function printLanguages() { + console.log("\nSupported Language Codes:\n"); + const sorted = Object.entries(LANGUAGE_NAMES).sort((a, b) => + a[1].localeCompare(b[1]) + ); + for (const [code, name] of sorted) { + console.log(` ${code.padEnd(8)} ${name}`); + } + console.log(""); +} + +function parseArgs(argv) { + const args = { + source: "", + languages: [], + preserveCode: true, + verbose: false, + force: false, + help: false, + listLanguages: false, + }; + + const positional = []; + let i = 2; // Skip node and script path + + while (i < argv.length) { + const arg = argv[i]; + + switch (arg) { + case "-h": + case "--help": + args.help = true; + break; + case "--list-languages": + args.listLanguages = true; + break; + case "-v": + case "--verbose": + args.verbose = true; + break; + case "-f": + case "--force": + args.force = true; + break; + case "--no-preserve-code": + args.preserveCode = false; + break; + case "-o": + case "--output": + args.outputDir = argv[++i]; + break; + case "-p": + case "--pattern": + args.pattern = argv[++i]; + break; + case "-m": + case "--model": + args.model = argv[++i]; + break; + case "--max-budget": + args.maxBudget = parseFloat(argv[++i]); + break; + default: + if (arg.startsWith("-")) { + console.error(`Unknown option: ${arg}`); + process.exit(1); + } + positional.push(arg); + } + i++; + } + + if (positional.length > 0) { + args.source = positional[0]; + args.languages = positional.slice(1); + } + + return args; +} + +async function main() { + const args = parseArgs(process.argv); + + if (args.help) { + printHelp(); + process.exit(0); + } + + if (args.listLanguages) { + printLanguages(); + process.exit(0); + } + + if (!args.source) { + console.error("Error: No source file specified"); + console.error("Run with --help for usage information"); + process.exit(1); + } + + if (args.languages.length === 0) { + console.error("Error: No target languages specified"); + console.error("Run with --help for usage information"); + process.exit(1); + } + + // Validate language codes + const invalidLangs = args.languages.filter( + (lang) => !SUPPORTED_LANGUAGES.includes(lang.toLowerCase()) + ); + if (invalidLangs.length > 0) { + console.error(`Error: Unknown language codes: ${invalidLangs.join(", ")}`); + console.error("Run with --list-languages to see supported codes"); + process.exit(1); + } + + try { + const result = await translateReadme({ + source: args.source, + languages: args.languages, + outputDir: args.outputDir, + pattern: args.pattern, + preserveCode: args.preserveCode, + model: args.model, + maxBudgetUsd: args.maxBudget, + verbose: args.verbose, + force: args.force, + }); + + if (result.failed > 0) { + process.exit(1); + } + } catch (error) { + console.error( + "Translation failed:", + error instanceof Error ? error.message : error + ); + process.exit(1); + } +} + +main(); diff --git a/scripts/translate-readme/index.cjs b/scripts/translate-readme/index.cjs new file mode 100644 index 0000000..fc19db5 --- /dev/null +++ b/scripts/translate-readme/index.cjs @@ -0,0 +1,346 @@ +/** + * README Translation Tool using Claude Agent SDK + * Converted to Node.js from TypeScript + */ + +const { query } = require("@anthropic-ai/claude-agent-sdk"); +const fs = require("fs/promises"); +const path = require("path"); +const { createHash } = require("crypto"); + +function hashContent(content) { + return createHash("sha256").update(content).digest("hex").slice(0, 16); +} + +async function readCache(cachePath) { + try { + const data = await fs.readFile(cachePath, "utf-8"); + return JSON.parse(data); + } catch { + return null; + } +} + +async function writeCache(cachePath, cache) { + await fs.writeFile(cachePath, JSON.stringify(cache, null, 2), "utf-8"); +} + +const LANGUAGE_NAMES = { + // Tier 1 - No-brainers + zh: "Chinese (Simplified)", + ja: "Japanese", + "pt-br": "Brazilian Portuguese", + ko: "Korean", + es: "Spanish", + de: "German", + fr: "French", + // Tier 2 - Strong tech scenes + he: "Hebrew", + ar: "Arabic", + ru: "Russian", + pl: "Polish", + cs: "Czech", + nl: "Dutch", + tr: "Turkish", + uk: "Ukrainian", + // Tier 3 - Emerging/Growing fast + vi: "Vietnamese", + id: "Indonesian", + th: "Thai", + hi: "Hindi", + bn: "Bengali", + ro: "Romanian", + sv: "Swedish", + // Tier 4 - Why not + it: "Italian", + el: "Greek", + hu: "Hungarian", + fi: "Finnish", + da: "Danish", + no: "Norwegian", + // Other supported + bg: "Bulgarian", + et: "Estonian", + lt: "Lithuanian", + lv: "Latvian", + pt: "Portuguese", + sk: "Slovak", + sl: "Slovenian", + "zh-tw": "Chinese (Traditional)", +}; + +function getLanguageName(code) { + return LANGUAGE_NAMES[code.toLowerCase()] || code; +} + +async function translateToLanguage(content, targetLang, options) { + const languageName = getLanguageName(targetLang); + + const preserveCodeInstructions = options.preserveCode + ? ` +IMPORTANT: Preserve all code blocks exactly as they are. Do NOT translate: +- Code inside \`\`\` blocks +- Inline code inside \` backticks +- Command examples +- File paths +- Variable names, function names, and technical identifiers +- URLs and links +` + : ""; + + const prompt = `Translate the following README.md content from English to ${languageName} (${targetLang}). + +${preserveCodeInstructions} +Guidelines: +- Maintain all Markdown formatting (headers, lists, links, etc.) +- Keep the same document structure +- Translate headings, descriptions, and explanatory text naturally +- Preserve technical accuracy +- Use appropriate technical terminology for ${languageName} +- Keep proper nouns (product names, company names) unchanged unless they have official translations + +Here is the README content to translate: + +--- +${content} +--- + +CRITICAL OUTPUT RULES: +- Output ONLY the raw translated markdown content +- Do NOT wrap output in \`\`\`markdown code fences +- Do NOT add any preamble, explanation, or commentary +- Start directly with the translated content +- The output will be saved directly to a .md file`; + + let translation = ""; + let costUsd = 0; + let charCount = 0; + const startTime = Date.now(); + + const stream = query({ + prompt, + options: { + model: options.model || "sonnet", + systemPrompt: `You are an expert technical translator specializing in software documentation. +You translate README files while preserving Markdown formatting and technical accuracy. +Always output only the translated content without any surrounding explanation.`, + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + includePartialMessages: true, + }, + }); + + const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let spinnerIdx = 0; + + for await (const message of stream) { + if (message.type === "stream_event") { + const event = message.event; + if (event.type === "content_block_delta" && event.delta?.type === "text_delta" && event.delta.text) { + translation += event.delta.text; + charCount += event.delta.text.length; + + if (options.verbose) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + const spinner = spinnerFrames[spinnerIdx++ % spinnerFrames.length]; + process.stdout.write(`\r ${spinner} Translating... ${charCount} chars (${elapsed}s)`); + } + } + } + + if (message.type === "assistant") { + for (const block of message.message.content) { + if (block.type === "text" && !translation) { + translation = block.text; + charCount = translation.length; + } + } + } + + if (message.type === "result") { + if (message.subtype === "success") { + costUsd = message.total_cost_usd; + if (!translation && message.result) { + translation = message.result; + charCount = translation.length; + } + } + } + } + + if (options.verbose) { + process.stdout.write("\r" + " ".repeat(60) + "\r"); + } + + let cleaned = translation.trim(); + if (cleaned.startsWith("```markdown")) { + cleaned = cleaned.slice("```markdown".length); + } else if (cleaned.startsWith("```md")) { + cleaned = cleaned.slice("```md".length); + } else if (cleaned.startsWith("```")) { + cleaned = cleaned.slice(3); + } + if (cleaned.endsWith("```")) { + cleaned = cleaned.slice(0, -3); + } + cleaned = cleaned.trim(); + + return { translation: cleaned, costUsd }; +} + +async function translateReadme(options) { + const { + source, + languages, + outputDir, + pattern = "README.{lang}.md", + preserveCode = true, + model, + maxBudgetUsd, + verbose = false, + force = false, + } = options; + + const parallel = Math.min(languages.length, 10); + const sourcePath = path.resolve(source); + const content = await fs.readFile(sourcePath, "utf-8"); + + const outDir = outputDir ? path.resolve(outputDir) : path.dirname(sourcePath); + await fs.mkdir(outDir, { recursive: true }); + + const sourceHash = hashContent(content); + const cachePath = path.join(outDir, ".translation-cache.json"); + const cache = await readCache(cachePath); + const isHashMatch = cache?.sourceHash === sourceHash; + + const results = []; + let totalCostUsd = 0; + + if (verbose) { + console.log(`📖 Source: ${sourcePath}`); + console.log(`📂 Output: ${outDir}`); + console.log(`🌍 Languages: ${languages.join(", ")}`); + console.log(`⚡ Running ${parallel} translations in parallel`); + console.log(""); + } + + async function translateLang(lang) { + const outputFilename = pattern.replace("{lang}", lang); + const outputPath = path.join(outDir, outputFilename); + + if (!force && isHashMatch && cache?.translations[lang]) { + const outputExists = await fs.access(outputPath).then(() => true).catch(() => false); + if (outputExists) { + if (verbose) { + console.log(` ✅ ${outputFilename} (cached, unchanged)`); + } + return { language: lang, outputPath, success: true, cached: true, costUsd: 0 }; + } + } + + if (verbose) { + console.log(`🔄 Translating to ${getLanguageName(lang)} (${lang})...`); + } + + try { + const { translation, costUsd } = await translateToLanguage(content, lang, { + preserveCode, + model, + verbose: verbose && parallel === 1, + }); + + await fs.writeFile(outputPath, translation, "utf-8"); + + if (verbose) { + console.log(` ✅ Saved to ${outputFilename} ($${costUsd.toFixed(4)})`); + } + + return { language: lang, outputPath, success: true, costUsd }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (verbose) { + console.log(` ❌ ${lang} failed: ${errorMessage}`); + } + return { language: lang, outputPath, success: false, error: errorMessage }; + } + } + + async function runWithConcurrency(items, limit, fn) { + const results = []; + const executing = new Set(); + + for (const item of items) { + if (maxBudgetUsd && totalCostUsd >= maxBudgetUsd) { + results.push({ + language: String(item), + outputPath: "", + success: false, + error: "Budget exceeded", + }); + continue; + } + + const p = fn(item).then((result) => { + results.push(result); + if (result.costUsd) { + totalCostUsd += result.costUsd; + } + }); + + const wrapped = p.finally(() => { + executing.delete(wrapped); + }); + + executing.add(wrapped); + + if (executing.size >= limit) { + await Promise.race(executing); + } + } + + await Promise.all(executing); + return results; + } + + const translationResults = await runWithConcurrency(languages, parallel, translateLang); + results.push(...translationResults); + + const newCache = { + sourceHash, + lastUpdated: new Date().toISOString(), + translations: { + ...(isHashMatch ? cache?.translations : {}), + ...Object.fromEntries( + results.filter(r => r.success && !r.cached).map(r => [ + r.language, + { hash: sourceHash, translatedAt: new Date().toISOString(), costUsd: r.costUsd || 0 } + ]) + ), + }, + }; + await writeCache(cachePath, newCache); + + const successful = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + + if (verbose) { + console.log(""); + console.log(`📊 Summary: ${successful} succeeded, ${failed} failed`); + console.log(`💰 Total cost: $${totalCostUsd.toFixed(4)}`); + } + + return { + results, + totalCostUsd, + successful, + failed, + }; +} + +const SUPPORTED_LANGUAGES = Object.keys(LANGUAGE_NAMES); + +module.exports = { + translateReadme, + SUPPORTED_LANGUAGES, + LANGUAGE_NAMES, +}; diff --git a/skills/memory/SKILL.md b/skills/memory/SKILL.md new file mode 100644 index 0000000..81bf283 --- /dev/null +++ b/skills/memory/SKILL.md @@ -0,0 +1,67 @@ +--- +name: memory +description: Use when you need to recall past work, previous decisions, error solutions, or project history. Activates the 3-layer memory search workflow for token-efficient retrieval. +--- + +# AgentKits Memory Skill + +## When to Activate + +Use this skill when: +- User asks about past work, previous sessions, or what was done before +- User references a decision, pattern, or error you don't have context for +- You need project history, conventions, or architectural decisions +- User asks "what did we do about X?" or "how did we handle Y?" +- You're missing context that should exist from earlier sessions +- Starting work on a feature that may have prior decisions recorded + +## Prerequisites + +Before searching, check if memories exist: +``` +memory_status() +``` +If the database is empty, skip recall and inform the user. + +## 3-Layer Search Workflow + +### Layer 1: Search Index (lightweight, ~50 tokens/result) +``` +memory_search(query="your search term") +``` +- Returns IDs, titles, categories, dates, and relevance scores +- Filter by category: `decision`, `pattern`, `error`, `context`, `observation` +- Filter by date: `dateStart="2025-01-01"`, `dateEnd="2025-12-31"` +- Sort: `orderBy="relevance"` (default), `"date_asc"`, `"date_desc"` + +### Layer 2: Timeline Context (understand what happened around a result) +``` +memory_timeline(anchor="MEMORY_ID") +``` +- Shows what happened before/after a specific memory +- Helps understand the sequence of events +- Use when you need temporal context + +### Layer 3: Full Details (only for filtered IDs) +``` +memory_details(ids=["ID1", "ID2"]) +``` +- Returns complete content for selected memories +- Limit to 3-5 IDs at a time to conserve tokens +- NEVER fetch details without filtering through Layer 1 first + +## Quick Topic Recall + +For a fast overview of everything known about a topic: +``` +memory_recall(topic="authentication") +``` +This returns a grouped summary. Follow up with `memory_details` for specifics. + +## Token Efficiency Rules + +1. ALWAYS start with `memory_search` (Layer 1), never jump to `memory_details` +2. Review search results and select only relevant IDs before fetching details +3. Use filters (category, date range) to narrow results +4. Limit `memory_details` to 3-5 IDs per call +5. This workflow saves ~87% tokens vs fetching everything at once diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..f2f36c9 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,8 @@ +/** + * Global test setup + * + * Disables AI enrichment by default to prevent slow SDK calls + * during tests. The ai-enrichment.test.ts file manages this + * env var independently for its own test cases. + */ +process.env.AGENTKITS_AI_ENRICHMENT = 'false'; diff --git a/src/cli/__tests__/platforms.test.ts b/src/cli/__tests__/platforms.test.ts new file mode 100644 index 0000000..429512d --- /dev/null +++ b/src/cli/__tests__/platforms.test.ts @@ -0,0 +1,192 @@ +/** + * Tests for Platform Registry + * + * @module @agentkits/memory/cli/__tests__/platforms.test + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + PLATFORMS, + ALL_PLATFORM_IDS, + detectPlatforms, + resolvePlatforms, + type PlatformId, +} from '../platforms.js'; + +describe('Platform Registry', () => { + // ===== PLATFORMS constant ===== + + describe('PLATFORMS', () => { + it('should define all 5 platforms', () => { + expect(ALL_PLATFORM_IDS).toHaveLength(5); + expect(ALL_PLATFORM_IDS).toContain('claude-code'); + expect(ALL_PLATFORM_IDS).toContain('cursor'); + expect(ALL_PLATFORM_IDS).toContain('windsurf'); + expect(ALL_PLATFORM_IDS).toContain('cline'); + expect(ALL_PLATFORM_IDS).toContain('opencode'); + }); + + it('should have valid configDir for each platform', () => { + for (const platform of Object.values(PLATFORMS)) { + expect(platform.configDir).toBeTruthy(); + expect(platform.configDir.startsWith('.')).toBe(true); + } + }); + + it('should have valid mcpConfigPath for each platform', () => { + for (const platform of Object.values(PLATFORMS)) { + expect(platform.mcpConfigPath).toBeTruthy(); + expect(platform.mcpConfigPath).toMatch(/\.(json)$/); + } + }); + + it('should mark claude-code and opencode as supporting hooks', () => { + expect(PLATFORMS['claude-code'].supportsHooks).toBe(true); + expect(PLATFORMS.opencode.supportsHooks).toBe(true); + }); + + it('should mark cursor, windsurf, cline as not supporting hooks', () => { + expect(PLATFORMS.cursor.supportsHooks).toBe(false); + expect(PLATFORMS.windsurf.supportsHooks).toBe(false); + expect(PLATFORMS.cline.supportsHooks).toBe(false); + }); + + it('should have rules files for cursor, windsurf, cline', () => { + expect(PLATFORMS.cursor.rulesFile).toBe('.cursorrules'); + expect(PLATFORMS.windsurf.rulesFile).toBe('.windsurfrules'); + expect(PLATFORMS.cline.rulesFile).toBe('.clinerules'); + }); + + it('should not have rules files for claude-code and opencode', () => { + expect(PLATFORMS['claude-code'].rulesFile).toBeNull(); + expect(PLATFORMS.opencode.rulesFile).toBeNull(); + }); + + it('should only have skillsDir for claude-code', () => { + expect(PLATFORMS['claude-code'].skillsDir).toBe('.claude/skills'); + expect(PLATFORMS.cursor.skillsDir).toBeNull(); + expect(PLATFORMS.windsurf.skillsDir).toBeNull(); + expect(PLATFORMS.cline.skillsDir).toBeNull(); + expect(PLATFORMS.opencode.skillsDir).toBeNull(); + }); + + it('should use embedded mcpConfigFormat only for claude-code', () => { + expect(PLATFORMS['claude-code'].mcpConfigFormat).toBe('embedded'); + expect(PLATFORMS.cursor.mcpConfigFormat).toBe('standalone'); + expect(PLATFORMS.windsurf.mcpConfigFormat).toBe('standalone'); + expect(PLATFORMS.cline.mcpConfigFormat).toBe('standalone'); + expect(PLATFORMS.opencode.mcpConfigFormat).toBe('standalone'); + }); + }); + + // ===== detectPlatforms ===== + + describe('detectPlatforms', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'platforms-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return empty array for empty directory', () => { + expect(detectPlatforms(tmpDir)).toEqual([]); + }); + + it('should detect claude-code from .claude directory', () => { + fs.mkdirSync(path.join(tmpDir, '.claude')); + const detected = detectPlatforms(tmpDir); + expect(detected).toContain('claude-code'); + }); + + it('should detect cursor from .cursor directory', () => { + fs.mkdirSync(path.join(tmpDir, '.cursor')); + const detected = detectPlatforms(tmpDir); + expect(detected).toContain('cursor'); + }); + + it('should detect multiple platforms', () => { + fs.mkdirSync(path.join(tmpDir, '.claude')); + fs.mkdirSync(path.join(tmpDir, '.cursor')); + fs.mkdirSync(path.join(tmpDir, '.windsurf')); + const detected = detectPlatforms(tmpDir); + expect(detected).toContain('claude-code'); + expect(detected).toContain('cursor'); + expect(detected).toContain('windsurf'); + expect(detected).toHaveLength(3); + }); + + it('should detect all 5 platforms when all present', () => { + for (const platform of Object.values(PLATFORMS)) { + fs.mkdirSync(path.join(tmpDir, platform.configDir)); + } + const detected = detectPlatforms(tmpDir); + expect(detected).toHaveLength(5); + }); + }); + + // ===== resolvePlatforms ===== + + describe('resolvePlatforms', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'platforms-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return all platforms for "all"', () => { + const result = resolvePlatforms('all', tmpDir); + expect(result).toEqual(ALL_PLATFORM_IDS); + }); + + it('should return single platform for "cursor"', () => { + const result = resolvePlatforms('cursor', tmpDir); + expect(result).toEqual(['cursor']); + }); + + it('should return multiple platforms for comma-separated list', () => { + const result = resolvePlatforms('cursor,windsurf', tmpDir); + expect(result).toEqual(['cursor', 'windsurf']); + }); + + it('should filter invalid platform names', () => { + const result = resolvePlatforms('cursor,invalid,windsurf', tmpDir); + expect(result).toEqual(['cursor', 'windsurf']); + }); + + it('should fall back to auto-detect when all names are invalid', () => { + fs.mkdirSync(path.join(tmpDir, '.cursor')); + const result = resolvePlatforms('invalid', tmpDir); + expect(result).toContain('cursor'); + }); + + it('should auto-detect when no platformArg is given', () => { + fs.mkdirSync(path.join(tmpDir, '.cursor')); + fs.mkdirSync(path.join(tmpDir, '.claude')); + const result = resolvePlatforms(undefined, tmpDir); + expect(result).toContain('claude-code'); + expect(result).toContain('cursor'); + }); + + it('should default to claude-code when nothing detected', () => { + const result = resolvePlatforms(undefined, tmpDir); + expect(result).toEqual(['claude-code']); + }); + + it('should handle empty string by auto-detecting', () => { + const result = resolvePlatforms('', tmpDir); + // Empty string is falsy so falls through to auto-detect + expect(result).toEqual(['claude-code']); + }); + }); +}); diff --git a/src/cli/__tests__/rules-generator.test.ts b/src/cli/__tests__/rules-generator.test.ts new file mode 100644 index 0000000..3112d7b --- /dev/null +++ b/src/cli/__tests__/rules-generator.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for Rules File Generator + * + * @module @agentkits/memory/cli/__tests__/rules-generator.test + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { generateRulesContent, installRulesFile } from '../rules-generator.js'; + +describe('Rules Generator', () => { + // ===== generateRulesContent ===== + + describe('generateRulesContent', () => { + it('should include platform name', () => { + const content = generateRulesContent('Cursor'); + expect(content).toContain('Cursor'); + }); + + it('should include all MCP tool names', () => { + const content = generateRulesContent('Test'); + expect(content).toContain('memory_status'); + expect(content).toContain('memory_save'); + expect(content).toContain('memory_search'); + expect(content).toContain('memory_timeline'); + expect(content).toContain('memory_details'); + expect(content).toContain('memory_recall'); + expect(content).toContain('memory_list'); + expect(content).toContain('memory_update'); + expect(content).toContain('memory_delete'); + }); + + it('should include workflow steps', () => { + const content = generateRulesContent('Test'); + // Workflow step numbers 0-4 + expect(content).toContain('memory_status()'); + expect(content).toContain('memory_save('); + expect(content).toContain('memory_search('); + expect(content).toContain('memory_timeline('); + expect(content).toContain('memory_details('); + }); + + it('should include category table', () => { + const content = generateRulesContent('Test'); + expect(content).toContain('decision'); + expect(content).toContain('pattern'); + expect(content).toContain('error'); + expect(content).toContain('context'); + expect(content).toContain('observation'); + }); + + it('should include start/end markers', () => { + const content = generateRulesContent('Test'); + expect(content).toContain(''); + expect(content).toContain(''); + }); + + it('should include token efficiency rules', () => { + const content = generateRulesContent('Test'); + expect(content).toContain('87%'); + }); + }); + + // ===== installRulesFile ===== + + describe('installRulesFile', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rules-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should create new rules file when not exists', () => { + const result = installRulesFile(tmpDir, '.cursorrules', false); + expect(result.installed).toBe(true); + expect(result.action).toBe('created'); + expect(fs.existsSync(result.path)).toBe(true); + + const content = fs.readFileSync(result.path, 'utf-8'); + expect(content).toContain('AgentKits Memory'); + expect(content).toContain('Cursor'); + }); + + it('should append to existing rules file without marker', () => { + const filePath = path.join(tmpDir, '.cursorrules'); + fs.writeFileSync(filePath, '# Existing rules\nSome content\n'); + + const result = installRulesFile(tmpDir, '.cursorrules', false); + expect(result.installed).toBe(true); + expect(result.action).toBe('updated'); + + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).toContain('# Existing rules'); + expect(content).toContain('AgentKits Memory'); + }); + + it('should skip existing file with marker when not forced', () => { + const filePath = path.join(tmpDir, '.cursorrules'); + fs.writeFileSync(filePath, '\nold content\n\n'); + + const result = installRulesFile(tmpDir, '.cursorrules', false); + expect(result.installed).toBe(false); + expect(result.action).toBe('skipped'); + }); + + it('should replace existing marker section when forced', () => { + const filePath = path.join(tmpDir, '.cursorrules'); + fs.writeFileSync(filePath, '# Header\n\nold content\n\n# Footer\n'); + + const result = installRulesFile(tmpDir, '.cursorrules', true); + expect(result.installed).toBe(true); + expect(result.action).toBe('updated'); + + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).toContain('# Header'); + expect(content).toContain('# Footer'); + expect(content).not.toContain('old content'); + expect(content).toContain('memory_save'); + }); + + it('should generate correct platform name from filename', () => { + const result = installRulesFile(tmpDir, '.windsurfrules', false); + const content = fs.readFileSync(result.path, 'utf-8'); + expect(content).toContain('Windsurf'); + }); + + it('should generate correct platform name for clinerules', () => { + const result = installRulesFile(tmpDir, '.clinerules', false); + const content = fs.readFileSync(result.path, 'utf-8'); + expect(content).toContain('Cline'); + }); + }); +}); diff --git a/src/cli/platforms.ts b/src/cli/platforms.ts new file mode 100644 index 0000000..f96f927 --- /dev/null +++ b/src/cli/platforms.ts @@ -0,0 +1,149 @@ +/** + * Platform definitions for AI coding assistants. + * + * Centralized registry of supported platforms with their + * config paths, MCP locations, rules files, and capabilities. + * + * @module @agentkits/memory/cli/platforms + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// ===== Types ===== + +export type PlatformId = 'claude-code' | 'cursor' | 'windsurf' | 'cline' | 'opencode'; + +export interface PlatformDefinition { + /** Unique platform identifier */ + id: PlatformId; + /** Human-readable name */ + name: string; + /** Config directory relative to project root */ + configDir: string; + /** MCP config file path relative to project root */ + mcpConfigPath: string; + /** + * How MCP server is stored in the config file: + * - 'embedded': mcpServers key inside an existing settings file (Claude Code) + * - 'standalone': dedicated mcp.json file with { mcpServers: { ... } } + */ + mcpConfigFormat: 'embedded' | 'standalone'; + /** Rules file name relative to project root (null if not supported) */ + rulesFile: string | null; + /** Skills directory relative to project root (null if not supported) */ + skillsDir: string | null; + /** Whether hooks are supported natively */ + supportsHooks: boolean; +} + +// ===== Platform Registry ===== + +export const PLATFORMS: Record = { + 'claude-code': { + id: 'claude-code', + name: 'Claude Code', + configDir: '.claude', + mcpConfigPath: '.claude/settings.json', + mcpConfigFormat: 'embedded', + rulesFile: null, // Claude Code uses CLAUDE.md (managed separately) + skillsDir: '.claude/skills', + supportsHooks: true, + }, + cursor: { + id: 'cursor', + name: 'Cursor', + configDir: '.cursor', + mcpConfigPath: '.cursor/mcp.json', + mcpConfigFormat: 'standalone', + rulesFile: '.cursorrules', + skillsDir: null, + supportsHooks: false, + }, + windsurf: { + id: 'windsurf', + name: 'Windsurf', + configDir: '.windsurf', + mcpConfigPath: '.windsurf/mcp.json', + mcpConfigFormat: 'standalone', + rulesFile: '.windsurfrules', + skillsDir: null, + supportsHooks: false, + }, + cline: { + id: 'cline', + name: 'Cline', + configDir: '.cline', + mcpConfigPath: '.mcp.json', + mcpConfigFormat: 'standalone', + rulesFile: '.clinerules', + skillsDir: null, + supportsHooks: false, + }, + opencode: { + id: 'opencode', + name: 'OpenCode', + configDir: '.opencode', + mcpConfigPath: '.mcp.json', + mcpConfigFormat: 'standalone', + rulesFile: null, + skillsDir: null, + supportsHooks: true, + }, +}; + +/** All platform IDs */ +export const ALL_PLATFORM_IDS: PlatformId[] = Object.keys(PLATFORMS) as PlatformId[]; + +// ===== Detection & Resolution ===== + +/** + * Detect which platforms are present in a project directory. + * Checks for existence of platform-specific config directories. + */ +export function detectPlatforms(projectDir: string): PlatformId[] { + const detected: PlatformId[] = []; + + for (const platform of Object.values(PLATFORMS)) { + const configPath = path.join(projectDir, platform.configDir); + if (fs.existsSync(configPath)) { + detected.push(platform.id); + } + } + + return detected; +} + +/** + * Resolve platforms from CLI --platform flag. + * + * Supports: + * - 'all' → all platforms + * - 'cursor,windsurf' → specific platforms + * - 'cursor' → single platform + * - undefined → auto-detect, fallback to ['claude-code'] + */ +export function resolvePlatforms( + platformArg: string | undefined, + projectDir: string +): PlatformId[] { + // Explicit 'all' + if (platformArg === 'all') { + return ALL_PLATFORM_IDS; + } + + // Explicit platform(s) + if (platformArg) { + const ids = platformArg.split(',').map(s => s.trim()) as PlatformId[]; + const valid = ids.filter(id => id in PLATFORMS); + if (valid.length > 0) return valid; + // Invalid platform names → fall through to auto-detect + } + + // Auto-detect + const detected = detectPlatforms(projectDir); + if (detected.length > 0) return detected; + + // Default: Claude Code + return ['claude-code']; +} diff --git a/src/cli/rules-generator.ts b/src/cli/rules-generator.ts new file mode 100644 index 0000000..a2c4fd5 --- /dev/null +++ b/src/cli/rules-generator.ts @@ -0,0 +1,126 @@ +/** + * Rules file generator for non-Claude platforms. + * + * Generates platform-specific rules files (.cursorrules, .windsurfrules, .clinerules) + * with MCP memory workflow instructions so AI assistants use memory tools proactively. + * + * @module @agentkits/memory/cli/rules-generator + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const MARKER_START = ''; +const MARKER_END = ''; + +/** + * Generate rules file content for AI coding assistants. + * Instructs the AI to use MCP memory tools proactively. + */ +export function generateRulesContent(platformName: string): string { + return `${MARKER_START} +# AgentKits Memory — ${platformName} + +This project uses AgentKits Memory for persistent project context across sessions. +The following MCP tools are available via the "memory" server. + +## Memory Workflow (ALWAYS FOLLOW) + +0. \`memory_status()\` — Check if memories exist BEFORE searching +1. \`memory_save(content, category, tags)\` — Save decisions, patterns, errors, context +2. \`memory_search(query)\` — Get index with IDs (~50 tokens/result) +3. \`memory_timeline(anchor="ID")\` — Get context around interesting results +4. \`memory_details(ids=["ID1","ID2"])\` — Fetch full content ONLY for filtered IDs + +**IMPORTANT:** Do NOT call memory_search/timeline/details on empty memory — save first. + +## Also Available + +- \`memory_recall(topic)\` — Quick topic overview +- \`memory_list()\` — List recent memories +- \`memory_update(id, content)\` — Update existing memory +- \`memory_delete(ids)\` — Remove outdated memories + +## When to Save Memories + +Save important context proactively using \`memory_save(content, category, tags, importance)\`: + +| Category | What to Save | +|----------|-------------| +| **decision** | Architectural choices, tech stack picks, trade-offs | +| **pattern** | Coding conventions, project patterns, recurring approaches | +| **error** | Bug fixes, error solutions, debugging insights | +| **context** | Project background, team conventions, environment setup | +| **observation** | What you learned during implementation | + +## Token Efficiency Rules + +1. ALWAYS start with \`memory_search\` (Layer 1), never jump to \`memory_details\` +2. Review search results and select only relevant IDs before fetching details +3. Use filters (category, date range) to narrow results +4. Limit \`memory_details\` to 3-5 IDs per call +5. This workflow saves ~87% tokens vs fetching everything at once + +## At Session Start + +1. Call \`memory_status()\` to check if memories exist +2. If memories exist, call \`memory_recall(topic)\` for relevant project context +3. Save important decisions and patterns as you work +${MARKER_END}`; +} + +/** + * Install rules file to project root. + * If the file exists, appends/replaces the AgentKits section. + * If the file doesn't exist, creates it. + */ +export function installRulesFile( + projectDir: string, + rulesFileName: string, + force: boolean, + asJson: boolean = false, +): { installed: boolean; path: string; action: 'created' | 'updated' | 'skipped' } { + const filePath = path.join(projectDir, rulesFileName); + const platformName = rulesFileName + .replace(/^\./, '') + .replace(/rules$/, '') + .replace(/^./, c => c.toUpperCase()); + + const newContent = generateRulesContent(platformName); + + // File doesn't exist → create + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, newContent + '\n'); + return { installed: true, path: filePath, action: 'created' }; + } + + // File exists — check for existing AgentKits section + const existing = fs.readFileSync(filePath, 'utf-8'); + const hasMarker = existing.includes(MARKER_START); + + if (hasMarker) { + if (!force) { + return { installed: false, path: filePath, action: 'skipped' }; + } + // Replace existing section + const regex = new RegExp( + escapeRegex(MARKER_START) + '[\\s\\S]*?' + escapeRegex(MARKER_END), + 'g' + ); + const updated = existing.replace(regex, newContent); + fs.writeFileSync(filePath, updated); + return { installed: true, path: filePath, action: 'updated' }; + } + + // File exists but no marker → append + const separator = existing.endsWith('\n') ? '\n' : '\n\n'; + fs.writeFileSync(filePath, existing + separator + newContent + '\n'); + if (!asJson) { + // Inform user we appended to existing file + } + return { installed: true, path: filePath, action: 'updated' }; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 4a474dd..a8bb641 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -3,13 +3,15 @@ * AgentKits Memory Setup CLI * * Sets up memory hooks, MCP server, and downloads embedding model. - * Supports multiple AI tools: Claude Code, Cursor, Windsurf, etc. + * Supports multiple AI tools: Claude Code, Cursor, Windsurf, Cline, OpenCode. * * Usage: * npx agentkits-memory-setup [options] * * Options: * --project-dir=X Project directory (default: cwd) + * --platform=X Target platform(s): claude-code, cursor, windsurf, cline, opencode, all + * Default: auto-detect, fallback to claude-code * --force Overwrite existing configuration * --skip-model Skip embedding model download * --skip-mcp Skip MCP server configuration @@ -22,6 +24,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { LocalEmbeddingsService } from '../embeddings/local-embeddings.js'; +import { type PlatformDefinition, type PlatformId, PLATFORMS, resolvePlatforms } from './platforms.js'; +import { installRulesFile } from './rules-generator.js'; const args = process.argv.slice(2); @@ -236,71 +240,144 @@ function mergeHooks( } /** - * Configure MCP server for different AI tools - * Creates/updates config files for: Claude Code, Cursor, Windsurf, etc. + * Configure MCP server for a specific platform. + * Handles two formats: + * - 'embedded': mcpServers key inside an existing settings file (Claude Code) + * - 'standalone': dedicated mcp.json file with { mcpServers: { ... } } */ -function configureMcp( +function configureMcpForPlatform( projectDir: string, - claudeSettings: ClaudeSettings, + platform: PlatformDefinition, force: boolean, - asJson: boolean -): { configured: string[]; skipped: string[] } { - const configured: string[] = []; - const skipped: string[] = []; - - // 1. Add to Claude Code settings.json (mcpServers key) - // Always merge with existing servers, never overwrite - if (!claudeSettings.mcpServers) { - claudeSettings.mcpServers = {}; - } - - if (!claudeSettings.mcpServers.memory || force) { - claudeSettings.mcpServers.memory = MEMORY_MCP_SERVER; - configured.push('Claude Code (.claude/settings.json)'); - } else { - skipped.push('Claude Code (already configured)'); + asJson: boolean, + claudeSettings?: ClaudeSettings, +): { configured: boolean; path: string } { + const mcpPath = path.join(projectDir, platform.mcpConfigPath); + + if (platform.mcpConfigFormat === 'embedded' && claudeSettings) { + // Claude Code: mcpServers key inside settings.json + if (!claudeSettings.mcpServers) { + claudeSettings.mcpServers = {}; + } + if (!claudeSettings.mcpServers.memory || force) { + claudeSettings.mcpServers.memory = MEMORY_MCP_SERVER; + return { configured: true, path: mcpPath }; + } + return { configured: false, path: mcpPath }; } - // 2. Create/update root .mcp.json for other tools (Cursor, Windsurf, Claude Code, etc.) - // Always merge with existing servers, never overwrite - const mcpJsonPath = path.join(projectDir, '.mcp.json'); + // Standalone mcp.json try { let existing: McpConfig = { mcpServers: {} }; - // Load existing config if present - if (fs.existsSync(mcpJsonPath)) { + if (fs.existsSync(mcpPath)) { try { - existing = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8')) as McpConfig; + existing = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')) as McpConfig; existing.mcpServers = existing.mcpServers || {}; } catch { - // If parse fails, start fresh but warn if (!asJson) { - console.warn(' ⚠ .mcp.json parse error, creating new config'); + console.warn(` ⚠ ${platform.mcpConfigPath} parse error, creating new config`); } existing = { mcpServers: {} }; } } - // Add or update memory server if (!existing.mcpServers.memory || force) { + // Ensure parent directory exists + const mcpDir = path.dirname(mcpPath); + if (!fs.existsSync(mcpDir)) { + fs.mkdirSync(mcpDir, { recursive: true }); + } existing.mcpServers.memory = MEMORY_MCP_SERVER; - fs.writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2)); - configured.push('Universal (.mcp.json)'); - } else { - skipped.push('.mcp.json (already configured)'); + fs.writeFileSync(mcpPath, JSON.stringify(existing, null, 2)); + return { configured: true, path: mcpPath }; } - } catch (error) { - skipped.push(`.mcp.json (error: ${error instanceof Error ? error.message : 'unknown'})`); + return { configured: false, path: mcpPath }; + } catch { + return { configured: false, path: mcpPath }; + } +} + +/** + * Install memory skills to a platform's skills directory. + * Copies SKILL.md files from package to project's skills directory. + */ +function installSkills( + projectDir: string, + platform: PlatformDefinition, + force: boolean, + asJson: boolean +): { installed: string[]; skipped: string[] } { + const installed: string[] = []; + const skipped: string[] = []; + + if (!platform.skillsDir) return { installed, skipped }; + + // Resolve package root: setup.ts is at dist/cli/setup.js → package root is ../../ + const packageRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..'); + const sourceSkillsDir = path.join(packageRoot, 'skills'); + + if (!fs.existsSync(sourceSkillsDir)) { + return { installed, skipped }; + } + + let skillDirs: fs.Dirent[]; + try { + skillDirs = fs.readdirSync(sourceSkillsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()); + } catch { + return { installed, skipped }; + } + + for (const skillDir of skillDirs) { + const sourcePath = path.join(sourceSkillsDir, skillDir.name, 'SKILL.md'); + const targetDir = path.join(projectDir, platform.skillsDir, skillDir.name); + const targetPath = path.join(targetDir, 'SKILL.md'); + + if (!fs.existsSync(sourcePath)) continue; + + if (fs.existsSync(targetPath) && !force) { + skipped.push(skillDir.name); + continue; + } + + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + fs.copyFileSync(sourcePath, targetPath); + installed.push(skillDir.name); } - if (!asJson && configured.length > 0) { - console.log('\n🔌 MCP Server configured for:'); - for (const tool of configured) { - console.log(` ✓ ${tool}`); + if (!asJson && installed.length > 0) { + console.log('\n🎯 Skills installed:'); + for (const skill of installed) { + console.log(` ✓ ${skill} (${platform.skillsDir}/${skill}/SKILL.md)`); } } - return { configured, skipped }; + return { installed, skipped }; +} + +/** + * Create default memory settings file if not exists + */ +function createDefaultSettings(memoryDir: string, force: boolean): boolean { + const settingsPath = path.join(memoryDir, 'settings.json'); + if (fs.existsSync(settingsPath) && !force) return false; + + const defaultSettings = { + context: { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: true, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, + }, + }; + fs.writeFileSync(settingsPath, JSON.stringify(defaultSettings, null, 2)); + return true; } async function downloadModel(cacheDir: string, asJson: boolean): Promise { @@ -371,6 +448,7 @@ async function main() { const skipModel = !!options['skip-model']; const skipMcp = !!options['skip-mcp']; const showHooks = !!options['show-hooks']; + const platformArg = options.platform as string | undefined; // Just show hooks config and exit if (showHooks) { @@ -378,16 +456,22 @@ async function main() { return; } + // Resolve target platforms + const targetPlatforms = resolvePlatforms(platformArg, projectDir); + + // Memory data always stored under .claude/memory (single source of truth) const claudeDir = path.join(projectDir, '.claude'); const settingsPath = path.join(claudeDir, 'settings.json'); const memoryDir = path.join(claudeDir, 'memory'); try { if (!asJson) { + const platformNames = targetPlatforms.map(id => PLATFORMS[id].name).join(', '); console.log('\n🧠 AgentKits Memory Setup\n'); + console.log(` Platforms: ${platformNames}`); } - // Create directories + // Always create memory directory (single source of truth) if (!fs.existsSync(claudeDir)) { fs.mkdirSync(claudeDir, { recursive: true }); } @@ -395,25 +479,72 @@ async function main() { fs.mkdirSync(memoryDir, { recursive: true }); } - // Load or create settings - let settings: ClaudeSettings = {}; + // Track results across all platforms + const mcpConfigured: string[] = []; + const mcpSkipped: string[] = []; + const rulesInstalled: string[] = []; + const rulesSkipped: string[] = []; + let hooksResult: MergeResult = { merged: {}, added: [], skipped: [], manualRequired: [] }; + let skillsResult = { installed: [] as string[], skipped: [] as string[] }; + + // Load Claude settings (needed for embedded MCP + hooks) + let claudeSettings: ClaudeSettings = {}; if (fs.existsSync(settingsPath)) { const content = fs.readFileSync(settingsPath, 'utf-8'); - settings = JSON.parse(content); + claudeSettings = JSON.parse(content); } - // Merge hooks - const hooksResult = mergeHooks(settings.hooks, MEMORY_HOOKS, force); - settings.hooks = hooksResult.merged; + // Process each platform + for (const platformId of targetPlatforms) { + const platform = PLATFORMS[platformId]; - // Configure MCP server - let mcpResult = { configured: [] as string[], skipped: [] as string[] }; - if (!skipMcp) { - mcpResult = configureMcp(projectDir, settings, force, asJson); + // 1. Configure MCP + if (!skipMcp) { + const mcpResult = configureMcpForPlatform( + projectDir, platform, force, asJson, + platformId === 'claude-code' ? claudeSettings : undefined, + ); + if (mcpResult.configured) { + mcpConfigured.push(`${platform.name} (${platform.mcpConfigPath})`); + } else { + mcpSkipped.push(`${platform.name} (already configured)`); + } + } + + // 2. Install hooks (Claude Code only for now; OpenCode in Phase B) + if (platformId === 'claude-code') { + hooksResult = mergeHooks(claudeSettings.hooks, MEMORY_HOOKS, force); + claudeSettings.hooks = hooksResult.merged; + } + + // 3. Install skills (platforms that support them) + if (platform.skillsDir) { + const result = installSkills(projectDir, platform, force, asJson); + skillsResult.installed.push(...result.installed); + skillsResult.skipped.push(...result.skipped); + } + + // 4. Install rules file (platforms that support them) + if (platform.rulesFile) { + const result = installRulesFile(projectDir, platform.rulesFile, force, asJson); + if (result.installed) { + rulesInstalled.push(`${platform.rulesFile} (${result.action})`); + } else { + rulesSkipped.push(`${platform.rulesFile} (already configured)`); + } + } + } + + // Write Claude settings (hooks + embedded MCP) + if (targetPlatforms.includes('claude-code')) { + fs.writeFileSync(settingsPath, JSON.stringify(claudeSettings, null, 2)); } - // Write settings - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + // Create default memory settings + const settingsCreated = createDefaultSettings(memoryDir, force); + if (!asJson && settingsCreated) { + console.log('\n⚙️ Default memory settings created'); + } // Download embedding model let modelDownloaded = false; @@ -423,12 +554,15 @@ async function main() { const result = { success: true, + platforms: targetPlatforms, settingsPath, memoryDir, hooksAdded: hooksResult.added, hooksSkipped: hooksResult.skipped, hooksManualRequired: hooksResult.manualRequired, - mcpConfigured: mcpResult.configured, + skillsInstalled: skillsResult.installed, + mcpConfigured, + rulesInstalled, modelDownloaded, message: 'Memory setup complete', }; @@ -436,11 +570,18 @@ async function main() { if (asJson) { console.log(JSON.stringify(result, null, 2)); } else { - console.log('✅ Setup Complete\n'); - console.log(`📁 Settings: ${settingsPath}`); + console.log('\n✅ Setup Complete\n'); console.log(`📁 Memory: ${memoryDir}`); - // Show hooks status + // Show MCP status + if (mcpConfigured.length > 0) { + console.log('\n🔌 MCP Server configured for:'); + for (const entry of mcpConfigured) { + console.log(` ✓ ${entry}`); + } + } + + // Show hooks status (Claude Code only) if (hooksResult.added.length > 0) { console.log(`\n📋 Hooks added: ${hooksResult.added.join(', ')}`); } @@ -448,6 +589,19 @@ async function main() { console.log(` Skipped: ${hooksResult.skipped.join(', ')}`); } + // Show skills status + if (skillsResult.installed.length > 0) { + console.log(`\n🎯 Skills: ${skillsResult.installed.join(', ')}`); + } + + // Show rules files status + if (rulesInstalled.length > 0) { + console.log('\n📝 Rules files:'); + for (const entry of rulesInstalled) { + console.log(` ✓ ${entry}`); + } + } + // Show manual action required if (hooksResult.manualRequired.length > 0) { console.log('\n⚠️ Manual review recommended:'); @@ -468,32 +622,34 @@ async function main() { console.log('📋 Show hooks config: npx agentkits-memory-setup --show-hooks\n'); // Show manual hook instructions if some hooks couldn't be added - const allHookEvents = Object.keys(MEMORY_HOOKS); - const addedEvents = hooksResult.added.map((h) => h.replace(/ \(.*\)$/, '')); - const missingEvents = allHookEvents.filter( - (e) => !addedEvents.includes(e) && !hooksResult.skipped.some((s) => s.startsWith(e)) - ); + if (targetPlatforms.includes('claude-code')) { + const allHookEvents = Object.keys(MEMORY_HOOKS); + const addedEvents = hooksResult.added.map((h) => h.replace(/ \(.*\)$/, '')); + const missingEvents = allHookEvents.filter( + (e) => !addedEvents.includes(e) && !hooksResult.skipped.some((s) => s.startsWith(e)) + ); - if (missingEvents.length > 0) { - console.log('━'.repeat(60)); - console.log('📝 MANUAL SETUP REQUIRED\n'); - console.log(`Some hooks could not be auto-configured.`); - console.log(`Missing: ${missingEvents.join(', ')}\n`); - console.log(`To add manually:`); - console.log(`1. Open: ${settingsPath}`); - console.log(`2. Add/merge the following into the "hooks" section:\n`); - - // Generate copy-paste JSON for missing hooks only - const missingHooksJson: Record = {}; - for (const event of missingEvents) { - const hookConfig = MEMORY_HOOKS[event]; - if (hookConfig) { - missingHooksJson[event] = hookConfig; + if (missingEvents.length > 0) { + console.log('━'.repeat(60)); + console.log('📝 MANUAL SETUP REQUIRED\n'); + console.log(`Some hooks could not be auto-configured.`); + console.log(`Missing: ${missingEvents.join(', ')}\n`); + console.log(`To add manually:`); + console.log(`1. Open: ${settingsPath}`); + console.log(`2. Add/merge the following into the "hooks" section:\n`); + + // Generate copy-paste JSON for missing hooks only + const missingHooksJson: Record = {}; + for (const event of missingEvents) { + const hookConfig = MEMORY_HOOKS[event]; + if (hookConfig) { + missingHooksJson[event] = hookConfig; + } } - } - console.log(JSON.stringify(missingHooksJson, null, 2)); - console.log('\n━'.repeat(60)); + console.log(JSON.stringify(missingHooksJson, null, 2)); + console.log('\n━'.repeat(60)); + } } } } catch (error) { diff --git a/src/cli/web-viewer.ts b/src/cli/web-viewer.ts index 197a815..70b7a6c 100644 --- a/src/cli/web-viewer.ts +++ b/src/cli/web-viewer.ts @@ -45,12 +45,116 @@ let _searchEngine: HybridSearchEngine | null = null; let _db: BetterDatabase | null = null; /** - * Get direct database access + * Get direct database access (memory.db) */ function getDatabase(): BetterDatabase { if (_db) return _db; _db = new Database(dbPath); _db.pragma('journal_mode = WAL'); + + // Ensure all tables exist (web viewer may start before MCP server or hooks) + _db.exec(` + CREATE TABLE IF NOT EXISTS memory_entries ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + content TEXT NOT NULL, + type TEXT DEFAULT 'semantic', + namespace TEXT DEFAULT 'default', + tags TEXT DEFAULT '[]', + metadata TEXT DEFAULT '{}', + embedding BLOB, + session_id TEXT, + owner_id TEXT, + access_level TEXT DEFAULT 'project', + created_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000), + updated_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000), + expires_at INTEGER, + version INTEGER DEFAULT 1, + "references" TEXT DEFAULT '[]', + access_count INTEGER DEFAULT 0, + last_accessed_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000) + ); + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT UNIQUE NOT NULL, + project TEXT NOT NULL, + prompt TEXT, + started_at INTEGER NOT NULL, + ended_at INTEGER, + observation_count INTEGER DEFAULT 0, + summary TEXT, + status TEXT DEFAULT 'active' + ); + CREATE TABLE IF NOT EXISTS observations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + tool_name TEXT NOT NULL, + tool_input TEXT, + tool_response TEXT, + cwd TEXT, + timestamp INTEGER NOT NULL, + type TEXT, + title TEXT, + prompt_number INTEGER, + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + subtitle TEXT, + narrative TEXT, + facts TEXT DEFAULT '[]', + concepts TEXT DEFAULT '[]', + embedding BLOB, + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ); + CREATE TABLE IF NOT EXISTS user_prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + prompt_number INTEGER NOT NULL, + prompt_text TEXT NOT NULL, + created_at INTEGER NOT NULL, + embedding BLOB, + UNIQUE(session_id, prompt_number), + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ); + CREATE TABLE IF NOT EXISTS session_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + request TEXT, + completed TEXT, + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + next_steps TEXT, + notes TEXT, + prompt_number INTEGER, + created_at INTEGER NOT NULL, + embedding BLOB, + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ); + `); + + // Task queue table (shared with hooks service — used for embed + enrich workers) + _db.exec(` + CREATE TABLE IF NOT EXISTS task_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_type TEXT NOT NULL, + target_table TEXT NOT NULL, + target_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + status TEXT DEFAULT 'pending' + ) + `); + + // Migration: add embedding column to existing session tables + for (const table of ['observations', 'user_prompts', 'session_summaries']) { + try { + const cols = _db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'embedding')) { + _db.exec(`ALTER TABLE ${table} ADD COLUMN embedding BLOB`); + } + } catch { /* ignore */ } + } + return _db; } @@ -87,6 +191,196 @@ async function getSearchEngine(): Promise { return _searchEngine; } +// ===== Session Hybrid Search ===== + +/** + * Cosine similarity between two Float32Arrays + */ +function cosineSimilarity(a: Float32Array, b: Float32Array): number { + if (a.length !== b.length) return 0; + let dot = 0, normA = 0, normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom === 0 ? 0 : dot / denom; +} + +/** + * Extract text to embed for a session table row + */ +function getSessionEmbeddingText( + table: 'observations' | 'user_prompts' | 'session_summaries', + row: Record +): string { + switch (table) { + case 'observations': { + const parts = [row.title, row.subtitle, row.narrative]; + try { + const concepts = JSON.parse((row.concepts as string) || '[]'); + if (concepts.length > 0) parts.push(concepts.join(', ')); + } catch { /* ignore */ } + return parts.filter(Boolean).join(' ').trim(); + } + case 'user_prompts': + return ((row.prompt_text as string) || '').trim(); + case 'session_summaries': { + const parts = [row.request, row.completed, row.next_steps, row.notes]; + return parts.filter(Boolean).join(' ').trim(); + } + } +} + +interface SessionSearchResult { + table: 'observations' | 'user_prompts' | 'session_summaries'; + id: string | number; + sessionId: string; + score: number; + keywordScore: number; + semanticScore: number; + time: number; + snippet: string; + data: Record; +} + +/** + * Hybrid search across all session tables (text + vector) + */ +async function searchSessionsHybrid( + db: BetterDatabase, + query: string, + options: { type?: 'hybrid' | 'text' | 'vector'; limit?: number } = {} +): Promise { + const { type = 'hybrid', limit = 30 } = options; + const results = new Map(); + const queryLower = query.toLowerCase(); + + // === Text search (LIKE) === + if (type === 'hybrid' || type === 'text') { + const pattern = `%${query}%`; + + // Observations + const obs = db.prepare(` + SELECT * FROM observations + WHERE title LIKE ? OR subtitle LIKE ? OR narrative LIKE ? OR tool_name LIKE ? + ORDER BY timestamp DESC LIMIT ? + `).all(pattern, pattern, pattern, pattern, limit) as Record[]; + for (const row of obs) { + const text = getSessionEmbeddingText('observations', row); + const idx = text.toLowerCase().indexOf(queryLower); + const kwScore = idx >= 0 ? Math.max(0.3, 1 - idx / 500) : 0.3; + results.set(`obs_${row.id}`, { + table: 'observations', id: row.id as string, sessionId: row.session_id as string, + score: type === 'text' ? kwScore : kwScore * 0.3, + keywordScore: kwScore, semanticScore: 0, + time: row.timestamp as number, + snippet: text.substring(0, 120), + data: { ...row, embedding: undefined }, + }); + } + + // User prompts + const prompts = db.prepare(` + SELECT * FROM user_prompts WHERE prompt_text LIKE ? + ORDER BY created_at DESC LIMIT ? + `).all(pattern, limit) as Record[]; + for (const row of prompts) { + const text = (row.prompt_text as string) || ''; + const idx = text.toLowerCase().indexOf(queryLower); + const kwScore = idx >= 0 ? Math.max(0.3, 1 - idx / 500) : 0.3; + results.set(`prompt_${row.id}`, { + table: 'user_prompts', id: row.id as number, sessionId: row.session_id as string, + score: type === 'text' ? kwScore : kwScore * 0.3, + keywordScore: kwScore, semanticScore: 0, + time: row.created_at as number, + snippet: text.substring(0, 120), + data: { ...row, embedding: undefined }, + }); + } + + // Session summaries + const summaries = db.prepare(` + SELECT * FROM session_summaries + WHERE request LIKE ? OR completed LIKE ? OR notes LIKE ? OR next_steps LIKE ? + ORDER BY created_at DESC LIMIT ? + `).all(pattern, pattern, pattern, pattern, limit) as Record[]; + for (const row of summaries) { + const text = getSessionEmbeddingText('session_summaries', row); + const idx = text.toLowerCase().indexOf(queryLower); + const kwScore = idx >= 0 ? Math.max(0.3, 1 - idx / 500) : 0.3; + results.set(`summary_${row.id}`, { + table: 'session_summaries', id: row.id as number, sessionId: row.session_id as string, + score: type === 'text' ? kwScore : kwScore * 0.3, + keywordScore: kwScore, semanticScore: 0, + time: row.created_at as number, + snippet: text.substring(0, 120), + data: { ...row, embedding: undefined }, + }); + } + } + + // === Vector search === + if ((type === 'hybrid' || type === 'vector') && query.trim()) { + try { + const embeddingsService = await getEmbeddingsService(); + const queryResult = await embeddingsService.embed(query); + const queryEmbedding = queryResult.embedding; + + const tables: Array<{ name: 'observations' | 'user_prompts' | 'session_summaries'; idCol: string; timeCol: string }> = [ + { name: 'observations', idCol: 'id', timeCol: 'timestamp' }, + { name: 'user_prompts', idCol: 'id', timeCol: 'created_at' }, + { name: 'session_summaries', idCol: 'id', timeCol: 'created_at' }, + ]; + + for (const { name, idCol, timeCol } of tables) { + const rows = db.prepare( + `SELECT * FROM ${name} WHERE embedding IS NOT NULL AND LENGTH(embedding) > 0 ORDER BY ${timeCol} DESC LIMIT 2000` + ).all() as Record[]; + + for (const row of rows) { + const embBuffer = row.embedding as Buffer; + if (!embBuffer || embBuffer.length === 0) continue; + const embedding = new Float32Array( + embBuffer.buffer.slice(embBuffer.byteOffset, embBuffer.byteOffset + embBuffer.byteLength) + ); + const sim = cosineSimilarity(queryEmbedding, embedding); + if (sim < 0.1) continue; + + const prefix = name === 'observations' ? 'obs' : name === 'user_prompts' ? 'prompt' : 'summary'; + const key = `${prefix}_${row[idCol]}`; + const existing = results.get(key); + + if (existing) { + existing.semanticScore = sim; + existing.score = existing.keywordScore * 0.3 + sim * 0.7; + } else { + const text = getSessionEmbeddingText(name, row); + results.set(key, { + table: name, + id: row[idCol] as string | number, + sessionId: row.session_id as string, + score: type === 'vector' ? sim : sim * 0.7, + keywordScore: 0, semanticScore: sim, + time: row[timeCol] as number, + snippet: text.substring(0, 120), + data: { ...row, embedding: undefined }, + }); + } + } + } + } catch { + // Embeddings not available, fall back to text-only results + } + } + + return Array.from(results.values()) + .filter(r => r.score >= 0.05) + .sort((a, b) => b.score - a.score) + .slice(0, limit); +} + /** * Get database statistics using direct SQL (faster for stats queries) */ @@ -984,7 +1278,17 @@ function getHTML(): string {

AgentKits Memory Database

-
+
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+
+ + Vector index: loading... +
+
+
Loading sessions...
+
+ +
+ + + +
@@ -1626,7 +1971,326 @@ function getHTML(): string { } }); - loadData(); + // Tab switching + function switchTab(tab) { + document.getElementById('memories-tab').style.display = tab === 'memories' ? '' : 'none'; + document.getElementById('sessions-tab').style.display = tab === 'sessions' ? '' : 'none'; + document.getElementById('header-actions-memories').style.display = tab === 'memories' ? '' : 'none'; + document.getElementById('header-actions-sessions').style.display = tab === 'sessions' ? '' : 'none'; + + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.style.borderBottomColor = 'transparent'; + btn.style.color = 'var(--text-secondary)'; + }); + const activeBtn = document.getElementById('tab-' + tab); + activeBtn.style.borderBottomColor = 'var(--accent)'; + activeBtn.style.color = 'var(--text-primary)'; + + if (tab === 'sessions') loadSessions(); + if (tab === 'memories') loadData(); + } + + // Sessions search + let sessionSearchQuery = ''; + let sessionSearchTimer = null; + function debounceSessionSearch() { + clearTimeout(sessionSearchTimer); + sessionSearchTimer = setTimeout(() => { + sessionSearchQuery = document.getElementById('session-search-input').value.trim(); + loadSessions(); + }, 300); + } + + // Session embedding management + async function loadSessionEmbeddingStats() { + try { + const res = await fetch('/api/sessions/embeddings/stats'); + const stats = await res.json(); + const el = document.getElementById('session-emb-stats'); + let totalAll = 0, totalWithEmb = 0; + const parts = []; + for (const [table, s] of Object.entries(stats)) { + const label = table === 'observations' ? 'Obs' : table === 'user_prompts' ? 'Prompts' : 'Summaries'; + totalAll += s.total; + totalWithEmb += s.withEmbedding; + const missing = s.total - s.withEmbedding; + const badge = missing > 0 + ? '' + s.withEmbedding + '/' + s.total + '' + : '' + s.total + ''; + parts.push(label + ': ' + badge); + } + const missingTotal = totalAll - totalWithEmb; + const status = missingTotal > 0 + ? '' + missingTotal + ' missing' + : 'All indexed'; + el.innerHTML = 'Vector index: ' + parts.join(' · ') + ' — ' + status; + } catch { /* ignore */ } + } + + async function generateSessionEmbeddings(mode) { + const el = document.getElementById('session-emb-stats'); + el.innerHTML = 'Generating embeddings...'; + try { + const res = await fetch('/api/sessions/embeddings/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: mode || 'missing' }), + }); + const result = await res.json(); + if (typeof showToast === 'function') { + showToast(result.success + ' embeddings generated', 'success'); + } + loadSessionEmbeddingStats(); + } catch (err) { + el.innerHTML = 'Error: ' + err.message + ''; + } + } + + // Sessions feed with pagination + const sessionPageSize = 30; + let sessionPage = 0; + + function sessionPrevPage() { if (sessionPage > 0) { sessionPage--; loadSessions(); } } + function sessionNextPage() { sessionPage++; loadSessions(); } + + async function loadSessions() { + const feed = document.getElementById('sessions-feed'); + const statsEl = document.getElementById('sessions-stats'); + const paginationEl = document.getElementById('session-pagination'); + loadSessionEmbeddingStats(); + + try { + let items = []; + let totalItems = 0; + + if (sessionSearchQuery) { + // Use hybrid search endpoint (no pagination for search — returns ranked results) + const searchType = document.getElementById('session-search-type').value; + const params = new URLSearchParams({ + q: sessionSearchQuery, + type: searchType, + limit: String(sessionPageSize), + }); + const res = await fetch('/api/sessions/search?' + params); + const results = await res.json(); + + statsEl.innerHTML = \` +
+
Search Results
+
\${results.length}
+
+ \`; + + // Map search results to timeline items (already have hasEmbedding from data) + for (const r of results) { + const d = r.data || {}; + const hasEmb = !!(d.hasEmbedding || (r.semanticScore && r.semanticScore > 0)); + if (r.table === 'observations') { + items.push({ + type: 'observation', time: r.time, sessionId: r.sessionId, score: r.score, hasEmbedding: hasEmb, + toolName: d.tool_name, title: d.title, obsType: d.type, promptNumber: d.prompt_number, + subtitle: d.subtitle, narrative: d.narrative, + }); + } else if (r.table === 'user_prompts') { + items.push({ + type: 'prompt', time: r.time, sessionId: r.sessionId, score: r.score, hasEmbedding: hasEmb, + promptNumber: d.prompt_number, text: d.prompt_text, + }); + } else if (r.table === 'session_summaries') { + items.push({ + type: 'summary', time: r.time, sessionId: r.sessionId, score: r.score, hasEmbedding: hasEmb, + request: d.request, completed: d.completed, filesModified: d.files_modified, + nextSteps: d.next_steps, notes: d.notes, + }); + } + } + totalItems = results.length; + paginationEl.innerHTML = ''; + } else { + // Browse mode with pagination + const offset = sessionPage * sessionPageSize; + const [sessRes, obsRes] = await Promise.all([ + fetch('/api/sessions?limit=' + sessionPageSize + '&offset=' + offset), + fetch('/api/observations?limit=' + sessionPageSize + '&offset=' + offset) + ]); + const data = await sessRes.json(); + const observations = await obsRes.json(); + + statsEl.innerHTML = \` +
+
Sessions
+
\${data.sessions?.length || 0}
+
+
+
User Prompts
+
\${data.prompts?.length || 0}
+
+
+
Summaries
+
\${data.summaries?.length || 0}
+
+
+
Observations
+
\${observations?.length || 0}
+
+ \`; + + for (const p of (data.prompts || [])) { + items.push({ type: 'prompt', time: p.created_at, sessionId: p.session_id, + promptNumber: p.prompt_number, text: p.prompt_text, project: p.project, + hasEmbedding: !!p.hasEmbedding }); + } + for (const s of (data.summaries || [])) { + items.push({ type: 'summary', time: s.created_at, sessionId: s.session_id, + request: s.request, completed: s.completed, filesModified: s.files_modified, + nextSteps: s.next_steps, notes: s.notes, project: s.project, + hasEmbedding: !!s.hasEmbedding }); + } + for (const o of observations) { + items.push({ type: 'observation', time: o.timestamp, sessionId: o.session_id, + toolName: o.tool_name, title: o.title, obsType: o.type, promptNumber: o.prompt_number, + subtitle: o.subtitle, narrative: o.narrative, concepts: o.concepts, + compressedSummary: o.compressed_summary, isCompressed: o.is_compressed, + hasEmbedding: !!o.hasEmbedding }); + } + totalItems = items.length; + + // Render pagination + const hasMore = observations.length === sessionPageSize || (data.prompts || []).length === sessionPageSize; + paginationEl.innerHTML = \` + + Page \${sessionPage + 1} + + \`; + } + + items.sort((a, b) => (b.time || 0) - (a.time || 0)); + + if (items.length === 0) { + feed.innerHTML = '
' + + (sessionSearchQuery ? 'No results for "' + escapeHtml(sessionSearchQuery) + '"' : 'No session data yet. Hook data will appear here after sessions run.') + '
'; + return; + } + + feed.innerHTML = items.map(item => { + const time = new Date(item.time).toLocaleString(); + const sid = (item.sessionId || '').substring(0, 8); + const scoreBadge = item.score !== undefined + ? '' + (item.score * 100).toFixed(0) + '%' + : ''; + const vecBadge = item.hasEmbedding + ? 'Vec' + : '--'; + + if (item.type === 'prompt') { + const promptId = 'prompt_' + Math.random().toString(36).slice(2, 8); + const promptText = item.text || ''; + const isLong = promptText.length > 200; + return \`
+
+ PROMPT #\${item.promptNumber || '?'} +
\${scoreBadge}\${vecBadge}
+
+
+ \${isLong ? '' + escapeHtml(truncate(promptText, 200)) + ' show more' : escapeHtml(promptText)} +
+
\`; + } + + if (item.type === 'summary') { + const sumId = 'sum_' + Math.random().toString(36).slice(2, 8); + let filesStr = ''; + try { filesStr = JSON.parse(item.filesModified || '[]').join(', '); } catch {} + const requestText = item.request || ''; + const completedText = item.completed || ''; + const isLong = requestText.length > 150 || completedText.length > 150 || filesStr.length > 150; + return \`
+
+ SUMMARY +
\${scoreBadge}\${vecBadge}
+
+
+ + \${requestText ? '
Request: ' + escapeHtml(truncate(requestText, 150)) + '
' : ''} + \${completedText ? '
Completed: ' + escapeHtml(truncate(completedText, 150)) + '
' : ''} + \${filesStr ? '
Files: ' + escapeHtml(truncate(filesStr, 100)) + '
' : ''} + \${isLong ? 'show more' : ''} +
+ +
+
\`; + } + + // observation + const icons = { + read: '', + write: '', + execute: '', + search: '' + }; + const icon = icons[item.obsType] || ''; + const titleText = item.compressedSummary || item.title || ''; + const subtitleText = item.subtitle ? escapeHtml(item.subtitle) : ''; + const narrativeText = item.narrative ? escapeHtml(item.narrative) : ''; + let intentBadges = ''; + try { + const concepts = JSON.parse(item.concepts || '[]'); + const intents = concepts.filter(c => c.startsWith('intent:')).map(c => c.slice(7)); + if (intents.length > 0) intentBadges = ' [' + intents.join(', ') + ']'; + } catch {} + const hasDetails = subtitleText || narrativeText; + const obsId = 'obs_' + Math.random().toString(36).slice(2, 8); + return \`
+
+ \${icon} \${escapeHtml(item.toolName || '')} \${escapeHtml(titleText)}\${intentBadges}\${scoreBadge} + \${vecBadge} \${time}\${item.promptNumber ? ' · P#' + item.promptNumber : ''}\${item.isCompressed ? ' · Z' : ''} +
+ \${hasDetails ? '' : ''} +
\`; + }).join(''); + + } catch (err) { + feed.innerHTML = '
Error loading sessions: ' + err.message + '
'; + } + } + + function toggleObsDetail(id) { + const el = document.getElementById(id); + if (el) el.style.display = el.style.display === 'none' ? '' : 'none'; + } + + function toggleExpand(id) { + const short = document.getElementById(id + '_short'); + const full = document.getElementById(id + '_full'); + if (short && full) { + const showFull = short.style.display !== 'none'; + short.style.display = showFull ? 'none' : ''; + full.style.display = showFull ? '' : 'none'; + } + } + + function truncate(text, max = 200) { + if (!text || text.length <= max) return text || ''; + return text.substring(0, max) + '…'; + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + loadSessions(); `; @@ -1723,7 +2387,7 @@ function handleRequest( try { const embeddingsService = await getEmbeddingsService(); const result = await embeddingsService.embed(data.content); - embeddingBuffer = Buffer.from(result.embedding.buffer); + embeddingBuffer = Buffer.from(result.embedding); } catch (e) { console.warn('[WebViewer] Failed to generate embedding:', e); } @@ -1825,7 +2489,7 @@ function handleRequest( try { const embeddingsService = await getEmbeddingsService(); const result = await embeddingsService.embed(data.content); - embeddingBuffer = Buffer.from(result.embedding.buffer); + embeddingBuffer = Buffer.from(result.embedding); } catch (e) { console.warn('[WebViewer] Failed to generate embedding:', e); } @@ -1914,7 +2578,7 @@ function handleRequest( for (const entry of entries) { try { const result = await embeddingsService.embed(entry.content); - const embeddingBuffer = Buffer.from(result.embedding.buffer); + const embeddingBuffer = Buffer.from(result.embedding); updateStmt.run(embeddingBuffer, Date.now(), entry.id); success++; } catch (e) { @@ -1938,6 +2602,317 @@ function handleRequest( return; } + // GET session hybrid search + if (url.pathname === '/api/sessions/search' && method === 'GET') { + const query = url.searchParams.get('q') || ''; + const searchType = (url.searchParams.get('type') || 'hybrid') as 'hybrid' | 'text' | 'vector'; + const limit = parseInt(url.searchParams.get('limit') || '30', 10); + + searchSessionsHybrid(db, query, { type: searchType, limit }) + .then((results) => { + res.writeHead(200); + res.end(JSON.stringify(results)); + }) + .catch((error) => { + res.writeHead(500); + res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Search failed' })); + }); + return; + } + + // GET session embeddings stats + if (url.pathname === '/api/sessions/embeddings/stats' && method === 'GET') { + try { + const stats: Record = {}; + for (const table of ['observations', 'user_prompts', 'session_summaries'] as const) { + const total = (db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number }).c; + const withEmb = (db.prepare(`SELECT COUNT(*) as c FROM ${table} WHERE embedding IS NOT NULL AND LENGTH(embedding) > 0`).get() as { c: number }).c; + stats[table] = { total, withEmbedding: withEmb }; + } + res.writeHead(200); + res.end(JSON.stringify(stats)); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + // POST generate session embeddings + if (url.pathname === '/api/sessions/embeddings/generate' && method === 'POST') { + readBody(req).then(async (body) => { + try { + const opts = JSON.parse(body || '{}') as { mode?: 'missing' | 'all' }; + const mode = opts.mode || 'missing'; + const embeddingsService = await getEmbeddingsService(); + + let totalSuccess = 0, totalFailed = 0; + const tableConfigs = [ + { name: 'observations' as const, idCol: 'id' }, + { name: 'user_prompts' as const, idCol: 'id' }, + { name: 'session_summaries' as const, idCol: 'id' }, + ]; + + for (const { name, idCol } of tableConfigs) { + const where = mode === 'missing' ? 'WHERE embedding IS NULL OR LENGTH(embedding) = 0' : ''; + const rows = db.prepare(`SELECT * FROM ${name} ${where}`).all() as Record[]; + const updateStmt = db.prepare(`UPDATE ${name} SET embedding = ? WHERE ${idCol} = ?`); + + for (const row of rows) { + const text = getSessionEmbeddingText(name, row); + if (!text) { totalFailed++; continue; } + try { + const result = await embeddingsService.embed(text); + const buffer = Buffer.from(result.embedding); + updateStmt.run(buffer, row[idCol]); + totalSuccess++; + } catch { totalFailed++; } + } + } + + res.writeHead(200); + res.end(JSON.stringify({ + processed: totalSuccess + totalFailed, + success: totalSuccess, + failed: totalFailed, + })); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Generation failed' })); + } + }).catch(() => { + res.writeHead(400); + res.end(JSON.stringify({ error: 'Invalid request body' })); + }); + return; + } + + // GET sessions data (sessions, prompts, summaries) - all in memory.db now + if (url.pathname === '/api/sessions' && method === 'GET') { + const limit = parseInt(url.searchParams.get('limit') || '20', 10); + const offset = parseInt(url.searchParams.get('offset') || '0', 10); + const query = url.searchParams.get('q') || ''; + + // Strip embedding BLOBs and add hasEmbedding flag + const stripEmb = (rows: Record[]) => + rows.map(r => ({ ...r, hasEmbedding: !!(r.embedding && (r.embedding as Buffer).length > 0), embedding: undefined })); + + try { + let sessions: Record[]; + if (query) { + const pattern = `%${query}%`; + sessions = db.prepare(` + SELECT * FROM sessions WHERE session_id LIKE ? OR project LIKE ? OR prompt LIKE ? OR summary LIKE ? + ORDER BY started_at DESC LIMIT ? OFFSET ? + `).all(pattern, pattern, pattern, pattern, limit, offset) as Record[]; + } else { + sessions = db.prepare(` + SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ? + `).all(limit, offset) as Record[]; + } + + // user_prompts + let prompts: Record[] = []; + try { + if (query) { + const pattern = `%${query}%`; + prompts = stripEmb(db.prepare(` + SELECT up.*, s.project FROM user_prompts up + JOIN sessions s ON s.session_id = up.session_id + WHERE up.prompt_text LIKE ? + ORDER BY up.created_at DESC LIMIT ? OFFSET ? + `).all(pattern, limit, offset) as Record[]); + } else { + prompts = stripEmb(db.prepare(` + SELECT up.*, s.project FROM user_prompts up + JOIN sessions s ON s.session_id = up.session_id + ORDER BY up.created_at DESC LIMIT ? OFFSET ? + `).all(limit, offset) as Record[]); + } + } catch { /* table may not exist */ } + + // session_summaries + let summaries: Record[] = []; + try { + if (query) { + const pattern = `%${query}%`; + summaries = stripEmb(db.prepare(` + SELECT * FROM session_summaries WHERE request LIKE ? OR completed LIKE ? OR notes LIKE ? OR next_steps LIKE ? + ORDER BY created_at DESC LIMIT ? OFFSET ? + `).all(pattern, pattern, pattern, pattern, limit, offset) as Record[]); + } else { + summaries = stripEmb(db.prepare(` + SELECT * FROM session_summaries ORDER BY created_at DESC LIMIT ? OFFSET ? + `).all(limit, offset) as Record[]); + } + } catch { /* table may not exist */ } + + res.writeHead(200); + res.end(JSON.stringify({ sessions, prompts, summaries })); + } catch (error) { + res.writeHead(200); + res.end(JSON.stringify({ sessions: [], prompts: [], summaries: [], error: String(error) })); + } + return; + } + + // GET observations from memory.db + if (url.pathname === '/api/observations' && method === 'GET') { + const limit = parseInt(url.searchParams.get('limit') || '50', 10); + const offset = parseInt(url.searchParams.get('offset') || '0', 10); + const sessionId = url.searchParams.get('session_id') || undefined; + const query = url.searchParams.get('q') || ''; + + // Strip embedding BLOBs and add hasEmbedding flag + const stripEmb = (rows: Record[]) => + rows.map(r => ({ ...r, hasEmbedding: !!(r.embedding && (r.embedding as Buffer).length > 0), embedding: undefined })); + + try { + let rows: Record[]; + if (query) { + const pattern = `%${query}%`; + if (sessionId) { + rows = db.prepare(` + SELECT * FROM observations WHERE session_id = ? AND (tool_name LIKE ? OR title LIKE ? OR subtitle LIKE ? OR narrative LIKE ?) + ORDER BY timestamp DESC LIMIT ? OFFSET ? + `).all(sessionId, pattern, pattern, pattern, pattern, limit, offset) as Record[]; + } else { + rows = db.prepare(` + SELECT * FROM observations WHERE tool_name LIKE ? OR title LIKE ? OR subtitle LIKE ? OR narrative LIKE ? + ORDER BY timestamp DESC LIMIT ? OFFSET ? + `).all(pattern, pattern, pattern, pattern, limit, offset) as Record[]; + } + } else if (sessionId) { + rows = db.prepare(` + SELECT * FROM observations WHERE session_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ? + `).all(sessionId, limit, offset) as Record[]; + } else { + rows = db.prepare(` + SELECT * FROM observations ORDER BY timestamp DESC LIMIT ? OFFSET ? + `).all(limit, offset) as Record[]; + } + res.writeHead(200); + res.end(JSON.stringify(stripEmb(rows))); + } catch { + res.writeHead(200); + res.end(JSON.stringify([])); + } + return; + } + + // ===== Hook API Endpoints ===== + + // GET /api/hook/sessions - List hook sessions + if (url.pathname === '/api/hook/sessions' && method === 'GET') { + const project = url.searchParams.get('project') || undefined; + const limit = parseInt(url.searchParams.get('limit') || '20', 10); + try { + let rows: Record[]; + if (project) { + rows = db.prepare( + 'SELECT * FROM sessions WHERE project = ? ORDER BY started_at DESC LIMIT ?' + ).all(project, limit) as Record[]; + } else { + rows = db.prepare( + 'SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?' + ).all(limit) as Record[]; + } + res.writeHead(200); + res.end(JSON.stringify(rows)); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + // GET /api/hook/observations - List hook observations + if (url.pathname === '/api/hook/observations' && method === 'GET') { + const project = url.searchParams.get('project') || undefined; + const limit = parseInt(url.searchParams.get('limit') || '50', 10); + try { + let rows: Record[]; + if (project) { + rows = db.prepare( + 'SELECT id, session_id, project, tool_name, timestamp, type, title, subtitle, narrative, facts, concepts, prompt_number, compressed_summary, is_compressed FROM observations WHERE project = ? ORDER BY timestamp DESC LIMIT ?' + ).all(project, limit) as Record[]; + } else { + rows = db.prepare( + 'SELECT id, session_id, project, tool_name, timestamp, type, title, subtitle, narrative, facts, concepts, prompt_number, compressed_summary, is_compressed FROM observations ORDER BY timestamp DESC LIMIT ?' + ).all(limit) as Record[]; + } + res.writeHead(200); + res.end(JSON.stringify(rows)); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + // GET /api/hook/session/:id - Session detail with observations and prompts + if (url.pathname.startsWith('/api/hook/session/') && method === 'GET') { + const sessionId = url.pathname.slice('/api/hook/session/'.length); + try { + const session = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(sessionId); + if (!session) { + res.writeHead(404); + res.end(JSON.stringify({ error: 'Session not found' })); + return; + } + const observations = db.prepare( + 'SELECT id, tool_name, timestamp, type, title, subtitle, narrative, facts, concepts, prompt_number, compressed_summary, is_compressed FROM observations WHERE session_id = ? ORDER BY timestamp ASC' + ).all(sessionId); + const prompts = db.prepare( + 'SELECT * FROM user_prompts WHERE session_id = ? ORDER BY prompt_number ASC' + ).all(sessionId); + const summary = db.prepare( + 'SELECT * FROM session_summaries WHERE session_id = ? ORDER BY created_at DESC LIMIT 1' + ).get(sessionId); + + res.writeHead(200); + res.end(JSON.stringify({ session, observations, prompts, summary })); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + // GET /api/hook/queue/status - Task queue stats + if (url.pathname === '/api/hook/queue/status' && method === 'GET') { + try { + const stats = db.prepare(` + SELECT task_type, status, COUNT(*) as count + FROM task_queue + GROUP BY task_type, status + `).all(); + const total = (db.prepare('SELECT COUNT(*) as c FROM task_queue').get() as { c: number }).c; + res.writeHead(200); + res.end(JSON.stringify({ total, breakdown: stats })); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + // POST /api/hook/cleanup - Clean old completed/failed queue tasks + if (url.pathname === '/api/hook/cleanup' && method === 'POST') { + try { + const oneDayAgo = Date.now() - 86400000; + const result = db.prepare( + "DELETE FROM task_queue WHERE status IN ('completed', 'failed') OR (status = 'processing' AND created_at < ?)" + ).run(oneDayAgo); + res.writeHead(200); + res.end(JSON.stringify({ deleted: result.changes })); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' })); } catch (error) { @@ -1954,3 +2929,14 @@ server.listen(PORT, () => { console.log(` Database: ${dbPath}\n`); console.log(` Press Ctrl+C to stop\n`); }); + +// Graceful shutdown: close server, DB, and embeddings service on SIGINT/SIGTERM +function cleanup() { + server.close(); + if (_db) { try { _db.close(); } catch { /* ignore */ } _db = null; } + if (_embeddingsService) { _embeddingsService = null; } + if (_searchEngine) { _searchEngine = null; } + process.exit(0); +} +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); diff --git a/src/embeddings/__tests__/embedding-subprocess.test.ts b/src/embeddings/__tests__/embedding-subprocess.test.ts new file mode 100644 index 0000000..c892eed --- /dev/null +++ b/src/embeddings/__tests__/embedding-subprocess.test.ts @@ -0,0 +1,355 @@ +/** + * Embedding Subprocess Tests + * + * Tests for the subprocess-based embedding service. + * Uses mock provider to avoid downloading the real model. + * + * @module @aitytech/agentkits-memory/embeddings/__tests__/embedding-subprocess.test + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { EmbeddingSubprocess } from '../embedding-subprocess.js'; +import { fork } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +// Mock child_process.fork to avoid spawning real processes +vi.mock('node:child_process', () => ({ + fork: vi.fn(), +})); + +const mockFork = vi.mocked(fork); + +/** + * Create a mock child process that emits events + */ +function createMockChild() { + const child = new EventEmitter() as EventEmitter & { + send: ReturnType; + kill: ReturnType; + }; + child.send = vi.fn(); + child.kill = vi.fn(); + return child; +} + +describe('EmbeddingSubprocess', () => { + let subprocess: EmbeddingSubprocess; + let mockChild: ReturnType; + + afterEach(async () => { + if (subprocess) { + await subprocess.shutdown(); + subprocess = undefined as any; + } + vi.restoreAllMocks(); + // Re-mock fork after restore + mockFork.mockReset(); + }); + + describe('spawn', () => { + it('should fork the embedding worker process', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test-cache' }); + subprocess.spawn(); + + expect(mockFork).toHaveBeenCalledTimes(1); + expect(mockFork).toHaveBeenCalledWith( + expect.stringContaining('embedding-worker.js'), + ['/tmp/test-cache'], + expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe', 'ipc'] }), + ); + }); + + it('should not double-spawn', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + subprocess.spawn(); + + expect(mockFork).toHaveBeenCalledTimes(1); + }); + }); + + describe('ready state', () => { + it('should mark as ready when worker sends ready message', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + expect(subprocess.isReady()).toBe(false); + + // Emit ready — the subprocess registered listener on the fork result + mockChild.emit('message', { type: 'ready' }); + + expect(subprocess.isReady()).toBe(true); + }); + + it('should verify fork returns our mock', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + // Verify fork was called and our mock has listeners + expect(mockChild.listenerCount('message')).toBeGreaterThan(0); + }); + }); + + describe('embed', () => { + it('should send embed request when worker is ready', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const embedPromise = subprocess.embed('test text'); + + // Worker should receive the request + expect(mockChild.send).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'embed', + text: 'test text', + }), + ); + + // Simulate worker response + const callArgs = mockChild.send.mock.calls[0][0] as { id: string }; + mockChild.emit('message', { + type: 'embed_result', + id: callArgs.id, + embedding: Array.from(new Float32Array(384).fill(0.1)), + timeMs: 50, + cached: false, + }); + + const result = await embedPromise; + + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(384); + }); + + it('should queue requests when worker is not ready', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + // Embed before ready — should be queued + const embedPromise = subprocess.embed('queued text'); + + // No message sent yet (queued) + expect(mockChild.send).not.toHaveBeenCalled(); + + // Worker becomes ready — queue should drain + mockChild.emit('message', { type: 'ready' }); + + expect(mockChild.send).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'embed', + text: 'queued text', + }), + ); + + // Simulate response + const callArgs = mockChild.send.mock.calls[0][0] as { id: string }; + mockChild.emit('message', { + type: 'embed_result', + id: callArgs.id, + embedding: Array.from(new Float32Array(384).fill(0.2)), + timeMs: 30, + cached: false, + }); + + const result = await embedPromise; + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(384); + }); + + it('should fall back to mock embedding on request timeout', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ + cacheDir: '/tmp/test', + requestTimeout: 50, // 50ms timeout for test + }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const result = await subprocess.embed('timeout text'); + + // Should get a mock embedding (non-zero, deterministic) + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(384); + // Mock embeddings are normalized, so magnitude should be ~1 + let magnitude = 0; + for (const v of result) magnitude += v * v; + expect(Math.sqrt(magnitude)).toBeCloseTo(1, 1); + }); + + it('should fall back to mock on worker error response', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const embedPromise = subprocess.embed('error text'); + + const callArgs = mockChild.send.mock.calls[0][0] as { id: string }; + mockChild.emit('message', { + type: 'error', + id: callArgs.id, + message: 'Embed failed', + }); + + const result = await embedPromise; + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(384); + }); + }); + + describe('getGenerator', () => { + it('should return an EmbeddingGenerator function', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + const generator = subprocess.getGenerator(); + + expect(typeof generator).toBe('function'); + }); + }); + + describe('respawn', () => { + it('should respawn worker on unexpected exit', () => { + mockChild = createMockChild(); + const secondChild = createMockChild(); + mockFork.mockReturnValueOnce(mockChild as any).mockReturnValueOnce(secondChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + expect(mockFork).toHaveBeenCalledTimes(1); + + // Simulate crash + mockChild.emit('exit', 1); + + expect(mockFork).toHaveBeenCalledTimes(2); + }); + + it('should stop respawning after max attempts', () => { + const children = Array.from({ length: 4 }, () => createMockChild()); + for (const c of children) mockFork.mockReturnValueOnce(c as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); // spawn #1 + + children[0].emit('exit', 1); // respawn #1 + children[1].emit('exit', 1); // respawn #2 + children[2].emit('exit', 1); // should NOT respawn (max 2 respawns) + + expect(mockFork).toHaveBeenCalledTimes(3); // initial + 2 respawns + }); + }); + + describe('shutdown', () => { + it('should clean up on shutdown', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + mockChild.emit('message', { type: 'ready' }); + + expect(subprocess.isReady()).toBe(true); + + await subprocess.shutdown(); + + expect(subprocess.isReady()).toBe(false); + }); + + it('should not respawn after shutdown', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + subprocess.shutdown(); + + mockChild.emit('exit', 0); + + // Should not have spawned a second time + expect(mockFork).toHaveBeenCalledTimes(1); + }); + }); + + describe('mock embedding consistency', () => { + it('should produce deterministic mock embeddings for same text', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ + cacheDir: '/tmp/test', + requestTimeout: 10, + }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const result1 = await subprocess.embed('deterministic test'); + const result2 = await subprocess.embed('deterministic test'); + + expect(Array.from(result1)).toEqual(Array.from(result2)); + }); + + it('should produce different mock embeddings for different text', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ + cacheDir: '/tmp/test', + requestTimeout: 10, + }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const result1 = await subprocess.embed('text one'); + const result2 = await subprocess.embed('text two'); + + expect(Array.from(result1)).not.toEqual(Array.from(result2)); + }); + }); + + describe('custom dimensions', () => { + it('should use custom dimensions for mock fallback', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ + cacheDir: '/tmp/test', + dimensions: 128, + requestTimeout: 10, + }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const result = await subprocess.embed('custom dims'); + + expect(result.length).toBe(128); + }); + }); +}); diff --git a/src/embeddings/embedding-subprocess.ts b/src/embeddings/embedding-subprocess.ts new file mode 100644 index 0000000..94f824c --- /dev/null +++ b/src/embeddings/embedding-subprocess.ts @@ -0,0 +1,305 @@ +/** + * Embedding Subprocess Client + * + * Manages a child process that runs the embedding model. + * MCP server uses this for non-blocking embeddings — the server + * starts instantly while the model loads in the background. + * + * Provides the standard EmbeddingGenerator interface. + * Falls back to mock embeddings on timeout or worker failure. + * + * @module @aitytech/agentkits-memory/embeddings/embedding-subprocess + */ + +import { fork, type ChildProcess } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import * as path from 'node:path'; +import type { EmbeddingGenerator } from '../types.js'; + +/** + * Configuration for the embedding subprocess + */ +export interface EmbeddingSubprocessConfig { + /** Cache directory for the embedding model */ + cacheDir: string; + /** Vector dimensions (default: 384) */ + dimensions?: number; + /** Timeout for worker initialization in ms (default: 60000) */ + initTimeout?: number; + /** Timeout for individual embed requests in ms (default: 30000) */ + requestTimeout?: number; +} + +// IPC message types +interface EmbedResultMessage { + type: 'embed_result'; + id: string; + embedding: number[]; + timeMs: number; + cached: boolean; +} + +interface ReadyMessage { + type: 'ready'; +} + +interface ErrorMessage { + type: 'error'; + id?: string; + message: string; +} + +type WorkerMessage = EmbedResultMessage | ReadyMessage | ErrorMessage; + +interface PendingRequest { + resolve: (embedding: Float32Array) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +/** + * Deterministic mock embedding for fallback. + * Matches the mock in LocalEmbeddingsService for consistency. + */ +function createMockEmbedding(text: string, dimensions: number): Float32Array { + const embedding = new Float32Array(dimensions); + let hash = 0; + for (let i = 0; i < text.length; i++) { + hash = ((hash << 5) - hash) + text.charCodeAt(i); + hash = hash & hash; + } + for (let i = 0; i < dimensions; i++) { + hash = ((hash << 5) - hash) + i; + hash = hash & hash; + embedding[i] = (hash % 1000) / 1000 - 0.5; + } + let norm = 0; + for (let i = 0; i < dimensions; i++) { + norm += embedding[i] * embedding[i]; + } + norm = Math.sqrt(norm); + if (norm > 0) { + for (let i = 0; i < dimensions; i++) { + embedding[i] /= norm; + } + } + return embedding; +} + +/** + * Embedding subprocess client. + * + * Spawns a child process that loads the embedding model. + * Requests are queued until the worker is ready. + * Falls back to mock embeddings on timeout. + */ +export class EmbeddingSubprocess { + private child: ChildProcess | null = null; + private ready = false; + private pending = new Map(); + private queue: Array<{ id: string; text: string }> = []; + private requestCounter = 0; + private respawnCount = 0; + private shuttingDown = false; + private initTimer: ReturnType | null = null; + + private readonly dimensions: number; + private readonly cacheDir: string; + private readonly initTimeout: number; + private readonly requestTimeout: number; + private readonly maxRespawns = 2; + + constructor(config: EmbeddingSubprocessConfig) { + this.cacheDir = config.cacheDir; + this.dimensions = config.dimensions ?? 384; + this.initTimeout = config.initTimeout ?? 60_000; + this.requestTimeout = config.requestTimeout ?? 30_000; + } + + /** + * Spawn the embedding worker process. Returns immediately. + * The worker loads the model in the background. + */ + spawn(): void { + if (this.child || this.shuttingDown) return; + + const workerPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'embedding-worker.js', + ); + + this.child = fork(workerPath, [this.cacheDir], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }); + + this.child.on('message', (msg: WorkerMessage) => { + this.handleMessage(msg); + }); + + this.child.on('exit', () => { + this.child = null; + this.ready = false; + + if (!this.shuttingDown && this.respawnCount < this.maxRespawns) { + this.respawnCount++; + this.spawn(); + } else { + // Worker dead, resolve all pending with mock + this.resolveAllWithMock('Worker exited'); + } + }); + + this.child.on('error', () => { + // Handled by 'exit' event + }); + + // Init timeout — if worker doesn't become ready, fall back to mock + this.initTimer = setTimeout(() => { + if (!this.ready) { + this.resolveAllWithMock('Init timeout'); + // Keep the worker alive — it may still become ready later + } + }, this.initTimeout); + } + + /** + * Handle IPC message from worker + */ + private handleMessage(msg: WorkerMessage): void { + if (msg.type === 'ready') { + this.ready = true; + if (this.initTimer) { + clearTimeout(this.initTimer); + this.initTimer = null; + } + this.drainQueue(); + return; + } + + if (msg.type === 'embed_result') { + const req = this.pending.get(msg.id); + if (req) { + clearTimeout(req.timer); + this.pending.delete(msg.id); + req.resolve(new Float32Array(msg.embedding)); + } + return; + } + + if (msg.type === 'error' && msg.id) { + const req = this.pending.get(msg.id); + if (req) { + clearTimeout(req.timer); + this.pending.delete(msg.id); + // Fall back to mock instead of rejecting + req.resolve(createMockEmbedding(msg.id, this.dimensions)); + } + } + } + + /** + * Send all queued requests to the worker + */ + private drainQueue(): void { + while (this.queue.length > 0) { + const item = this.queue.shift()!; + this.child?.send({ type: 'embed', id: item.id, text: item.text }); + } + } + + /** + * Resolve all pending and queued requests with mock embeddings + */ + private resolveAllWithMock(_reason: string): void { + // Resolve queued items + for (const item of this.queue) { + const req = this.pending.get(item.id); + if (req) { + clearTimeout(req.timer); + this.pending.delete(item.id); + req.resolve(createMockEmbedding(item.text, this.dimensions)); + } + } + this.queue = []; + + // Resolve remaining pending + for (const [id, req] of this.pending) { + clearTimeout(req.timer); + req.resolve(createMockEmbedding(id, this.dimensions)); + } + this.pending.clear(); + } + + /** + * Generate embedding for text. + * Queues the request if worker is not ready yet. + * Falls back to mock on timeout. + */ + async embed(text: string): Promise { + const id = String(this.requestCounter++); + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(id); + resolve(createMockEmbedding(text, this.dimensions)); + }, this.requestTimeout); + + this.pending.set(id, { resolve, reject: () => {}, timer }); + + if (this.ready && this.child) { + this.child.send({ type: 'embed', id, text }); + } else { + this.queue.push({ id, text }); + } + }); + } + + /** + * Get an EmbeddingGenerator function compatible with ProjectMemoryService + */ + getGenerator(): EmbeddingGenerator { + return (content: string) => this.embed(content); + } + + /** + * Whether the worker is ready to process requests + */ + isReady(): boolean { + return this.ready; + } + + /** + * Shutdown the worker gracefully + */ + async shutdown(): Promise { + this.shuttingDown = true; + + if (this.initTimer) { + clearTimeout(this.initTimer); + this.initTimer = null; + } + + // Clear pending requests + for (const [, req] of this.pending) { + clearTimeout(req.timer); + } + this.pending.clear(); + this.queue = []; + + if (this.child) { + try { + this.child.send({ type: 'shutdown' }); + } catch { + // IPC may already be closed + } + // Force kill after grace period + const child = this.child; + setTimeout(() => { + try { child.kill(); } catch { /* already dead */ } + }, 1000); + this.child = null; + } + + this.ready = false; + } +} diff --git a/src/embeddings/embedding-worker.ts b/src/embeddings/embedding-worker.ts new file mode 100644 index 0000000..8a8d79d --- /dev/null +++ b/src/embeddings/embedding-worker.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Embedding Worker Process + * + * Runs as a child process spawned by EmbeddingSubprocess. + * Loads the ML model once and handles embed requests via Node IPC. + * + * Usage: fork('embedding-worker.js', [cacheDir]) + * + * @module @aitytech/agentkits-memory/embeddings/embedding-worker + */ + +import { LocalEmbeddingsService } from './local-embeddings.js'; + +// IPC message types (worker → parent) +interface ReadyMessage { + type: 'ready'; +} + +interface EmbedResultMessage { + type: 'embed_result'; + id: string; + embedding: number[]; + timeMs: number; + cached: boolean; +} + +interface ErrorMessage { + type: 'error'; + id?: string; + message: string; +} + +// IPC message types (parent → worker) +interface EmbedRequest { + type: 'embed'; + id: string; + text: string; +} + +interface ShutdownRequest { + type: 'shutdown'; +} + +type ParentMessage = EmbedRequest | ShutdownRequest; +type WorkerResponse = ReadyMessage | EmbedResultMessage | ErrorMessage; + +function send(msg: WorkerResponse): void { + if (process.send) { + process.send(msg); + } +} + +async function main(): Promise { + const cacheDir = process.argv[2] || ''; + + const service = new LocalEmbeddingsService({ + cacheDir, + cacheEnabled: true, + }); + + try { + await service.initialize(); + send({ type: 'ready' }); + } catch (error) { + send({ + type: 'error', + message: `Init failed: ${error instanceof Error ? error.message : String(error)}`, + }); + // Still send ready — LocalEmbeddingsService falls back to mock internally + send({ type: 'ready' }); + } + + process.on('message', async (msg: ParentMessage) => { + if (msg.type === 'shutdown') { + await service.shutdown(); + process.exit(0); + } + + if (msg.type === 'embed') { + try { + const result = await service.embed(msg.text); + send({ + type: 'embed_result', + id: msg.id, + embedding: Array.from(result.embedding), + timeMs: result.timeMs, + cached: result.cached, + }); + } catch (error) { + send({ + type: 'error', + id: msg.id, + message: error instanceof Error ? error.message : String(error), + }); + } + } + }); +} + +main().catch((error) => { + send({ + type: 'error', + message: `Worker fatal: ${error instanceof Error ? error.message : String(error)}`, + }); + process.exit(1); +}); diff --git a/src/embeddings/index.ts b/src/embeddings/index.ts index 145bfdf..cfd000f 100644 --- a/src/embeddings/index.ts +++ b/src/embeddings/index.ts @@ -18,3 +18,5 @@ export { } from './local-embeddings.js'; export { PersistentEmbeddingCache, createPersistentEmbeddingCache } from './embedding-cache.js'; + +export { EmbeddingSubprocess, type EmbeddingSubprocessConfig } from './embedding-subprocess.js'; diff --git a/src/hooks/__tests__/adapters.test.ts b/src/hooks/__tests__/adapters.test.ts new file mode 100644 index 0000000..3b1fe62 --- /dev/null +++ b/src/hooks/__tests__/adapters.test.ts @@ -0,0 +1,399 @@ +/** + * Tests for Hook Platform Adapters + * + * @module @agentkits/memory/hooks/__tests__/adapters.test + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ClaudeCodeAdapter } from '../adapters/claude-code-adapter.js'; +import { OpenCodeAdapter } from '../adapters/opencode-adapter.js'; +import { GenericAdapter } from '../adapters/generic-adapter.js'; +import { resolveAdapter } from '../adapters/platform-adapter.js'; +import type { HookResult } from '../types.js'; + +describe('Platform Adapters', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + // ===== resolveAdapter ===== + + describe('resolveAdapter', () => { + beforeEach(() => { + delete process.env.AGENTKITS_PLATFORM; + }); + + it('should default to claude-code when no env var', () => { + const adapter = resolveAdapter(); + expect(adapter.name).toBe('claude-code'); + }); + + it('should resolve opencode from env var', () => { + process.env.AGENTKITS_PLATFORM = 'opencode'; + const adapter = resolveAdapter(); + expect(adapter.name).toBe('opencode'); + }); + + it('should resolve generic from env var', () => { + process.env.AGENTKITS_PLATFORM = 'generic'; + const adapter = resolveAdapter(); + expect(adapter.name).toBe('generic'); + }); + + it('should resolve claude-code from env var', () => { + process.env.AGENTKITS_PLATFORM = 'claude-code'; + const adapter = resolveAdapter(); + expect(adapter.name).toBe('claude-code'); + }); + + it('should fall back to claude-code for unknown platform', () => { + process.env.AGENTKITS_PLATFORM = 'unknown-platform'; + const adapter = resolveAdapter(); + expect(adapter.name).toBe('claude-code'); + }); + }); + + // ===== ClaudeCodeAdapter ===== + + describe('ClaudeCodeAdapter', () => { + const adapter = new ClaudeCodeAdapter(); + + it('should have name claude-code', () => { + expect(adapter.name).toBe('claude-code'); + }); + + it('should support all 5 events', () => { + expect(adapter.supportedEvents).toContain('context'); + expect(adapter.supportedEvents).toContain('session-init'); + expect(adapter.supportedEvents).toContain('observation'); + expect(adapter.supportedEvents).toContain('summarize'); + expect(adapter.supportedEvents).toContain('user-message'); + }); + + describe('parseInput', () => { + it('should parse valid Claude Code JSON', () => { + const input = JSON.stringify({ + session_id: 'test-session', + cwd: '/test/project', + prompt: 'Hello world', + tool_name: 'Edit', + tool_input: { file_path: '/test/file.ts' }, + tool_result: 'success', + transcript_path: '/test/transcript.jsonl', + stop_reason: 'user', + }); + + const result = adapter.parseInput(input); + expect(result.sessionId).toBe('test-session'); + expect(result.cwd).toBe('/test/project'); + expect(result.project).toBe('project'); + expect(result.prompt).toBe('Hello world'); + expect(result.toolName).toBe('Edit'); + expect(result.toolInput).toEqual({ file_path: '/test/file.ts' }); + expect(result.toolResponse).toBe('success'); + expect(result.transcriptPath).toBe('/test/transcript.jsonl'); + expect(result.stopReason).toBe('user'); + expect(result.timestamp).toBeGreaterThan(0); + }); + + it('should generate session ID when missing', () => { + const result = adapter.parseInput(JSON.stringify({ cwd: '/test' })); + expect(result.sessionId).toMatch(/^session_/); + }); + + it('should use process.cwd() when cwd missing', () => { + const result = adapter.parseInput(JSON.stringify({ session_id: 'x' })); + expect(result.cwd).toBe(process.cwd()); + }); + + it('should handle invalid JSON gracefully', () => { + const result = adapter.parseInput('not json'); + expect(result.sessionId).toMatch(/^session_/); + expect(result.cwd).toBe(process.cwd()); + }); + + it('should handle empty string', () => { + const result = adapter.parseInput(''); + expect(result.sessionId).toMatch(/^session_/); + }); + }); + + describe('formatOutput', () => { + it('should format result with additionalContext as SessionStart', () => { + const result: HookResult = { + continue: true, + suppressOutput: false, + additionalContext: 'Memory context here', + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.hookSpecificOutput).toBeDefined(); + expect(output.hookSpecificOutput.hookEventName).toBe('SessionStart'); + expect(output.hookSpecificOutput.additionalContext).toBe('Memory context here'); + }); + + it('should format standard response without context', () => { + const result: HookResult = { + continue: true, + suppressOutput: true, + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.continue).toBe(true); + expect(output.suppressOutput).toBe(true); + expect(output.hookSpecificOutput).toBeUndefined(); + }); + }); + }); + + // ===== OpenCodeAdapter ===== + + describe('OpenCodeAdapter', () => { + const adapter = new OpenCodeAdapter(); + + it('should have name opencode', () => { + expect(adapter.name).toBe('opencode'); + }); + + it('should support 4 events (no user-message)', () => { + expect(adapter.supportedEvents).toContain('context'); + expect(adapter.supportedEvents).toContain('session-init'); + expect(adapter.supportedEvents).toContain('observation'); + expect(adapter.supportedEvents).toContain('summarize'); + expect(adapter.supportedEvents).not.toContain('user-message'); + }); + + describe('parseInput', () => { + it('should parse same format as Claude Code', () => { + const input = JSON.stringify({ + session_id: 'oc-session', + cwd: '/test/oc', + prompt: 'Test prompt', + tool_name: 'Bash', + }); + + const result = adapter.parseInput(input); + expect(result.sessionId).toBe('oc-session'); + expect(result.cwd).toBe('/test/oc'); + expect(result.prompt).toBe('Test prompt'); + expect(result.toolName).toBe('Bash'); + }); + + it('should handle invalid JSON', () => { + const result = adapter.parseInput('broken'); + expect(result.sessionId).toMatch(/^session_/); + }); + }); + + describe('formatOutput', () => { + it('should include additionalContext at top level', () => { + const result: HookResult = { + continue: true, + suppressOutput: false, + additionalContext: 'context text', + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.continue).toBe(true); + expect(output.additionalContext).toBe('context text'); + // OpenCode doesn't use hookSpecificOutput + expect(output.hookSpecificOutput).toBeUndefined(); + }); + + it('should include error when present', () => { + const result: HookResult = { + continue: true, + suppressOutput: true, + error: 'Something went wrong', + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.error).toBe('Something went wrong'); + }); + + it('should output minimal JSON without context or error', () => { + const result: HookResult = { + continue: true, + suppressOutput: true, + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.continue).toBe(true); + expect(output.additionalContext).toBeUndefined(); + expect(output.error).toBeUndefined(); + }); + }); + }); + + // ===== GenericAdapter ===== + + describe('GenericAdapter', () => { + const adapter = new GenericAdapter(); + + it('should have name generic', () => { + expect(adapter.name).toBe('generic'); + }); + + describe('parseInput', () => { + it('should parse camelCase fields', () => { + const input = JSON.stringify({ + sessionId: 'gen-session', + cwd: '/test/gen', + prompt: 'Test', + toolName: 'Read', + toolInput: { file_path: '/x' }, + toolResponse: 'content', + transcriptPath: '/test/t.jsonl', + stopReason: 'done', + }); + + const result = adapter.parseInput(input); + expect(result.sessionId).toBe('gen-session'); + expect(result.cwd).toBe('/test/gen'); + expect(result.prompt).toBe('Test'); + expect(result.toolName).toBe('Read'); + expect(result.toolInput).toEqual({ file_path: '/x' }); + expect(result.toolResponse).toBe('content'); + expect(result.transcriptPath).toBe('/test/t.jsonl'); + expect(result.stopReason).toBe('done'); + }); + + it('should also accept snake_case fields as fallback', () => { + const input = JSON.stringify({ + session_id: 'snake-session', + cwd: '/test', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_result: 'files', + transcript_path: '/t.jsonl', + stop_reason: 'end', + }); + + const result = adapter.parseInput(input); + expect(result.sessionId).toBe('snake-session'); + expect(result.toolName).toBe('Bash'); + expect(result.toolInput).toEqual({ command: 'ls' }); + expect(result.toolResponse).toBe('files'); + expect(result.transcriptPath).toBe('/t.jsonl'); + expect(result.stopReason).toBe('end'); + }); + + it('should prefer camelCase over snake_case', () => { + const input = JSON.stringify({ + sessionId: 'camel', + session_id: 'snake', + cwd: '/test', + }); + + const result = adapter.parseInput(input); + expect(result.sessionId).toBe('camel'); + }); + + it('should accept project field', () => { + const input = JSON.stringify({ + cwd: '/test/dir', + project: 'my-custom-project', + }); + + const result = adapter.parseInput(input); + expect(result.project).toBe('my-custom-project'); + }); + + it('should derive project from cwd when not provided', () => { + const input = JSON.stringify({ cwd: '/test/my-project' }); + const result = adapter.parseInput(input); + expect(result.project).toBe('my-project'); + }); + + it('should handle invalid JSON', () => { + const result = adapter.parseInput('{}{}'); + expect(result.sessionId).toMatch(/^session_/); + }); + }); + + describe('formatOutput', () => { + it('should format with additionalContext', () => { + const result: HookResult = { + continue: true, + suppressOutput: false, + additionalContext: 'some context', + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.continue).toBe(true); + expect(output.additionalContext).toBe('some context'); + }); + + it('should format with error', () => { + const result: HookResult = { + continue: true, + suppressOutput: true, + error: 'fail', + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.error).toBe('fail'); + }); + + it('should format minimal response', () => { + const result: HookResult = { + continue: true, + suppressOutput: true, + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(Object.keys(output)).toEqual(['continue']); + }); + }); + }); + + // ===== Cross-adapter consistency ===== + + describe('Cross-adapter consistency', () => { + const adapters = [ + new ClaudeCodeAdapter(), + new OpenCodeAdapter(), + new GenericAdapter(), + ]; + + it('all adapters should handle empty JSON', () => { + for (const adapter of adapters) { + const result = adapter.parseInput('{}'); + expect(result.sessionId).toBeTruthy(); + expect(result.cwd).toBeTruthy(); + expect(result.project).toBeTruthy(); + expect(result.timestamp).toBeGreaterThan(0); + } + }); + + it('all adapters should produce valid JSON output', () => { + const hookResult: HookResult = { + continue: true, + suppressOutput: false, + additionalContext: 'test', + }; + + for (const adapter of adapters) { + const output = adapter.formatOutput(hookResult); + expect(() => JSON.parse(output)).not.toThrow(); + } + }); + + it('all adapters should have a name', () => { + for (const adapter of adapters) { + expect(adapter.name).toBeTruthy(); + expect(typeof adapter.name).toBe('string'); + } + }); + + it('all adapters should list supported events', () => { + for (const adapter of adapters) { + expect(adapter.supportedEvents.length).toBeGreaterThan(0); + expect(adapter.supportedEvents).toContain('context'); + } + }); + }); +}); diff --git a/src/hooks/__tests__/ai-enrichment.test.ts b/src/hooks/__tests__/ai-enrichment.test.ts new file mode 100644 index 0000000..eaa40fc --- /dev/null +++ b/src/hooks/__tests__/ai-enrichment.test.ts @@ -0,0 +1,1063 @@ +/** + * Unit Tests for AI Enrichment Module + * + * Tests the enrichment logic, env toggle, fallback behavior, + * parseAIResponse, buildExtractionPrompt, and mock CLI flow. + * + * @module @agentkits/memory/hooks/__tests__/ai-enrichment + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + enrichWithAI, + isAIEnrichmentAvailable, + isAIEnrichmentEnabled, + resetAIEnrichmentCache, + parseAIResponse, + buildExtractionPrompt, + parseSummaryResponse, + buildSummaryPrompt, + enrichSummaryWithAI, + buildCompressionPrompt, + parseCompressionResponse, + compressObservationWithAI, + buildSessionDigestPrompt, + parseSessionDigestResponse, + generateSessionDigestWithAI, + _setRunClaudePrintMockForTesting, + _setCliAvailableForTesting, + setAIProviderConfig, +} from '../ai-enrichment.js'; + +describe('AI Enrichment Module', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + resetAIEnrichmentCache(); + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + resetAIEnrichmentCache(); + }); + + describe('environment variable control', () => { + it('should return null when AGENTKITS_AI_ENRICHMENT=false', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when AGENTKITS_AI_ENRICHMENT=0', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = '0'; + const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); + expect(result).toBeNull(); + }); + + it('should attempt enrichment when AGENTKITS_AI_ENRICHMENT=true', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'true'; + const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); + // Returns enriched data if CLI available, null otherwise + if (result !== null) { + expect(typeof result.subtitle).toBe('string'); + expect(typeof result.narrative).toBe('string'); + expect(Array.isArray(result.facts)).toBe(true); + expect(Array.isArray(result.concepts)).toBe(true); + } + }); + + it('should auto-detect when env not set', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); + // Returns enriched data if CLI available, null otherwise + if (result !== null) { + expect(typeof result.subtitle).toBe('string'); + expect(typeof result.narrative).toBe('string'); + } + }); + + it('should handle AGENTKITS_AI_ENRICHMENT=1', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = '1'; + resetAIEnrichmentCache(); + const result = await enrichWithAI('Read', '{}', '{}'); + // Returns enriched data if CLI available, null otherwise + if (result !== null) { + expect(typeof result.subtitle).toBe('string'); + } + }); + }); + + describe('isAIEnrichmentEnabled (sync)', () => { + it('should return false when env=false', () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + expect(isAIEnrichmentEnabled()).toBe(false); + }); + + it('should return false when env=0', () => { + process.env.AGENTKITS_AI_ENRICHMENT = '0'; + expect(isAIEnrichmentEnabled()).toBe(false); + }); + + it('should return true when env=true', () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'true'; + expect(isAIEnrichmentEnabled()).toBe(true); + }); + + it('should return true when env=1', () => { + process.env.AGENTKITS_AI_ENRICHMENT = '1'; + expect(isAIEnrichmentEnabled()).toBe(true); + }); + + it('should return true when env not set (auto-detect optimistic)', () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + expect(isAIEnrichmentEnabled()).toBe(true); + }); + }); + + describe('isAIEnrichmentAvailable', () => { + it('should return false when env disabled', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + const available = await isAIEnrichmentAvailable(); + expect(available).toBe(false); + }); + + it('should return boolean when auto-detecting', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const available = await isAIEnrichmentAvailable(); + expect(typeof available).toBe('boolean'); + }); + + it('should return true when CLI available mock is set', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setCliAvailableForTesting(true); + const available = await isAIEnrichmentAvailable(); + expect(available).toBe(true); + }); + }); + + describe('resetAIEnrichmentCache', () => { + it('should reset cached state', async () => { + // First call with env=false should return null + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + const disabledResult = await enrichWithAI('Read', '{}', '{}'); + expect(disabledResult).toBeNull(); + + // Reset cache + resetAIEnrichmentCache(); + + // Now with auto-detect, result depends on CLI availability + delete process.env.AGENTKITS_AI_ENRICHMENT; + const result = await enrichWithAI('Read', '{}', '{}'); + // If CLI is available, returns enriched data; otherwise null + if (result !== null) { + expect(typeof result.subtitle).toBe('string'); + } + }); + }); + + describe('buildExtractionPrompt', () => { + it('should include tool name, input, and response', () => { + const prompt = buildExtractionPrompt('Read', '{"file_path":"src/index.ts"}', 'file content here'); + expect(prompt).toContain('Tool: Read'); + expect(prompt).toContain('Input: {"file_path":"src/index.ts"}'); + expect(prompt).toContain('Response: file content here'); + }); + + it('should truncate long input to 2000 chars', () => { + const longInput = 'x'.repeat(5000); + const prompt = buildExtractionPrompt('Read', longInput, 'short'); + expect(prompt).toContain('Input: ' + 'x'.repeat(2000)); + expect(prompt).not.toContain('x'.repeat(2001)); + }); + + it('should truncate long response to 2000 chars', () => { + const longResponse = 'y'.repeat(5000); + const prompt = buildExtractionPrompt('Read', 'short', longResponse); + expect(prompt).toContain('Response: ' + 'y'.repeat(2000)); + expect(prompt).not.toContain('y'.repeat(2001)); + }); + + it('should include JSON structure instructions', () => { + const prompt = buildExtractionPrompt('Bash', 'ls', 'output'); + expect(prompt).toContain('"subtitle"'); + expect(prompt).toContain('"narrative"'); + expect(prompt).toContain('"facts"'); + expect(prompt).toContain('"concepts"'); + }); + }); + + describe('parseAIResponse', () => { + it('should parse valid JSON', () => { + const json = JSON.stringify({ + subtitle: 'Test subtitle', + narrative: 'Test narrative sentence.', + facts: ['Fact 1', 'Fact 2'], + concepts: ['concept1', 'concept2'], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Test subtitle'); + expect(result!.narrative).toBe('Test narrative sentence.'); + expect(result!.facts).toEqual(['Fact 1', 'Fact 2']); + expect(result!.concepts).toEqual(['concept1', 'concept2']); + }); + + it('should strip ```json code fences', () => { + const json = '```json\n{"subtitle":"Test","narrative":"Test.","facts":["f"],"concepts":["c"]}\n```'; + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Test'); + }); + + it('should strip ``` code fences without json tag', () => { + const json = '```\n{"subtitle":"Test","narrative":"Test.","facts":["f"],"concepts":["c"]}\n```'; + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Test'); + }); + + it('should handle whitespace around JSON', () => { + const json = ' \n {"subtitle":"Test","narrative":"Test.","facts":[],"concepts":[]} \n '; + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Test'); + }); + + it('should return null for invalid JSON', () => { + const result = parseAIResponse('not json at all'); + expect(result).toBeNull(); + }); + + it('should return null for empty string', () => { + const result = parseAIResponse(''); + expect(result).toBeNull(); + }); + + it('should return null when subtitle is not a string', () => { + const json = JSON.stringify({ + subtitle: 123, + narrative: 'Test.', + facts: [], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).toBeNull(); + }); + + it('should return null when narrative is not a string', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: null, + facts: [], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).toBeNull(); + }); + + it('should return null when facts is not an array', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: 'not array', + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).toBeNull(); + }); + + it('should return null when concepts is not an array', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [], + concepts: 'not array', + }); + const result = parseAIResponse(json); + expect(result).toBeNull(); + }); + + it('should truncate subtitle to 200 chars', () => { + const json = JSON.stringify({ + subtitle: 'A'.repeat(300), + narrative: 'Test.', + facts: [], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.subtitle.length).toBe(200); + }); + + it('should truncate narrative to 500 chars', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'B'.repeat(600), + facts: [], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.narrative.length).toBe(500); + }); + + it('should limit facts to 5 items', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7'], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.facts.length).toBe(5); + }); + + it('should limit concepts to 8 items', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [], + concepts: ['c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9', 'c10'], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.concepts.length).toBe(8); + }); + + it('should compute confidence score from AI-reported value', () => { + const json = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module for login flow.', + facts: ['File has 200 lines'], + concepts: ['authentication'], + confidence: 0.92, + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.confidence).toBeCloseTo(0.92, 1); + }); + + it('should default confidence to 0.5 when not provided', () => { + const json = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module for login flow.', + facts: ['Fact 1'], + concepts: ['auth'], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.confidence).toBeCloseTo(0.5, 1); + }); + + it('should penalize confidence for very short subtitle', () => { + const json = JSON.stringify({ + subtitle: 'Hi', + narrative: 'Read the auth module for login flow.', + facts: ['Fact 1'], + concepts: ['auth'], + confidence: 0.9, + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.confidence).toBeLessThan(0.9); + }); + + it('should penalize confidence for empty facts', () => { + const json = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module for login flow.', + facts: [], + concepts: ['auth'], + confidence: 0.9, + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.confidence).toBeLessThan(0.9); + }); + + it('should clamp confidence to 0-1 range', () => { + const json = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module for login flow.', + facts: ['Fact'], + concepts: ['auth'], + confidence: 1.5, + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.confidence).toBeLessThanOrEqual(1); + expect(result!.confidence).toBeGreaterThanOrEqual(0); + }); + + it('should truncate individual fact strings to 200 chars', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: ['C'.repeat(300)], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.facts[0].length).toBe(200); + }); + + it('should truncate individual concept strings to 50 chars', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [], + concepts: ['D'.repeat(100)], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.concepts[0].length).toBe(50); + }); + + it('should convert non-string fact values to strings', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [42, true, null], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.facts).toEqual(['42', 'true', 'null']); + }); + + it('should convert non-string concept values to strings', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [], + concepts: [42, false], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.concepts).toEqual(['42', 'false']); + }); + }); + + describe('enrichWithAI with mock CLI', () => { + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + }); + + it('should return enriched observation on success', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const validResponse = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module to understand login flow.', + facts: ['File has 200 lines', 'Uses JWT tokens'], + concepts: ['authentication', 'jwt', 'typescript'], + }); + _setRunClaudePrintMockForTesting(() => validResponse); + + const result = await enrichWithAI('Read', '{"file_path":"auth.ts"}', 'export class Auth {}'); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Examining auth module'); + expect(result!.facts).toHaveLength(2); + expect(result!.concepts).toContain('jwt'); + }); + + it('should return null when CLI returns empty result', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => null); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when CLI returns invalid JSON', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => 'not valid json'); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when CLI returns incomplete structure', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => '{"subtitle":"test"}'); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when mock returns empty string', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => ''); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when mock throws', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => { throw new Error('CLI error'); }); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should work with AGENTKITS_AI_ENRICHMENT=true and mock', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'true'; + const validResponse = JSON.stringify({ + subtitle: 'Running tests', + narrative: 'Executed test suite.', + facts: ['5 tests passed'], + concepts: ['testing'], + }); + _setRunClaudePrintMockForTesting(() => validResponse); + + const result = await enrichWithAI('Bash', 'npm test', '5 passed'); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Running tests'); + }); + + it('should still return null when env=false even with mock set', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + _setRunClaudePrintMockForTesting(() => '{"subtitle":"Test","narrative":"Test.","facts":[],"concepts":[]}'); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should parse markdown-fenced response from mock CLI', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const fencedResponse = + '```json\n{"subtitle":"Fenced","narrative":"Fenced response.","facts":["f1"],"concepts":["c1"]}\n```'; + _setRunClaudePrintMockForTesting(() => fencedResponse); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Fenced'); + }); + + it('should pass prompt to mock', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + let capturedPrompt = ''; + _setRunClaudePrintMockForTesting((prompt) => { + capturedPrompt = prompt; + return JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [], + concepts: [], + }); + }); + + await enrichWithAI('Read', '{"file_path":"test.ts"}', 'content'); + expect(capturedPrompt).toContain('Tool: Read'); + expect(capturedPrompt).toContain('test.ts'); + }); + }); + + describe('parseSummaryResponse', () => { + it('should parse valid summary JSON', () => { + const json = JSON.stringify({ + completed: 'Fixed a bug in the parser.', + nextSteps: 'Run integration tests.', + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.completed).toBe('Fixed a bug in the parser.'); + expect(result!.nextSteps).toBe('Run integration tests.'); + }); + + it('should accept nextSteps as array', () => { + const json = JSON.stringify({ + completed: 'Fixed a bug.', + nextSteps: ['Run tests', 'Deploy to staging'], + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.nextSteps).toBe('Run tests; Deploy to staging'); + }); + + it('should default nextSteps to None when missing', () => { + const json = JSON.stringify({ + completed: 'All done.', + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.nextSteps).toBe('None'); + }); + + it('should return null when completed is not a string', () => { + const json = JSON.stringify({ + completed: 123, + nextSteps: 'Test', + }); + const result = parseSummaryResponse(json); + expect(result).toBeNull(); + }); + + it('should truncate completed to 1000 chars', () => { + const json = JSON.stringify({ + completed: 'A'.repeat(1500), + nextSteps: 'Test', + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.completed.length).toBe(1000); + }); + + it('should truncate nextSteps to 500 chars', () => { + const json = JSON.stringify({ + completed: 'Done.', + nextSteps: 'B'.repeat(600), + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.nextSteps.length).toBe(500); + }); + + it('should strip markdown fences', () => { + const json = '```json\n{"completed":"Done.","nextSteps":"None"}\n```'; + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.completed).toBe('Done.'); + }); + + it('should strip plain ``` markdown fences (without json suffix)', () => { + const json = '```\n{"completed":"Done.","nextSteps":"None"}\n```'; + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.completed).toBe('Done.'); + }); + + it('should return null for invalid JSON', () => { + expect(parseSummaryResponse('not json')).toBeNull(); + }); + + it('should parse decisions array', () => { + const json = JSON.stringify({ + completed: 'Fixed the bug.', + nextSteps: 'None', + decisions: ['Used mutex for thread safety', 'Chose retry pattern over circuit breaker'], + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.decisions).toHaveLength(2); + expect(result!.decisions[0]).toBe('Used mutex for thread safety'); + }); + + it('should default to empty decisions when not provided', () => { + const json = JSON.stringify({ + completed: 'Done.', + nextSteps: 'None', + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.decisions).toEqual([]); + }); + + it('should cap decisions at 5 items', () => { + const json = JSON.stringify({ + completed: 'Done.', + nextSteps: 'None', + decisions: Array.from({ length: 10 }, (_, i) => `Decision ${i}`), + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.decisions).toHaveLength(5); + }); + + it('should filter out non-string decisions', () => { + const json = JSON.stringify({ + completed: 'Done.', + nextSteps: 'None', + decisions: ['Valid', 123, null, '', 'Also valid'], + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.decisions).toEqual(['Valid', 'Also valid']); + }); + }); + + describe('buildSummaryPrompt', () => { + it('should include template summary and assistant message', () => { + const prompt = buildSummaryPrompt('Request: Fix bug', 'I fixed the bug.'); + expect(prompt).toContain('Template Summary'); + expect(prompt).toContain('Request: Fix bug'); + expect(prompt).toContain('Last Assistant Message'); + expect(prompt).toContain('I fixed the bug.'); + }); + + it('should truncate long inputs', () => { + const longTemplate = 'T'.repeat(5000); + const longMessage = 'M'.repeat(5000); + const prompt = buildSummaryPrompt(longTemplate, longMessage); + // Should contain truncated versions (3000 chars each) + expect(prompt.length).toBeLessThan(10000); + }); + + it('should ask for decisions in prompt', () => { + const prompt = buildSummaryPrompt('Request: Fix auth', 'Fixed the auth flow.'); + expect(prompt).toContain('decisions'); + expect(prompt).toContain('WHY'); + }); + }); + + describe('enrichSummaryWithAI with mock CLI', () => { + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + }); + + it('should return enriched summary on success', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const validResponse = JSON.stringify({ + completed: 'Fixed the parser bug and verified with tests.', + nextSteps: 'None', + }); + _setRunClaudePrintMockForTesting(() => validResponse); + + const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed the parser.'); + expect(result).not.toBeNull(); + expect(result!.completed).toBe('Fixed the parser bug and verified with tests.'); + expect(result!.nextSteps).toBe('None'); + }); + + it('should return null when CLI unavailable', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setCliAvailableForTesting(false); + + const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); + expect(result).toBeNull(); + }); + + it('should return null when CLI returns invalid response', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => 'not json'); + + const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); + expect(result).toBeNull(); + }); + + it('should return null when runClaudePrint throws (catch block)', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => { throw new Error('Unexpected CLI error'); }); + + const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); + expect(result).toBeNull(); + }); + + it('should return null when runClaudePrint returns null', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => null as unknown as string); + + const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); + expect(result).toBeNull(); + }); + }); + + describe('buildCompressionPrompt', () => { + it('should include tool name and input/response', () => { + const prompt = buildCompressionPrompt('Read', '{"file_path":"test.ts"}', 'file content'); + expect(prompt).toContain('Tool: Read'); + expect(prompt).toContain('Input: {"file_path":"test.ts"}'); + expect(prompt).toContain('Response: file content'); + expect(prompt).toContain('compressed_summary'); + }); + + it('should include context hints when provided', () => { + const prompt = buildCompressionPrompt('Read', '{}', '{}', 'Examining config', 'Read config file.'); + expect(prompt).toContain('Context: Examining config | Read config file.'); + }); + + it('should omit context line when no hints', () => { + const prompt = buildCompressionPrompt('Read', '{}', '{}'); + expect(prompt).not.toContain('Context:'); + }); + + it('should truncate long input to 1000 chars', () => { + const longInput = 'x'.repeat(3000); + const prompt = buildCompressionPrompt('Read', longInput, 'short'); + expect(prompt).toContain('x'.repeat(1000)); + expect(prompt).not.toContain('x'.repeat(1001)); + }); + + it('should truncate long response to 1000 chars', () => { + const longResponse = 'y'.repeat(3000); + const prompt = buildCompressionPrompt('Read', 'short', longResponse); + expect(prompt).toContain('y'.repeat(1000)); + expect(prompt).not.toContain('y'.repeat(1001)); + }); + }); + + describe('parseCompressionResponse', () => { + it('should parse valid compression JSON', () => { + const json = JSON.stringify({ compressed_summary: 'Read auth.ts to check login flow' }); + const result = parseCompressionResponse(json); + expect(result).not.toBeNull(); + expect(result!.compressed_summary).toBe('Read auth.ts to check login flow'); + }); + + it('should strip markdown fences', () => { + const json = '```json\n{"compressed_summary":"Test summary"}\n```'; + const result = parseCompressionResponse(json); + expect(result).not.toBeNull(); + expect(result!.compressed_summary).toBe('Test summary'); + }); + + it('should strip plain ``` fences', () => { + const json = '```\n{"compressed_summary":"Test summary"}\n```'; + const result = parseCompressionResponse(json); + expect(result).not.toBeNull(); + expect(result!.compressed_summary).toBe('Test summary'); + }); + + it('should return null for invalid JSON', () => { + expect(parseCompressionResponse('not json')).toBeNull(); + }); + + it('should return null when compressed_summary is missing', () => { + const json = JSON.stringify({ other: 'field' }); + expect(parseCompressionResponse(json)).toBeNull(); + }); + + it('should return null when compressed_summary is empty', () => { + const json = JSON.stringify({ compressed_summary: '' }); + expect(parseCompressionResponse(json)).toBeNull(); + }); + + it('should return null when compressed_summary is not a string', () => { + const json = JSON.stringify({ compressed_summary: 123 }); + expect(parseCompressionResponse(json)).toBeNull(); + }); + + it('should truncate to 200 chars', () => { + const json = JSON.stringify({ compressed_summary: 'A'.repeat(300) }); + const result = parseCompressionResponse(json); + expect(result).not.toBeNull(); + expect(result!.compressed_summary.length).toBe(200); + }); + }); + + describe('compressObservationWithAI with mock CLI', () => { + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + }); + + it('should return compressed observation on success', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Read auth module for login flow' }) + ); + + const result = await compressObservationWithAI('Read', '{"file_path":"auth.ts"}', 'export class Auth {}', 'Examining auth', 'Read auth module.'); + expect(result).not.toBeNull(); + expect(result!.compressed_summary).toBe('Read auth module for login flow'); + }); + + it('should return null when CLI unavailable', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setCliAvailableForTesting(false); + + const result = await compressObservationWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when CLI returns invalid response', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => 'not json'); + + const result = await compressObservationWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when CLI throws', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => { throw new Error('Error'); }); + + const result = await compressObservationWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when env=false', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Should not reach here' }) + ); + + const result = await compressObservationWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + }); + + describe('buildSessionDigestPrompt', () => { + it('should include request, observations, and completion', () => { + const prompt = buildSessionDigestPrompt( + 'Fix auth bug', + ['Read auth.ts', 'Edited login handler', 'Ran tests'], + 'Fixed authentication', + ['src/auth.ts'] + ); + expect(prompt).toContain('Request: Fix auth bug'); + expect(prompt).toContain('Read auth.ts'); + expect(prompt).toContain('Edited login handler'); + expect(prompt).toContain('Completed: Fixed authentication'); + expect(prompt).toContain('Files modified: src/auth.ts'); + expect(prompt).toContain('"digest"'); + }); + + it('should omit files line when no files modified', () => { + const prompt = buildSessionDigestPrompt('Test', ['obs1'], 'Done', []); + expect(prompt).not.toContain('Files modified:'); + }); + + it('should limit observation summaries to 30', () => { + const obs = Array.from({ length: 50 }, (_, i) => `Obs ${i}`); + const prompt = buildSessionDigestPrompt('Test', obs, 'Done', []); + // Should contain obs 0-29 but not 30+ + expect(prompt).toContain('Obs 29'); + expect(prompt).not.toContain('Obs 30'); + }); + }); + + describe('parseSessionDigestResponse', () => { + it('should parse valid digest JSON', () => { + const json = JSON.stringify({ digest: 'Session fixed auth bug in 3 files.' }); + const result = parseSessionDigestResponse(json); + expect(result).not.toBeNull(); + expect(result!.digest).toBe('Session fixed auth bug in 3 files.'); + }); + + it('should strip markdown fences', () => { + const json = '```json\n{"digest":"Test digest"}\n```'; + const result = parseSessionDigestResponse(json); + expect(result).not.toBeNull(); + expect(result!.digest).toBe('Test digest'); + }); + + it('should return null for invalid JSON', () => { + expect(parseSessionDigestResponse('not json')).toBeNull(); + }); + + it('should return null when digest is missing', () => { + expect(parseSessionDigestResponse(JSON.stringify({ other: 'x' }))).toBeNull(); + }); + + it('should return null when digest is empty', () => { + expect(parseSessionDigestResponse(JSON.stringify({ digest: '' }))).toBeNull(); + }); + + it('should return null when digest is not a string', () => { + expect(parseSessionDigestResponse(JSON.stringify({ digest: 42 }))).toBeNull(); + }); + + it('should truncate to 600 chars', () => { + const json = JSON.stringify({ digest: 'D'.repeat(800) }); + const result = parseSessionDigestResponse(json); + expect(result).not.toBeNull(); + expect(result!.digest.length).toBe(600); + }); + }); + + describe('generateSessionDigestWithAI with mock CLI', () => { + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + }); + + it('should return digest on success', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ digest: 'Fixed auth bug by patching JWT validation.' }) + ); + + const result = await generateSessionDigestWithAI( + 'Fix auth', ['Read auth.ts', 'Edit auth.ts'], 'Fixed JWT', ['auth.ts'] + ); + expect(result).not.toBeNull(); + expect(result!.digest).toBe('Fixed auth bug by patching JWT validation.'); + }); + + it('should return null when CLI unavailable', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setCliAvailableForTesting(false); + + const result = await generateSessionDigestWithAI('Test', [], 'Done', []); + expect(result).toBeNull(); + }); + + it('should return null when CLI returns null', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => null as unknown as string); + + const result = await generateSessionDigestWithAI('Test', [], 'Done', []); + expect(result).toBeNull(); + }); + }); + + describe('error handling', () => { + it('should not throw on enrichment failure', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + // Should gracefully handle any input without throwing + const result = await enrichWithAI('InvalidTool', 'not json', 'not json'); + // May return enriched data if CLI is available, or null if not + if (result !== null) { + expect(typeof result.subtitle).toBe('string'); + expect(typeof result.narrative).toBe('string'); + } + }); + + it('should respect timeout (returns null on slow response)', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const start = Date.now(); + const result = await enrichWithAI('Read', '{}', '{}', 100); + const elapsed = Date.now() - start; + expect(result).toBeNull(); + expect(elapsed).toBeLessThan(5000); + }); + }); + + // ===== Provider Config ===== + + describe('setAIProviderConfig', () => { + it('should accept provider config without error', () => { + expect(() => setAIProviderConfig({ provider: 'openai', apiKey: 'test' })).not.toThrow(); + }); + + it('should accept undefined to reset config', () => { + setAIProviderConfig({ provider: 'gemini', apiKey: 'key' }); + expect(() => setAIProviderConfig(undefined)).not.toThrow(); + }); + + it('should force re-resolution so next enrichment uses new provider', async () => { + // Set to openai with no API key → provider unavailable → enrichment returns null + setAIProviderConfig({ provider: 'openai' }); + delete process.env.AGENTKITS_AI_ENRICHMENT; + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should not affect mock-based testing', async () => { + setAIProviderConfig({ provider: 'openai', apiKey: 'test' }); + _setRunClaudePrintMockForTesting(() => JSON.stringify({ + subtitle: 'Test subtitle', + narrative: 'Test narrative about something.', + facts: ['Fact 1'], + concepts: ['concept1'], + confidence: 0.9, + })); + delete process.env.AGENTKITS_AI_ENRICHMENT; + const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', 'content'); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Test subtitle'); + }); + + it('should be cleared by resetAIEnrichmentCache', () => { + setAIProviderConfig({ provider: 'gemini', apiKey: 'key' }); + resetAIEnrichmentCache(); + // After reset, should use default provider (claude-cli) + // No error should occur + expect(() => enrichWithAI('Read', '{}', '{}')).not.toThrow(); + }); + }); +}); diff --git a/src/hooks/__tests__/ai-provider.test.ts b/src/hooks/__tests__/ai-provider.test.ts new file mode 100644 index 0000000..3076293 --- /dev/null +++ b/src/hooks/__tests__/ai-provider.test.ts @@ -0,0 +1,282 @@ +/** + * Tests for AI Provider abstraction + * + * @module @agentkits/memory/hooks/__tests__/ai-provider.test + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + resolveAIProvider, + createClaudeCliProvider, + createOpenAIProvider, + createGeminiProvider, + type AIProviderConfig, +} from '../ai-provider.js'; + +describe('AI Provider', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clean env vars before each test + delete process.env.AGENTKITS_AI_PROVIDER; + delete process.env.AGENTKITS_AI_API_KEY; + delete process.env.AGENTKITS_AI_BASE_URL; + delete process.env.AGENTKITS_AI_MODEL; + }); + + afterEach(() => { + // Restore env + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + // ===== resolveAIProvider ===== + + describe('resolveAIProvider', () => { + it('should default to claude-cli when no config and no env', () => { + const provider = resolveAIProvider(); + expect(provider.name).toBe('claude-cli'); + }); + + it('should use settings config provider', () => { + const config: AIProviderConfig = { provider: 'openai', apiKey: 'test-key' }; + const provider = resolveAIProvider(config); + expect(provider.name).toBe('openai'); + }); + + it('should use gemini provider from settings', () => { + const config: AIProviderConfig = { provider: 'gemini', apiKey: 'gemini-key' }; + const provider = resolveAIProvider(config); + expect(provider.name).toBe('gemini'); + }); + + it('should override settings with env var AGENTKITS_AI_PROVIDER', () => { + process.env.AGENTKITS_AI_PROVIDER = 'gemini'; + process.env.AGENTKITS_AI_API_KEY = 'env-key'; + const config: AIProviderConfig = { provider: 'openai', apiKey: 'settings-key' }; + const provider = resolveAIProvider(config); + expect(provider.name).toBe('gemini'); + }); + + it('should merge env API key with settings provider', () => { + process.env.AGENTKITS_AI_API_KEY = 'env-key'; + const config: AIProviderConfig = { provider: 'openai' }; + const provider = resolveAIProvider(config); + expect(provider.name).toBe('openai'); + // Provider should be available because env key is set + expect(provider.isAvailable()).toBe(true); + }); + + it('should fall back to claude-cli for unknown provider', () => { + process.env.AGENTKITS_AI_PROVIDER = 'unknown-provider'; + const provider = resolveAIProvider(); + expect(provider.name).toBe('claude-cli'); + }); + + it('should use env model override', () => { + process.env.AGENTKITS_AI_MODEL = 'custom-model'; + const provider = resolveAIProvider(); + expect(provider.name).toBe('claude-cli'); + // Can't directly test model, but it shouldn't throw + }); + }); + + // ===== Claude CLI Provider ===== + + describe('createClaudeCliProvider', () => { + it('should create a provider with name claude-cli', () => { + const provider = createClaudeCliProvider('haiku'); + expect(provider.name).toBe('claude-cli'); + }); + + it('should cache isAvailable result', () => { + const provider = createClaudeCliProvider('haiku'); + // First call checks CLI, second should use cache + const first = provider.isAvailable(); + const second = provider.isAvailable(); + expect(first).toBe(second); + }); + + it('should return null from run when CLI is not available', async () => { + const provider = createClaudeCliProvider('nonexistent-model'); + // execFileSync will throw for invalid claude args, provider catches and returns null + const result = await provider.run('test prompt', 'system', 5000); + // On CI or machines without claude CLI, this returns null + expect(result === null || typeof result === 'string').toBe(true); + }); + }); + + // ===== OpenAI Provider ===== + + describe('createOpenAIProvider', () => { + it('should return unavailable when no API key', () => { + const provider = createOpenAIProvider('', 'https://api.openai.com/v1', 'gpt-4o-mini'); + expect(provider.isAvailable()).toBe(false); + }); + + it('should return available when API key is set', () => { + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + expect(provider.isAvailable()).toBe(true); + expect(provider.name).toBe('openai'); + }); + + it('should return null when fetch fails', async () => { + // Mock fetch to simulate network error + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + + it('should return null when response is not ok', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + })); + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + + it('should parse successful response correctly', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: '{"subtitle": "test"}' } }], + }), + })); + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBe('{"subtitle": "test"}'); + }); + + it('should send correct request format', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: 'result' } }] }), + }); + vi.stubGlobal('fetch', mockFetch); + + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + await provider.run('my prompt', 'my system', 10000); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.openai.com/v1/chat/completions'); + expect(options.method).toBe('POST'); + expect(options.headers['Authorization']).toBe('Bearer sk-test'); + + const body = JSON.parse(options.body); + expect(body.model).toBe('gpt-4o-mini'); + expect(body.messages).toEqual([ + { role: 'system', content: 'my system' }, + { role: 'user', content: 'my prompt' }, + ]); + expect(body.temperature).toBe(0.3); + expect(body.max_tokens).toBe(1024); + }); + + it('should strip trailing slash from base URL', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: 'ok' } }] }), + }); + vi.stubGlobal('fetch', mockFetch); + + const provider = createOpenAIProvider('sk-test', 'https://openrouter.ai/api/v1/', 'model'); + await provider.run('prompt', 'system', 5000); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe('https://openrouter.ai/api/v1/chat/completions'); + }); + + it('should return null when response has no choices', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [] }), + })); + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + }); + + // ===== Gemini Provider ===== + + describe('createGeminiProvider', () => { + it('should return unavailable when no API key', () => { + const provider = createGeminiProvider('', 'gemini-2.0-flash'); + expect(provider.isAvailable()).toBe(false); + }); + + it('should return available when API key is set', () => { + const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); + expect(provider.isAvailable()).toBe(true); + expect(provider.name).toBe('gemini'); + }); + + it('should return null when fetch fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + + it('should parse successful response correctly', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [{ content: { parts: [{ text: '{"result": "ok"}' }] } }], + }), + })); + const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBe('{"result": "ok"}'); + }); + + it('should send correct Gemini API format', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [{ content: { parts: [{ text: 'result' }] } }], + }), + }); + vi.stubGlobal('fetch', mockFetch); + + const provider = createGeminiProvider('gemini-key', 'gemini-2.0-flash'); + await provider.run('my prompt', 'my system', 10000); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain('generativelanguage.googleapis.com'); + expect(url).toContain('gemini-2.0-flash'); + expect(url).toContain('key=gemini-key'); + + const body = JSON.parse(options.body); + expect(body.system_instruction).toEqual({ parts: [{ text: 'my system' }] }); + expect(body.contents).toEqual([{ parts: [{ text: 'my prompt' }] }]); + expect(body.generationConfig.temperature).toBe(0.3); + expect(body.generationConfig.maxOutputTokens).toBe(1024); + }); + + it('should return null when response has no candidates', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ candidates: [] }), + })); + const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + + it('should return null on non-200 response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + })); + const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/hooks/__tests__/handlers.test.ts b/src/hooks/__tests__/handlers.test.ts index 3741d88..2f79258 100644 --- a/src/hooks/__tests__/handlers.test.ts +++ b/src/hooks/__tests__/handlers.test.ts @@ -13,6 +13,7 @@ import { ContextHook, createContextHook } from '../context.js'; import { SessionInitHook, createSessionInitHook } from '../session-init.js'; import { ObservationHook, createObservationHook } from '../observation.js'; import { SummarizeHook, createSummarizeHook } from '../summarize.js'; +import { UserMessageHook, createUserMessageHook } from '../user-message.js'; const TEST_DIR = path.join(process.cwd(), '.test-hook-handlers'); @@ -74,22 +75,43 @@ describe('Hook Handlers', () => { }); describe('ContextHook', () => { - it('should return no context for new project', async () => { + it('should return empty-state guidance for new project', async () => { const hook = trackHook(createContextHook(TEST_DIR)); const input = createTestInput(); const result = await hook.execute(input); expect(result.continue).toBe(true); - expect(result.suppressOutput).toBe(true); - expect(result.additionalContext).toBeUndefined(); + expect(result.suppressOutput).toBe(false); + // Should inject guidance even on empty state + expect(result.additionalContext).toBeDefined(); + expect(result.additionalContext).toContain('Memory tools available'); + expect(result.additionalContext).toContain('memory_save'); + expect(result.additionalContext).toContain('Do NOT call'); }); - it('should return context for existing project', async () => { - // Set up existing data + it('should return context with prompts and summaries', async () => { + // Set up session with prompts, observations, and structured summary const service = new MemoryHookService(TEST_DIR); - await service.initSession('old-session', 'test-project', 'Previous task'); + await service.initSession('old-session', 'test-project', 'Implement auth'); + await service.saveUserPrompt('old-session', 'test-project', 'Implement auth'); + await service.saveUserPrompt('old-session', 'test-project', 'Add tests'); await service.storeObservation('old-session', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); + await service.storeObservation('old-session', 'test-project', 'Write', { file_path: 'auth.ts' }, {}, TEST_DIR); + + // Save structured summary + await service.saveSessionSummary({ + sessionId: 'old-session', + project: 'test-project', + request: '[#1] Implement auth → [#2] Add tests', + completed: '2 file(s) modified', + filesRead: ['file.ts'], + filesModified: ['auth.ts'], + nextSteps: 'Deploy to staging', + notes: '', + promptNumber: 2, + }); + await service.completeSession('old-session', 'Done'); await service.shutdown(); @@ -103,7 +125,14 @@ describe('Hook Handlers', () => { expect(result.suppressOutput).toBe(false); expect(result.additionalContext).toBeDefined(); expect(result.additionalContext).toContain('# Memory Context'); - expect(result.additionalContext).toContain('test-project'); + expect(result.additionalContext).toContain('Previous Session Summaries'); + expect(result.additionalContext).toContain('Implement auth'); + expect(result.additionalContext).toContain('Recent User Prompts'); + expect(result.additionalContext).toContain('Add tests'); + expect(result.additionalContext).toContain('auth.ts'); + // Should include tool-usage instructions + expect(result.additionalContext).toContain('Memory tools available'); + expect(result.additionalContext).toContain('memory_search'); }); it('should handle errors gracefully', async () => { @@ -144,24 +173,53 @@ describe('Hook Handlers', () => { expect(session?.prompt).toBe('Hello Claude'); }); - it('should not overwrite existing session', async () => { - // Create initial session + it('should save all user prompts (not just first)', async () => { + // First prompt const hook1 = trackHook(createSessionInitHook(TEST_DIR)); await hook1.execute(createTestInput({ prompt: 'First prompt' })); await hook1.shutdown(); - // Try to re-init with different prompt + // Second prompt (same session) const hook2 = trackHook(createSessionInitHook(TEST_DIR)); await hook2.execute(createTestInput({ prompt: 'Second prompt' })); await hook2.shutdown(); - // Verify original prompt preserved + // Third prompt + const hook3 = trackHook(createSessionInitHook(TEST_DIR)); + await hook3.execute(createTestInput({ prompt: 'Third prompt' })); + await hook3.shutdown(); + + // Verify session prompt still has first prompt const service = new MemoryHookService(TEST_DIR); await service.initialize(); const session = service.getSession('test-session-123'); - await service.shutdown(); expect(session?.prompt).toBe('First prompt'); + + // Verify ALL prompts are saved in user_prompts table + const prompts = await service.getSessionPrompts('test-session-123'); + await service.shutdown(); + + expect(prompts.length).toBe(3); + expect(prompts[0].promptNumber).toBe(1); + expect(prompts[0].promptText).toBe('First prompt'); + expect(prompts[1].promptNumber).toBe(2); + expect(prompts[1].promptText).toBe('Second prompt'); + expect(prompts[2].promptNumber).toBe(3); + expect(prompts[2].promptText).toBe('Third prompt'); + }); + + it('should not save prompt when prompt is empty', async () => { + const hook = trackHook(createSessionInitHook(TEST_DIR)); + await hook.execute(createTestInput({ prompt: undefined })); + await hook.shutdown(); + + const service = new MemoryHookService(TEST_DIR); + await service.initialize(); + const prompts = await service.getSessionPrompts('test-session-123'); + await service.shutdown(); + + expect(prompts.length).toBe(0); }); it('should handle errors gracefully', async () => { @@ -179,10 +237,10 @@ describe('Hook Handlers', () => { }); describe('ObservationHook', () => { - it('should store observation', async () => { - // Initialize session first + it('should store observation with prompt number', async () => { + // Initialize session and save a prompt const initHook = trackHook(createSessionInitHook(TEST_DIR)); - await initHook.execute(createTestInput()); + await initHook.execute(createTestInput({ prompt: 'Fix the bug' })); await initHook.shutdown(); // Store observation @@ -199,7 +257,7 @@ describe('Hook Handlers', () => { expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); - // Verify observation was stored + // Verify observation was stored with prompt number const service = new MemoryHookService(TEST_DIR); await service.initialize(); const observations = await service.getSessionObservations('test-session-123'); @@ -207,6 +265,7 @@ describe('Hook Handlers', () => { expect(observations.length).toBe(1); expect(observations[0].toolName).toBe('Read'); + expect(observations[0].promptNumber).toBe(1); }); it('should skip if no tool name', async () => { @@ -251,8 +310,8 @@ describe('Hook Handlers', () => { const input = createTestInput({ sessionId: 'new-session', toolName: 'Read', - toolInput: {}, - toolResponse: {}, + toolInput: { file_path: '/test/file.ts' }, + toolResponse: { content: 'test' }, }); const result = await hook.execute(input); @@ -269,12 +328,128 @@ describe('Hook Handlers', () => { expect(session).not.toBeNull(); }); + it('should return fast with template data (no AI blocking)', async () => { + // Initialize session + const initHook = trackHook(createSessionInitHook(TEST_DIR)); + await initHook.execute(createTestInput({ prompt: 'Test task' })); + await initHook.shutdown(); + + const hook = trackHook(createObservationHook(TEST_DIR)); + const input = createTestInput({ + toolName: 'Read', + toolInput: { file_path: '/path/to/auth.ts' }, + toolResponse: { content: 'export class Auth {}' }, + }); + + // Measure execution time — should be fast (<500ms) since AI is fire-and-forget + const start = Date.now(); + const result = await hook.execute(input); + const elapsed = Date.now() - start; + await hook.shutdown(); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + // Should complete quickly (template-only, no AI blocking) + expect(elapsed).toBeLessThan(2000); + + // Verify template data was stored immediately + const service = new MemoryHookService(TEST_DIR); + await service.initialize(); + const observations = await service.getSessionObservations('test-session-123'); + await service.shutdown(); + + expect(observations.length).toBe(1); + expect(observations[0].subtitle).toBeDefined(); + expect(observations[0].subtitle.length).toBeGreaterThan(0); + expect(observations[0].narrative).toBeDefined(); + expect(observations[0].narrative.length).toBeGreaterThan(0); + }); + + it('should not spawn enrichment when AGENTKITS_AI_ENRICHMENT=false', async () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + + try { + const initHook = trackHook(createSessionInitHook(TEST_DIR)); + await initHook.execute(createTestInput({ prompt: 'Test' })); + await initHook.shutdown(); + + const hook = trackHook(createObservationHook(TEST_DIR)); + const input = createTestInput({ + toolName: 'Write', + toolInput: { file_path: 'test.ts' }, + toolResponse: {}, + }); + + const result = await hook.execute(input); + await hook.shutdown(); + + // Should still store observation successfully + expect(result.continue).toBe(true); + + const service = new MemoryHookService(TEST_DIR); + await service.initialize(); + const observations = await service.getSessionObservations('test-session-123'); + await service.shutdown(); + + expect(observations.length).toBe(1); + expect(observations[0].toolName).toBe('Write'); + } finally { + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + } + }); + + it('should skip empty/no-op tool calls (both input and response are {})', async () => { + const hook = trackHook(createObservationHook(TEST_DIR)); + const input = createTestInput({ + toolName: 'Read', + toolInput: {}, + toolResponse: {}, + }); + + const result = await hook.execute(input); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + + // Should NOT initialize service or store anything + // Verify by checking no observations exist + const service = new MemoryHookService(TEST_DIR); + await service.initialize(); + const observations = await service.getSessionObservations('test-session-123'); + await service.shutdown(); + + expect(observations.length).toBe(0); + }); + + it('should skip when toolInput is undefined and toolResponse is undefined', async () => { + const hook = trackHook(createObservationHook(TEST_DIR)); + const input = createTestInput({ + toolName: 'Bash', + toolInput: undefined, + toolResponse: undefined, + }); + + const result = await hook.execute(input); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + }); + it('should handle errors gracefully', async () => { const hook = new ObservationHook({ initialize: async () => { throw new Error('Test error'); }, } as unknown as MemoryHookService); - const input = createTestInput({ toolName: 'Read' }); + const input = createTestInput({ + toolName: 'Read', + toolInput: { file_path: '/test/file.ts' }, + toolResponse: { content: 'test' }, + }); const result = await hook.execute(input); expect(result.continue).toBe(true); @@ -284,15 +459,17 @@ describe('Hook Handlers', () => { }); describe('SummarizeHook', () => { - it('should complete session with summary', async () => { - // Set up session with observations + it('should complete session with structured summary', async () => { + // Set up session with prompts and observations const service = new MemoryHookService(TEST_DIR); - await service.initSession('test-session-123', 'test-project'); - await service.storeObservation('test-session-123', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); - await service.storeObservation('test-session-123', 'test-project', 'Write', { file_path: 'b.ts' }, {}, TEST_DIR); + await service.initSession('test-session-123', 'test-project', 'Fix authentication bug'); + await service.saveUserPrompt('test-session-123', 'test-project', 'Fix authentication bug'); + await service.storeObservation('test-session-123', 'test-project', 'Read', { file_path: 'auth.ts' }, {}, TEST_DIR); + await service.storeObservation('test-session-123', 'test-project', 'Write', { file_path: 'auth.ts' }, {}, TEST_DIR); + await service.storeObservation('test-session-123', 'test-project', 'Bash', { command: 'npm test' }, {}, TEST_DIR); await service.shutdown(); - // Run summarize hook (it already calls shutdown internally) + // Run summarize hook const hook = trackHook(createSummarizeHook(TEST_DIR)); const input = createTestInput(); @@ -301,15 +478,26 @@ describe('Hook Handlers', () => { expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); - // Verify session was completed + // Verify session was completed with text summary const service2 = new MemoryHookService(TEST_DIR); await service2.initialize(); const session = service2.getSession('test-session-123'); - await service2.shutdown(); expect(session?.status).toBe('completed'); expect(session?.summary).toBeDefined(); - expect(session?.summary).toContain('file'); + expect(session?.summary).toContain('Request:'); + expect(session?.summary).toContain('Fix authentication bug'); + + // Verify structured summary was saved + const summaries = await service2.getRecentSummaries('test-project'); + await service2.shutdown(); + + expect(summaries.length).toBe(1); + expect(summaries[0].request).toContain('Fix authentication bug'); + expect(summaries[0].filesRead).toContain('auth.ts'); + expect(summaries[0].filesModified).toContain('auth.ts'); + expect(summaries[0].completed).toContain('file(s) modified'); + expect(summaries[0].notes).toContain('npm test'); }); it('should handle non-existent session', async () => { @@ -322,6 +510,66 @@ describe('Hook Handlers', () => { expect(result.suppressOutput).toBe(true); }); + it('should spawn enrich worker when AI enrichment is enabled', async () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + process.env.AGENTKITS_AI_ENRICHMENT = 'true'; + + try { + // Set up session with observations + const service = new MemoryHookService(TEST_DIR); + await service.initSession('test-session-123', 'test-project', 'Test task'); + await service.storeObservation('test-session-123', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + await service.shutdown(); + + // Run summarize hook + const hook = trackHook(createSummarizeHook(TEST_DIR)); + const input = createTestInput(); + + const result = await hook.execute(input); + + // Hook should still succeed (worker spawn is fire-and-forget) + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + } finally { + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + } + }); + + it('should spawn enrich-summary process when AI enrichment enabled and transcriptPath provided', async () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + process.env.AGENTKITS_AI_ENRICHMENT = 'true'; + + try { + // Set up session + const service = new MemoryHookService(TEST_DIR); + await service.initSession('test-session-123', 'test-project', 'Test task'); + await service.storeObservation('test-session-123', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + await service.shutdown(); + + // Run summarize hook with transcriptPath + const hook = trackHook(createSummarizeHook(TEST_DIR)); + const input = createTestInput({ + transcriptPath: '/tmp/test-transcript.jsonl', + }); + + const result = await hook.execute(input); + + // Hook should still succeed (spawn is fire-and-forget, spawn failure is caught) + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + } finally { + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + } + }); + it('should handle errors gracefully', async () => { const hook = new SummarizeHook({ initialize: async () => { throw new Error('Test error'); }, @@ -336,4 +584,64 @@ describe('Hook Handlers', () => { expect(result.error).toBeDefined(); }); }); + + describe('UserMessageHook', () => { + it('should display status for new project (no context)', async () => { + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const hook = trackHook(createUserMessageHook(TEST_DIR)); + const input = createTestInput(); + + const result = await hook.execute(input); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + // Should write to stderr + expect(stderrSpy).toHaveBeenCalled(); + const output = stderrSpy.mock.calls.map(c => c[0]).join('\n'); + expect(output).toContain('AgentKits Memory Loaded'); + expect(output).toContain('Fresh memory'); + expect(output).toContain('memory_save'); + stderrSpy.mockRestore(); + }); + + it('should display stats when context exists', async () => { + // Set up session with observations and prompts + const service = new MemoryHookService(TEST_DIR); + await service.initSession('old-session', 'test-project', 'Test task'); + await service.saveUserPrompt('old-session', 'test-project', 'Test task'); + await service.storeObservation('old-session', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); + await service.completeSession('old-session', 'Done'); + await service.shutdown(); + + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const hook = trackHook(createUserMessageHook(TEST_DIR)); + const input = createTestInput({ sessionId: 'new-session' }); + + const result = await hook.execute(input); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + expect(stderrSpy).toHaveBeenCalled(); + const output = stderrSpy.mock.calls.map(c => c[0]).join('\n'); + expect(output).toContain('AgentKits Memory Loaded'); + expect(output).toContain('observation'); + expect(output).toContain('memory_search'); + stderrSpy.mockRestore(); + }); + + it('should handle errors gracefully', async () => { + const hook = new UserMessageHook({ + initialize: async () => { throw new Error('Test error'); }, + } as unknown as MemoryHookService); + + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const input = createTestInput(); + const result = await hook.execute(input); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + expect(result.error).toBeDefined(); + stderrSpy.mockRestore(); + }); + }); }); diff --git a/src/hooks/__tests__/integration.test.ts b/src/hooks/__tests__/integration.test.ts index bf2f121..2e5b95d 100644 --- a/src/hooks/__tests__/integration.test.ts +++ b/src/hooks/__tests__/integration.test.ts @@ -87,7 +87,10 @@ describe('Hook System Integration', () => { ); expect(contextResult.continue).toBe(true); - expect(contextResult.additionalContext).toBeUndefined(); // No previous sessions + // Empty state now injects guidance (save-first workflow) + expect(contextResult.additionalContext).toBeDefined(); + expect(contextResult.additionalContext).toContain('memory_save'); + expect(contextResult.additionalContext).toContain('Do NOT call'); await contextHook.shutdown(); // 2. User Prompt Submit - Session Init Hook @@ -210,9 +213,9 @@ describe('Hook System Integration', () => { expect(contextResult.continue).toBe(true); expect(contextResult.suppressOutput).toBe(false); expect(contextResult.additionalContext).toBeDefined(); - expect(contextResult.additionalContext).toContain('Previous Sessions'); + expect(contextResult.additionalContext).toContain('Previous Session Summaries'); expect(contextResult.additionalContext).toContain('Recent Activity'); - expect(contextResult.additionalContext).toContain('Write'); + expect(contextResult.additionalContext).toContain('auth.ts'); }); it('should handle multiple projects independently', async () => { @@ -333,8 +336,8 @@ describe('Hook System Integration', () => { sessionId, project, toolName: 'Read', - toolInput: {}, - toolResponse: {}, + toolInput: { file_path: '/test/file.ts' }, + toolResponse: { content: 'test content' }, })); // Another successful observation @@ -342,8 +345,8 @@ describe('Hook System Integration', () => { sessionId, project, toolName: 'Write', - toolInput: {}, - toolResponse: {}, + toolInput: { file_path: '/test/output.ts' }, + toolResponse: { success: true }, })); await obsHook.shutdown(); @@ -376,8 +379,8 @@ describe('Hook System Integration', () => { sessionId: 'multi-1', project, toolName: 'Read', - toolInput: {}, - toolResponse: {}, + toolInput: { file_path: '/test/file1.ts' }, + toolResponse: { content: 'content1' }, })); await obsHook1.shutdown(); @@ -386,8 +389,8 @@ describe('Hook System Integration', () => { sessionId: 'multi-2', project, toolName: 'Write', - toolInput: {}, - toolResponse: {}, + toolInput: { file_path: '/test/file2.ts' }, + toolResponse: { success: true }, })); await obsHook2.shutdown(); diff --git a/src/hooks/__tests__/service-queue-worker.test.ts b/src/hooks/__tests__/service-queue-worker.test.ts new file mode 100644 index 0000000..8a6be8b --- /dev/null +++ b/src/hooks/__tests__/service-queue-worker.test.ts @@ -0,0 +1,981 @@ +/** + * Tests for task queue, worker lifecycle, session summaries, + * user prompts, transcript extraction, and embedding text generation. + * + * Covers the uncovered lines in service.ts that the original + * service.test.ts did not reach. + * + * @module @agentkits/memory/hooks/__tests__/service-queue-worker + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'; +import * as path from 'node:path'; +import { MemoryHookService, extractLastAssistantMessage } from '../service.js'; +import { _setRunClaudePrintMockForTesting, resetAIEnrichmentCache } from '../ai-enrichment.js'; + +const TEST_DIR = path.join(process.cwd(), '.test-queue-worker'); + +describe('MemoryHookService - Queue, Worker, Summaries', () => { + let service: MemoryHookService; + + beforeEach(async () => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true }); + } + mkdirSync(TEST_DIR, { recursive: true }); + + service = new MemoryHookService(TEST_DIR); + await service.initialize(); + }); + + afterEach(async () => { + try { await service.shutdown(); } catch { /* ignore */ } + resetAIEnrichmentCache(); + _setRunClaudePrintMockForTesting(null); + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true }); + } + }); + + // ===== Task Queue ===== + + describe('queueTask', () => { + it('should insert a task into the queue', async () => { + service.queueTask('embed', 'observations', 'obs_123'); + + // Verify via direct DB query through public methods + // We'll check by looking at processEmbeddingQueue behavior + // But we can also rely on the storeObservation auto-queueing test below + // For now, just ensure it doesn't throw + expect(true).toBe(true); + }); + + it('should not throw when db is null', async () => { + await service.shutdown(); + // After shutdown, db is null + expect(() => service.queueTask('embed', 'observations', 'obs_123')).not.toThrow(); + }); + + it('should queue both embed and enrich tasks when storing observation', async () => { + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + + // Both tasks should be queued — processEmbeddingQueue should find the embed task + // processEnrichmentQueue should find the enrich task + // We can't easily query task_queue directly, but we verify via processing + }); + + it('should queue embed task when saving user prompt', async () => { + await service.initSession('s1', 'proj'); + const prompt = await service.saveUserPrompt('s1', 'proj', 'Hello world'); + + expect(prompt.id).toBeGreaterThan(0); + expect(prompt.promptNumber).toBe(1); + expect(prompt.promptText).toBe('Hello world'); + }); + + it('should queue embed task when saving session summary', async () => { + await service.initSession('s1', 'proj'); + const summary = await service.saveSessionSummary({ + sessionId: 's1', + project: 'proj', + request: 'Add feature', + completed: '1 file modified', + filesRead: ['a.ts'], + filesModified: ['b.ts'], + nextSteps: '', + notes: '', + promptNumber: 1, + }); + + expect(summary.id).toBeGreaterThan(0); + expect(summary.request).toBe('Add feature'); + expect(summary.completed).toBe('1 file modified'); + expect(summary.createdAt).toBeGreaterThan(0); + }); + }); + + // ===== User Prompts ===== + + describe('saveUserPrompt', () => { + it('should save multiple prompts with incrementing numbers', async () => { + await service.initSession('s1', 'proj'); + + const p1 = await service.saveUserPrompt('s1', 'proj', 'First prompt'); + const p2 = await service.saveUserPrompt('s1', 'proj', 'Second prompt'); + const p3 = await service.saveUserPrompt('s1', 'proj', 'Third prompt'); + + expect(p1.promptNumber).toBe(1); + expect(p2.promptNumber).toBe(2); + expect(p3.promptNumber).toBe(3); + }); + + it('should auto-create session if not exists', async () => { + const prompt = await service.saveUserPrompt('new-session', 'proj', 'Hello'); + + expect(prompt.id).toBeGreaterThan(0); + const session = service.getSession('new-session'); + expect(session).not.toBeNull(); + }); + }); + + describe('getSessionPrompts', () => { + it('should return prompts in order', async () => { + await service.initSession('s1', 'proj'); + await service.saveUserPrompt('s1', 'proj', 'First'); + await service.saveUserPrompt('s1', 'proj', 'Second'); + + const prompts = await service.getSessionPrompts('s1'); + + expect(prompts).toHaveLength(2); + expect(prompts[0].promptText).toBe('First'); + expect(prompts[1].promptText).toBe('Second'); + }); + + it('should return empty array for session with no prompts', async () => { + await service.initSession('s1', 'proj'); + const prompts = await service.getSessionPrompts('s1'); + expect(prompts).toHaveLength(0); + }); + }); + + describe('getRecentPrompts', () => { + it('should return prompts across sessions for project', async () => { + await service.initSession('s1', 'proj'); + await service.initSession('s2', 'proj'); + await service.saveUserPrompt('s1', 'proj', 'Session 1 prompt'); + await service.saveUserPrompt('s2', 'proj', 'Session 2 prompt'); + + const prompts = await service.getRecentPrompts('proj'); + + expect(prompts).toHaveLength(2); + }); + + it('should not return prompts from other projects', async () => { + await service.initSession('s1', 'proj-a'); + await service.initSession('s2', 'proj-b'); + await service.saveUserPrompt('s1', 'proj-a', 'A prompt'); + await service.saveUserPrompt('s2', 'proj-b', 'B prompt'); + + const prompts = await service.getRecentPrompts('proj-a'); + expect(prompts).toHaveLength(1); + expect(prompts[0].promptText).toBe('A prompt'); + }); + }); + + describe('getPromptNumber', () => { + it('should return 0 for session with no prompts', async () => { + await service.initSession('s1', 'proj'); + expect(service.getPromptNumber('s1')).toBe(0); + }); + + it('should return correct count after saving prompts', async () => { + await service.initSession('s1', 'proj'); + await service.saveUserPrompt('s1', 'proj', 'First'); + await service.saveUserPrompt('s1', 'proj', 'Second'); + expect(service.getPromptNumber('s1')).toBe(2); + }); + + it('should return 0 when db is null', async () => { + await service.shutdown(); + expect(service.getPromptNumber('s1')).toBe(0); + }); + }); + + // ===== Session Summaries ===== + + describe('generateStructuredSummary', () => { + it('should summarize observations by type', async () => { + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + await service.storeObservation('s1', 'proj', 'Write', { file_path: 'b.ts' }, {}, TEST_DIR); + await service.storeObservation('s1', 'proj', 'Bash', { command: 'npm test' }, {}, TEST_DIR); + await service.storeObservation('s1', 'proj', 'WebSearch', { query: 'test' }, {}, TEST_DIR); + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.completed).toContain('file(s) modified'); + expect(summary.completed).toContain('file(s) read'); + expect(summary.completed).toContain('command(s) executed'); + expect(summary.completed).toContain('search(es)'); + expect(summary.filesRead).toContain('a.ts'); + expect(summary.filesModified).toContain('b.ts'); + }); + + it('should include user prompts in request field', async () => { + await service.initSession('s1', 'proj'); + await service.saveUserPrompt('s1', 'proj', 'Fix the bug'); + await service.saveUserPrompt('s1', 'proj', 'Also add tests'); + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.request).toContain('Fix the bug'); + expect(summary.request).toContain('Also add tests'); + expect(summary.request).toContain('[#1]'); + expect(summary.request).toContain('[#2]'); + }); + + it('should fallback to session prompt when no user_prompts exist', async () => { + await service.initSession('s1', 'proj', 'My initial task'); + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.request).toBe('My initial task'); + }); + + it('should include command notes', async () => { + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Bash', { command: 'npm test' }, {}, TEST_DIR); + await service.storeObservation('s1', 'proj', 'Bash', { command: 'npm run build' }, {}, TEST_DIR); + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.notes).toContain('Commands:'); + expect(summary.notes).toContain('npm test'); + expect(summary.notes).toContain('npm run build'); + }); + + it('should return empty for session with no observations', async () => { + await service.initSession('s1', 'proj'); + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.completed).toBe('No activity recorded'); + expect(summary.filesRead).toHaveLength(0); + expect(summary.filesModified).toHaveLength(0); + }); + + it('should truncate more than 5 commands with +N more', async () => { + await service.initSession('s1', 'proj'); + for (let i = 0; i < 8; i++) { + await service.storeObservation('s1', 'proj', 'Bash', { command: `cmd-${i}` }, {}, TEST_DIR); + } + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.notes).toContain('(+3 more)'); + }); + }); + + describe('saveSessionSummary', () => { + it('should persist summary to database', async () => { + await service.initSession('s1', 'proj'); + + const saved = await service.saveSessionSummary({ + sessionId: 's1', + project: 'proj', + request: 'Implement feature X', + completed: '3 files modified', + filesRead: ['a.ts', 'b.ts'], + filesModified: ['c.ts'], + nextSteps: 'Write tests', + notes: 'Commands: npm test', + promptNumber: 2, + }); + + const summaries = await service.getRecentSummaries('proj'); + expect(summaries).toHaveLength(1); + expect(summaries[0].request).toBe('Implement feature X'); + expect(summaries[0].completed).toBe('3 files modified'); + expect(summaries[0].filesRead).toEqual(['a.ts', 'b.ts']); + expect(summaries[0].filesModified).toEqual(['c.ts']); + expect(summaries[0].nextSteps).toBe('Write tests'); + expect(summaries[0].notes).toBe('Commands: npm test'); + expect(summaries[0].promptNumber).toBe(2); + }); + }); + + describe('getRecentSummaries', () => { + it('should return summaries in reverse chronological order', async () => { + await service.initSession('s1', 'proj'); + await service.initSession('s2', 'proj'); + await service.saveSessionSummary({ + sessionId: 's1', project: 'proj', request: 'First', + completed: '', filesRead: [], filesModified: [], + nextSteps: '', notes: '', promptNumber: 1, + }); + await new Promise(resolve => setTimeout(resolve, 10)); + await service.saveSessionSummary({ + sessionId: 's2', project: 'proj', request: 'Second', + completed: '', filesRead: [], filesModified: [], + nextSteps: '', notes: '', promptNumber: 1, + }); + + const summaries = await service.getRecentSummaries('proj'); + + expect(summaries).toHaveLength(2); + expect(summaries[0].request).toBe('Second'); + expect(summaries[1].request).toBe('First'); + }); + + it('should respect limit parameter', async () => { + await service.initSession('s1', 'proj'); + for (let i = 0; i < 5; i++) { + await service.saveSessionSummary({ + sessionId: 's1', project: 'proj', request: `Summary ${i}`, + completed: '', filesRead: [], filesModified: [], + nextSteps: '', notes: '', promptNumber: 1, + }); + } + + const summaries = await service.getRecentSummaries('proj', 2); + expect(summaries).toHaveLength(2); + }); + }); + + // ===== Enrich Session Summary ===== + + describe('enrichSessionSummary', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should enrich summary with AI data', async () => { + await service.initSession('s1', 'proj'); + await service.saveSessionSummary({ + sessionId: 's1', project: 'proj', request: 'Fix bug', + completed: '1 file modified', filesRead: [], filesModified: ['a.ts'], + nextSteps: '', notes: '', promptNumber: 1, + }); + + // Create a fake transcript file + const transcriptPath = path.join(TEST_DIR, 'transcript.jsonl'); + writeFileSync(transcriptPath, JSON.stringify({ + type: 'assistant', + message: { content: 'I fixed the bug in a.ts by updating the validation logic.' }, + }) + '\n'); + + // Mock AI response + _setRunClaudePrintMockForTesting(() => JSON.stringify({ + completed: 'Fixed validation bug in a.ts by correcting the regex pattern.', + nextSteps: 'Consider adding unit tests for the validation function.', + })); + + const result = await service.enrichSessionSummary('s1', transcriptPath); + expect(result).toBe(true); + + // Verify the enriched data + const summaries = await service.getRecentSummaries('proj'); + expect(summaries[0].completed).toBe('Fixed validation bug in a.ts by correcting the regex pattern.'); + expect(summaries[0].nextSteps).toBe('Consider adding unit tests for the validation function.'); + }); + + it('should return false for non-existent session', async () => { + const transcriptPath = path.join(TEST_DIR, 'transcript.jsonl'); + writeFileSync(transcriptPath, '{}'); + + const result = await service.enrichSessionSummary('non-existent', transcriptPath); + expect(result).toBe(false); + }); + + it('should return false when transcript has no assistant message', async () => { + await service.initSession('s1', 'proj'); + await service.saveSessionSummary({ + sessionId: 's1', project: 'proj', request: 'Task', + completed: '', filesRead: [], filesModified: [], + nextSteps: '', notes: '', promptNumber: 1, + }); + + const transcriptPath = path.join(TEST_DIR, 'transcript.jsonl'); + writeFileSync(transcriptPath, JSON.stringify({ type: 'user', message: { content: 'Hi' } }) + '\n'); + + const result = await service.enrichSessionSummary('s1', transcriptPath); + expect(result).toBe(false); + }); + + it('should return false when AI enrichment fails', async () => { + await service.initSession('s1', 'proj'); + await service.saveSessionSummary({ + sessionId: 's1', project: 'proj', request: 'Task', + completed: '', filesRead: [], filesModified: [], + nextSteps: '', notes: '', promptNumber: 1, + }); + + const transcriptPath = path.join(TEST_DIR, 'transcript.jsonl'); + writeFileSync(transcriptPath, JSON.stringify({ + type: 'assistant', + message: { content: 'Done.' }, + }) + '\n'); + + _setRunClaudePrintMockForTesting(() => 'not valid json'); + + const result = await service.enrichSessionSummary('s1', transcriptPath); + expect(result).toBe(false); + }); + }); + + // ===== Worker Lock File ===== + + describe('ensureWorkerRunning', () => { + it('should not throw when called', () => { + // Worker spawning will fail (no dist/hooks/cli.js) but shouldn't throw + expect(() => service.ensureWorkerRunning(TEST_DIR, 'embed-session', 'test-embed.lock')).not.toThrow(); + }); + + it('should skip when lock file has alive PID', () => { + const lockDir = path.join(TEST_DIR, '.claude/memory'); + const lockFile = path.join(lockDir, 'test-worker.lock'); + + // Write current process PID (which is alive) + writeFileSync(lockFile, String(process.pid)); + + // Should return early (worker "alive") + service.ensureWorkerRunning(TEST_DIR, 'embed-session', 'test-worker.lock'); + + // Lock file should still exist (not cleaned up) + expect(existsSync(lockFile)).toBe(true); + }); + + it('should clean up stale lock file with dead PID', () => { + const lockDir = path.join(TEST_DIR, '.claude/memory'); + const lockFile = path.join(lockDir, 'test-stale.lock'); + + // Write a PID that doesn't exist (very high number) + writeFileSync(lockFile, '999999999'); + + // Should clean up stale lock and try to spawn + service.ensureWorkerRunning(TEST_DIR, 'embed-session', 'test-stale.lock'); + + // Lock file should be recreated (atomic O_EXCL) with '0' placeholder + // or cleaned up if spawn failed — either way the stale one was removed + }); + + it('should clean up lock file with invalid content', () => { + const lockDir = path.join(TEST_DIR, '.claude/memory'); + const lockFile = path.join(lockDir, 'test-invalid.lock'); + + writeFileSync(lockFile, 'not-a-pid'); + + // Should handle gracefully + expect(() => service.ensureWorkerRunning(TEST_DIR, 'embed-session', 'test-invalid.lock')).not.toThrow(); + }); + + it('should clean up lock file with pid 0', () => { + const lockDir = path.join(TEST_DIR, '.claude/memory'); + const lockFile = path.join(lockDir, 'test-zero.lock'); + + writeFileSync(lockFile, '0'); + + // PID 0 is invalid — should clean up + expect(() => service.ensureWorkerRunning(TEST_DIR, 'embed-session', 'test-zero.lock')).not.toThrow(); + }); + }); + + // ===== Process Enrichment Queue ===== + + describe('processEnrichmentQueue', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + // Clean up lock files + const lockFile = path.join(TEST_DIR, '.claude/memory', 'enrich-worker.lock'); + try { unlinkSync(lockFile); } catch { /* ignore */ } + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should return 0 when queue is empty', async () => { + const count = await service.processEnrichmentQueue(); + expect(count).toBe(0); + }); + + it('should process queued enrich tasks', async () => { + // Store an observation (auto-queues enrich task) + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Read', { file_path: 'a.ts' }, { content: 'hello' }, TEST_DIR); + + // Mock AI response for enrichment + _setRunClaudePrintMockForTesting(() => JSON.stringify({ + subtitle: 'Reading a.ts', + narrative: 'Examined a.ts to understand its contents.', + facts: ['File is small'], + concepts: ['typescript'], + })); + + const count = await service.processEnrichmentQueue(); + expect(count).toBe(1); + + // Verify observation was enriched + const obs = await service.getSessionObservations('s1'); + expect(obs[0].subtitle).toBe('Reading a.ts'); + expect(obs[0].narrative).toBe('Examined a.ts to understand its contents.'); + }); + + it('should still count task when enrichObservation returns false (graceful failure)', async () => { + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + + // Mock AI that throws — enrichObservation catches internally and returns false + _setRunClaudePrintMockForTesting(() => { throw new Error('Network error'); }); + + const count = await service.processEnrichmentQueue(); + // enrichObservation catches the error internally (returns false, doesn't throw) + // so processEnrichmentQueue treats it as processed and deletes the task + expect(count).toBe(1); + + // Queue should be empty now — second run processes nothing + _setRunClaudePrintMockForTesting(() => JSON.stringify({ + subtitle: 'Reading a.ts', + narrative: 'Read a.ts.', + facts: [], + concepts: [], + })); + + const count2 = await service.processEnrichmentQueue(); + expect(count2).toBe(0); + }); + + it('should clean up lock file after processing', async () => { + const lockFile = path.join(TEST_DIR, '.claude/memory', 'enrich-worker.lock'); + + await service.processEnrichmentQueue(); + + // Lock file should be cleaned up + expect(existsSync(lockFile)).toBe(false); + }); + + it('should handle session_summaries task type (skip without error)', async () => { + // Manually queue a session_summaries enrich task + service.queueTask('enrich', 'session_summaries', '1'); + + const count = await service.processEnrichmentQueue(); + // Should count it as processed (deleted from queue) + expect(count).toBe(1); + }); + }); + + // ===== Process Embedding Queue ===== + + describe('processEmbeddingQueue', () => { + afterEach(() => { + // Clean up lock files + const lockFile = path.join(TEST_DIR, '.claude/memory', 'embed-worker.lock'); + try { unlinkSync(lockFile); } catch { /* ignore */ } + }); + + it('should return 0 when queue is empty and no missing embeddings', async () => { + const count = await service.processEmbeddingQueue(); + expect(count).toBe(0); + }); + + it('should clean up lock file after processing', async () => { + const lockFile = path.join(TEST_DIR, '.claude/memory', 'embed-worker.lock'); + + await service.processEmbeddingQueue(); + + expect(existsSync(lockFile)).toBe(false); + }); + + it('should skip queue items with unknown target_table', async () => { + service.queueTask('embed', 'nonexistent_table', '1'); + + // processEmbeddingQueue requires the embedding model which we can't load in tests + // But we can verify the queue item gets cleaned up by the unknown table check + // The function will attempt to load LocalEmbeddingsService — this test verifies + // the early exit for unknown tables + try { + await service.processEmbeddingQueue(); + } catch { + // May fail on model loading — that's OK, the table check happens before embedding + } + }); + }); +}); + +// ===== Schema Migration ===== + +describe('MemoryHookService - Schema Migration', () => { + const MIGRATE_DIR = path.join(process.cwd(), '.test-migration'); + + beforeEach(() => { + if (existsSync(MIGRATE_DIR)) { + rmSync(MIGRATE_DIR, { recursive: true }); + } + mkdirSync(MIGRATE_DIR, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(MIGRATE_DIR)) { + rmSync(MIGRATE_DIR, { recursive: true }); + } + }); + + it('should migrate old observations table (add missing columns)', async () => { + // Manually create a DB with old schema (missing new columns) + const Database = (await import('better-sqlite3')).default; + const dbDir = path.join(MIGRATE_DIR, '.claude', 'memory'); + mkdirSync(dbDir, { recursive: true }); + const dbPath = path.join(dbDir, 'memory.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + + // Create observations table WITHOUT the new columns (prompt_number, files_read, etc.) + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT UNIQUE NOT NULL, + project TEXT NOT NULL, + prompt TEXT DEFAULT '', + started_at INTEGER NOT NULL, + ended_at INTEGER, + observation_count INTEGER DEFAULT 0, + summary TEXT, + status TEXT DEFAULT 'active' + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS observations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + tool_name TEXT NOT NULL, + tool_input TEXT, + tool_response TEXT, + cwd TEXT, + timestamp INTEGER NOT NULL, + type TEXT NOT NULL, + title TEXT + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS user_prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + prompt_number INTEGER NOT NULL, + prompt_text TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS session_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + request TEXT DEFAULT '', + completed TEXT DEFAULT '', + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + next_steps TEXT DEFAULT '', + notes TEXT DEFAULT '', + prompt_number INTEGER DEFAULT 0, + created_at INTEGER NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS task_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_type TEXT NOT NULL, + target_table TEXT NOT NULL, + target_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + status TEXT DEFAULT 'pending' + ) + `); + db.close(); + + // Now initialize MemoryHookService — it should detect missing columns and migrate + const service = new MemoryHookService(MIGRATE_DIR); + await service.initialize(); + + // Verify migration worked by storing an observation with new fields + await service.initSession('s1', 'proj'); + const obs = await service.storeObservation('s1', 'proj', 'Read', { file_path: 'test.ts' }, {}, MIGRATE_DIR); + + // Should have subtitle, narrative, etc. (these use the new columns) + const observations = await service.getSessionObservations('s1'); + expect(observations.length).toBe(1); + expect(observations[0].subtitle).toBeDefined(); + expect(observations[0].narrative).toBeDefined(); + expect(observations[0].promptNumber).toBeDefined(); + + await service.shutdown(); + }); + + it('should add embedding column to session tables during migration', async () => { + const Database = (await import('better-sqlite3')).default; + const dbDir = path.join(MIGRATE_DIR, '.claude', 'memory'); + mkdirSync(dbDir, { recursive: true }); + const dbPath = path.join(dbDir, 'memory.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + + // Create tables WITHOUT embedding column + db.exec(` + CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT UNIQUE NOT NULL, + project TEXT NOT NULL, + prompt TEXT DEFAULT '', + started_at INTEGER NOT NULL, + ended_at INTEGER, + observation_count INTEGER DEFAULT 0, + summary TEXT, + status TEXT DEFAULT 'active' + ) + `); + db.exec(` + CREATE TABLE observations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + tool_name TEXT NOT NULL, + tool_input TEXT, + tool_response TEXT, + cwd TEXT, + timestamp INTEGER NOT NULL, + type TEXT NOT NULL, + title TEXT, + prompt_number INTEGER, + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + subtitle TEXT, + narrative TEXT, + facts TEXT DEFAULT '[]', + concepts TEXT DEFAULT '[]' + ) + `); + db.exec(` + CREATE TABLE user_prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + prompt_number INTEGER NOT NULL, + prompt_text TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + db.exec(` + CREATE TABLE session_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + request TEXT DEFAULT '', + completed TEXT DEFAULT '', + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + next_steps TEXT DEFAULT '', + notes TEXT DEFAULT '', + prompt_number INTEGER DEFAULT 0, + created_at INTEGER NOT NULL + ) + `); + db.exec(` + CREATE TABLE task_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_type TEXT NOT NULL, + target_table TEXT NOT NULL, + target_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + status TEXT DEFAULT 'pending' + ) + `); + db.close(); + + // Initialize service — should add embedding BLOB to observations, user_prompts, session_summaries + const service = new MemoryHookService(MIGRATE_DIR); + await service.initialize(); + + // Verify by checking the column exists (store and retrieve data that uses embedding) + // The embedding column is BLOB — it won't break regular operations + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Read', { file_path: 'x.ts' }, {}, MIGRATE_DIR); + + const observations = await service.getSessionObservations('s1'); + expect(observations.length).toBe(1); + + await service.shutdown(); + + // Double-verify by opening DB directly and checking columns + const db2 = new Database(dbPath); + const obsCols = db2.prepare("PRAGMA table_info(observations)").all() as Array<{ name: string }>; + const promptCols = db2.prepare("PRAGMA table_info(user_prompts)").all() as Array<{ name: string }>; + const summaryCols = db2.prepare("PRAGMA table_info(session_summaries)").all() as Array<{ name: string }>; + db2.close(); + + expect(obsCols.some(c => c.name === 'embedding')).toBe(true); + expect(promptCols.some(c => c.name === 'embedding')).toBe(true); + expect(summaryCols.some(c => c.name === 'embedding')).toBe(true); + }); +}); + +// ===== extractLastAssistantMessage (exported standalone function) ===== + +describe('extractLastAssistantMessage', () => { + const TRANSCRIPT_DIR = path.join(process.cwd(), '.test-transcript'); + + beforeEach(() => { + if (existsSync(TRANSCRIPT_DIR)) rmSync(TRANSCRIPT_DIR, { recursive: true }); + mkdirSync(TRANSCRIPT_DIR, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TRANSCRIPT_DIR)) rmSync(TRANSCRIPT_DIR, { recursive: true }); + }); + + it('should return null for empty path', () => { + expect(extractLastAssistantMessage('')).toBeNull(); + }); + + it('should return null for non-existent file', () => { + expect(extractLastAssistantMessage('/nonexistent/path.jsonl')).toBeNull(); + }); + + it('should return null for empty file', () => { + const p = path.join(TRANSCRIPT_DIR, 'empty.jsonl'); + writeFileSync(p, ''); + expect(extractLastAssistantMessage(p)).toBeNull(); + }); + + it('should extract last assistant message (string content)', () => { + const p = path.join(TRANSCRIPT_DIR, 'transcript.jsonl'); + const lines = [ + JSON.stringify({ type: 'user', message: { content: 'Hello' } }), + JSON.stringify({ type: 'assistant', message: { content: 'I will help you.' } }), + JSON.stringify({ type: 'user', message: { content: 'Thanks' } }), + JSON.stringify({ type: 'assistant', message: { content: 'All done. The bug is fixed.' } }), + ]; + writeFileSync(p, lines.join('\n')); + + const result = extractLastAssistantMessage(p); + expect(result).toBe('All done. The bug is fixed.'); + }); + + it('should extract text from array content (skip tool_use blocks)', () => { + const p = path.join(TRANSCRIPT_DIR, 'array.jsonl'); + const msg = { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'I found the issue.' }, + { type: 'tool_use', id: 'tool1', name: 'Read', input: {} }, + { type: 'text', text: 'Here is the fix.' }, + ], + }, + }; + writeFileSync(p, JSON.stringify(msg)); + + const result = extractLastAssistantMessage(p); + expect(result).toBe('I found the issue.\nHere is the fix.'); + }); + + it('should strip system-reminder tags', () => { + const p = path.join(TRANSCRIPT_DIR, 'reminders.jsonl'); + const msg = { + type: 'assistant', + message: { + content: 'Real content here. This should be stripped More content.', + }, + }; + writeFileSync(p, JSON.stringify(msg)); + + const result = extractLastAssistantMessage(p); + expect(result).toContain('Real content here.'); + expect(result).toContain('More content.'); + expect(result).not.toContain('system-reminder'); + expect(result).not.toContain('This should be stripped'); + }); + + it('should skip lines that are not parseable JSON', () => { + const p = path.join(TRANSCRIPT_DIR, 'malformed.jsonl'); + const lines = [ + 'not json', + JSON.stringify({ type: 'assistant', message: { content: 'Valid message.' } }), + '{ broken json', + ]; + writeFileSync(p, lines.join('\n')); + + const result = extractLastAssistantMessage(p); + expect(result).toBe('Valid message.'); + }); + + it('should skip assistant messages without content', () => { + const p = path.join(TRANSCRIPT_DIR, 'nocontent.jsonl'); + const lines = [ + JSON.stringify({ type: 'assistant', message: { content: 'Good message.' } }), + JSON.stringify({ type: 'assistant', message: {} }), // no content + JSON.stringify({ type: 'assistant' }), // no message + ]; + writeFileSync(p, lines.join('\n')); + + const result = extractLastAssistantMessage(p); + expect(result).toBe('Good message.'); + }); + + it('should skip assistant messages with empty text array', () => { + const p = path.join(TRANSCRIPT_DIR, 'emptyarray.jsonl'); + const lines = [ + JSON.stringify({ type: 'assistant', message: { content: 'First good one.' } }), + JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }] } }), + ]; + writeFileSync(p, lines.join('\n')); + + const result = extractLastAssistantMessage(p); + // The second message has only tool_use, no text — should fall back to first + expect(result).toBe('First good one.'); + }); + + it('should truncate very long messages to 5000 chars', () => { + const p = path.join(TRANSCRIPT_DIR, 'long.jsonl'); + const longText = 'A'.repeat(10000); + writeFileSync(p, JSON.stringify({ + type: 'assistant', + message: { content: longText }, + })); + + const result = extractLastAssistantMessage(p); + expect(result).not.toBeNull(); + expect(result!.length).toBe(5000); + }); + + it('should return null when only user messages exist', () => { + const p = path.join(TRANSCRIPT_DIR, 'useronly.jsonl'); + writeFileSync(p, JSON.stringify({ type: 'user', message: { content: 'Hello' } })); + + expect(extractLastAssistantMessage(p)).toBeNull(); + }); + + it('should return null when path is a directory (readFileSync throws)', () => { + // existsSync returns true for directories, but readFileSync throws + const dir = path.join(TRANSCRIPT_DIR, 'subdir'); + mkdirSync(dir, { recursive: true }); + expect(extractLastAssistantMessage(dir)).toBeNull(); + }); + + it('should collapse triple+ newlines to double newlines', () => { + const p = path.join(TRANSCRIPT_DIR, 'newlines.jsonl'); + writeFileSync(p, JSON.stringify({ + type: 'assistant', + message: { content: 'Line one.\n\n\n\n\nLine two.' }, + })); + + const result = extractLastAssistantMessage(p); + expect(result).toBe('Line one.\n\nLine two.'); + }); +}); diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index 789dfe1..78a89ee 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -5,9 +5,11 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { existsSync, rmSync, mkdirSync } from 'node:fs'; +import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import * as path from 'node:path'; import { MemoryHookService, createHookService } from '../service.js'; +import { _setRunClaudePrintMockForTesting, resetAIEnrichmentCache } from '../ai-enrichment.js'; +import { computeContentHash } from '../types.js'; const TEST_DIR = path.join(process.cwd(), '.test-memory-hooks'); @@ -47,7 +49,7 @@ describe('MemoryHookService', () => { // Let's verify by adding some data and persisting await service.initSession('test', 'test-project'); - const dbPath = path.join(TEST_DIR, '.claude/memory', 'hooks.db'); + const dbPath = path.join(TEST_DIR, '.claude/memory', 'memory.db'); expect(existsSync(dbPath)).toBe(true); }); @@ -247,6 +249,46 @@ describe('MemoryHookService', () => { expect(context.markdown).toContain('test-project'); }); + it('should include tool-usage instructions in context header', async () => { + await service.initSession('session-1', 'test-project', 'Test'); + await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); + + const context = await service.getContext('test-project'); + + expect(context.markdown).toContain('Memory tools available'); + expect(context.markdown).toContain('memory_search'); + expect(context.markdown).toContain('memory_timeline'); + expect(context.markdown).toContain('memory_details'); + expect(context.markdown).toContain('memory_save'); + expect(context.markdown).toContain('memory_recall'); + expect(context.markdown).toContain('memory_delete'); + expect(context.markdown).toContain('memory_update'); + }); + + it('should include observation IDs in context', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); + + const context = await service.getContext('test-project'); + const obs = context.recentObservations[0]; + + // Observation ID should appear in markdown (format: [obs_xxxx_yyyy]) + expect(context.markdown).toContain(`[${obs.id}]`); + }); + + it('should include token economics footer when context exists', async () => { + await service.initSession('session-1', 'test-project', 'Test'); + await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); + await service.completeSession('session-1', 'Done'); + + const context = await service.getContext('test-project'); + + expect(context.markdown).toContain('tokens shown'); + expect(context.markdown).toContain('tokens available'); + expect(context.markdown).toContain('memory_search'); + expect(context.markdown).toContain('memory_details'); + }); + it('should include all observation type icons in context', async () => { await service.initSession('session-1', 'test-project'); @@ -386,7 +428,7 @@ describe('MemoryHookService', () => { const summary = await service.generateSummary('session-1'); - expect(summary).toBe('No activity recorded in this session.'); + expect(summary).toContain('No activity recorded'); }); it('should list files in summary', async () => { @@ -420,7 +462,105 @@ describe('MemoryHookService', () => { const summary = await service.generateSummary('session-1'); - expect(summary).toContain('7 files touched'); + expect(summary).toContain('7 file(s) modified'); + }); + }); + + describe('enrichObservation', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should enrich an existing observation with AI data', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', + { file_path: 'auth.ts' }, { content: 'export class Auth {}' }, TEST_DIR + ); + + // Reset cache and set up mock AI enrichment + resetAIEnrichmentCache(); + const validResponse = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module to understand login flow.', + facts: ['File has 200 lines', 'Uses JWT tokens'], + concepts: ['authentication', 'jwt'], + }); + _setRunClaudePrintMockForTesting(() => validResponse); + + const result = await service.enrichObservation(obs.id); + expect(result).toBe(true); + + // Verify the observation was updated in DB + const observations = await service.getSessionObservations('session-1'); + expect(observations[0].subtitle).toBe('Examining auth module'); + expect(observations[0].narrative).toBe('Read the auth module to understand login flow.'); + expect(observations[0].facts).toContain('File has 200 lines'); + expect(observations[0].concepts).toContain('jwt'); + }); + + it('should return false for non-existent observation', async () => { + await service.initialize(); + const result = await service.enrichObservation('obs_nonexistent_0000'); + expect(result).toBe(false); + }); + + it('should return false when AI enrichment returns null', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR + ); + + // Mock CLI that returns invalid response + _setRunClaudePrintMockForTesting(() => 'not valid json'); + + const result = await service.enrichObservation(obs.id); + expect(result).toBe(false); + + // Original template data should still be intact + const observations = await service.getSessionObservations('session-1'); + expect(observations[0].subtitle).toBeDefined(); + expect(observations[0].subtitle.length).toBeGreaterThan(0); + }); + + it('should return false when AI enrichment throws', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR + ); + + // Mock CLI that throws + _setRunClaudePrintMockForTesting(() => { throw new Error('CLI error'); }); + + const result = await service.enrichObservation(obs.id); + expect(result).toBe(false); + }); + + it('should preserve template data when enrichment is disabled', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', + { file_path: 'src/index.ts' }, { content: 'hello' }, TEST_DIR + ); + + // Template data should be present immediately + expect(obs.subtitle).toBeDefined(); + expect(obs.subtitle.length).toBeGreaterThan(0); + expect(obs.narrative).toBeDefined(); + expect(obs.narrative.length).toBeGreaterThan(0); }); }); @@ -432,7 +572,7 @@ describe('MemoryHookService', () => { await service.shutdown(); // Delete the database file - const dbPath = path.join(TEST_DIR, '.claude/memory', 'hooks.db'); + const dbPath = path.join(TEST_DIR, '.claude/memory', 'memory.db'); expect(existsSync(dbPath)).toBe(true); rmSync(dbPath); expect(existsSync(dbPath)).toBe(false); @@ -477,11 +617,1331 @@ describe('MemoryHookService', () => { }); }); - describe('createHookService factory', () => { - it('should create service with default config', () => { - const svc = createHookService(TEST_DIR); + describe('content hash deduplication', () => { + it('should deduplicate identical prompts within 5-minute window', async () => { + await service.initSession('session-1', 'test-project'); - expect(svc).toBeInstanceOf(MemoryHookService); + const prompt1 = await service.saveUserPrompt('session-1', 'test-project', 'Hello Claude'); + const prompt2 = await service.saveUserPrompt('session-1', 'test-project', 'Hello Claude'); + + // Should return the same prompt (dedup) + expect(prompt1.id).toBe(prompt2.id); + expect(prompt1.contentHash).toBeDefined(); + expect(prompt2.contentHash).toBe(prompt1.contentHash); + }); + + it('should allow different prompts in same session', async () => { + await service.initSession('session-1', 'test-project'); + + const prompt1 = await service.saveUserPrompt('session-1', 'test-project', 'First prompt'); + const prompt2 = await service.saveUserPrompt('session-1', 'test-project', 'Second prompt'); + + expect(prompt1.id).not.toBe(prompt2.id); + expect(prompt1.promptNumber).toBe(1); + expect(prompt2.promptNumber).toBe(2); + }); + + it('should deduplicate identical observations within 60-second window', async () => { + await service.initSession('session-1', 'test-project'); + + const obs1 = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + const obs2 = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + + // Should return the same observation (dedup) + expect(obs1.id).toBe(obs2.id); + + // Session count should only increment once + const session = service.getSession('session-1'); + expect(session?.observationCount).toBe(1); + }); + + it('should allow same tool on different files', async () => { + await service.initSession('session-1', 'test-project'); + + const obs1 = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR + ); + const obs2 = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'b.ts' }, {}, TEST_DIR + ); + + expect(obs1.id).not.toBe(obs2.id); + }); + }); + + describe('session resume detection', () => { + it('should link new session to recent parent in same project', async () => { + // Create first session + await service.initSession('session-old', 'test-project'); + + // Create second session shortly after + const session2 = await service.initSession('session-new', 'test-project'); + + expect(session2.parentSessionId).toBe('session-old'); + }); + + it('should not link sessions from different projects', async () => { + await service.initSession('session-1', 'project-a'); + const session2 = await service.initSession('session-2', 'project-b'); + + expect(session2.parentSessionId).toBeUndefined(); + }); + + it('should return existing session on re-init (no duplicate parent)', async () => { + await service.initSession('session-1', 'test-project'); + const first = await service.initSession('session-2', 'test-project'); + const second = await service.initSession('session-2', 'test-project'); + + // Re-init returns the same session + expect(first.sessionId).toBe(second.sessionId); + expect(first.parentSessionId).toBe(second.parentSessionId); + }); + }); + + describe('context XML wrapper', () => { + it('should wrap context in agentkits-memory-context tags', async () => { + await service.initSession('session-1', 'test-project', 'Test'); + await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); + + const context = await service.getContext('test-project'); + + expect(context.markdown).toContain(''); + expect(context.markdown).toContain(''); + expect(context.markdown).toContain("Use these naturally when relevant. Don't force them into every response."); + }); + }); + + describe('context grouping by prompt', () => { + it('should group observations by prompt number when prompts exist', async () => { + await service.initSession('session-1', 'test-project'); + + // Save prompt first + await service.saveUserPrompt('session-1', 'test-project', 'Fix the bug'); + + // Store observation linked to prompt 1 + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'bug.ts' }, {}, TEST_DIR + ); + + const context = await service.getContext('test-project'); + + // Should have prompt-based grouping + expect(context.markdown).toContain('Prompt #1'); + expect(context.markdown).toContain('Fix the bug'); + }); + }); + + describe('compressObservation', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should compress an observation and clear raw data', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', + { file_path: 'auth.ts' }, { content: 'big file content' }, TEST_DIR + ); + + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Read auth.ts for login flow analysis' }) + ); + + const result = await service.compressObservation(obs.id); + expect(result).toBe(true); + + // Verify compressed data in DB + const observations = await service.getSessionObservations('session-1'); + expect(observations[0].compressedSummary).toBe('Read auth.ts for login flow analysis'); + expect(observations[0].isCompressed).toBe(true); + expect(observations[0].toolInput).toBe('{}'); // raw data cleared + expect(observations[0].toolResponse).toBe('{}'); // raw data cleared + }); + + it('should skip already-compressed observations', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR + ); + + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'First compression' }) + ); + + // First compress + await service.compressObservation(obs.id); + + // Second compress should skip (already compressed) + const result = await service.compressObservation(obs.id); + expect(result).toBe(false); + }); + + it('should return false for non-existent observation', async () => { + await service.initialize(); + const result = await service.compressObservation('obs_nonexistent_0000'); + expect(result).toBe(false); + }); + + it('should return false when AI returns null', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR + ); + + _setRunClaudePrintMockForTesting(() => 'not json'); + + const result = await service.compressObservation(obs.id); + expect(result).toBe(false); + }); + }); + + describe('compressSessionObservations', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should compress all session observations and create digest', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + await service.storeObservation('session-1', 'test-project', 'Write', { file_path: 'b.ts' }, {}, TEST_DIR); + + // Save structured summary (needed for digest generation) + const structured = await service.generateStructuredSummary('session-1'); + await service.saveSessionSummary(structured); + + let callCount = 0; + _setRunClaudePrintMockForTesting(() => { + callCount++; + // First two calls: observation compression, third: session digest + if (callCount <= 2) { + return JSON.stringify({ compressed_summary: `Compressed obs ${callCount}` }); + } + return JSON.stringify({ digest: 'Session compressed all observations successfully.' }); + }); + + const result = await service.compressSessionObservations('session-1'); + expect(result.compressed).toBe(2); + expect(result.digestCreated).toBe(true); + }); + + it('should handle session with no summary gracefully', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); + + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Compressed' }) + ); + + const result = await service.compressSessionObservations('session-1'); + expect(result.compressed).toBe(1); + expect(result.digestCreated).toBe(false); // No summary = no digest + }); + }); + + describe('processCompressionQueue', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should process compress tasks from queue', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + + // Queue a compress task manually + service.queueTask('compress', 'observations', obs.id); + + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Read test.ts' }) + ); + + const count = await service.processCompressionQueue(); + expect(count).toBe(1); + + // Verify observation is compressed + const observations = await service.getSessionObservations('session-1'); + expect(observations[0].compressedSummary).toBe('Read test.ts'); + expect(observations[0].isCompressed).toBe(true); + }); + + it('should return 0 when queue is empty', async () => { + await service.initialize(); + const count = await service.processCompressionQueue(); + expect(count).toBe(0); + }); + }); + + describe('task queue retry limits', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should skip tasks that have reached max retries', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + + // Queue a compress task and set retry_count to 3 (max) + service.queueTask('compress', 'observations', obs.id); + service.db.prepare("UPDATE task_queue SET retry_count = 3 WHERE task_type = 'compress'").run(); + + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Should not run' }) + ); + + const count = await service.processCompressionQueue(); + expect(count).toBe(0); // Skipped — retry limit reached + + // Task should still be in queue as pending with retry_count=3 + const task = service.db.prepare("SELECT * FROM task_queue WHERE task_type = 'compress'").get() as Record; + expect(task.status).toBe('pending'); + expect(task.retry_count).toBe(3); + }); + + it('should increment retry_count on failure and mark failed at max', async () => { + await service.initSession('session-1', 'test-project'); + + // Queue a compress task for observations table + service.db.prepare( + "INSERT INTO task_queue (task_type, target_table, target_id, created_at) VALUES ('compress', 'observations', 'bad-id', ?)" + ).run(Date.now()); + + // Monkey-patch compressObservation to throw (simulating unhandled error) + const origCompress = service.compressObservation.bind(service); + service.compressObservation = async () => { throw new Error('unexpected failure'); }; + + // Worker loop retries within a single call — all 3 attempts exhaust in one run + const count = await service.processCompressionQueue(); + expect(count).toBe(3); // 3 failed attempts processed + + // Task should now be marked 'failed' with retry_count=3 + const task = service.db.prepare("SELECT * FROM task_queue WHERE task_type = 'compress'").get() as Record; + expect(task.status).toBe('failed'); + expect(task.retry_count).toBe(3); + + // Next call skips entirely — no pending tasks under retry limit + const count2 = await service.processCompressionQueue(); + expect(count2).toBe(0); + + // Restore + service.compressObservation = origCompress; + }); + + it('should include retry_count column in task_queue schema', async () => { + await service.initialize(); + const cols = service.db.prepare("PRAGMA table_info(task_queue)").all() as Array<{ name: string }>; + expect(cols.some(c => c.name === 'retry_count')).toBe(true); + }); + + it('should default retry_count to 0 for new tasks', async () => { + await service.initSession('session-1', 'test-project'); + service.queueTask('embed', 'observations', 'test-id'); + const task = service.db.prepare("SELECT retry_count FROM task_queue WHERE task_type = 'embed'").get() as Record; + expect(task.retry_count).toBe(0); + }); + }); + + describe('computeContentHash', () => { + it('should produce consistent hashes', () => { + const hash1 = computeContentHash('a', 'b', 'c'); + const hash2 = computeContentHash('a', 'b', 'c'); + expect(hash1).toBe(hash2); + }); + + it('should produce different hashes for different inputs', () => { + const hash1 = computeContentHash('a', 'b'); + const hash2 = computeContentHash('a', 'c'); + expect(hash1).not.toBe(hash2); + }); + + it('should produce 16-char hex string', () => { + const hash = computeContentHash('test'); + expect(hash).toMatch(/^[0-9a-f]{16}$/); + }); + }); + + // ===== Embedding Reliability ===== + + describe('hasPendingEmbeddings', () => { + it('should return false when no pending tasks or missing embeddings', async () => { + await service.initialize(); + expect(service.hasPendingEmbeddings()).toBe(false); + }); + + it('should return true when pending embed tasks exist', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + // storeObservation auto-queues embed task + expect(service.hasPendingEmbeddings()).toBe(true); + }); + + it('should return true when observations have null embeddings', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + // Clear task queue but leave embedding null + service.db.prepare("DELETE FROM task_queue").run(); + expect(service.hasPendingEmbeddings()).toBe(true); + }); + + it('should return false when db not initialized', async () => { + // service.db is null before initialize + expect(service.hasPendingEmbeddings()).toBe(false); + }); + + it('should ignore failed tasks at max retries', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + // Set all tasks to failed with max retries + service.db.prepare("UPDATE task_queue SET status = 'failed', retry_count = 3").run(); + // But observations still have null embeddings — should detect via DB scan + expect(service.hasPendingEmbeddings()).toBe(true); + }); + }); + + describe('hasPendingEnrichments', () => { + it('should return false when no pending enrich tasks', async () => { + await service.initialize(); + expect(service.hasPendingEnrichments()).toBe(false); + }); + + it('should return true when pending enrich tasks exist', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + // Manually queue an enrich task + service.db.prepare( + "INSERT INTO task_queue (task_type, target_id, target_table, status, created_at) VALUES ('enrich', ?, 'observations', 'pending', datetime('now'))" + ).run(obs.id); + expect(service.hasPendingEnrichments()).toBe(true); + }); + + it('should ignore failed tasks at max retries', async () => { + await service.initialize(); + service.db.prepare( + "INSERT INTO task_queue (task_type, target_id, target_table, status, retry_count, created_at) VALUES ('enrich', 'obs-1', 'observations', 'failed', 3, datetime('now'))" + ).run(); + expect(service.hasPendingEnrichments()).toBe(false); + }); + + it('should return false when db not initialized', () => { + expect(service.hasPendingEnrichments()).toBe(false); + }); + }); + + describe('hasPendingCompressions', () => { + it('should return false when no pending compress tasks', async () => { + await service.initialize(); + expect(service.hasPendingCompressions()).toBe(false); + }); + + it('should return true when pending compress tasks exist', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + service.db.prepare( + "INSERT INTO task_queue (task_type, target_id, target_table, status, created_at) VALUES ('compress', ?, 'observations', 'pending', datetime('now'))" + ).run(obs.id); + expect(service.hasPendingCompressions()).toBe(true); + }); + + it('should return true when pending digest tasks exist', async () => { + await service.initSession('session-1', 'test-project'); + service.db.prepare( + "INSERT INTO task_queue (task_type, target_id, target_table, status, created_at) VALUES ('digest', 'session-1', 'sessions', 'pending', datetime('now'))" + ).run(); + expect(service.hasPendingCompressions()).toBe(true); + }); + + it('should ignore failed tasks at max retries', async () => { + await service.initialize(); + service.db.prepare( + "INSERT INTO task_queue (task_type, target_id, target_table, status, retry_count, created_at) VALUES ('compress', 'obs-1', 'observations', 'failed', 3, datetime('now'))" + ).run(); + expect(service.hasPendingCompressions()).toBe(false); + }); + + it('should return false when db not initialized', () => { + expect(service.hasPendingCompressions()).toBe(false); + }); + }); + + // ===== Persistent Settings ===== + + describe('loadSettings / saveSettings', () => { + it('should return defaults when no settings file exists', async () => { + await service.initialize(); + const settings = service.loadSettings(); + expect(settings.context.showSummaries).toBe(true); + expect(settings.context.showPrompts).toBe(true); + expect(settings.context.showObservations).toBe(true); + expect(settings.context.showToolGuidance).toBe(true); + expect(settings.context.maxSummaries).toBe(3); + expect(settings.context.maxPrompts).toBe(10); + expect(settings.context.maxObservations).toBe(10); + }); + + it('should read settings from .claude/memory/settings.json', async () => { + await service.initialize(); + // Write a custom settings file + const settingsPath = path.join(TEST_DIR, '.claude', 'memory', 'settings.json'); + mkdirSync(path.dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify({ + context: { showSummaries: false, maxObservations: 25 }, + })); + + const settings = service.loadSettings(); + expect(settings.context.showSummaries).toBe(false); + expect(settings.context.maxObservations).toBe(25); + // Missing keys get defaults + expect(settings.context.showPrompts).toBe(true); + expect(settings.context.maxSummaries).toBe(3); + }); + + it('should return defaults on corrupted settings file', async () => { + await service.initialize(); + const settingsPath = path.join(TEST_DIR, '.claude', 'memory', 'settings.json'); + mkdirSync(path.dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, '{ invalid json'); + + const settings = service.loadSettings(); + expect(settings.context.showSummaries).toBe(true); + expect(settings.context.maxObservations).toBe(10); + }); + + it('should save settings to disk', async () => { + await service.initialize(); + const customSettings = { + context: { + showSummaries: false, + showPrompts: true, + showObservations: true, + showToolGuidance: false, + maxSummaries: 5, + maxPrompts: 20, + maxObservations: 30, + }, + }; + service.saveSettings(customSettings); + + // Verify file contents + const settingsPath = path.join(TEST_DIR, '.claude', 'memory', 'settings.json'); + const saved = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(saved.context.showSummaries).toBe(false); + expect(saved.context.maxObservations).toBe(30); + }); + + it('should round-trip save and load', async () => { + await service.initialize(); + const original = { + context: { + showSummaries: false, + showPrompts: false, + showObservations: true, + showToolGuidance: true, + maxSummaries: 1, + maxPrompts: 5, + maxObservations: 15, + }, + }; + service.saveSettings(original); + const loaded = service.loadSettings(); + expect(loaded).toEqual(original); + }); + + it('should save and load aiProvider settings', async () => { + await service.initialize(); + const settings = { + context: { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: true, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, + }, + aiProvider: { + provider: 'openai' as const, + apiKey: 'sk-test-key', + baseUrl: 'https://openrouter.ai/api/v1', + model: 'anthropic/claude-3.5-haiku', + }, + }; + service.saveSettings(settings); + const loaded = service.loadSettings(); + expect(loaded.aiProvider).toBeDefined(); + expect(loaded.aiProvider!.provider).toBe('openai'); + expect(loaded.aiProvider!.apiKey).toBe('sk-test-key'); + expect(loaded.aiProvider!.baseUrl).toBe('https://openrouter.ai/api/v1'); + expect(loaded.aiProvider!.model).toBe('anthropic/claude-3.5-haiku'); + }); + + it('should return undefined aiProvider when not set in settings', async () => { + await service.initialize(); + const loaded = service.loadSettings(); + expect(loaded.aiProvider).toBeUndefined(); + }); + }); + + describe('getContext with settings', () => { + it('should use settings from disk', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR + ); + + // Save settings to disable summaries + service.saveSettings({ + context: { + showSummaries: false, + showPrompts: true, + showObservations: true, + showToolGuidance: false, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, + }, + }); + + const context = await service.getContext('test-project'); + // Tool guidance should be absent when showToolGuidance=false + expect(context.markdown).not.toContain('Memory tools available'); + }); + + it('should respect configOverride over disk settings', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR + ); + + // Disk settings: guidance enabled + service.saveSettings({ + context: { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: true, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, + }, + }); + + // Override: disable guidance + const context = await service.getContext('test-project', { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: false, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, + }); + expect(context.markdown).not.toContain('Memory tools available'); + }); + + it('should limit observations by maxObservations setting', async () => { + await service.initSession('session-1', 'test-project'); + // Store 5 observations + for (let i = 0; i < 5; i++) { + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: `file${i}.ts` }, {}, TEST_DIR + ); + } + + // Set maxObservations=2 + service.saveSettings({ + context: { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: true, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 2, + }, + }); + + const context = await service.getContext('test-project'); + expect(context.recentObservations.length).toBe(2); + }); + }); + + describe('createHookService factory', () => { + it('should create service with default config', () => { + const svc = createHookService(TEST_DIR); + + expect(svc).toBeInstanceOf(MemoryHookService); + }); + }); + + // ===== Feature #5: Intent Tags ===== + + describe('intent detection in storeObservation', () => { + it('should add intent tags to concepts when prompt exists', async () => { + await service.initialize(); + await service.initSession('intent-session', 'test-project', 'Fix the login bug'); + await service.saveUserPrompt('intent-session', 'test-project', 'Fix the login bug'); + + const obs = await service.storeObservation( + 'intent-session', 'test-project', 'Edit', + { file_path: 'src/auth.ts', old_string: 'foo', new_string: 'bar' }, + { success: true }, TEST_DIR + ); + + expect(obs.concepts).toBeDefined(); + const intentConcepts = obs.concepts!.filter(c => c.startsWith('intent:')); + expect(intentConcepts.length).toBeGreaterThan(0); + expect(intentConcepts).toContain('intent:bugfix'); + }); + + it('should default to investigation for read without prompt', async () => { + await service.initialize(); + await service.initSession('intent-session-2', 'test-project'); + + const obs = await service.storeObservation( + 'intent-session-2', 'test-project', 'Read', + { file_path: 'src/app.ts' }, + { content: 'file contents' }, TEST_DIR + ); + + const intentConcepts = obs.concepts!.filter(c => c.startsWith('intent:')); + expect(intentConcepts).toContain('intent:investigation'); + }); + }); + + describe('getLatestPromptText', () => { + it('should return latest prompt text', async () => { + await service.initialize(); + await service.initSession('prompt-text-session', 'test-project', 'Hello'); + await service.saveUserPrompt('prompt-text-session', 'test-project', 'First prompt'); + await service.saveUserPrompt('prompt-text-session', 'test-project', 'Second prompt'); + + const text = service.getLatestPromptText('prompt-text-session'); + expect(text).toBe('Second prompt'); + }); + + it('should return null when no prompts exist', async () => { + await service.initialize(); + const text = service.getLatestPromptText('nonexistent-session'); + expect(text).toBeNull(); + }); + }); + + // ===== Feature #8: Lifecycle Management ===== + + describe('lifecycle management', () => { + it('should archive old completed sessions', async () => { + await service.initialize(); + + // Create an old completed session + await service.initSession('old-session', 'test-project', 'old task'); + await service.completeSession('old-session', 'Done'); + + // Manually backdate the session + // @ts-expect-error accessing private db for testing + service.db.prepare("UPDATE sessions SET ended_at = ? WHERE session_id = ?").run( + Date.now() - 40 * 86400000, // 40 days ago + 'old-session' + ); + + const result = await service.runLifecycleTasks({ archiveAfterDays: 30 }); + expect(result.archived).toBe(1); + + // Verify session is archived + const session = service.getSession('old-session'); + expect(session?.status).toBe('archived'); + }); + + it('should not archive recent sessions', async () => { + await service.initialize(); + + await service.initSession('recent-session', 'test-project', 'recent task'); + await service.completeSession('recent-session', 'Done'); + + const result = await service.runLifecycleTasks({ archiveAfterDays: 30 }); + expect(result.archived).toBe(0); + + const session = service.getSession('recent-session'); + expect(session?.status).toBe('completed'); + }); + + it('should delete archived sessions when autoDelete enabled', async () => { + await service.initialize(); + + await service.initSession('delete-session', 'test-project', 'delete task'); + await service.completeSession('delete-session', 'Done'); + + // @ts-expect-error accessing private db for testing + service.db.prepare("UPDATE sessions SET status = 'archived', ended_at = ? WHERE session_id = ?").run( + Date.now() - 100 * 86400000, // 100 days ago + 'delete-session' + ); + + const result = await service.runLifecycleTasks({ + autoDelete: true, + deleteAfterDays: 90, + }); + expect(result.deleted).toBe(1); + expect(result.vacuumed).toBe(true); + + const session = service.getSession('delete-session'); + expect(session).toBeNull(); + }); + + it('should not delete when autoDelete is false (default)', async () => { + await service.initialize(); + + await service.initSession('keep-session', 'test-project', 'keep task'); + await service.completeSession('keep-session', 'Done'); + + // @ts-expect-error accessing private db for testing + service.db.prepare("UPDATE sessions SET status = 'archived', ended_at = ? WHERE session_id = ?").run( + Date.now() - 100 * 86400000, + 'keep-session' + ); + + const result = await service.runLifecycleTasks({ autoDelete: false }); + expect(result.deleted).toBe(0); + + const session = service.getSession('keep-session'); + expect(session).not.toBeNull(); + }); + + it('should queue compression for old uncompressed observations', async () => { + await service.initialize(); + await service.initSession('compress-lc-session', 'test-project', 'task'); + + await service.storeObservation( + 'compress-lc-session', 'test-project', 'Read', + { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR + ); + + // Backdate the observation + // @ts-expect-error accessing private db for testing + service.db.prepare("UPDATE observations SET timestamp = ? WHERE session_id = ?").run( + Date.now() - 10 * 86400000, // 10 days ago + 'compress-lc-session' + ); + + const result = await service.runLifecycleTasks({ compressAfterDays: 7 }); + expect(result.compressed).toBe(1); + }); + }); + + describe('lifecycle stats', () => { + it('should return database statistics', async () => { + await service.initialize(); + + await service.initSession('stats-session', 'test-project', 'stats'); + await service.storeObservation( + 'stats-session', 'test-project', 'Read', + { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR + ); + await service.saveUserPrompt('stats-session', 'test-project', 'test prompt'); + + const stats = await service.getLifecycleStats(); + expect(stats.totalSessions).toBeGreaterThanOrEqual(1); + expect(stats.activeSessions).toBeGreaterThanOrEqual(1); + expect(stats.totalObservations).toBeGreaterThanOrEqual(1); + expect(stats.totalPrompts).toBeGreaterThanOrEqual(1); + expect(stats.dbSizeBytes).toBeGreaterThan(0); + }); + }); + + // ===== Feature #9: Export/Import ===== + + describe('structured diff capture', () => { + it('should include diff facts for Edit observations', async () => { + await service.initialize(); + await service.initSession('diff-session', 'test-project'); + const obs = await service.storeObservation( + 'diff-session', 'test-project', 'Edit', + { file_path: 'src/auth.ts', old_string: 'function login(user) {', new_string: 'function login(user, opts) {' }, + {}, TEST_DIR + ); + + expect(obs.facts).toBeDefined(); + expect(obs.facts!.some(f => f.includes('DIFF'))).toBe(true); + expect(obs.facts!.some(f => f.includes('function login(user) {'))).toBe(true); + expect(obs.facts!.some(f => f.includes('function login(user, opts) {'))).toBe(true); + }); + + it('should include diff info in narrative for Edit', async () => { + await service.initialize(); + await service.initSession('diff-session-2', 'test-project'); + const obs = await service.storeObservation( + 'diff-session-2', 'test-project', 'Edit', + { file_path: 'src/app.ts', old_string: 'const x = 1;', new_string: 'const x = 2;' }, + {}, TEST_DIR + ); + + expect(obs.narrative).toBeDefined(); + expect(obs.narrative).toContain('const x = 1;'); + expect(obs.narrative).toContain('const x = 2;'); + }); + + it('should handle MultiEdit with multiple diffs', async () => { + await service.initialize(); + await service.initSession('multi-diff', 'test-project'); + const obs = await service.storeObservation( + 'multi-diff', 'test-project', 'MultiEdit', + { + file_path: 'src/index.ts', + edits: [ + { old_string: 'import { a } from "./a"', new_string: 'import { a, b } from "./a"' }, + { old_string: 'export default a;', new_string: 'export default { a, b };' }, + ], + }, + {}, TEST_DIR + ); + + expect(obs.facts!.filter(f => f.includes('DIFF')).length).toBe(2); + }); + }); + + describe('decision rationale in summaries', () => { + it('should extract decisions from Edit observations', async () => { + await service.initialize(); + await service.initSession('decision-session', 'test-project'); + await service.saveUserPrompt('decision-session', 'test-project', 'Fix the auth bug'); + + await service.storeObservation( + 'decision-session', 'test-project', 'Edit', + { file_path: 'src/auth.ts', old_string: 'function login(user) {', new_string: 'function login(user, opts) {' }, + {}, TEST_DIR + ); + + const summary = await service.generateStructuredSummary('decision-session'); + + expect(summary.decisions).toBeDefined(); + expect(summary.decisions.length).toBeGreaterThan(0); + expect(summary.decisions[0]).toContain('auth.ts'); + expect(summary.decisions[0]).toContain('function login'); + }); + + it('should include intent tags in decisions', async () => { + await service.initialize(); + await service.initSession('intent-decision', 'test-project'); + await service.saveUserPrompt('intent-decision', 'test-project', 'Refactor the handler'); + + await service.storeObservation( + 'intent-decision', 'test-project', 'Edit', + { file_path: 'src/handler.ts', old_string: 'async handle(req)', new_string: 'async handleRequest(req, res)' }, + {}, TEST_DIR + ); + + const summary = await service.generateStructuredSummary('intent-decision'); + + expect(summary.decisions.length).toBeGreaterThan(0); + expect(summary.decisions[0]).toContain('refactor'); + }); + + it('should include decisions in saved session summaries', async () => { + await service.initialize(); + await service.initSession('saved-decision', 'test-project'); + await service.saveUserPrompt('saved-decision', 'test-project', 'Add feature'); + + await service.storeObservation( + 'saved-decision', 'test-project', 'Edit', + { file_path: 'src/feature.ts', old_string: 'const x = 1;', new_string: 'const x = getValue();' }, + {}, TEST_DIR + ); + + const structured = await service.generateStructuredSummary('saved-decision'); + const saved = await service.saveSessionSummary(structured); + + expect(saved.decisions).toBeDefined(); + expect(saved.decisions.length).toBeGreaterThan(0); + + // Verify it roundtrips through DB + const summaries = await service.getRecentSummaries('test-project'); + const found = summaries.find(s => s.sessionId === 'saved-decision'); + expect(found).toBeDefined(); + expect(found!.decisions.length).toBeGreaterThan(0); + }); + + it('should return empty decisions when no Edit observations', async () => { + await service.initialize(); + await service.initSession('no-decision', 'test-project'); + await service.storeObservation( + 'no-decision', 'test-project', 'Read', + { file_path: 'src/app.ts' }, {}, TEST_DIR + ); + + const summary = await service.generateStructuredSummary('no-decision'); + + expect(summary.decisions).toEqual([]); + }); + + it('should show decisions in context markdown', async () => { + await service.initialize(); + await service.initSession('ctx-decision', 'test-project'); + await service.saveUserPrompt('ctx-decision', 'test-project', 'Fix bug'); + + await service.storeObservation( + 'ctx-decision', 'test-project', 'Edit', + { file_path: 'src/fix.ts', old_string: 'return null;', new_string: 'return defaultValue;' }, + {}, TEST_DIR + ); + + const structured = await service.generateStructuredSummary('ctx-decision'); + await service.saveSessionSummary(structured); + await service.completeSession('ctx-decision', 'Done'); + + const ctx = await service.getContext('test-project'); + expect(ctx.markdown).toContain('Decisions'); + }); + }); + + describe('errors in structured summaries', () => { + it('should extract errors from Bash stderr/stdout', async () => { + await service.initialize(); + await service.initSession('error-session', 'test-project'); + + await service.storeObservation( + 'error-session', 'test-project', 'Bash', + { command: 'npm run build' }, + { stderr: 'Error: TS2304 Cannot find name "foo"', stdout: '' }, + TEST_DIR + ); + + const summary = await service.generateStructuredSummary('error-session'); + expect(summary.errors).toBeDefined(); + expect(summary.errors.length).toBeGreaterThan(0); + expect(summary.errors[0]).toContain('TS2304'); + }); + + it('should not include false positive errors like "0 errors"', async () => { + await service.initialize(); + await service.initSession('no-error-session', 'test-project'); + + await service.storeObservation( + 'no-error-session', 'test-project', 'Bash', + { command: 'tsc' }, + { stdout: 'Build completed with 0 errors', stderr: '' }, + TEST_DIR + ); + + const summary = await service.generateStructuredSummary('no-error-session'); + expect(summary.errors.length).toBe(0); + }); + + it('should return empty errors when no Bash observations', async () => { + await service.initialize(); + await service.initSession('read-only-session', 'test-project'); + + await service.storeObservation( + 'read-only-session', 'test-project', 'Read', + { file_path: 'file.ts' }, {}, TEST_DIR + ); + + const summary = await service.generateStructuredSummary('read-only-session'); + expect(summary.errors).toEqual([]); + }); + + it('should cap errors at 10', async () => { + await service.initialize(); + await service.initSession('many-error-session', 'test-project'); + + // Create output with many error lines + const errorLines = Array.from({ length: 20 }, (_, i) => `Error: issue ${i}`).join('\n'); + await service.storeObservation( + 'many-error-session', 'test-project', 'Bash', + { command: 'build' }, + { stdout: errorLines, stderr: '' }, + TEST_DIR + ); + + const summary = await service.generateStructuredSummary('many-error-session'); + expect(summary.errors.length).toBeLessThanOrEqual(10); + }); + + it('should include errors in saved session summary', async () => { + await service.initialize(); + await service.initSession('saved-error', 'test-project'); + + await service.storeObservation( + 'saved-error', 'test-project', 'Bash', + { command: 'tsc' }, + { stderr: 'fatal error: out of memory' }, + TEST_DIR + ); + + const structured = await service.generateStructuredSummary('saved-error'); + const saved = await service.saveSessionSummary(structured); + expect(saved.errors.length).toBeGreaterThan(0); + + // Verify roundtrip through DB + const summaries = await service.getRecentSummaries('test-project'); + const found = summaries.find(s => s.sessionId === 'saved-error'); + expect(found).toBeDefined(); + expect(found!.errors.length).toBeGreaterThan(0); + }); + + it('should show errors in context markdown', async () => { + await service.initialize(); + await service.initSession('ctx-error', 'test-project'); + + await service.storeObservation( + 'ctx-error', 'test-project', 'Bash', + { command: 'npm test' }, + { stderr: 'Error: Test failed unexpectedly' }, + TEST_DIR + ); + + const structured = await service.generateStructuredSummary('ctx-error'); + await service.saveSessionSummary(structured); + await service.completeSession('ctx-error', 'Done'); + + const ctx = await service.getContext('test-project'); + expect(ctx.markdown).toContain('Errors'); + }); + }); + + describe('cross-session pattern detection', () => { + it('should detect recurring concepts across observations', async () => { + await service.initialize(); + + // Create multiple sessions with overlapping concepts + await service.initSession('pattern-s1', 'test-project'); + await service.storeObservation( + 'pattern-s1', 'test-project', 'Edit', + { file_path: 'src/auth.ts', old_string: 'function login(user) {', new_string: 'function login(user, opts) {' }, + {}, TEST_DIR + ); + + await service.initSession('pattern-s2', 'test-project'); + await service.storeObservation( + 'pattern-s2', 'test-project', 'Edit', + { file_path: 'src/auth.ts', old_string: 'function logout() {', new_string: 'function logout(session) {' }, + {}, TEST_DIR + ); + + const patterns = await service.detectCrossSessionPatterns('test-project'); + + // Both edits touched 'authentication' concept from auth.ts path + expect(patterns.length).toBeGreaterThan(0); + // At least some patterns should appear with count >= 2 + expect(patterns.every(p => p.count >= 2)).toBe(true); + expect(patterns.every(p => p.category)).toBe(true); + }); + + it('should categorize patterns by prefix', async () => { + await service.initialize(); + + // Create observations with fn: and intent: concepts + await service.initSession('cat-s1', 'test-project'); + await service.saveUserPrompt('cat-s1', 'test-project', 'Fix the login bug'); + await service.storeObservation( + 'cat-s1', 'test-project', 'Edit', + { file_path: 'src/app.ts', old_string: 'function handleAuth() {', new_string: 'function handleAuth(opts) {' }, + {}, TEST_DIR + ); + + await service.initSession('cat-s2', 'test-project'); + await service.saveUserPrompt('cat-s2', 'test-project', 'Fix the signup bug'); + await service.storeObservation( + 'cat-s2', 'test-project', 'Edit', + { file_path: 'src/signup.ts', old_string: 'function handleAuth() {', new_string: 'function handleAuth(token) {' }, + {}, TEST_DIR + ); + + const patterns = await service.detectCrossSessionPatterns('test-project'); + + // Check category values + const categories = new Set(patterns.map(p => p.category)); + // Should have valid categories + for (const cat of categories) { + expect(['topic', 'intent', 'function', 'class', 'code-pattern']).toContain(cat); + } + }); + + it('should return empty for project with no observations', async () => { + await service.initialize(); + const patterns = await service.detectCrossSessionPatterns('empty-project'); + expect(patterns).toEqual([]); + }); + + it('should respect limit parameter', async () => { + await service.initialize(); + await service.initSession('limit-s1', 'test-project'); + + // Create many observations to generate many concepts + for (let i = 0; i < 5; i++) { + await service.storeObservation( + 'limit-s1', 'test-project', 'Edit', + { file_path: `src/file${i}.ts`, old_string: 'const a = 1;', new_string: 'const a = 2;' }, + {}, TEST_DIR + ); + } + + const patterns = await service.detectCrossSessionPatterns('test-project', 3); + expect(patterns.length).toBeLessThanOrEqual(3); + }); + }); + + describe('export/import', () => { + it('should export sessions with observations and prompts', async () => { + await service.initialize(); + await service.initSession('export-session', 'test-project', 'export task'); + await service.saveUserPrompt('export-session', 'test-project', 'export prompt'); + await service.storeObservation( + 'export-session', 'test-project', 'Read', + { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR + ); + await service.completeSession('export-session', 'Done'); + + const data = await service.exportToJSON('test-project'); + expect(data.version).toBe('1.0'); + expect(data.project).toBe('test-project'); + expect(data.sessions.length).toBeGreaterThanOrEqual(1); + + const session = data.sessions.find(s => s.sessionId === 'export-session'); + expect(session).toBeDefined(); + expect(session!.observations.length).toBeGreaterThanOrEqual(1); + expect(session!.prompts.length).toBeGreaterThanOrEqual(1); + }); + + it('should export specific sessions by ID', async () => { + await service.initialize(); + await service.initSession('export-a', 'test-project', 'task A'); + await service.initSession('export-b', 'test-project', 'task B'); + + const data = await service.exportToJSON('test-project', ['export-a']); + expect(data.sessions.length).toBe(1); + expect(data.sessions[0].sessionId).toBe('export-a'); + }); + + it('should import exported data with new session IDs', async () => { + await service.initialize(); + await service.initSession('import-src', 'test-project', 'import task'); + await service.saveUserPrompt('import-src', 'test-project', 'import prompt'); + await service.storeObservation( + 'import-src', 'test-project', 'Read', + { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR + ); + + const exported = await service.exportToJSON('test-project', ['import-src']); + + // Importing into same DB: content_hash dedup will skip existing obs/prompts + // but session is always created new + const result = await service.importFromJSON(exported); + + expect(result.imported.sessions).toBe(1); + // Observations and prompts are deduplicated by content_hash since they already exist + expect(result.imported.observations + result.skipped.observations).toBeGreaterThanOrEqual(1); + expect(result.imported.prompts + result.skipped.prompts).toBeGreaterThanOrEqual(1); + }); + + it('should dedup observations by content_hash on reimport', async () => { + await service.initialize(); + await service.initSession('dedup-session', 'test-project', 'dedup task'); + await service.storeObservation( + 'dedup-session', 'test-project', 'Read', + { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR + ); + + const exported = await service.exportToJSON('test-project', ['dedup-session']); + + // First import: content_hash already exists in same DB → skipped + const result1 = await service.importFromJSON(exported); + // Second import: still skipped (same hash) + const result2 = await service.importFromJSON(exported); + + // Both imports should have the session created but obs deduplicated + expect(result1.imported.sessions).toBe(1); + expect(result2.imported.sessions).toBe(1); + // Both skip observations since content_hash already in DB + expect(result1.skipped.observations + result2.skipped.observations).toBeGreaterThanOrEqual(1); + }); + + it('should preserve errors in export/import roundtrip', async () => { + await service.initialize(); + await service.initSession('err-export', 'test-project'); + + await service.storeObservation( + 'err-export', 'test-project', 'Bash', + { command: 'build' }, + { stderr: 'Error: compilation failed' }, + TEST_DIR + ); + + const structured = await service.generateStructuredSummary('err-export'); + await service.saveSessionSummary(structured); + await service.completeSession('err-export', 'Done'); + + const exported = await service.exportToJSON('test-project', ['err-export']); + const session = exported.sessions.find(s => s.sessionId === 'err-export'); + expect(session).toBeDefined(); + expect(session!.summary).toBeDefined(); + expect(session!.summary!.errors.length).toBeGreaterThan(0); }); }); }); diff --git a/src/hooks/__tests__/types.test.ts b/src/hooks/__tests__/types.test.ts index 87329a6..df0a3f6 100644 --- a/src/hooks/__tests__/types.test.ts +++ b/src/hooks/__tests__/types.test.ts @@ -10,6 +10,16 @@ import { getProjectName, getObservationType, generateObservationTitle, + generateObservationSubtitle, + generateObservationNarrative, + extractFilePaths, + extractFacts, + extractConcepts, + extractCodeDiffs, + formatDiffFact, + classifyChangeType, + detectIntent, + extractIntents, truncate, parseHookInput, formatResponse, @@ -338,4 +348,698 @@ describe('Hook Types Utilities', () => { expect(STANDARD_RESPONSE.suppressOutput).toBe(true); }); }); + + describe('extractFilePaths', () => { + it('should extract read file paths', () => { + const result = extractFilePaths('Read', { file_path: '/path/to/file.ts' }); + expect(result.filesRead).toEqual(['/path/to/file.ts']); + expect(result.filesModified).toEqual([]); + }); + + it('should extract write file paths', () => { + const result = extractFilePaths('Write', { file_path: '/path/to/file.ts' }); + expect(result.filesRead).toEqual([]); + expect(result.filesModified).toEqual(['/path/to/file.ts']); + }); + + it('should extract edit file paths', () => { + const result = extractFilePaths('Edit', { file_path: '/path/to/file.ts' }); + expect(result.filesRead).toEqual([]); + expect(result.filesModified).toEqual(['/path/to/file.ts']); + }); + + it('should use path fallback', () => { + const result = extractFilePaths('Read', { path: '/path/to/dir' }); + expect(result.filesRead).toEqual(['/path/to/dir']); + }); + + it('should return empty for Bash', () => { + const result = extractFilePaths('Bash', { command: 'npm test' }); + expect(result.filesRead).toEqual([]); + expect(result.filesModified).toEqual([]); + }); + + it('should handle null input', () => { + const result = extractFilePaths('Read', null); + expect(result.filesRead).toEqual([]); + expect(result.filesModified).toEqual([]); + }); + + it('should handle string input', () => { + const result = extractFilePaths('Read', JSON.stringify({ file_path: '/path/file.ts' })); + expect(result.filesRead).toEqual(['/path/file.ts']); + }); + }); + + describe('generateObservationSubtitle', () => { + it('should generate subtitle for Read', () => { + const subtitle = generateObservationSubtitle('Read', { file_path: '/src/index.ts' }); + expect(subtitle).toBe('Examining index.ts'); + }); + + it('should generate subtitle for Write', () => { + const subtitle = generateObservationSubtitle('Write', { file_path: '/src/auth.ts' }); + expect(subtitle).toBe('Creating/updating auth.ts'); + }); + + it('should generate subtitle for Edit', () => { + const subtitle = generateObservationSubtitle('Edit', { file_path: '/src/utils.ts' }); + expect(subtitle).toBe('Modifying utils.ts'); + }); + + it('should generate subtitle for Bash with known commands', () => { + expect(generateObservationSubtitle('Bash', { command: 'npm test' })).toBe('Running npm command'); + expect(generateObservationSubtitle('Bash', { command: 'git status' })).toBe('Git operation'); + expect(generateObservationSubtitle('Bash', { command: 'docker build .' })).toBe('Docker operation'); + }); + + it('should generate subtitle for Glob', () => { + const subtitle = generateObservationSubtitle('Glob', { pattern: '**/*.ts' }); + expect(subtitle).toBe('Searching for **/*.ts pattern'); + }); + + it('should generate subtitle for Grep', () => { + const subtitle = generateObservationSubtitle('Grep', { pattern: 'function' }); + expect(subtitle).toBe('Searching code for "function"'); + }); + + it('should generate subtitle for Task', () => { + const subtitle = generateObservationSubtitle('Task', { subagent_type: 'Explore' }); + expect(subtitle).toBe('Delegating to Explore'); + }); + + it('should generate subtitle for WebSearch', () => { + const subtitle = generateObservationSubtitle('WebSearch', { query: 'typescript best practices' }); + expect(subtitle).toContain('typescript best practices'); + }); + + it('should handle unknown tools', () => { + const subtitle = generateObservationSubtitle('CustomTool', {}); + expect(subtitle).toBe('Using CustomTool tool'); + }); + + it('should handle generateObservationSubtitle catch (unparseable string input)', () => { + const subtitle = generateObservationSubtitle('Read', 'invalid json {{{'); + expect(subtitle).toContain('Read'); + }); + }); + + describe('generateObservationNarrative', () => { + it('should generate narrative for Read', () => { + const narrative = generateObservationNarrative('Read', { file_path: '/src/index.ts' }); + expect(narrative).toContain('/src/index.ts'); + expect(narrative).toContain('Read'); + }); + + it('should generate narrative for Write', () => { + const narrative = generateObservationNarrative('Write', { file_path: '/src/new.ts' }); + expect(narrative).toContain('/src/new.ts'); + expect(narrative).toContain('Wrote'); + }); + + it('should generate narrative for Bash test commands', () => { + const narrative = generateObservationNarrative('Bash', { command: 'npm test' }); + expect(narrative).toContain('test'); + }); + + it('should generate narrative for Bash build commands', () => { + const narrative = generateObservationNarrative('Bash', { command: 'tsc' }); + expect(narrative).toContain('Built'); + }); + + it('should generate narrative for Grep', () => { + const narrative = generateObservationNarrative('Grep', { pattern: 'TODO', path: 'src' }); + expect(narrative).toContain('TODO'); + expect(narrative).toContain('src'); + }); + + it('should handle unknown tools', () => { + const narrative = generateObservationNarrative('CustomTool', {}); + expect(narrative).toBe('Used CustomTool tool.'); + }); + + it('should generate narrative for Edit tool', () => { + const narrative = generateObservationNarrative('Edit', { file_path: '/src/utils.ts', old_string: 'const x = 1' }); + expect(narrative).toContain('/src/utils.ts'); + expect(narrative).toContain('Edited'); + expect(narrative).toContain('const x = 1'); + }); + + it('should generate narrative for MultiEdit tool', () => { + const narrative = generateObservationNarrative('MultiEdit', { file_path: '/src/app.ts' }); + expect(narrative).toContain('/src/app.ts'); + expect(narrative).toContain('Edited'); + // No old_string provided — should show 'code' as fallback + expect(narrative).toContain('code'); + }); + + it('should generate narrative for Glob tool', () => { + const narrative = generateObservationNarrative('Glob', { pattern: '**/*.tsx' }); + expect(narrative).toContain('**/*.tsx'); + expect(narrative).toContain('Searched'); + }); + + it('should generate narrative for Task tool', () => { + const narrative = generateObservationNarrative('Task', { description: 'explore code', subagent_type: 'Explore' }); + expect(narrative).toContain('Explore'); + expect(narrative).toContain('explore code'); + expect(narrative).toContain('Delegated'); + }); + + it('should generate narrative for WebSearch tool', () => { + const narrative = generateObservationNarrative('WebSearch', { query: 'react hooks' }); + expect(narrative).toContain('react hooks'); + }); + + it('should generate narrative for WebFetch tool', () => { + const narrative = generateObservationNarrative('WebFetch', { url: 'https://docs.example.com' }); + expect(narrative).toContain('https://docs.example.com'); + }); + + it('should handle generateObservationNarrative catch (unparseable string input)', () => { + const narrative = generateObservationNarrative('Read', 'invalid json {{{'); + expect(narrative).toBe('Used Read tool.'); + }); + + it('should generate narrative for Bash git commands', () => { + const narrative = generateObservationNarrative('Bash', { command: 'git status' }); + expect(narrative).toContain('git'); + }); + }); + + describe('extractFacts', () => { + it('should extract facts from Read', () => { + const facts = extractFacts('Read', { file_path: '/src/index.ts' }, {}); + expect(facts).toContain('File read: /src/index.ts'); + }); + + it('should extract facts from Write', () => { + const facts = extractFacts('Write', { file_path: '/src/new.ts' }, {}); + expect(facts).toContain('File created/updated: /src/new.ts'); + }); + + it('should extract facts from Edit with structured diff', () => { + const facts = extractFacts('Edit', { file_path: '/src/index.ts', old_string: 'old code', new_string: 'new code' }, {}); + expect(facts.length).toBe(2); + expect(facts[0]).toContain('/src/index.ts'); + expect(facts[1]).toContain('DIFF'); + expect(facts[1]).toContain('old code'); + expect(facts[1]).toContain('new code'); + }); + + it('should extract facts from Bash with test results', () => { + const facts = extractFacts('Bash', { command: 'npm test' }, { stdout: '5 tests passed' }); + expect(facts.some(f => f.includes('npm test'))).toBe(true); + expect(facts.some(f => f === 'Tests passed')).toBe(true); + }); + + it('should extract facts from Bash with errors', () => { + const facts = extractFacts('Bash', { command: 'tsc' }, { stdout: 'Error: TS2304' }); + expect(facts.some(f => f === 'Errors encountered')).toBe(true); + }); + + it('should extract facts from WebSearch', () => { + const facts = extractFacts('WebSearch', { query: 'typescript tutorial' }, {}); + expect(facts).toContain('Web search: typescript tutorial'); + }); + + it('should handle null inputs', () => { + const facts = extractFacts('Read', null, null); + expect(facts).toEqual([]); + }); + + it('should extract facts from Glob', () => { + const facts = extractFacts('Glob', { pattern: '**/*.ts' }, {}); + expect(facts).toContain('Pattern searched: **/*.ts'); + }); + + it('should extract facts from Grep', () => { + const facts = extractFacts('Grep', { pattern: 'TODO', path: 'src/' }, {}); + expect(facts).toContain('Code pattern searched: TODO'); + expect(facts).toContain('Search scope: src/'); + }); + + it('should extract facts from WebFetch', () => { + const facts = extractFacts('WebFetch', { url: 'https://example.com' }, {}); + expect(facts).toContain('URL fetched: https://example.com'); + }); + + it('should extract facts from Task', () => { + const facts = extractFacts('Task', { description: 'Find files', subagent_type: 'Explore' }, {}); + expect(facts).toContain('Sub-task: Find files'); + expect(facts).toContain('Agent type: Explore'); + }); + + it('should extract test failed facts from Bash', () => { + const facts = extractFacts('Bash', { command: 'npm test' }, { stdout: '2 tests failed ✗' }); + expect(facts).toContain('Tests failed'); + }); + + it('should extract error facts from Bash', () => { + const facts = extractFacts('Bash', { command: 'tsc' }, { stdout: 'Error: something went wrong' }); + expect(facts).toContain('Errors encountered'); + }); + + it('should handle MultiEdit with edits array', () => { + const facts = extractFacts('MultiEdit', { + file_path: 'app.ts', + edits: [ + { old_string: 'const x = 1', new_string: 'const x = 2' }, + { old_string: 'let y = 3', new_string: 'let y = 4' }, + ], + }, {}); + expect(facts).toContain('File modified: app.ts'); + expect(facts.some(f => f.includes('DIFF'))).toBe(true); + expect(facts.some(f => f.includes('const x = 1'))).toBe(true); + }); + + it('should fallback to Code replaced for MultiEdit without edits array', () => { + const facts = extractFacts('MultiEdit', { file_path: 'app.ts', old_string: 'old' }, {}); + expect(facts).toContain('File modified: app.ts'); + expect(facts.some(f => f.includes('Code replaced'))).toBe(true); + }); + + it('should handle string toolInput (JSON string)', () => { + const facts = extractFacts('Read', JSON.stringify({ file_path: 'test.ts' }), '{}'); + expect(facts).toContain('File read: test.ts'); + }); + }); + + describe('extractConcepts', () => { + it('should extract concepts from TypeScript files', () => { + const concepts = extractConcepts('Read', { file_path: 'src/hooks/types.ts' }); + expect(concepts).toContain('typescript'); + expect(concepts).toContain('hooks'); + }); + + it('should extract concepts from test files', () => { + const concepts = extractConcepts('Read', { file_path: 'src/__tests__/index.test.ts' }); + expect(concepts).toContain('testing'); + expect(concepts).toContain('typescript'); + }); + + it('should extract concepts from Bash commands', () => { + const concepts = extractConcepts('Bash', { command: 'npm test' }); + expect(concepts).toContain('testing'); + expect(concepts).toContain('package-management'); + }); + + it('should extract concepts from git commands', () => { + const concepts = extractConcepts('Bash', { command: 'git status' }); + expect(concepts).toContain('version-control'); + }); + + it('should extract concepts from WebSearch', () => { + const concepts = extractConcepts('WebSearch', {}); + expect(concepts).toContain('research'); + }); + + it('should extract concepts from Task', () => { + const concepts = extractConcepts('Task', { subagent_type: 'Explore' }); + expect(concepts).toContain('delegation'); + expect(concepts).toContain('Explore'); + }); + + it('should deduplicate concepts', () => { + const concepts = extractConcepts('Bash', { command: 'npm test && npm test' }); + const unique = new Set(concepts); + expect(concepts.length).toBe(unique.size); + }); + + it('should handle null inputs', () => { + const concepts = extractConcepts('Read', null); + expect(concepts).toEqual([]); + }); + + it('should extract fn: concepts from Edit tool', () => { + const concepts = extractConcepts('Edit', { + file_path: 'src/auth.ts', + old_string: 'function login(user) {', + new_string: 'function login(user, opts) {', + }); + expect(concepts.some(c => c.startsWith('fn:'))).toBe(true); + expect(concepts).toContain('fn:login'); + }); + + it('should extract class: concepts from Edit tool', () => { + const concepts = extractConcepts('Edit', { + file_path: 'src/service.ts', + old_string: 'class AuthService {', + new_string: 'class AuthService extends BaseService {', + }); + expect(concepts).toContain('class:AuthService'); + }); + + it('should extract pattern: concepts from Edit tool', () => { + const concepts = extractConcepts('Edit', { + file_path: 'src/types.ts', + old_string: 'export interface Foo {', + new_string: 'export interface Foo { bar: string; }', + }); + expect(concepts).toContain('pattern:export'); + expect(concepts).toContain('pattern:interface'); + }); + + it('should extract async pattern concept', () => { + const concepts = extractConcepts('Edit', { + file_path: 'src/api.ts', + old_string: 'async function fetchData() {', + new_string: 'async function fetchData(id: string) {', + }); + expect(concepts).toContain('pattern:async'); + expect(concepts).toContain('fn:fetchData'); + }); + + it('should extract error-handling pattern concept', () => { + const concepts = extractConcepts('Edit', { + file_path: 'src/handler.ts', + old_string: 'return result;', + new_string: 'try { return result; } catch (e) { throw e; }', + }); + expect(concepts).toContain('pattern:error-handling'); + }); + + it('should not extract fn/class/pattern from non-Edit tools', () => { + const concepts = extractConcepts('Read', { + file_path: 'src/auth.ts', + }); + expect(concepts.some(c => c.startsWith('fn:'))).toBe(false); + expect(concepts.some(c => c.startsWith('class:'))).toBe(false); + expect(concepts.some(c => c.startsWith('pattern:'))).toBe(false); + }); + }); + + describe('detectIntent', () => { + it('should detect bugfix intent from prompt', () => { + const intents = detectIntent('Edit', { file_path: 'src/app.ts' }, {}, 'Fix the login bug'); + expect(intents).toContain('bugfix'); + }); + + it('should detect feature intent from prompt', () => { + const intents = detectIntent('Write', { file_path: 'src/new.ts' }, {}, 'Add a new payment feature'); + expect(intents).toContain('feature'); + }); + + it('should detect refactor intent from prompt', () => { + const intents = detectIntent('Edit', { file_path: 'src/app.ts' }, {}, 'Refactor the auth module'); + expect(intents).toContain('refactor'); + }); + + it('should detect testing intent from prompt', () => { + const intents = detectIntent('Bash', { command: 'vitest' }, {}, 'Run the tests'); + expect(intents).toContain('testing'); + }); + + it('should detect documentation intent from prompt', () => { + const intents = detectIntent('Write', { file_path: 'README.md' }, {}, 'Update the docs'); + expect(intents).toContain('documentation'); + }); + + it('should detect configuration intent from prompt', () => { + const intents = detectIntent('Edit', { file_path: 'config.json' }, {}, 'Update config settings'); + expect(intents).toContain('configuration'); + }); + + it('should detect optimization intent from prompt', () => { + const intents = detectIntent('Edit', { file_path: 'src/app.ts' }, {}, 'Optimize performance'); + expect(intents).toContain('optimization'); + }); + + it('should detect multiple intents', () => { + const intents = detectIntent('Edit', { file_path: 'src/app.test.ts' }, {}, 'Fix failing test'); + expect(intents).toContain('bugfix'); + expect(intents).toContain('testing'); + }); + + it('should default to investigation for read tools without prompt', () => { + const intents = detectIntent('Read', { file_path: 'src/app.ts' }, {}); + expect(intents).toContain('investigation'); + }); + + it('should detect testing from Bash test commands', () => { + const intents = detectIntent('Bash', { command: 'npx vitest run' }, {}); + expect(intents).toContain('testing'); + }); + + it('should detect testing from test file paths', () => { + const intents = detectIntent('Edit', { file_path: 'src/__tests__/app.test.ts' }, {}); + expect(intents).toContain('testing'); + }); + + it('should detect documentation from .md write', () => { + const intents = detectIntent('Write', { file_path: 'docs/guide.md' }, {}); + expect(intents).toContain('documentation'); + }); + + it('should detect configuration from config file writes', () => { + const intents = detectIntent('Edit', { file_path: 'tsconfig.json' }, {}); + expect(intents).toContain('configuration'); + }); + + it('should fallback to investigation when no signals found', () => { + const intents = detectIntent('Task', {}, {}); + expect(intents).toContain('investigation'); + }); + }); + + describe('extractIntents', () => { + it('should extract intent tags from concepts', () => { + const intents = extractIntents(['typescript', 'intent:bugfix', 'hooks', 'intent:testing']); + expect(intents).toEqual(['bugfix', 'testing']); + }); + + it('should return empty array when no intent tags', () => { + const intents = extractIntents(['typescript', 'hooks', 'api']); + expect(intents).toEqual([]); + }); + + it('should handle empty array', () => { + const intents = extractIntents([]); + expect(intents).toEqual([]); + }); + }); + + describe('extractCodeDiffs', () => { + it('should extract diff from Edit tool', () => { + const diffs = extractCodeDiffs('Edit', { + file_path: 'src/auth.ts', + old_string: 'function login(user) {', + new_string: 'function login(user, opts) {', + }); + expect(diffs).toHaveLength(1); + expect(diffs[0].file).toBe('src/auth.ts'); + expect(diffs[0].before).toBe('function login(user) {'); + expect(diffs[0].after).toBe('function login(user, opts) {'); + }); + + it('should extract multiple diffs from MultiEdit', () => { + const diffs = extractCodeDiffs('MultiEdit', { + file_path: 'src/app.ts', + edits: [ + { old_string: 'const a = 1;', new_string: 'const a = 2;' }, + { old_string: 'let b = true;', new_string: 'let b = false;' }, + ], + }); + expect(diffs).toHaveLength(2); + expect(diffs[0].before).toBe('const a = 1;'); + expect(diffs[1].before).toBe('let b = true;'); + }); + + it('should return empty array for non-Edit tools', () => { + expect(extractCodeDiffs('Read', { file_path: 'a.ts' })).toEqual([]); + expect(extractCodeDiffs('Bash', { command: 'test' })).toEqual([]); + expect(extractCodeDiffs('Write', { file_path: 'a.ts' })).toEqual([]); + }); + + it('should truncate long strings to 200 chars', () => { + const longStr = 'x'.repeat(300); + const diffs = extractCodeDiffs('Edit', { + file_path: 'a.ts', + old_string: longStr, + new_string: 'short', + }); + expect(diffs[0].before.length).toBe(200); + expect(diffs[0].after).toBe('short'); + }); + + it('should calculate changeLines correctly', () => { + const diffs = extractCodeDiffs('Edit', { + file_path: 'a.ts', + old_string: 'line1', + new_string: 'line1\nline2\nline3', + }); + expect(diffs[0].changeLines).toBe(2); // 3 lines - 1 line = +2 + }); + + it('should cap MultiEdit at 5 edits', () => { + const edits = Array.from({ length: 10 }, (_, i) => ({ + old_string: `old${i}`, + new_string: `new${i}`, + })); + const diffs = extractCodeDiffs('MultiEdit', { file_path: 'a.ts', edits }); + expect(diffs).toHaveLength(5); + }); + + it('should handle JSON string input', () => { + const diffs = extractCodeDiffs('Edit', JSON.stringify({ + file_path: 'src/app.ts', + old_string: 'before', + new_string: 'after', + })); + expect(diffs).toHaveLength(1); + expect(diffs[0].before).toBe('before'); + }); + + it('should handle missing old_string or new_string', () => { + const diffs = extractCodeDiffs('Edit', { file_path: 'a.ts' }); + expect(diffs).toEqual([]); + }); + + it('should include changeType in extracted diffs', () => { + const diffs = extractCodeDiffs('Edit', { + file_path: 'a.ts', + old_string: 'function foo() {', + new_string: 'function foo(arg) {', + }); + expect(diffs[0].changeType).toBe('modification'); + }); + + it('should classify addition when old_string is empty', () => { + const diffs = extractCodeDiffs('Edit', { + file_path: 'a.ts', + old_string: '', + new_string: 'const newVar = 1;', + }); + expect(diffs[0].changeType).toBe('addition'); + }); + + it('should classify replacement for different first tokens', () => { + const diffs = extractCodeDiffs('Edit', { + file_path: 'a.ts', + old_string: 'import { a } from "./a"', + new_string: 'export { b } from "./b"', + }); + expect(diffs[0].changeType).toBe('replacement'); + }); + + it('should include changeType in MultiEdit diffs', () => { + const diffs = extractCodeDiffs('MultiEdit', { + file_path: 'a.ts', + edits: [ + { old_string: '', new_string: 'new line' }, + { old_string: 'const a = 1;', new_string: 'const a = 2;' }, + ], + }); + expect(diffs[0].changeType).toBe('addition'); + expect(diffs[1].changeType).toBe('modification'); + }); + }); + + describe('classifyChangeType', () => { + it('should classify addition (empty before, non-empty after)', () => { + expect(classifyChangeType('', 'const x = 1;')).toBe('addition'); + expect(classifyChangeType(' ', 'new code')).toBe('addition'); + }); + + it('should classify deletion (non-empty before, empty after)', () => { + expect(classifyChangeType('const x = 1;', '')).toBe('deletion'); + expect(classifyChangeType('old code', ' ')).toBe('deletion'); + }); + + it('should classify modification (same first token)', () => { + expect(classifyChangeType('function login(user) {', 'function login(user, opts) {')).toBe('modification'); + expect(classifyChangeType('const x = 1;', 'const x = 2;')).toBe('modification'); + }); + + it('should classify replacement (different first token)', () => { + expect(classifyChangeType('const x = 1;', 'let y = 2;')).toBe('replacement'); + expect(classifyChangeType('import { a }', 'export { b }')).toBe('replacement'); + }); + }); + + describe('formatDiffFact', () => { + it('should format diff as compact fact string', () => { + const fact = formatDiffFact({ + file: 'src/auth.ts', + before: 'function login(user) {', + after: 'function login(user, opts) {', + changeLines: 0, + changeType: 'modification', + }); + expect(fact).toContain('DIFF'); + expect(fact).toContain('auth.ts'); + expect(fact).toContain('function login(user) {'); + expect(fact).toContain('function login(user, opts) {'); + }); + + it('should use filename only (not full path)', () => { + const fact = formatDiffFact({ + file: '/very/long/path/to/file.ts', + before: 'old', + after: 'new', + changeLines: 0, + changeType: 'modification', + }); + expect(fact).toContain('file.ts'); + expect(fact).not.toContain('/very/long'); + }); + + it('should truncate long first lines to 60 chars', () => { + const longLine = 'x'.repeat(100); + const fact = formatDiffFact({ + file: 'a.ts', + before: longLine, + after: 'short', + changeLines: 0, + changeType: 'modification', + }); + // First line is truncated to 60 chars + expect(fact.length).toBeLessThan(200); + }); + + it('should show [addition] tag for addition changeType', () => { + const fact = formatDiffFact({ + file: 'a.ts', + before: '', + after: 'new code', + changeLines: 1, + changeType: 'addition', + }); + expect(fact).toContain('[addition]'); + }); + + it('should show [deletion] tag for deletion changeType', () => { + const fact = formatDiffFact({ + file: 'a.ts', + before: 'old code', + after: '', + changeLines: -1, + changeType: 'deletion', + }); + expect(fact).toContain('[deletion]'); + }); + + it('should show [replacement] tag for replacement changeType', () => { + const fact = formatDiffFact({ + file: 'a.ts', + before: 'const x = 1;', + after: 'let y = 2;', + changeLines: 0, + changeType: 'replacement', + }); + expect(fact).toContain('[replacement]'); + }); + + it('should not show tag for modification changeType', () => { + const fact = formatDiffFact({ + file: 'a.ts', + before: 'const x = 1;', + after: 'const x = 2;', + changeLines: 0, + changeType: 'modification', + }); + expect(fact).not.toContain('[modification]'); + expect(fact).not.toContain('['); + }); + }); }); diff --git a/src/hooks/adapters/claude-code-adapter.ts b/src/hooks/adapters/claude-code-adapter.ts new file mode 100644 index 0000000..3f164dd --- /dev/null +++ b/src/hooks/adapters/claude-code-adapter.ts @@ -0,0 +1,74 @@ +/** + * Claude Code Platform Adapter + * + * Handles the stdin JSON format from Claude Code hooks. + * This is the default adapter and matches the existing behavior + * of parseHookInput() and formatResponse() in types.ts. + * + * @module @agentkits/memory/hooks/adapters/claude-code-adapter + */ + +import type { PlatformAdapter } from './platform-adapter.js'; +import type { NormalizedHookInput, HookResult, ClaudeCodeHookInput, ClaudeCodeHookResponse } from '../types.js'; +import { getProjectName, STANDARD_RESPONSE } from '../types.js'; + +/** + * Claude Code platform adapter. + * + * Input format: + * { session_id, cwd, prompt, tool_name, tool_input, tool_result, + * transcript_path, stop_reason } + * + * Output format: + * { continue, suppressOutput, hookSpecificOutput: { + * hookEventName, additionalContext } } + */ +export class ClaudeCodeAdapter implements PlatformAdapter { + readonly name = 'claude-code'; + + readonly supportedEvents = [ + 'context', 'session-init', 'observation', 'summarize', 'user-message', + ] as const; + + parseInput(stdin: string): NormalizedHookInput { + try { + const raw: ClaudeCodeHookInput = JSON.parse(stdin); + const cwd = raw.cwd || process.cwd(); + + return { + sessionId: raw.session_id || `session_${Date.now()}`, + cwd, + project: getProjectName(cwd), + prompt: raw.prompt, + toolName: raw.tool_name, + toolInput: raw.tool_input, + toolResponse: raw.tool_result, + transcriptPath: raw.transcript_path, + stopReason: raw.stop_reason, + timestamp: Date.now(), + }; + } catch { + const cwd = process.cwd(); + return { + sessionId: `session_${Date.now()}`, + cwd, + project: getProjectName(cwd), + timestamp: Date.now(), + }; + } + } + + formatOutput(result: HookResult): string { + if (result.additionalContext) { + const response: ClaudeCodeHookResponse = { + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: result.additionalContext, + }, + }; + return JSON.stringify(response); + } + + return JSON.stringify(STANDARD_RESPONSE); + } +} diff --git a/src/hooks/adapters/generic-adapter.ts b/src/hooks/adapters/generic-adapter.ts new file mode 100644 index 0000000..d0a05ae --- /dev/null +++ b/src/hooks/adapters/generic-adapter.ts @@ -0,0 +1,75 @@ +/** + * Generic Platform Adapter (Fallback) + * + * Accepts an already-normalized JSON format on stdin (camelCase fields). + * Useful for testing, scripting, and future platforms that adopt + * a standardized hook format. + * + * @module @agentkits/memory/hooks/adapters/generic-adapter + */ + +import type { PlatformAdapter } from './platform-adapter.js'; +import type { NormalizedHookInput, HookResult } from '../types.js'; +import { getProjectName } from '../types.js'; + +/** + * Generic platform adapter. + * + * Input format (camelCase, already normalized): + * { sessionId, cwd, prompt?, toolName?, toolInput?, + * toolResponse?, transcriptPath?, stopReason? } + * + * Output format: + * { continue, additionalContext?, error? } + */ +export class GenericAdapter implements PlatformAdapter { + readonly name = 'generic'; + + readonly supportedEvents = [ + 'context', 'session-init', 'observation', 'summarize', + ] as const; + + parseInput(stdin: string): NormalizedHookInput { + try { + const raw = JSON.parse(stdin); + const cwd = raw.cwd || process.cwd(); + + return { + sessionId: raw.sessionId || raw.session_id || `session_${Date.now()}`, + cwd, + project: raw.project || getProjectName(cwd), + prompt: raw.prompt, + toolName: raw.toolName || raw.tool_name, + toolInput: raw.toolInput || raw.tool_input, + toolResponse: raw.toolResponse || raw.tool_result, + transcriptPath: raw.transcriptPath || raw.transcript_path, + stopReason: raw.stopReason || raw.stop_reason, + timestamp: Date.now(), + }; + } catch { + const cwd = process.cwd(); + return { + sessionId: `session_${Date.now()}`, + cwd, + project: getProjectName(cwd), + timestamp: Date.now(), + }; + } + } + + formatOutput(result: HookResult): string { + const response: Record = { + continue: true, + }; + + if (result.additionalContext) { + response.additionalContext = result.additionalContext; + } + + if (result.error) { + response.error = result.error; + } + + return JSON.stringify(response); + } +} diff --git a/src/hooks/adapters/index.ts b/src/hooks/adapters/index.ts new file mode 100644 index 0000000..6f4ddad --- /dev/null +++ b/src/hooks/adapters/index.ts @@ -0,0 +1,11 @@ +/** + * Hook Platform Adapters + * + * @module @agentkits/memory/hooks/adapters + */ + +export type { PlatformAdapter } from './platform-adapter.js'; +export { resolveAdapter } from './platform-adapter.js'; +export { ClaudeCodeAdapter } from './claude-code-adapter.js'; +export { OpenCodeAdapter } from './opencode-adapter.js'; +export { GenericAdapter } from './generic-adapter.js'; diff --git a/src/hooks/adapters/opencode-adapter.ts b/src/hooks/adapters/opencode-adapter.ts new file mode 100644 index 0000000..d832470 --- /dev/null +++ b/src/hooks/adapters/opencode-adapter.ts @@ -0,0 +1,80 @@ +/** + * OpenCode Platform Adapter + * + * Handles the hook format for OpenCode. + * OpenCode uses a similar hook system to Claude Code. + * Initially mirrors Claude Code format; separate class allows + * future divergence as OpenCode evolves its hook API. + * + * @module @agentkits/memory/hooks/adapters/opencode-adapter + */ + +import type { PlatformAdapter } from './platform-adapter.js'; +import type { NormalizedHookInput, HookResult } from '../types.js'; +import { getProjectName } from '../types.js'; + +/** + * OpenCode platform adapter. + * + * OpenCode hook format is currently compatible with Claude Code. + * Field mapping may diverge in future versions. + * + * Input format (same as Claude Code for now): + * { session_id, cwd, prompt, tool_name, tool_input, tool_result, + * transcript_path, stop_reason } + * + * Output format (simplified — no hookSpecificOutput): + * { continue, additionalContext? } + */ +export class OpenCodeAdapter implements PlatformAdapter { + readonly name = 'opencode'; + + readonly supportedEvents = [ + 'context', 'session-init', 'observation', 'summarize', + ] as const; + + parseInput(stdin: string): NormalizedHookInput { + try { + const raw = JSON.parse(stdin); + const cwd = raw.cwd || process.cwd(); + + return { + sessionId: raw.session_id || `session_${Date.now()}`, + cwd, + project: getProjectName(cwd), + prompt: raw.prompt, + toolName: raw.tool_name, + toolInput: raw.tool_input, + toolResponse: raw.tool_result, + transcriptPath: raw.transcript_path, + stopReason: raw.stop_reason, + timestamp: Date.now(), + }; + } catch { + const cwd = process.cwd(); + return { + sessionId: `session_${Date.now()}`, + cwd, + project: getProjectName(cwd), + timestamp: Date.now(), + }; + } + } + + formatOutput(result: HookResult): string { + // OpenCode uses a simpler output format + const response: Record = { + continue: true, + }; + + if (result.additionalContext) { + response.additionalContext = result.additionalContext; + } + + if (result.error) { + response.error = result.error; + } + + return JSON.stringify(response); + } +} diff --git a/src/hooks/adapters/platform-adapter.ts b/src/hooks/adapters/platform-adapter.ts new file mode 100644 index 0000000..e0f8ddc --- /dev/null +++ b/src/hooks/adapters/platform-adapter.ts @@ -0,0 +1,68 @@ +/** + * Platform Adapter Interface + * + * Abstracts platform-specific stdin/stdout formats for hook handlers. + * Each AI coding assistant (Claude Code, OpenCode, etc.) sends different + * JSON formats; adapters normalize them to NormalizedHookInput. + * + * @module @agentkits/memory/hooks/adapters/platform-adapter + */ + +import type { NormalizedHookInput, HookResult } from '../types.js'; +import { ClaudeCodeAdapter } from './claude-code-adapter.js'; +import { OpenCodeAdapter } from './opencode-adapter.js'; +import { GenericAdapter } from './generic-adapter.js'; + +/** + * Platform adapter interface. + * Translates between platform-specific stdin/stdout formats + * and the normalized internal types used by hook handlers. + */ +export interface PlatformAdapter { + /** Platform identifier */ + readonly name: string; + + /** + * Parse platform-specific stdin JSON into normalized input. + */ + parseInput(stdin: string): NormalizedHookInput; + + /** + * Format hook result into platform-specific stdout JSON. + */ + formatOutput(result: HookResult): string; + + /** + * Supported hook event types for this platform. + */ + readonly supportedEvents: readonly string[]; +} + +/** + * Resolve the appropriate adapter based on environment. + * + * Resolution order: + * 1. AGENTKITS_PLATFORM env var (explicit override) + * 2. Auto-detect from Claude-specific env vars + * 3. Default: claude-code (backward compatible) + */ +export function resolveAdapter(): PlatformAdapter { + const envPlatform = process.env.AGENTKITS_PLATFORM; + + if (envPlatform) { + switch (envPlatform) { + case 'opencode': + return new OpenCodeAdapter(); + case 'generic': + return new GenericAdapter(); + case 'claude-code': + return new ClaudeCodeAdapter(); + default: + // Unknown platform → fallback to Claude Code + return new ClaudeCodeAdapter(); + } + } + + // Default: Claude Code (backward compatible) + return new ClaudeCodeAdapter(); +} diff --git a/src/hooks/ai-enrichment.ts b/src/hooks/ai-enrichment.ts new file mode 100644 index 0000000..65efd7e --- /dev/null +++ b/src/hooks/ai-enrichment.ts @@ -0,0 +1,515 @@ +/** + * AI Enrichment for Observations and Session Summaries + * + * Uses pluggable AI providers (Claude CLI, OpenAI-compatible, Gemini) to + * generate richer subtitle, narrative, facts, and concepts from tool + * observations, and to enhance session summaries using transcript data. + * Falls back to template-based extraction when the provider is not available. + * + * @module @agentkits/memory/hooks/ai-enrichment + */ + +import { resolveAIProvider, type AIProviderConfig, type ResolvedProvider } from './ai-provider.js'; + +/** + * Enriched observation data from AI extraction + */ +export interface EnrichedObservation { + subtitle: string; + narrative: string; + facts: string[]; + concepts: string[]; + /** Confidence score 0.0-1.0 indicating extraction quality */ + confidence: number; +} + +/** + * Environment variable to enable/disable AI enrichment. + * Set AGENTKITS_AI_ENRICHMENT=true to enable, false to disable. + * When not set, defaults to auto-detect (uses AI if provider available). + */ +const AI_ENRICHMENT_ENV_KEY = 'AGENTKITS_AI_ENRICHMENT'; + +/** Resolved provider (cached at module level, lazy-initialized) */ +let _resolvedProvider: ResolvedProvider | null = null; + +/** Provider config from settings.json (set by service.ts) */ +let _providerConfig: AIProviderConfig | undefined; + +/** Mock function for testing (replaces provider.run when set) */ +let _mockRunClaudePrint: ((prompt: string, systemPrompt: string, timeoutMs: number) => string | null) | null = null; + +/** + * Set AI provider config from settings.json. + * Called by service.ts after loading settings. Forces re-resolution on next use. + */ +export function setAIProviderConfig(config?: AIProviderConfig): void { + _providerConfig = config; + _resolvedProvider = null; // force re-resolution +} + +/** + * Get the resolved AI provider (lazy-initialized, cached). + */ +function getProvider(): ResolvedProvider { + if (!_resolvedProvider) { + _resolvedProvider = resolveAIProvider(_providerConfig); + } + return _resolvedProvider; +} + +/** + * Check if AI enrichment is enabled via environment variable + * - 'true' / '1' → force enable + * - 'false' / '0' → force disable + * - not set → auto-detect (try provider, fallback to template) + */ +function isEnvEnabled(): boolean | null { + const value = process.env[AI_ENRICHMENT_ENV_KEY]; + if (!value) return null; // auto-detect + return value === 'true' || value === '1'; +} + +/** + * Check if the current AI provider is available. + * Respects AGENTKITS_AI_ENRICHMENT env var override. + */ +function isProviderAvailable(): boolean { + const envEnabled = isEnvEnabled(); + if (envEnabled === false) return false; + + // When mock is set, provider is "available" + if (_mockRunClaudePrint) return true; + + return getProvider().isAvailable(); +} + +/** + * Run a prompt through the current AI provider. + * Uses mock if set (testing), otherwise delegates to the resolved provider. + */ +async function runProvider(prompt: string, systemPrompt: string, timeoutMs: number): Promise { + if (_mockRunClaudePrint) { + return _mockRunClaudePrint(prompt, systemPrompt, timeoutMs); + } + return getProvider().run(prompt, systemPrompt, timeoutMs); +} + +/** + * Synchronous check: is AI enrichment potentially enabled? + * Used by observation hook to decide whether to spawn background process. + * Does NOT check provider availability (that may be slow). Just checks env var. + */ +export function isAIEnrichmentEnabled(): boolean { + const envEnabled = isEnvEnabled(); + if (envEnabled === false) return false; + // If explicitly enabled or auto-detect, optimistically return true. + // The background process will handle provider availability check. + return true; +} + +/** + * Build the extraction prompt for a tool observation + */ +export function buildExtractionPrompt( + toolName: string, + toolInput: string, + toolResponse: string +): string { + return `Analyze this Claude Code tool observation and extract structured insights. + +Tool: ${toolName} +Input: ${toolInput.substring(0, 2000)} +Response: ${toolResponse.substring(0, 2000)} + +Return ONLY a JSON object (no markdown, no code fences) with these fields: +{ + "subtitle": "Brief context description (5-10 words, e.g. 'Examining authentication module')", + "narrative": "One sentence explaining what happened and why (e.g. 'Read the authentication module to understand the login flow before making changes.')", + "facts": ["Array of factual observations", "e.g. 'File auth.ts contains 150 lines'", "Max 5 facts"], + "concepts": ["Array of technical concepts/topics involved", "e.g. 'authentication', 'typescript'", "Include 'intent:' tags for: bugfix, feature, refactor, testing, investigation, documentation, configuration, optimization", "Include 'fn:' for functions changed, 'class:' for classes, 'pattern:' for patterns used", "Max 8 concepts"], + "confidence": 0.85 +}`; +} + +/** + * Parse JSON from AI response, handling common formatting issues + */ +export function parseAIResponse(text: string): EnrichedObservation | null { + try { + // Strip markdown code fences if present + let cleaned = text.trim(); + if (cleaned.startsWith('```json')) { + cleaned = cleaned.slice(7); + } else if (cleaned.startsWith('```')) { + cleaned = cleaned.slice(3); + } + if (cleaned.endsWith('```')) { + cleaned = cleaned.slice(0, -3); + } + cleaned = cleaned.trim(); + + const parsed = JSON.parse(cleaned); + + // Validate structure + if ( + typeof parsed.subtitle !== 'string' || + typeof parsed.narrative !== 'string' || + !Array.isArray(parsed.facts) || + !Array.isArray(parsed.concepts) + ) { + return null; + } + + // Compute confidence: AI-reported value weighted with heuristic checks + let confidence = typeof parsed.confidence === 'number' ? Math.max(0, Math.min(1, parsed.confidence)) : 0.5; + // Penalize empty or very short fields + if (parsed.subtitle.length < 5) confidence *= 0.7; + if (parsed.narrative.length < 10) confidence *= 0.7; + if (parsed.facts.length === 0) confidence *= 0.8; + confidence = Math.round(confidence * 100) / 100; + + return { + subtitle: parsed.subtitle.substring(0, 200), + narrative: parsed.narrative.substring(0, 500), + facts: parsed.facts.slice(0, 5).map((f: unknown) => String(f).substring(0, 200)), + concepts: parsed.concepts.slice(0, 8).map((c: unknown) => String(c).substring(0, 50)), + confidence, + }; + } catch { + return null; + } +} + +/** + * Enrich an observation using the configured AI provider. + * + * Returns enriched data if provider is available and succeeds, + * or null to signal fallback to template-based extraction. + */ +export async function enrichWithAI( + toolName: string, + toolInput: string, + toolResponse: string, + timeoutMs: number = 15000 +): Promise { + if (!isProviderAvailable()) return null; + + try { + const prompt = buildExtractionPrompt(toolName, toolInput, toolResponse); + const systemPrompt = 'You are a code observation analyzer. Extract structured insights from tool usage observations. Return only valid JSON.'; + + const resultText = await runProvider(prompt, systemPrompt, timeoutMs); + if (!resultText) return null; + return parseAIResponse(resultText); + } catch { + return null; + } +} + +/** + * Check if AI enrichment is available (provider configured and reachable) + */ +export async function isAIEnrichmentAvailable(): Promise { + return isProviderAvailable(); +} + +/** + * Reset cached provider and mock state (for testing) + */ +export function resetAIEnrichmentCache(): void { + _resolvedProvider = null; + _providerConfig = undefined; + _mockRunClaudePrint = null; +} + +/** + * Override provider availability for testing (inject mock). + * Sets the mock which makes isProviderAvailable() return true. + */ +export function _setCliAvailableForTesting(available: boolean): void { + if (available) { + // Set a pass-through mock that marks provider as available + if (!_mockRunClaudePrint) { + _mockRunClaudePrint = () => null; + } + } else { + _mockRunClaudePrint = null; + // Force provider to be unavailable by clearing cache + _resolvedProvider = null; + } +} + +/** + * Inject a mock for the AI provider run function (for testing). + * The mock receives (prompt, systemPrompt, timeoutMs) and returns string | null. + * Pass null to clear the mock. + */ +export function _setRunClaudePrintMockForTesting( + fn: ((prompt: string, systemPrompt: string, timeoutMs: number) => string | null) | null +): void { + _mockRunClaudePrint = fn; +} + +// ===== Session Summary Enrichment ===== + +/** + * Enriched session summary data from AI extraction + */ +export interface EnrichedSummary { + completed: string; + nextSteps: string; + decisions: string[]; +} + +/** + * Build prompt for enriching a session summary using transcript context + */ +export function buildSummaryPrompt( + templateSummary: string, + lastAssistantMessage: string +): string { + return `Analyze this Claude Code session and produce an enriched summary. + +## Template Summary (from observations) +${templateSummary.substring(0, 3000)} + +## Last Assistant Message (from transcript) +${lastAssistantMessage.substring(0, 3000)} + +Return ONLY a JSON object (no markdown, no code fences) with these fields: +{ + "completed": "Concise paragraph describing what was actually completed (2-4 sentences). Merge info from both the template summary and the assistant's final message.", + "nextSteps": "Concise list of remaining work or follow-up items, if any. Use 'None' if everything was completed.", + "decisions": ["Array of key decision rationales — WHY specific changes were made, not just WHAT changed. E.g. 'Used mutex for token refresh to prevent race condition'. Max 5 decisions. Empty array if no clear decisions."] +}`; +} + +/** + * Parse enriched summary from AI response + */ +export function parseSummaryResponse(text: string): EnrichedSummary | null { + try { + let cleaned = text.trim(); + if (cleaned.startsWith('```json')) cleaned = cleaned.slice(7); + else if (cleaned.startsWith('```')) cleaned = cleaned.slice(3); + if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3); + cleaned = cleaned.trim(); + + const parsed = JSON.parse(cleaned); + + // Handle completed: must be string + const completed = typeof parsed.completed === 'string' ? parsed.completed : null; + if (!completed) return null; + + // Handle nextSteps: accept string or array (AI often returns arrays for "list") + let nextSteps: string; + if (typeof parsed.nextSteps === 'string') { + nextSteps = parsed.nextSteps; + } else if (Array.isArray(parsed.nextSteps)) { + nextSteps = parsed.nextSteps.map((s: unknown) => String(s)).join('; '); + } else { + nextSteps = 'None'; + } + + // Handle decisions: accept array or empty + let decisions: string[] = []; + if (Array.isArray(parsed.decisions)) { + decisions = parsed.decisions + .filter((d: unknown) => typeof d === 'string' && d.length > 0) + .slice(0, 5) + .map((d: unknown) => String(d).substring(0, 200)); + } + + return { + completed: completed.substring(0, 1000), + nextSteps: nextSteps.substring(0, 500), + decisions, + }; + } catch { + return null; + } +} + +/** + * Enrich a session summary using the configured AI provider. + * + * Takes template-based summary + last assistant message from transcript, + * returns AI-enhanced completed/nextSteps fields. + */ +export async function enrichSummaryWithAI( + templateSummary: string, + lastAssistantMessage: string, + timeoutMs: number = 20000 +): Promise { + if (!isProviderAvailable()) return null; + + try { + const prompt = buildSummaryPrompt(templateSummary, lastAssistantMessage); + const systemPrompt = 'You are a session summary analyzer. Produce concise, accurate session summaries. Return only valid JSON.'; + + const resultText = await runProvider(prompt, systemPrompt, timeoutMs); + if (!resultText) return null; + return parseSummaryResponse(resultText); + } catch { + return null; + } +} + +// ===== Per-Observation Compression ===== + +/** + * Compressed observation data + */ +export interface CompressedObservation { + compressed_summary: string; +} + +/** + * Build prompt for compressing a single observation into a dense summary. + * Uses existing subtitle/narrative as hints for faster, more accurate compression. + */ +export function buildCompressionPrompt( + toolName: string, + toolInput: string, + toolResponse: string, + subtitle?: string, + narrative?: string +): string { + const hints = [subtitle, narrative].filter(Boolean).join(' | '); + return `Compress this tool observation into a single dense summary (50-150 chars). + +Tool: ${toolName} +${hints ? `Context: ${hints}\n` : ''}Input: ${toolInput.substring(0, 1000)} +Response: ${toolResponse.substring(0, 1000)} + +Return ONLY a JSON object (no markdown, no code fences): +{"compressed_summary": "dense summary here"}`; +} + +/** + * Parse compression response from AI + */ +export function parseCompressionResponse(text: string): CompressedObservation | null { + try { + let cleaned = text.trim(); + if (cleaned.startsWith('```json')) cleaned = cleaned.slice(7); + else if (cleaned.startsWith('```')) cleaned = cleaned.slice(3); + if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3); + cleaned = cleaned.trim(); + + const parsed = JSON.parse(cleaned); + if (typeof parsed.compressed_summary !== 'string' || !parsed.compressed_summary) return null; + + return { + compressed_summary: parsed.compressed_summary.substring(0, 200), + }; + } catch { + return null; + } +} + +/** + * Compress a single observation using the configured AI provider. + * Returns a dense 50-150 char summary suitable for context injection. + */ +export async function compressObservationWithAI( + toolName: string, + toolInput: string, + toolResponse: string, + subtitle?: string, + narrative?: string, + timeoutMs: number = 10000 +): Promise { + if (!isProviderAvailable()) return null; + + try { + const prompt = buildCompressionPrompt(toolName, toolInput, toolResponse, subtitle, narrative); + const systemPrompt = 'You are a data compressor. Produce the shortest possible accurate summary. Return only valid JSON.'; + + const resultText = await runProvider(prompt, systemPrompt, timeoutMs); + if (!resultText) return null; + return parseCompressionResponse(resultText); + } catch { + return null; + } +} + +// ===== Session-Level Digest ===== + +/** + * Session digest data from AI compression + */ +export interface SessionDigest { + digest: string; +} + +/** + * Build prompt for generating a compressed session digest. + * Takes the session's request, observation summaries, and completion info. + */ +export function buildSessionDigestPrompt( + request: string, + observationSummaries: string[], + completed: string, + filesModified: string[] +): string { + const obsText = observationSummaries.slice(0, 30).join('\n- '); + const filesText = filesModified.slice(0, 10).join(', '); + return `Compress this session into a single dense digest (200-500 chars). + +Request: ${request.substring(0, 500)} +Observations: +- ${obsText} +Completed: ${completed.substring(0, 300)} +${filesText ? `Files modified: ${filesText}\n` : ''} +Return ONLY a JSON object (no markdown, no code fences): +{"digest": "dense session digest here"}`; +} + +/** + * Parse session digest response from AI + */ +export function parseSessionDigestResponse(text: string): SessionDigest | null { + try { + let cleaned = text.trim(); + if (cleaned.startsWith('```json')) cleaned = cleaned.slice(7); + else if (cleaned.startsWith('```')) cleaned = cleaned.slice(3); + if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3); + cleaned = cleaned.trim(); + + const parsed = JSON.parse(cleaned); + if (typeof parsed.digest !== 'string' || !parsed.digest) return null; + + return { + digest: parsed.digest.substring(0, 600), + }; + } catch { + return null; + } +} + +/** + * Generate a session-level digest using the configured AI provider. + * Compresses an entire session into a 200-500 char digest. + */ +export async function generateSessionDigestWithAI( + request: string, + observationSummaries: string[], + completed: string, + filesModified: string[], + timeoutMs: number = 15000 +): Promise { + if (!isProviderAvailable()) return null; + + try { + const prompt = buildSessionDigestPrompt(request, observationSummaries, completed, filesModified); + const systemPrompt = 'You are a session compressor. Produce the shortest possible accurate digest of a coding session. Return only valid JSON.'; + + const resultText = await runProvider(prompt, systemPrompt, timeoutMs); + if (!resultText) return null; + return parseSessionDigestResponse(resultText); + } catch { + return null; + } +} diff --git a/src/hooks/ai-provider.ts b/src/hooks/ai-provider.ts new file mode 100644 index 0000000..3444d75 --- /dev/null +++ b/src/hooks/ai-provider.ts @@ -0,0 +1,236 @@ +/** + * AI Provider Abstraction + * + * Pluggable providers for AI enrichment/compression operations. + * Supports Claude CLI (default), OpenAI-compatible APIs, and Google Gemini. + * + * @module @agentkits/memory/hooks/ai-provider + */ + +import { execFileSync } from 'node:child_process'; + +// ===== Types ===== + +/** + * AI provider configuration. + * Stored in `.claude/memory/settings.json` under the `aiProvider` key. + */ +export interface AIProviderConfig { + /** Provider type */ + provider: 'claude-cli' | 'openai' | 'gemini'; + /** API key (for HTTP providers; omit for claude-cli) */ + apiKey?: string; + /** Base URL for OpenAI-compatible API (default: https://api.openai.com/v1) */ + baseUrl?: string; + /** Model name (default varies by provider) */ + model?: string; +} + +/** Default provider configuration */ +export const DEFAULT_AI_PROVIDER_CONFIG: AIProviderConfig = { + provider: 'claude-cli', +}; + +/** + * Provider function contract — takes prompt + system prompt + timeout, + * returns raw text response or null on failure. + */ +export type AIProviderFn = ( + prompt: string, + systemPrompt: string, + timeoutMs: number +) => Promise; + +/** Synchronous availability check (best-effort). */ +export type AIProviderAvailableCheck = () => boolean; + +/** Resolved provider with run function and availability check. */ +export interface ResolvedProvider { + run: AIProviderFn; + isAvailable: AIProviderAvailableCheck; + name: string; +} + +// ===== Provider: Claude CLI ===== + +/** + * Create a provider that uses `claude --print` CLI. + * Wraps the existing execFileSync-based approach. + */ +export function createClaudeCliProvider(model: string): ResolvedProvider { + let cliAvailable: boolean | null = null; + + const isAvailable: AIProviderAvailableCheck = () => { + if (cliAvailable !== null) return cliAvailable; + try { + execFileSync('claude', ['--version'], { + encoding: 'utf-8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'ignore'], + }); + cliAvailable = true; + } catch { + cliAvailable = false; + } + return cliAvailable; + }; + + const run: AIProviderFn = async (prompt, systemPrompt, timeoutMs) => { + try { + const result = execFileSync('claude', [ + '--print', + '--model', model, + '--system-prompt', systemPrompt, + '--max-turns', '1', + '--no-input', + '-p', prompt, + ], { + encoding: 'utf-8', + timeout: timeoutMs, + stdio: ['pipe', 'pipe', 'ignore'], + }); + return result.trim() || null; + } catch { + return null; + } + }; + + return { run, isAvailable, name: 'claude-cli' }; +} + +// ===== Provider: OpenAI-Compatible ===== + +/** + * Create a provider that calls any OpenAI-compatible chat completions API. + * Covers: OpenRouter, GLM/ZhipuAI, Ollama, LM Studio, vLLM, Together.ai, etc. + */ +export function createOpenAIProvider( + apiKey: string, + baseUrl: string, + model: string +): ResolvedProvider { + const isAvailable: AIProviderAvailableCheck = () => !!apiKey; + + const run: AIProviderFn = async (prompt, systemPrompt, timeoutMs) => { + try { + const url = `${baseUrl.replace(/\/$/, '')}/chat/completions`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: prompt }, + ], + temperature: 0.3, + max_tokens: 1024, + }), + signal: AbortSignal.timeout(timeoutMs), + }); + + if (!response.ok) return null; + + const data = await response.json() as { + choices?: Array<{ message?: { content?: string } }>; + }; + return data.choices?.[0]?.message?.content?.trim() || null; + } catch { + return null; + } + }; + + return { run, isAvailable, name: 'openai' }; +} + +// ===== Provider: Google Gemini ===== + +/** + * Create a provider that calls Google's Gemini API. + * Uses the generateContent endpoint with system_instruction support. + */ +export function createGeminiProvider( + apiKey: string, + model: string +): ResolvedProvider { + const isAvailable: AIProviderAvailableCheck = () => !!apiKey; + + const run: AIProviderFn = async (prompt, systemPrompt, timeoutMs) => { + try { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + system_instruction: { parts: [{ text: systemPrompt }] }, + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + temperature: 0.3, + maxOutputTokens: 1024, + }, + }), + signal: AbortSignal.timeout(timeoutMs), + }); + + if (!response.ok) return null; + + const data = await response.json() as { + candidates?: Array<{ + content?: { parts?: Array<{ text?: string }> }; + }>; + }; + return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || null; + } catch { + return null; + } + }; + + return { run, isAvailable, name: 'gemini' }; +} + +// ===== Provider Resolver ===== + +/** + * Resolve which AI provider to use. + * + * Resolution order: + * 1. Environment variables (AGENTKITS_AI_PROVIDER, etc.) — override everything + * 2. Settings config (from settings.json) — persistent user preference + * 3. Default: claude-cli + */ +export function resolveAIProvider(settingsConfig?: AIProviderConfig): ResolvedProvider { + // Env vars override settings + const envProvider = process.env.AGENTKITS_AI_PROVIDER; + const envApiKey = process.env.AGENTKITS_AI_API_KEY; + const envBaseUrl = process.env.AGENTKITS_AI_BASE_URL; + const envModel = process.env.AGENTKITS_AI_MODEL; + + // Merge: env > settings > defaults + const provider = envProvider || settingsConfig?.provider || 'claude-cli'; + const apiKey = envApiKey || settingsConfig?.apiKey || ''; + const baseUrl = envBaseUrl || settingsConfig?.baseUrl || 'https://api.openai.com/v1'; + + switch (provider) { + case 'openai': + return createOpenAIProvider( + apiKey, + baseUrl, + envModel || settingsConfig?.model || 'gpt-4o-mini' + ); + + case 'gemini': + return createGeminiProvider( + apiKey, + envModel || settingsConfig?.model || 'gemini-2.0-flash' + ); + + case 'claude-cli': + default: + return createClaudeCliProvider( + envModel || settingsConfig?.model || 'haiku' + ); + } +} diff --git a/src/hooks/cli.ts b/src/hooks/cli.ts index 9b50230..ef6e721 100644 --- a/src/hooks/cli.ts +++ b/src/hooks/cli.ts @@ -13,15 +13,23 @@ * session-init - UserPromptSubmit: initialize session * observation - PostToolUse: capture tool usage * summarize - Stop: generate session summary + * user-message - SessionStart: display status to user (stderr) + * enrich [cwd] - Background: AI-enrich a stored observation + * enrich-summary - Background: AI-enrich session summary + * embed-session - Background worker: process embedding queue + * enrich-session - Background worker: process enrichment queue * * @module @agentkits/memory/hooks/cli */ -import { parseHookInput, formatResponse, STANDARD_RESPONSE } from './types.js'; +import { STANDARD_RESPONSE, HookResult, NormalizedHookInput, DEFAULT_MEMORY_SETTINGS, DEFAULT_CONTEXT_CONFIG } from './types.js'; +import { resolveAdapter } from './adapters/index.js'; import { createContextHook } from './context.js'; import { createSessionInitHook } from './session-init.js'; import { createObservationHook } from './observation.js'; import { createSummarizeHook } from './summarize.js'; +import { createUserMessageHook } from './user-message.js'; +import { MemoryHookService } from './service.js'; /** * Read stdin until EOF @@ -65,34 +73,280 @@ async function main(): Promise { if (!event) { console.error('Usage: agentkits-memory-hook '); - console.error('Events: context, session-init, observation, summarize'); + console.error('Events: context, session-init, observation, summarize, user-message, enrich, enrich-summary, embed-session, enrich-session, compress-session, lifecycle, lifecycle-stats, export, import, settings'); process.exit(1); } + // Handle 'enrich' command directly (no stdin, runs as background process) + if (event === 'enrich') { + const obsId = process.argv[3]; + const cwdArg = process.argv[4] || process.cwd(); + if (obsId) { + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + await svc.enrichObservation(obsId); + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'enrich-summary' command (no stdin, runs as background process) + if (event === 'enrich-summary') { + const sessionId = process.argv[3]; + const cwdArg = process.argv[4] || process.cwd(); + const transcriptPath = process.argv[5]; + if (sessionId && transcriptPath) { + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + await svc.enrichSessionSummary(sessionId, transcriptPath); + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'embed-session' command (no stdin, runs as background process) + // Processes the SQLite embedding queue + any records missing embeddings. + // Loops until queue is empty (batch limit per iteration). Usage: embed-session + if (event === 'embed-session') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + // Graceful shutdown on signals (cleanup lock file + DB) + const cleanup = async () => { try { await svc.shutdown(); } catch {} process.exit(0); }; + process.on('SIGTERM', cleanup); + process.on('SIGINT', cleanup); + // Safety: self-terminate after 5 minutes to prevent zombie processes + const killTimer = setTimeout(() => { cleanup(); }, 5 * 60 * 1000); + killTimer.unref(); + try { + // Loop until no more work — each call processes up to WORKER_BATCH_LIMIT items + let processed: number; + do { + processed = await svc.processEmbeddingQueue(); + } while (processed > 0); + } finally { + clearTimeout(killTimer); + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'enrich-session' command (no stdin, runs as background process) + // Processes the SQLite enrichment queue — calls claude --print for each observation. + // Usage: enrich-session + if (event === 'enrich-session') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + // Graceful shutdown on signals (cleanup lock file + DB) + const cleanup = async () => { try { await svc.shutdown(); } catch {} process.exit(0); }; + process.on('SIGTERM', cleanup); + process.on('SIGINT', cleanup); + // Safety: self-terminate after 5 minutes to prevent zombie processes + const killTimer = setTimeout(() => { cleanup(); }, 5 * 60 * 1000); + killTimer.unref(); + try { + // Loop until no more work — each call processes up to WORKER_BATCH_LIMIT items + let processed: number; + do { + processed = await svc.processEnrichmentQueue(); + } while (processed > 0); + } finally { + clearTimeout(killTimer); + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'compress-session' command (no stdin, runs as background process) + // Processes the SQLite compression queue — compresses observations + generates session digests. + // Usage: compress-session + if (event === 'compress-session') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + const cleanup = async () => { try { await svc.shutdown(); } catch {} process.exit(0); }; + process.on('SIGTERM', cleanup); + process.on('SIGINT', cleanup); + // Safety: self-terminate after 5 minutes to prevent zombie processes + const killTimer = setTimeout(() => { cleanup(); }, 5 * 60 * 1000); + killTimer.unref(); + try { + // Loop until no more work — each call processes up to WORKER_BATCH_LIMIT items + let processed: number; + do { + processed = await svc.processCompressionQueue(); + } while (processed > 0); + } finally { + clearTimeout(killTimer); + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'lifecycle' command (no stdin, runs lifecycle tasks) + // Usage: lifecycle [--compress-days=7] [--archive-days=30] [--delete] [--delete-days=90] + if (event === 'lifecycle') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + try { + const config: Record = {}; + for (const arg of process.argv.slice(4)) { + if (arg.startsWith('--compress-days=')) config.compressAfterDays = parseInt(arg.split('=')[1], 10); + if (arg.startsWith('--archive-days=')) config.archiveAfterDays = parseInt(arg.split('=')[1], 10); + if (arg === '--delete') config.autoDelete = true; + if (arg.startsWith('--delete-days=')) { config.deleteAfterDays = parseInt(arg.split('=')[1], 10); config.autoDelete = true; } + } + const result = await svc.runLifecycleTasks(config); + console.log(JSON.stringify(result, null, 2)); + } finally { + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'lifecycle-stats' command + // Usage: lifecycle-stats + if (event === 'lifecycle-stats') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + try { + const stats = await svc.getLifecycleStats(); + console.log(JSON.stringify(stats, null, 2)); + } finally { + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'export' command + // Usage: export + if (event === 'export') { + const cwdArg = process.argv[3] || process.cwd(); + const project = process.argv[4]; + const outputPath = process.argv[5]; + if (!project || !outputPath) { + console.error('Usage: export '); + process.exit(1); + } + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + try { + const data = await svc.exportToJSON(project); + const { writeFileSync } = await import('node:fs'); + writeFileSync(outputPath, JSON.stringify(data, null, 2)); + console.error(`Exported ${data.sessions.length} sessions to ${outputPath}`); + } finally { + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'import' command + // Usage: import + if (event === 'import') { + const cwdArg = process.argv[3] || process.cwd(); + const inputPath = process.argv[4]; + if (!inputPath) { + console.error('Usage: import '); + process.exit(1); + } + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + try { + const { readFileSync } = await import('node:fs'); + const data = JSON.parse(readFileSync(inputPath, 'utf-8')); + const result = await svc.importFromJSON(data); + console.log(JSON.stringify(result, null, 2)); + } finally { + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'settings' command + // Usage: settings [key=value ...] [--reset] + if (event === 'settings') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + try { + const settingsArgs = process.argv.slice(4); + + if (settingsArgs.includes('--reset')) { + svc.saveSettings({ ...DEFAULT_MEMORY_SETTINGS, context: { ...DEFAULT_CONTEXT_CONFIG } }); + console.log(JSON.stringify(DEFAULT_MEMORY_SETTINGS, null, 2)); + } else if (settingsArgs.length === 0) { + console.log(JSON.stringify(svc.loadSettings(), null, 2)); + } else { + const settings = svc.loadSettings(); + for (const arg of settingsArgs) { + const eqIndex = arg.indexOf('='); + if (eqIndex <= 0) continue; + const key = arg.slice(0, eqIndex); + const value = arg.slice(eqIndex + 1); + + // Handle aiProvider.* keys (e.g., aiProvider.provider=openai) + if (key.startsWith('aiProvider.')) { + const subKey = key.slice('aiProvider.'.length); + if (!settings.aiProvider) { + settings.aiProvider = { provider: 'claude-cli' }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (settings.aiProvider as any)[subKey] = value; + } else if (key in settings.context) { + const contextKey = key as keyof typeof settings.context; + const current = settings.context[contextKey]; + if (typeof current === 'boolean') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (settings.context as any)[key] = value === 'true'; + } else if (typeof current === 'number') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (settings.context as any)[key] = parseInt(value, 10); + } + } + } + svc.saveSettings(settings); + console.log(JSON.stringify(settings, null, 2)); + } + } finally { + await svc.shutdown(); + } + process.exit(0); + } + // Read stdin const stdin = await readStdin(); - // Parse input - const input = parseHookInput(stdin); + // Resolve platform adapter and parse input + const adapter = resolveAdapter(); + const input = adapter.parseInput(stdin); // Select and execute handler - let result; + let result: HookResult | undefined; + let hook: { execute(input: NormalizedHookInput): Promise; shutdown(): Promise } | null = null; switch (event) { case 'context': - result = await createContextHook(input.cwd).execute(input); + hook = createContextHook(input.cwd); break; case 'session-init': - result = await createSessionInitHook(input.cwd).execute(input); + hook = createSessionInitHook(input.cwd); break; case 'observation': - result = await createObservationHook(input.cwd).execute(input); + hook = createObservationHook(input.cwd); break; case 'summarize': - result = await createSummarizeHook(input.cwd).execute(input); + hook = createSummarizeHook(input.cwd); + break; + + case 'user-message': + hook = createUserMessageHook(input.cwd); break; default: @@ -101,15 +355,27 @@ async function main(): Promise { process.exit(0); } - // Output response - console.log(formatResponse(result)); + // Execute hook with guaranteed shutdown (closes DB connection) + try { + result = await hook!.execute(input); + } finally { + try { await hook!.shutdown(); } catch { /* ignore shutdown errors */ } + } + + // Output response using platform adapter + console.log(adapter.formatOutput(result)); } catch (error) { - // Log error to stderr + // Log error to stderr (visible in verbose mode with exit 0) console.error('[AgentKits Memory] CLI error:', error); - // Output standard response (don't block Claude) + // Output standard response so Claude can continue console.log(JSON.stringify(STANDARD_RESPONSE)); + + // MUST exit 0: exit code 2 would block UserPromptSubmit (erases prompt) + // and Stop (prevents Claude from stopping). Memory errors should never + // disrupt Claude's operation. + process.exit(0); } } diff --git a/src/hooks/context.ts b/src/hooks/context.ts index f1e8aca..df67038 100644 --- a/src/hooks/context.ts +++ b/src/hooks/context.ts @@ -47,22 +47,51 @@ export class ContextHook implements EventHandler { // Initialize service await this.service.initialize(); + // Catch-up: spawn workers if stale pending tasks exist from previous sessions + try { + if (this.service.hasPendingEmbeddings()) { + this.service.ensureWorkerRunning(input.cwd, 'embed-session', 'embed-worker.lock'); + } + if (this.service.hasPendingEnrichments()) { + this.service.ensureWorkerRunning(input.cwd, 'enrich-session', 'enrich-worker.lock'); + } + if (this.service.hasPendingCompressions()) { + this.service.ensureWorkerRunning(input.cwd, 'compress-session', 'compress-worker.lock'); + } + } catch { /* non-critical — don't block context injection */ } + // Get context for this project const context = await this.service.getContext(input.project); + const hasHistory = context.markdown && !context.markdown.includes('No previous session context'); + + // Display status to user via stderr (merged from user-message hook) + const obsCount = context.recentObservations.length; + const sessionCount = context.sessionSummaries.length || context.previousSessions.length; + const promptCount = context.userPrompts.length; + if (obsCount > 0 || sessionCount > 0 || promptCount > 0) { + const stats: string[] = []; + if (sessionCount > 0) stats.push(`${sessionCount} session${sessionCount > 1 ? 's' : ''}`); + if (obsCount > 0) stats.push(`${obsCount} observation${obsCount > 1 ? 's' : ''}`); + if (promptCount > 0) stats.push(`${promptCount} prompt${promptCount > 1 ? 's' : ''}`); + console.error(`\n AgentKits Memory: ${stats.join(', ')}\n`); + } else { + console.error('\n AgentKits Memory: Fresh — use memory_save to start\n'); + } - // No context to inject - if (!context.markdown || context.markdown.includes('No previous session context')) { + if (hasHistory) { + // Inject full context with history return { continue: true, - suppressOutput: true, + suppressOutput: false, + additionalContext: context.markdown, }; } - // Inject context as additional context + // Empty state: still inject tool guidance so Claude knows memory tools exist return { continue: true, suppressOutput: false, - additionalContext: context.markdown, + additionalContext: this.buildEmptyStateGuidance(input.project), }; } catch (error) { // Log error but don't block session @@ -75,6 +104,36 @@ export class ContextHook implements EventHandler { }; } } + + /** + * Build guidance for empty state (no previous sessions/memories). + * Teaches Claude about available memory tools and proper usage order. + */ + private buildEmptyStateGuidance(project: string): string { + return `# Memory Context - ${project} + +> **Memory tools available** — Use MCP tools to search and manage project memory: +> \`memory_save\`, \`memory_recall\`, \`memory_list\`, \`memory_search\`, \`memory_timeline\`, \`memory_details\`, \`memory_update\`, \`memory_delete\`, \`memory_status\` + +## Getting Started + +No previous session context found. This is a fresh memory. + +**To build memory**, use \`memory_save(content, category, tags, importance)\` to store: +- **decisions** — architectural choices, tech stack picks, trade-offs +- **patterns** — coding conventions, project patterns, recurring approaches +- **errors** — bug fixes, error solutions, debugging insights +- **context** — project background, team conventions, environment setup + +**Important:** Do NOT call \`memory_search\`, \`memory_timeline\`, or \`memory_details\` until memories exist. +Use \`memory_status()\` to check if memories are available before searching. + +**After saving**, use the 3-layer search workflow: +1. \`memory_search(query)\` → Get index with IDs (~50 tokens/result) +2. \`memory_timeline(anchor="ID")\` → Get context around interesting results +3. \`memory_details(ids=["ID1","ID2"])\` → Fetch full content ONLY for filtered IDs +`; + } } /** diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 60c3c97..facaada 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -18,6 +18,7 @@ export { ContextHook, createContextHook } from './context.js'; export { SessionInitHook, createSessionInitHook } from './session-init.js'; export { ObservationHook, createObservationHook } from './observation.js'; export { SummarizeHook, createSummarizeHook } from './summarize.js'; +export { UserMessageHook, createUserMessageHook } from './user-message.js'; // Re-export default service export { default } from './service.js'; diff --git a/src/hooks/observation.ts b/src/hooks/observation.ts index 785f93b..6c2af59 100644 --- a/src/hooks/observation.ts +++ b/src/hooks/observation.ts @@ -15,13 +15,28 @@ import { import { MemoryHookService } from './service.js'; /** - * Tools to skip capturing (internal/noisy tools) + * Tools to skip capturing (internal/noisy tools). + * Includes our own memory MCP tools to avoid self-referential loops. */ const SKIP_TOOLS = new Set([ 'TodoWrite', 'TodoRead', 'AskFollowupQuestion', + 'AskUserQuestion', 'AttemptCompletion', + // Low-signal tools (directory listings add noise) + 'LS', + // Skip our own memory tools (avoid capturing memory ops as observations) + 'mcp__memory__memory_save', + 'mcp__memory__memory_search', + 'mcp__memory__memory_timeline', + 'mcp__memory__memory_details', + 'mcp__memory__memory_delete', + 'mcp__memory__memory_update', + 'mcp__memory__memory_recall', + 'mcp__memory__memory_list', + 'mcp__memory__memory_status', + 'mcp__memory____IMPORTANT', ]); /** @@ -69,14 +84,24 @@ export class ObservationHook implements EventHandler { }; } + // Skip empty/no-op tool calls (e.g. Read with no file_path) + const inputStr = JSON.stringify(input.toolInput || {}); + const responseStr = JSON.stringify(input.toolResponse || {}); + if (inputStr === '{}' && responseStr === '{}') { + return { + continue: true, + suppressOutput: true, + }; + } + // Initialize service await this.service.initialize(); // Ensure session exists (create if not) await this.service.initSession(input.sessionId, input.project); - // Store the observation - await this.service.storeObservation( + // Store the observation (template-based, fast <50ms) + const obs = await this.service.storeObservation( input.sessionId, input.project, input.toolName, @@ -85,6 +110,9 @@ export class ObservationHook implements EventHandler { input.cwd ); + // Enrichment + embedding are queued in service.ts storeObservation() + // Workers are spawned at session end (summarize hook) + return { continue: true, suppressOutput: true, diff --git a/src/hooks/service.ts b/src/hooks/service.ts index 3ae0a6a..78573d9 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -7,19 +7,43 @@ * @module @agentkits/memory/hooks/service */ -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, constants as fsConstants } from 'node:fs'; +import { spawn } from 'node:child_process'; import * as path from 'node:path'; import Database from 'better-sqlite3'; import type { Database as BetterDatabase } from 'better-sqlite3'; import { Observation, SessionRecord, + UserPrompt, + SessionSummary, MemoryContext, generateObservationId, getObservationType, generateObservationTitle, + generateObservationSubtitle, + generateObservationNarrative, + extractFilePaths, + extractFacts, + extractConcepts, + detectIntent, + extractIntents, + extractCodeDiffs, truncate, + computeContentHash, + ContextConfig, + DEFAULT_CONTEXT_CONFIG, + MemorySettings, + DEFAULT_MEMORY_SETTINGS, + LifecycleConfig, + DEFAULT_LIFECYCLE_CONFIG, + LifecycleResult, + LifecycleStats, + ExportData, + ExportSession, + ImportResult, } from './types.js'; +import { enrichWithAI, enrichSummaryWithAI, compressObservationWithAI, generateSessionDigestWithAI, setAIProviderConfig } from './ai-enrichment.js'; /** * Memory Hook Service Configuration @@ -43,7 +67,7 @@ export interface MemoryHookServiceConfig { const DEFAULT_CONFIG: MemoryHookServiceConfig = { baseDir: '.claude/memory', - dbFilename: 'hooks.db', + dbFilename: 'memory.db', // Single DB: hooks + memories in one file maxContextObservations: 20, maxContextSessions: 5, maxResponseSize: 5000, @@ -83,10 +107,18 @@ export class MemoryHookService { // Enable WAL mode for better performance this.db.pragma('journal_mode = WAL'); + // Prevent SQLITE_BUSY when concurrent processes access the DB + this.db.pragma('busy_timeout = 10000'); // Create schema this.createSchema(); + // Configure AI provider from persistent settings + const settings = this.loadSettings(); + if (settings.aiProvider) { + setAIProviderConfig(settings.aiProvider); + } + this.initialized = true; } @@ -104,7 +136,7 @@ export class MemoryHookService { // ===== Session Management ===== /** - * Initialize or get session + * Initialize or get session (idempotent) */ async initSession(sessionId: string, project: string, prompt?: string): Promise { await this.ensureInitialized(); @@ -115,12 +147,25 @@ export class MemoryHookService { return existing; } + // Resume detection: find recent session in same project within 30 min + let parentSessionId: string | null = null; + const thirtyMinAgo = Date.now() - 30 * 60 * 1000; + const recentSession = this.db!.prepare(` + SELECT session_id FROM sessions + WHERE project = ? AND started_at > ? AND session_id != ? + ORDER BY started_at DESC LIMIT 1 + `).get(project, thirtyMinAgo, sessionId) as { session_id: string } | undefined; + + if (recentSession) { + parentSessionId = recentSession.session_id; + } + // Create new session const now = Date.now(); const result = this.db!.prepare(` - INSERT INTO sessions (session_id, project, prompt, started_at, observation_count, status) - VALUES (?, ?, ?, ?, 0, 'active') - `).run(sessionId, project, prompt || '', now); + INSERT INTO sessions (session_id, project, prompt, started_at, observation_count, status, parent_session_id) + VALUES (?, ?, ?, ?, 0, 'active', ?) + `).run(sessionId, project, prompt || '', now, parentSessionId); return { id: Number(result.lastInsertRowid), @@ -130,9 +175,138 @@ export class MemoryHookService { startedAt: now, observationCount: 0, status: 'active', + parentSessionId: parentSessionId || undefined, + }; + } + + // ===== User Prompt Management ===== + + /** + * Save a user prompt (tracks ALL prompts, not just the first) + */ + async saveUserPrompt(sessionId: string, project: string, promptText: string): Promise { + await this.ensureInitialized(); + + // Ensure session exists + await this.initSession(sessionId, project, promptText); + + // Dedup: check for identical prompt in same project within 5 minutes + const contentHash = computeContentHash(project, promptText); + const fiveMinAgo = Date.now() - 5 * 60 * 1000; + const existing = this.db!.prepare(` + SELECT up.* FROM user_prompts up + JOIN sessions s ON s.session_id = up.session_id + WHERE up.content_hash = ? AND s.project = ? AND up.created_at > ? + LIMIT 1 + `).get(contentHash, project, fiveMinAgo) as Record | undefined; + + if (existing) { + return { + id: existing.id as number, + sessionId: existing.session_id as string, + promptNumber: existing.prompt_number as number, + promptText: existing.prompt_text as string, + createdAt: existing.created_at as number, + contentHash, + }; + } + + // Get next prompt number + const promptNumber = this.getPromptNumber(sessionId) + 1; + const now = Date.now(); + + const result = this.db!.prepare(` + INSERT OR IGNORE INTO user_prompts (session_id, prompt_number, prompt_text, content_hash, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(sessionId, promptNumber, promptText, contentHash, now); + + const id = result.changes > 0 ? Number(result.lastInsertRowid) : 0; + + // Queue embedding generation if insert succeeded + if (id > 0) { + this.queueTask('embed', 'user_prompts', id); + } + + return { + id, + sessionId, + promptNumber, + promptText, + createdAt: now, + contentHash, }; } + /** + * Get the latest prompt text for a session (for intent detection) + */ + getLatestPromptText(sessionId: string): string | null { + if (!this.db) return null; + + const row = this.db.prepare( + 'SELECT prompt_text FROM user_prompts WHERE session_id = ? ORDER BY prompt_number DESC LIMIT 1' + ).get(sessionId) as { prompt_text: string } | undefined; + + return row?.prompt_text || null; + } + + /** + * Get current prompt number for a session (0 if no prompts yet) + */ + getPromptNumber(sessionId: string): number { + if (!this.db) return 0; + + const row = this.db.prepare( + 'SELECT COUNT(*) as count FROM user_prompts WHERE session_id = ?' + ).get(sessionId) as { count: number } | undefined; + + return row?.count || 0; + } + + /** + * Get all prompts for a session + */ + async getSessionPrompts(sessionId: string): Promise { + await this.ensureInitialized(); + + const rows = this.db!.prepare(` + SELECT * FROM user_prompts + WHERE session_id = ? + ORDER BY prompt_number ASC + `).all(sessionId) as Record[]; + + return rows.map(row => ({ + id: row.id as number, + sessionId: row.session_id as string, + promptNumber: row.prompt_number as number, + promptText: row.prompt_text as string, + createdAt: row.created_at as number, + })); + } + + /** + * Get recent prompts across all sessions for a project + */ + async getRecentPrompts(project: string, limit: number = 20): Promise { + await this.ensureInitialized(); + + const rows = this.db!.prepare(` + SELECT up.* FROM user_prompts up + JOIN sessions s ON s.session_id = up.session_id + WHERE s.project = ? + ORDER BY up.created_at DESC + LIMIT ? + `).all(project, limit) as Record[]; + + return rows.map(row => ({ + id: row.id as number, + sessionId: row.session_id as string, + promptNumber: row.prompt_number as number, + promptText: row.prompt_text as string, + createdAt: row.created_at as number, + })); + } + /** * Get session by ID */ @@ -197,18 +371,54 @@ export class MemoryHookService { const now = Date.now(); const type = getObservationType(toolName); const title = generateObservationTitle(toolName, toolInput); + const promptNumber = this.getPromptNumber(sessionId); + const { filesRead, filesModified } = extractFilePaths(toolName, toolInput); - // Truncate large responses - const inputStr = JSON.stringify(toolInput || {}); + // Truncate large responses (safe stringify handles circular refs) + const safeStringify = (val: unknown): string => { + try { return JSON.stringify(val || {}); } catch { return '{}'; } + }; + const inputStr = safeStringify(toolInput); const responseStr = truncate( - JSON.stringify(toolResponse || {}), + safeStringify(toolResponse), this.config.maxResponseSize ); + // Dedup: check for identical observation in same session within 60 seconds + const contentHash = computeContentHash(sessionId, toolName, inputStr); + const oneMinAgo = now - 60 * 1000; + const existingObs = this.db!.prepare( + 'SELECT id FROM observations WHERE content_hash = ? AND session_id = ? AND timestamp > ? LIMIT 1' + ).get(contentHash, sessionId, oneMinAgo) as { id: string } | undefined; + + if (existingObs) { + // Return existing observation without re-inserting + const row = this.db!.prepare('SELECT * FROM observations WHERE id = ?').get(existingObs.id) as Record; + return this.rowToObservation(row); + } + + // Template-based extraction only (fast, <10ms) + // AI enrichment runs asynchronously via fire-and-forget process + const subtitle = generateObservationSubtitle(toolName, toolInput, toolResponse); + const narrative = generateObservationNarrative(toolName, toolInput, toolResponse); + const facts = extractFacts(toolName, toolInput, toolResponse); + const concepts = extractConcepts(toolName, toolInput, toolResponse); + + // Detect developer intent and add as intent: prefixed tags + const latestPrompt = this.getLatestPromptText(sessionId); + const intents = detectIntent(toolName, toolInput, toolResponse, latestPrompt || undefined); + for (const intent of intents) { + concepts.push(`intent:${intent}`); + } + this.db!.prepare(` - INSERT INTO observations (id, session_id, project, tool_name, tool_input, tool_response, cwd, timestamp, type, title) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, sessionId, project, toolName, inputStr, responseStr, cwd, now, type, title); + INSERT INTO observations (id, session_id, project, tool_name, tool_input, tool_response, cwd, timestamp, type, title, prompt_number, files_read, files_modified, subtitle, narrative, facts, concepts, content_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, sessionId, project, toolName, inputStr, responseStr, cwd, now, type, title, promptNumber || null, JSON.stringify(filesRead), JSON.stringify(filesModified), subtitle, narrative, JSON.stringify(facts), JSON.stringify(concepts), contentHash); + + // Queue background tasks (embedding + AI enrichment) + this.queueTask('embed', 'observations', id); + this.queueTask('enrich', 'observations', id); // Update session observation count this.db!.prepare(` @@ -228,9 +438,525 @@ export class MemoryHookService { timestamp: now, type, title, + promptNumber: promptNumber || undefined, + filesRead, + filesModified, + subtitle, + narrative, + facts, + concepts, }; } + /** + * Enrich an existing observation with AI-generated data. + * Called from a background process after the observation is saved. + * Updates subtitle, narrative, facts, and concepts in-place. + */ + async enrichObservation(id: string): Promise { + await this.ensureInitialized(); + + const row = this.db!.prepare( + 'SELECT tool_name, tool_input, tool_response FROM observations WHERE id = ?' + ).get(id) as { tool_name: string; tool_input: string; tool_response: string } | undefined; + + if (!row) return false; + + const aiResult = await enrichWithAI(row.tool_name, row.tool_input, row.tool_response).catch(() => null); + if (!aiResult) return false; + + this.db!.prepare(` + UPDATE observations + SET subtitle = ?, narrative = ?, facts = ?, concepts = ? + WHERE id = ? + `).run( + aiResult.subtitle, + aiResult.narrative, + JSON.stringify(aiResult.facts), + JSON.stringify(aiResult.concepts), + id + ); + + return true; + } + + /** + * Compress a single observation using AI. + * Replaces raw tool_input/tool_response with a dense compressed_summary. + * Sets is_compressed=1 to indicate the raw data has been replaced. + */ + async compressObservation(id: string): Promise { + await this.ensureInitialized(); + + const row = this.db!.prepare( + 'SELECT tool_name, tool_input, tool_response, subtitle, narrative, is_compressed FROM observations WHERE id = ?' + ).get(id) as { tool_name: string; tool_input: string; tool_response: string; subtitle: string; narrative: string; is_compressed: number } | undefined; + + if (!row || row.is_compressed === 1) return false; + + const result = await compressObservationWithAI( + row.tool_name, row.tool_input, row.tool_response, row.subtitle, row.narrative + ).catch(() => null); + + if (!result) return false; + + // Write compressed summary and clear raw data to save space + this.db!.prepare(` + UPDATE observations + SET compressed_summary = ?, is_compressed = 1, tool_input = '{}', tool_response = '{}' + WHERE id = ? + `).run(result.compressed_summary, id); + + return true; + } + + /** + * Compress all observations for a session and generate a session digest. + * 1. Compresses each observation individually (10:1-25:1 ratio) + * 2. Generates a session-level digest from summaries (20:1-100:1 ratio) + * 3. Stores digest in session_digests table with embedding queued + */ + async compressSessionObservations(sessionId: string): Promise<{ compressed: number; digestCreated: boolean }> { + await this.ensureInitialized(); + + // Get all uncompressed observations for this session + const rows = this.db!.prepare( + 'SELECT id FROM observations WHERE session_id = ? AND is_compressed = 0' + ).all(sessionId) as { id: string }[]; + + let compressed = 0; + for (const row of rows) { + const ok = await this.compressObservation(row.id); + if (ok) compressed++; + } + + // Generate session digest + let digestCreated = false; + const summary = this.db!.prepare( + 'SELECT * FROM session_summaries WHERE session_id = ? ORDER BY created_at DESC LIMIT 1' + ).get(sessionId) as Record | undefined; + + if (summary) { + // Collect observation summaries for digest input + const obsRows = this.db!.prepare( + 'SELECT compressed_summary, subtitle, title FROM observations WHERE session_id = ? ORDER BY timestamp ASC' + ).all(sessionId) as { compressed_summary: string | null; subtitle: string | null; title: string | null }[]; + + const obsSummaries = obsRows.map(r => r.compressed_summary || r.subtitle || r.title || '').filter(Boolean); + + const filesModified = JSON.parse((summary.files_modified as string) || '[]') as string[]; + const request = (summary.request as string) || ''; + const completed = (summary.completed as string) || ''; + + const digest = await generateSessionDigestWithAI( + request, obsSummaries, completed, filesModified + ).catch(() => null); + + if (digest) { + const project = (summary.project as string) || ''; + this.db!.prepare(` + INSERT OR REPLACE INTO session_digests (session_id, project, digest, observation_count, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(sessionId, project, digest.digest, obsRows.length, Date.now()); + + // Queue embedding for the digest + const digestRow = this.db!.prepare( + 'SELECT id FROM session_digests WHERE session_id = ?' + ).get(sessionId) as { id: number } | undefined; + if (digestRow) { + this.queueTask('embed', 'session_digests', digestRow.id); + } + + digestCreated = true; + } + } + + return { compressed, digestCreated }; + } + + /** + * Build embedding text for a session record based on table type. + */ + private getSessionEmbeddingText( + table: 'observations' | 'user_prompts' | 'session_summaries' | 'session_digests', + row: Record + ): string { + if (table === 'observations') { + // Prefer compressed summary if available + if (row.compressed_summary) { + return (row.compressed_summary as string).trim(); + } + const parts = [row.title, row.subtitle, row.narrative]; + try { + const concepts = JSON.parse((row.concepts as string) || '[]'); + if (concepts.length > 0) parts.push(concepts.join(', ')); + } catch { /* ignore */ } + return (parts.filter(Boolean) as string[]).join(' ').trim(); + } else if (table === 'user_prompts') { + return ((row.prompt_text as string) || '').trim(); + } else if (table === 'session_digests') { + return ((row.digest as string) || '').trim(); + } else { + const parts = [row.request, row.completed, row.next_steps, row.notes]; + return (parts.filter(Boolean) as string[]).join(' ').trim(); + } + } + + // ===== Embedding Queue + Worker ===== + + /** Max records to process per worker invocation */ + private static readonly WORKER_BATCH_LIMIT = 200; + /** Max retries before marking a task as permanently failed */ + private static readonly MAX_TASK_RETRIES = 3; + + /** + * Queue a background task. Inserts into SQLite task_queue — atomic, <1ms. + * Called from hook handlers — non-blocking, no model/API loading. + */ + queueTask( + taskType: 'embed' | 'enrich' | 'compress', + table: string, + recordId: string | number + ): void { + if (!this.db) return; + this.db.prepare( + 'INSERT INTO task_queue (task_type, target_table, target_id, created_at) VALUES (?, ?, ?, ?)' + ).run(taskType, table, String(recordId), Date.now()); + } + + /** + * Spawn a detached background worker if not already running. + * Uses a PID-based lock file to prevent multiple concurrent workers. + * @param workerType - 'embed-session' or 'enrich-session' + * @param lockName - unique lock file name for this worker type + */ + ensureWorkerRunning(cwd: string, workerType: string, lockName: string): void { + const lockFile = path.join(path.dirname(this.dbPath), lockName); + + // Check if worker is already running (stale lock cleanup) + if (existsSync(lockFile)) { + try { + const pid = parseInt(readFileSync(lockFile, 'utf-8').trim(), 10); + if (pid > 0) { + try { + process.kill(pid, 0); // signal 0 = check if alive + return; // Worker still running + } catch { + // Process dead — remove stale lock + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + } else { + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + } catch { + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + } + + // Atomic lock acquisition: O_CREAT | O_EXCL fails if file exists (prevents race) + let fd: number; + try { + fd = openSync(lockFile, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY); + } catch { + // Another process created the lock between our check and open — that's fine + return; + } + + // Write PID placeholder (will be overwritten by worker with its actual PID) + try { + writeFileSync(lockFile, '0'); + } finally { + try { closeSync(fd); } catch { /* ignore */ } + } + + // Spawn detached worker (worker writes its own PID to lock file on start) + try { + const cliPath = path.resolve(cwd, 'dist/hooks/cli.js'); + const child = spawn('node', [cliPath, workerType, cwd], { + detached: true, + stdio: 'ignore', + env: { ...process.env }, + }); + child.on('error', () => { /* spawn failure handled — lock cleaned below */ }); + child.unref(); + } catch { + // Failed to spawn — clean up lock + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + } + + /** + * Process embedding tasks from the queue. + * Loads embedding model ONCE, processes queued items + DB catch-up. + * Uses lock file to prevent concurrent workers. + */ + async processEmbeddingQueue(): Promise { + await this.ensureInitialized(); + + const lockFile = path.join(path.dirname(this.dbPath), 'embed-worker.lock'); + writeFileSync(lockFile, String(process.pid)); + + let count = 0; + try { + const { LocalEmbeddingsService } = await import('../embeddings/local-embeddings.js'); + const cacheDir = path.join(path.dirname(this.dbPath), 'embeddings-cache'); + const embService = new LocalEmbeddingsService({ cacheDir }); + await embService.initialize(); + + const idColMap: Record = { + observations: 'id', + user_prompts: 'rowid', + session_summaries: 'rowid', + session_digests: 'id', + }; + + // Atomic claim: SELECT + UPDATE in a single transaction to prevent race conditions + const claimEmbedTask = this.db!.transaction(() => { + const item = this.db!.prepare( + "SELECT id, target_table, target_id, retry_count FROM task_queue WHERE task_type = 'embed' AND status = 'pending' AND retry_count < ? ORDER BY id ASC LIMIT 1" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { id: number; target_table: string; target_id: string; retry_count: number } | undefined; + if (item) { + this.db!.prepare("UPDATE task_queue SET status = 'processing' WHERE id = ?").run(item.id); + } + return item; + }); + + // Phase 1: Process queued embed tasks + while (count < MemoryHookService.WORKER_BATCH_LIMIT) { + const item = claimEmbedTask(); + + if (!item) break; + + const idCol = idColMap[item.target_table]; + if (!idCol) { + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); + continue; + } + + try { + const row = this.db!.prepare( + `SELECT * FROM ${item.target_table} WHERE ${idCol} = ? AND embedding IS NULL` + ).get(item.target_id) as Record | undefined; + + if (!row) { + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); + continue; + } + + const text = this.getSessionEmbeddingText( + item.target_table as 'observations' | 'user_prompts' | 'session_summaries', row + ); + if (!text) { + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); + continue; + } + + const result = await embService.embed(text); + const buffer = Buffer.from(result.embedding); + this.db!.prepare(`UPDATE ${item.target_table} SET embedding = ? WHERE ${idCol} = ?`).run(buffer, item.target_id); + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); + count++; + } catch { + // Increment retry_count; mark as 'failed' if max retries exceeded + const newRetry = (item.retry_count || 0) + 1; + if (newRetry >= MemoryHookService.MAX_TASK_RETRIES) { + this.db!.prepare("UPDATE task_queue SET status = 'failed', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } else { + this.db!.prepare("UPDATE task_queue SET status = 'pending', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } + count++; + } + } + + // Phase 2: Catch up on DB records missing embeddings + if (count < MemoryHookService.WORKER_BATCH_LIMIT) { + for (const [tableName, idCol] of Object.entries(idColMap)) { + if (count >= MemoryHookService.WORKER_BATCH_LIMIT) break; + try { + const remaining = MemoryHookService.WORKER_BATCH_LIMIT - count; + const rows = this.db!.prepare( + `SELECT *, ${idCol} as _rid FROM ${tableName} WHERE embedding IS NULL ORDER BY rowid DESC LIMIT ?` + ).all(remaining) as Record[]; + + for (const row of rows) { + if (count >= MemoryHookService.WORKER_BATCH_LIMIT) break; + const text = this.getSessionEmbeddingText( + tableName as 'observations' | 'user_prompts' | 'session_summaries', row + ); + if (!text) continue; + try { + const result = await embService.embed(text); + const buffer = Buffer.from(result.embedding); + this.db!.prepare(`UPDATE ${tableName} SET embedding = ? WHERE ${idCol} = ?`).run(buffer, row._rid); + count++; + } catch { /* skip */ } + } + } catch { /* table might not exist */ } + } + } + } finally { + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + + return count; + } + + /** + * Process enrichment tasks from the queue. + * Calls claude --print sequentially for each observation. + * Uses lock file to prevent concurrent workers. + */ + async processEnrichmentQueue(): Promise { + await this.ensureInitialized(); + + const lockFile = path.join(path.dirname(this.dbPath), 'enrich-worker.lock'); + writeFileSync(lockFile, String(process.pid)); + + let count = 0; + try { + // Atomic claim: SELECT + UPDATE in a single transaction to prevent race conditions + const claimEnrichTask = this.db!.transaction(() => { + const item = this.db!.prepare( + "SELECT id, target_table, target_id, retry_count FROM task_queue WHERE task_type = 'enrich' AND status = 'pending' AND retry_count < ? ORDER BY id ASC LIMIT 1" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { id: number; target_table: string; target_id: string; retry_count: number } | undefined; + if (item) { + this.db!.prepare("UPDATE task_queue SET status = 'processing' WHERE id = ?").run(item.id); + } + return item; + }); + + while (count < MemoryHookService.WORKER_BATCH_LIMIT) { + const item = claimEnrichTask(); + + if (!item) break; + + try { + if (item.target_table === 'observations') { + await this.enrichObservation(item.target_id); + } else if (item.target_table === 'session_summaries') { + // Summary enrichment needs transcript path — skip from queue + // (handled separately in summarize hook with direct spawn) + } + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); + count++; + } catch { + const newRetry = (item.retry_count || 0) + 1; + if (newRetry >= MemoryHookService.MAX_TASK_RETRIES) { + this.db!.prepare("UPDATE task_queue SET status = 'failed', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } else { + this.db!.prepare("UPDATE task_queue SET status = 'pending', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } + count++; + } + } + } finally { + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + + return count; + } + + /** + * Process compression tasks from the queue. + * Compresses observations and generates session digests. + * Uses lock file to prevent concurrent workers. + */ + async processCompressionQueue(): Promise { + await this.ensureInitialized(); + + const lockFile = path.join(path.dirname(this.dbPath), 'compress-worker.lock'); + writeFileSync(lockFile, String(process.pid)); + + let count = 0; + try { + // Atomic claim: SELECT + UPDATE in a single transaction + const claimCompressTask = this.db!.transaction(() => { + const item = this.db!.prepare( + "SELECT id, target_table, target_id, retry_count FROM task_queue WHERE task_type = 'compress' AND status = 'pending' AND retry_count < ? ORDER BY id ASC LIMIT 1" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { id: number; target_table: string; target_id: string; retry_count: number } | undefined; + if (item) { + this.db!.prepare("UPDATE task_queue SET status = 'processing' WHERE id = ?").run(item.id); + } + return item; + }); + + while (count < MemoryHookService.WORKER_BATCH_LIMIT) { + const item = claimCompressTask(); + + if (!item) break; + + try { + if (item.target_table === 'observations') { + await this.compressObservation(item.target_id); + } else if (item.target_table === 'sessions') { + // Compress all observations for a session + generate digest + await this.compressSessionObservations(item.target_id); + } + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); + count++; + } catch { + const newRetry = (item.retry_count || 0) + 1; + if (newRetry >= MemoryHookService.MAX_TASK_RETRIES) { + this.db!.prepare("UPDATE task_queue SET status = 'failed', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } else { + this.db!.prepare("UPDATE task_queue SET status = 'pending', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } + count++; + } + } + } finally { + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + + return count; + } + + /** + * Check if there are pending embedding tasks or records missing embeddings. + * Used to decide whether to spawn the embed worker on session start. + */ + hasPendingEmbeddings(): boolean { + if (!this.db) return false; + // Check task_queue for pending embed tasks + const pending = this.db.prepare( + "SELECT COUNT(*) as cnt FROM task_queue WHERE task_type = 'embed' AND status = 'pending' AND retry_count < ?" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { cnt: number }; + if (pending.cnt > 0) return true; + + // Check for records missing embeddings (lightweight count, limit 1) + for (const table of ['observations', 'user_prompts', 'session_summaries', 'session_digests']) { + try { + const missing = this.db.prepare( + `SELECT 1 FROM ${table} WHERE embedding IS NULL LIMIT 1` + ).get(); + if (missing) return true; + } catch { /* table might not exist */ } + } + return false; + } + + /** + * Check if there are pending enrichment tasks in the queue + */ + hasPendingEnrichments(): boolean { + if (!this.db) return false; + const pending = this.db.prepare( + "SELECT COUNT(*) as cnt FROM task_queue WHERE task_type = 'enrich' AND status = 'pending' AND retry_count < ?" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { cnt: number }; + return pending.cnt > 0; + } + + /** + * Check if there are pending compression tasks in the queue + */ + hasPendingCompressions(): boolean { + if (!this.db) return false; + const pending = this.db.prepare( + "SELECT COUNT(*) as cnt FROM task_queue WHERE task_type IN ('compress', 'digest') AND status = 'pending' AND retry_count < ?" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { cnt: number }; + return pending.cnt > 0; + } + /** * Get observations for a session */ @@ -263,17 +989,51 @@ export class MemoryHookService { return rows.map(row => this.rowToObservation(row)); } + // ===== Settings ===== + + /** + * Load persistent settings from .claude/memory/settings.json + * Returns merged with defaults (missing keys get default values) + */ + loadSettings(): MemorySettings { + const settingsPath = path.join(path.dirname(this.dbPath), 'settings.json'); + try { + if (existsSync(settingsPath)) { + const raw = JSON.parse(readFileSync(settingsPath, 'utf-8')); + return { + context: { ...DEFAULT_CONTEXT_CONFIG, ...(raw.context || {}) }, + aiProvider: raw.aiProvider || undefined, + }; + } + } catch { + // Ignore parse errors, return defaults + } + return { ...DEFAULT_MEMORY_SETTINGS, context: { ...DEFAULT_CONTEXT_CONFIG } }; + } + + /** + * Save settings to .claude/memory/settings.json + */ + saveSettings(settings: MemorySettings): void { + const settingsPath = path.join(path.dirname(this.dbPath), 'settings.json'); + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + } + // ===== Context Generation ===== /** * Get memory context for session start */ - async getContext(project: string): Promise { + async getContext(project: string, configOverride?: ContextConfig): Promise { await this.ensureInitialized(); + // Load persistent settings, allow runtime override + const settings = this.loadSettings(); + const config = configOverride || settings.context; + const recentObservations = await this.getRecentObservations( project, - this.config.maxContextObservations + config.maxObservations ); const previousSessions = await this.getRecentSessions( @@ -281,12 +1041,19 @@ export class MemoryHookService { this.config.maxContextSessions ); - // Generate markdown - const markdown = this.formatContextMarkdown(recentObservations, previousSessions, project); + const userPrompts = await this.getRecentPrompts(project, config.maxPrompts); + const sessionSummaries = await this.getRecentSummaries(project, config.maxSummaries); + + // Generate markdown with settings-driven config + const markdown = this.formatContextMarkdown( + recentObservations, previousSessions, userPrompts, sessionSummaries, project, config + ); return { recentObservations, previousSessions, + userPrompts, + sessionSummaries, markdown, }; } @@ -297,36 +1064,129 @@ export class MemoryHookService { private formatContextMarkdown( observations: Observation[], sessions: SessionRecord[], - project: string + prompts: UserPrompt[], + summaries: SessionSummary[], + project: string, + config: ContextConfig = DEFAULT_CONTEXT_CONFIG ): string { const lines: string[] = []; lines.push(`# Memory Context - ${project}`); lines.push(''); - lines.push('*AgentKits CPS™ - Auto-captured session memory*'); - lines.push(''); - // Recent observations - if (observations.length > 0) { - lines.push('## Recent Activity'); + // Tool-usage instruction header (CRITICAL for LLM tool adoption) + if (config.showToolGuidance) { + lines.push('> **Memory tools available** — Use MCP tools to search and manage project memory:'); + lines.push('> `memory_search(query)` → `memory_timeline(anchor)` → `memory_details(ids)` (3-layer workflow)'); + lines.push('> Also: `memory_save`, `memory_recall`, `memory_list`, `memory_delete`, `memory_update`, `memory_status`'); + lines.push(''); + } + + // Structured summaries from previous sessions (most valuable context) + if (config.showSummaries && summaries.length > 0) { + lines.push('## Previous Session Summaries'); + lines.push(''); + + for (const summary of summaries.slice(0, config.maxSummaries)) { + const time = this.formatRelativeTime(summary.createdAt); + lines.push(`### Session (${time})`); + if (summary.request) { + lines.push(`**Request:** ${summary.request.substring(0, 300)}`); + } + if (summary.completed) { + lines.push(`**Completed:** ${summary.completed}`); + } + if (summary.filesModified.length > 0) { + lines.push(`**Files Modified:** ${summary.filesModified.slice(0, 10).join(', ')}`); + } + if (summary.decisions && summary.decisions.length > 0) { + lines.push(`**Decisions:** ${summary.decisions.slice(0, 5).join('; ')}`); + } + if (summary.errors && summary.errors.length > 0) { + lines.push(`**Errors:** ${summary.errors.slice(0, 3).join('; ')}`); + } + if (summary.nextSteps) { + lines.push(`**Next Steps:** ${summary.nextSteps}`); + } + lines.push(''); + } + } + + // Recent user prompts (shows what user has been asking) + if (config.showPrompts && prompts.length > 0) { + lines.push('## Recent User Prompts'); lines.push(''); - lines.push('| Time | Action | Details |'); - lines.push('|------|--------|---------|'); - for (const obs of observations.slice(0, 10)) { - const time = this.formatRelativeTime(obs.timestamp); - const icon = this.getObservationIcon(obs.type); - lines.push(`| ${time} | ${icon} ${obs.toolName} | ${obs.title || ''} |`); + for (const prompt of prompts.slice(0, config.maxPrompts)) { + const time = this.formatRelativeTime(prompt.createdAt); + lines.push(`- (${time}) ${prompt.promptText.substring(0, 150)}${prompt.promptText.length > 150 ? '...' : ''}`); } lines.push(''); } - // Previous sessions - if (sessions.length > 0) { + // Recent observations — group by prompt when available + if (config.showObservations && observations.length > 0) { + const maxObs = config.maxObservations; + const slicedObs = observations.slice(0, maxObs); + + // Check if we have prompt-linked observations to group + const hasPromptLinks = prompts.length > 0 && slicedObs.some(o => o.promptNumber); + + if (hasPromptLinks) { + lines.push('## Recent Activity'); + lines.push(''); + + // Group observations by prompt number + const obsByPrompt = new Map(); + for (const obs of slicedObs) { + const pn = obs.promptNumber || 0; + if (!obsByPrompt.has(pn)) obsByPrompt.set(pn, []); + obsByPrompt.get(pn)!.push(obs); + } + + for (const [pn, obsGroup] of obsByPrompt) { + const prompt = prompts.find(p => p.promptNumber === pn); + if (prompt) { + lines.push(`### Prompt #${pn}: ${prompt.promptText.substring(0, 80)}${prompt.promptText.length > 80 ? '...' : ''}`); + } else if (pn > 0) { + lines.push(`### Prompt #${pn}`); + } + for (const obs of obsGroup) { + const icon = this.getObservationIcon(obs.type); + const detail = obs.compressedSummary || obs.subtitle || obs.title || obs.toolName; + const intentBadge = this.formatIntentBadge(obs.concepts || []); + lines.push(`- ${icon} **${detail}**${intentBadge} [${obs.id}]`); + } + lines.push(''); + } + } else { + // Flat list (no prompt grouping) + lines.push('## Recent Activity'); + lines.push(''); + + for (const obs of slicedObs) { + const time = this.formatRelativeTime(obs.timestamp); + const icon = this.getObservationIcon(obs.type); + const detail = obs.compressedSummary || obs.subtitle || obs.title || obs.toolName; + const intentBadge = this.formatIntentBadge(obs.concepts || []); + lines.push(`- ${icon} **${detail}**${intentBadge} (${time}) [${obs.id}]`); + if (obs.narrative) { + lines.push(` ${obs.narrative}`); + } + if (obs.concepts && obs.concepts.length > 0) { + lines.push(` *Concepts: ${obs.concepts.join(', ')}*`); + } + } + lines.push(''); + } + } + + // Previous sessions (fallback if no structured summaries) + if (config.showSummaries && summaries.length === 0 && sessions.length > 0) { lines.push('## Previous Sessions'); lines.push(''); - for (const session of sessions.slice(0, 3)) { + for (const session of sessions.slice(0, config.maxSummaries)) { const time = this.formatRelativeTime(session.startedAt); const status = session.status === 'completed' ? '✓' : '→'; lines.push(`### ${status} Session (${time})`); @@ -345,67 +1205,629 @@ export class MemoryHookService { } // No context available - if (observations.length === 0 && sessions.length === 0) { + if (observations.length === 0 && sessions.length === 0 && prompts.length === 0) { lines.push('*No previous session context available.*'); lines.push(''); } - return lines.join('\n'); + // Token economics footer (motivates LLM to use progressive disclosure) + const totalObs = observations.length; + const totalSessions = summaries.length || sessions.length; + if (totalObs > 0 || totalSessions > 0) { + const estimatedFullTokens = (totalObs * 500) + (totalSessions * 200); + const contextTokens = lines.join('\n').length / 4; // rough estimate + lines.push('---'); + lines.push(`*Context: ~${Math.round(contextTokens)} tokens shown. ~${estimatedFullTokens.toLocaleString()} tokens available via \`memory_search\` → \`memory_details\`.*`); + lines.push(''); + } + + // Wrap in XML tags with usage disclaimer + const content = lines.join('\n'); + return `\n${content}\nUse these naturally when relevant. Don't force them into every response.\n`; } /** - * Generate session summary from observations + * Generate session summary from observations (legacy text format) */ async generateSummary(sessionId: string): Promise { - const observations = await this.getSessionObservations(sessionId); - - if (observations.length === 0) { - return 'No activity recorded in this session.'; + const structured = await this.generateStructuredSummary(sessionId); + // Format as readable text + const parts: string[] = []; + if (structured.request) parts.push(`Request: ${structured.request}`); + if (structured.completed) parts.push(`Completed: ${structured.completed}`); + if (structured.filesModified.length > 0) { + parts.push(`Files modified: ${structured.filesModified.join(', ')}`); } + if (structured.nextSteps) parts.push(`Next: ${structured.nextSteps}`); + return parts.join('. ') || 'No activity recorded.'; + } - // Group by type - const byType: Record = {}; - const files: Set = new Set(); + /** + * Generate structured session summary from observations + prompts + */ + async generateStructuredSummary(sessionId: string): Promise> { + const observations = await this.getSessionObservations(sessionId); + const prompts = await this.getSessionPrompts(sessionId); + const session = this.getSession(sessionId); - for (const obs of observations) { - byType[obs.type] = (byType[obs.type] || 0) + 1; + // Extract file paths, commands, decisions, and errors from observations + const filesRead: Set = new Set(); + const filesModified: Set = new Set(); + const commands: string[] = []; + const decisions: string[] = []; + const errors: string[] = []; - // Extract file paths + for (const obs of observations) { try { const input = JSON.parse(obs.toolInput); - if (input.file_path || input.path) { - files.add(input.file_path || input.path); + const filePath = input.file_path || input.path || ''; + + if (obs.type === 'read' && filePath) { + filesRead.add(filePath); + } else if (obs.type === 'write' && filePath) { + filesModified.add(filePath); + + // Extract decision rationale from Edit/MultiEdit diffs + if (obs.toolName === 'Edit' || obs.toolName === 'MultiEdit') { + const diffs = extractCodeDiffs(obs.toolName, input); + const intents = extractIntents(obs.concepts || []); + const intentLabel = intents.length > 0 ? ` (${intents.join(', ')})` : ''; + const fileName = filePath.split(/[/\\]/).pop() || filePath; + + for (const diff of diffs.slice(0, 2)) { + const beforeLine = diff.before.split('\n')[0].trim().substring(0, 40); + const afterLine = diff.after.split('\n')[0].trim().substring(0, 40); + if (beforeLine && afterLine && beforeLine !== afterLine) { + decisions.push(`${fileName}${intentLabel}: "${beforeLine}" → "${afterLine}"`); + } + } + } + } else if (obs.type === 'execute' && input.command) { + commands.push(input.command.substring(0, 80)); + + // Extract errors from Bash output + try { + const response = JSON.parse(obs.toolResponse); + const stderr = (response?.stderr || '') as string; + const stdout = (response?.stdout || response?.output || '') as string; + const output = stderr + '\n' + stdout; + + // Detect error patterns + const errorLines = output.split('\n').filter((line: string) => { + const l = line.toLowerCase(); + return (l.includes('error') || l.includes('failed') || l.includes('exception') || l.includes('fatal')) + && !l.includes('0 errors') && !l.includes('no errors') && line.trim().length > 5; + }); + + for (const errLine of errorLines.slice(0, 3)) { + const trimmed = errLine.trim().substring(0, 150); + if (trimmed && !errors.includes(trimmed)) { + errors.push(trimmed); + } + } + } catch { /* ignore response parse errors */ } } } catch { // Ignore parse errors } } - // Build summary - const parts: string[] = []; + // Build request from user prompts + const request = prompts.length > 0 + ? prompts.map(p => `[#${p.promptNumber}] ${p.promptText.substring(0, 200)}`).join(' → ') + : session?.prompt || ''; + + // Build completed from observation summary + const byType: Record = {}; + for (const obs of observations) { + byType[obs.type] = (byType[obs.type] || 0) + 1; + } + const completedParts: string[] = []; + if (byType.write) completedParts.push(`${byType.write} file(s) modified`); + if (byType.read) completedParts.push(`${byType.read} file(s) read`); + if (byType.execute) completedParts.push(`${byType.execute} command(s) executed`); + if (byType.search) completedParts.push(`${byType.search} search(es)`); + + // Build notes from commands + const notes = commands.length > 0 + ? `Commands: ${commands.slice(0, 5).join('; ')}${commands.length > 5 ? ` (+${commands.length - 5} more)` : ''}` + : ''; + + return { + sessionId, + project: session?.project || '', + request: truncate(request, 500), + completed: completedParts.join(', ') || 'No activity recorded', + filesRead: Array.from(filesRead).slice(0, 20), + filesModified: Array.from(filesModified).slice(0, 20), + nextSteps: '', + notes, + decisions: decisions.slice(0, 10), + errors: errors.slice(0, 10), + promptNumber: prompts.length, + }; + } + + // ===== Session Summary Storage ===== - if (byType.write) { - parts.push(`${byType.write} file(s) modified`); + /** + * Save structured session summary to session_summaries table + */ + async saveSessionSummary(summary: Omit): Promise { + await this.ensureInitialized(); + + const now = Date.now(); + const result = this.db!.prepare(` + INSERT INTO session_summaries + (session_id, project, request, completed, files_read, files_modified, next_steps, notes, decisions, errors, prompt_number, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + summary.sessionId, + summary.project, + summary.request, + summary.completed, + JSON.stringify(summary.filesRead), + JSON.stringify(summary.filesModified), + summary.nextSteps, + summary.notes, + JSON.stringify(summary.decisions || []), + JSON.stringify(summary.errors || []), + summary.promptNumber, + now + ); + + const id = Number(result.lastInsertRowid); + + // Queue embedding generation + if (id > 0) { + this.queueTask('embed', 'session_summaries', id); } - if (byType.read) { - parts.push(`${byType.read} file(s) read`); + + return { + ...summary, + id, + createdAt: now, + }; + } + + /** + * Get recent session summaries for a project + */ + async getRecentSummaries(project: string, limit: number = 5): Promise { + await this.ensureInitialized(); + + const rows = this.db!.prepare(` + SELECT * FROM session_summaries + WHERE project = ? + ORDER BY created_at DESC + LIMIT ? + `).all(project, limit) as Record[]; + + return rows.map(row => this.rowToSummary(row)); + } + + /** + * Enrich a session summary with AI using transcript data. + * Called from a background process after the template summary is saved. + * Reads the transcript JSONL, extracts last assistant message, + * then uses AI to enhance the completed/nextSteps fields. + */ + async enrichSessionSummary(sessionId: string, transcriptPath: string): Promise { + await this.ensureInitialized(); + + // Get existing summary from DB + const rows = this.db!.prepare( + 'SELECT * FROM session_summaries WHERE session_id = ? ORDER BY created_at DESC LIMIT 1' + ).all(sessionId) as Record[]; + + if (rows.length === 0) return false; + + const summary = this.rowToSummary(rows[0]); + + // Extract last assistant message from transcript + const lastMessage = extractLastAssistantMessage(transcriptPath); + if (!lastMessage) return false; + + // Build template summary text for AI context + const templateText = [ + summary.request ? `Request: ${summary.request}` : '', + summary.completed ? `Completed: ${summary.completed}` : '', + summary.filesModified.length > 0 ? `Files modified: ${summary.filesModified.join(', ')}` : '', + summary.notes ? `Notes: ${summary.notes}` : '', + ].filter(Boolean).join('\n'); + + // Call AI enrichment + const enriched = await enrichSummaryWithAI(templateText, lastMessage).catch(() => null); + if (!enriched) return false; + + // Update summary in-place (including AI-extracted decisions if available) + this.db!.prepare(` + UPDATE session_summaries + SET completed = ?, next_steps = ?, decisions = ? + WHERE id = ? + `).run( + enriched.completed, + enriched.nextSteps, + JSON.stringify(enriched.decisions || summary.decisions || []), + summary.id + ); + + return true; + } + + private rowToSummary(row: Record): SessionSummary { + return { + id: row.id as number, + sessionId: row.session_id as string, + project: row.project as string, + request: row.request as string || '', + completed: row.completed as string || '', + filesRead: JSON.parse((row.files_read as string) || '[]'), + filesModified: JSON.parse((row.files_modified as string) || '[]'), + nextSteps: row.next_steps as string || '', + notes: row.notes as string || '', + decisions: JSON.parse((row.decisions as string) || '[]'), + errors: JSON.parse((row.errors as string) || '[]'), + promptNumber: row.prompt_number as number || 0, + createdAt: row.created_at as number, + }; + } + + // ===== Lifecycle Management ===== + + /** + * Run lifecycle tasks: compress old observations, archive old sessions, + * optionally delete archived sessions, and vacuum. + */ + async runLifecycleTasks(config: Partial = {}): Promise { + await this.ensureInitialized(); + + const cfg = { ...DEFAULT_LIFECYCLE_CONFIG, ...config }; + const now = Date.now(); + let compressed = 0; + let archived = 0; + let deleted = 0; + let vacuumed = false; + + // 1. Compress old uncompressed observations + if (cfg.autoCompress) { + const cutoff = now - cfg.compressAfterDays * 86400000; + const rows = this.db!.prepare( + 'SELECT id FROM observations WHERE is_compressed = 0 AND timestamp < ? LIMIT 100' + ).all(cutoff) as { id: string }[]; + + for (const row of rows) { + this.queueTask('compress', 'observations', row.id); + compressed++; + } } - if (byType.execute) { - parts.push(`${byType.execute} command(s) executed`); + + // 2. Archive old completed sessions + if (cfg.autoArchive) { + const cutoff = now - cfg.archiveAfterDays * 86400000; + const result = this.db!.prepare( + "UPDATE sessions SET status = 'archived' WHERE status = 'completed' AND ended_at IS NOT NULL AND ended_at < ?" + ).run(cutoff); + archived = result.changes; } - if (byType.search) { - parts.push(`${byType.search} search(es)`); + + // 3. Delete archived sessions (opt-in) + if (cfg.autoDelete) { + const cutoff = now - cfg.deleteAfterDays * 86400000; + const sessions = this.db!.prepare( + "SELECT session_id FROM sessions WHERE status = 'archived' AND ended_at IS NOT NULL AND ended_at < ?" + ).all(cutoff) as { session_id: string }[]; + + if (sessions.length > 0) { + const deleteTransaction = this.db!.transaction(() => { + for (const s of sessions) { + this.db!.prepare('DELETE FROM observations WHERE session_id = ?').run(s.session_id); + this.db!.prepare('DELETE FROM user_prompts WHERE session_id = ?').run(s.session_id); + this.db!.prepare('DELETE FROM session_summaries WHERE session_id = ?').run(s.session_id); + this.db!.prepare('DELETE FROM session_digests WHERE session_id = ?').run(s.session_id); + this.db!.prepare('DELETE FROM sessions WHERE session_id = ?').run(s.session_id); + } + }); + deleteTransaction(); + deleted = sessions.length; + + // 4. Vacuum if deletes occurred + if (cfg.autoVacuum && deleted > 0) { + this.db!.exec('VACUUM'); + vacuumed = true; + } + } } - let summary = parts.join(', ') || 'Various operations performed'; + return { compressed, archived, deleted, vacuumed }; + } + + /** + * Get lifecycle statistics for the database + */ + async getLifecycleStats(): Promise { + await this.ensureInitialized(); + + const sessionStats = this.db!.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = 'archived' THEN 1 ELSE 0 END) as archived + FROM sessions + `).get() as { total: number; active: number; completed: number; archived: number }; + + const obsStats = this.db!.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN is_compressed = 1 THEN 1 ELSE 0 END) as compressed, + SUM(CASE WHEN is_compressed = 0 THEN 1 ELSE 0 END) as uncompressed + FROM observations + `).get() as { total: number; compressed: number; uncompressed: number }; + + const promptStats = this.db!.prepare( + 'SELECT COUNT(*) as total FROM user_prompts' + ).get() as { total: number }; + + // Get DB file size + let dbSizeBytes = 0; + try { + const { statSync } = await import('node:fs'); + const stat = statSync(this.dbPath); + dbSizeBytes = stat.size; + } catch { /* ignore */ } + + return { + totalSessions: sessionStats.total || 0, + activeSessions: sessionStats.active || 0, + completedSessions: sessionStats.completed || 0, + archivedSessions: sessionStats.archived || 0, + totalObservations: obsStats.total || 0, + compressedObservations: obsStats.compressed || 0, + uncompressedObservations: obsStats.uncompressed || 0, + totalPrompts: promptStats.total || 0, + dbSizeBytes, + }; + } + + // ===== Cross-Session Pattern Detection ===== - if (files.size > 0 && files.size <= 5) { - summary += `. Files: ${Array.from(files).join(', ')}`; - } else if (files.size > 5) { - summary += `. ${files.size} files touched.`; + /** + * Detect recurring patterns across sessions for a project. + * Analyzes concept frequency across recent observations to identify + * common workflows, frequently modified files, and recurring intents. + * Returns top patterns sorted by frequency. + */ + async detectCrossSessionPatterns(project: string, limit: number = 10): Promise> { + await this.ensureInitialized(); + + // Get concepts from recent observations (last 30 days) + const cutoff = Date.now() - 30 * 86400000; + const rows = this.db!.prepare(` + SELECT concepts FROM observations + WHERE project = ? AND timestamp > ? AND concepts IS NOT NULL + `).all(project, cutoff) as { concepts: string }[]; + + // Count concept frequency + const conceptCounts = new Map(); + for (const row of rows) { + try { + const concepts = JSON.parse(row.concepts) as string[]; + for (const concept of concepts) { + conceptCounts.set(concept, (conceptCounts.get(concept) || 0) + 1); + } + } catch { /* ignore */ } + } + + // Categorize and sort + const patterns = Array.from(conceptCounts.entries()) + .filter(([, count]) => count >= 2) // Only patterns that appear at least twice + .map(([pattern, count]) => { + let category = 'topic'; + if (pattern.startsWith('intent:')) category = 'intent'; + else if (pattern.startsWith('fn:')) category = 'function'; + else if (pattern.startsWith('class:')) category = 'class'; + else if (pattern.startsWith('pattern:')) category = 'code-pattern'; + return { pattern, count, category }; + }) + .sort((a, b) => b.count - a.count) + .slice(0, limit); + + return patterns; + } + + // ===== Export/Import ===== + + /** + * Export sessions and related data to JSON format + */ + async exportToJSON(project: string, sessionIds?: string[]): Promise { + await this.ensureInitialized(); + + let sessions: Record[]; + if (sessionIds && sessionIds.length > 0) { + const placeholders = sessionIds.map(() => '?').join(','); + sessions = this.db!.prepare( + `SELECT * FROM sessions WHERE session_id IN (${placeholders})` + ).all(...sessionIds) as Record[]; + } else { + sessions = this.db!.prepare( + 'SELECT * FROM sessions WHERE project = ? ORDER BY started_at DESC' + ).all(project) as Record[]; } - return summary; + const exportSessions: ExportSession[] = []; + + for (const session of sessions) { + const sid = session.session_id as string; + + const observations = this.db!.prepare( + 'SELECT * FROM observations WHERE session_id = ? ORDER BY timestamp ASC' + ).all(sid) as Record[]; + + const prompts = this.db!.prepare( + 'SELECT * FROM user_prompts WHERE session_id = ? ORDER BY prompt_number ASC' + ).all(sid) as Record[]; + + const summary = this.db!.prepare( + 'SELECT * FROM session_summaries WHERE session_id = ? ORDER BY created_at DESC LIMIT 1' + ).get(sid) as Record | undefined; + + exportSessions.push({ + sessionId: sid, + project: session.project as string, + prompt: session.prompt as string, + startedAt: session.started_at as number, + endedAt: (session.ended_at as number) || undefined, + status: session.status as string, + parentSessionId: (session.parent_session_id as string) || undefined, + observations: observations.map(o => ({ + id: o.id as string, + toolName: o.tool_name as string, + timestamp: o.timestamp as number, + type: o.type as string, + title: o.title as string | undefined, + subtitle: o.subtitle as string | undefined, + narrative: o.narrative as string | undefined, + facts: JSON.parse((o.facts as string) || '[]'), + concepts: JSON.parse((o.concepts as string) || '[]'), + contentHash: o.content_hash as string | undefined, + compressedSummary: o.compressed_summary as string | undefined, + isCompressed: (o.is_compressed as number) === 1, + })), + prompts: prompts.map(p => ({ + promptNumber: p.prompt_number as number, + promptText: p.prompt_text as string, + createdAt: p.created_at as number, + contentHash: p.content_hash as string | undefined, + })), + summary: summary ? { + request: summary.request as string, + completed: summary.completed as string, + filesRead: JSON.parse((summary.files_read as string) || '[]'), + filesModified: JSON.parse((summary.files_modified as string) || '[]'), + nextSteps: summary.next_steps as string, + notes: summary.notes as string, + decisions: JSON.parse((summary.decisions as string) || '[]'), + errors: JSON.parse((summary.errors as string) || '[]'), + } : undefined, + }); + } + + return { + version: '1.0', + exportedAt: Date.now(), + project, + sessions: exportSessions, + }; + } + + /** + * Import sessions and related data from JSON format. + * Generates new session IDs prefixed with 'imported_' to avoid conflicts. + * Deduplicates observations and prompts via content_hash. + */ + async importFromJSON(data: ExportData): Promise { + await this.ensureInitialized(); + + let importedSessions = 0; + let importedObservations = 0; + let importedPrompts = 0; + let skippedObservations = 0; + let skippedPrompts = 0; + + const importTransaction = this.db!.transaction(() => { + for (const session of data.sessions) { + const newSessionId = `imported_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`; + + // Insert session + this.db!.prepare(` + INSERT INTO sessions (session_id, project, prompt, started_at, ended_at, observation_count, status, parent_session_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + newSessionId, session.project, session.prompt, + session.startedAt, session.endedAt || null, + session.observations.length, session.status || 'completed', + session.parentSessionId || null + ); + importedSessions++; + + // Import observations (dedup by content_hash) + for (const obs of session.observations) { + if (obs.contentHash) { + const existing = this.db!.prepare( + 'SELECT id FROM observations WHERE content_hash = ? LIMIT 1' + ).get(obs.contentHash) as { id: string } | undefined; + if (existing) { + skippedObservations++; + continue; + } + } + + const newObsId = generateObservationId(); + this.db!.prepare(` + INSERT INTO observations (id, session_id, project, tool_name, tool_input, tool_response, cwd, timestamp, type, title, subtitle, narrative, facts, concepts, content_hash, compressed_summary, is_compressed) + VALUES (?, ?, ?, ?, '{}', '{}', '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + newObsId, newSessionId, session.project, obs.toolName, + obs.timestamp, obs.type, obs.title || null, obs.subtitle || null, + obs.narrative || null, JSON.stringify(obs.facts || []), + JSON.stringify(obs.concepts || []), obs.contentHash || null, + obs.compressedSummary || null, obs.isCompressed ? 1 : 0 + ); + importedObservations++; + + // Queue embedding + this.queueTask('embed', 'observations', newObsId); + } + + // Import prompts (dedup by content_hash) + for (const prompt of session.prompts) { + if (prompt.contentHash) { + const fiveMinWindow = prompt.createdAt + 5 * 60 * 1000; + const existing = this.db!.prepare( + 'SELECT id FROM user_prompts WHERE content_hash = ? AND created_at < ? LIMIT 1' + ).get(prompt.contentHash, fiveMinWindow) as { id: number } | undefined; + if (existing) { + skippedPrompts++; + continue; + } + } + + const result = this.db!.prepare(` + INSERT INTO user_prompts (session_id, prompt_number, prompt_text, content_hash, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(newSessionId, prompt.promptNumber, prompt.promptText, prompt.contentHash || null, prompt.createdAt); + importedPrompts++; + + if (result.changes > 0) { + this.queueTask('embed', 'user_prompts', Number(result.lastInsertRowid)); + } + } + + // Import summary + if (session.summary) { + const s = session.summary; + this.db!.prepare(` + INSERT INTO session_summaries (session_id, project, request, completed, files_read, files_modified, next_steps, notes, decisions, errors, prompt_number, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + newSessionId, session.project, s.request, s.completed, + JSON.stringify(s.filesRead || []), JSON.stringify(s.filesModified || []), + s.nextSteps || '', s.notes || '', JSON.stringify(s.decisions || []), + JSON.stringify(s.errors || []), + session.prompts.length, Date.now() + ); + } + } + }); + + importTransaction(); + + return { + imported: { sessions: importedSessions, observations: importedObservations, prompts: importedPrompts }, + skipped: { observations: skippedObservations, prompts: skippedPrompts }, + }; } // ===== Private Methods ===== @@ -429,7 +1851,8 @@ export class MemoryHookService { ended_at INTEGER, observation_count INTEGER DEFAULT 0, summary TEXT, - status TEXT DEFAULT 'active' + status TEXT DEFAULT 'active', + parent_session_id TEXT ) `); @@ -445,14 +1868,177 @@ export class MemoryHookService { timestamp INTEGER NOT NULL, type TEXT, title TEXT, + prompt_number INTEGER, + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + subtitle TEXT, + narrative TEXT, + facts TEXT DEFAULT '[]', + concepts TEXT DEFAULT '[]', + embedding BLOB, + content_hash TEXT, + compressed_summary TEXT, + is_compressed INTEGER DEFAULT 0, + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ) + `); + + // User prompts table - tracks ALL prompts in a session + this.db.exec(` + CREATE TABLE IF NOT EXISTS user_prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + prompt_number INTEGER NOT NULL, + prompt_text TEXT NOT NULL, + created_at INTEGER NOT NULL, + embedding BLOB, + content_hash TEXT, + UNIQUE(session_id, prompt_number), + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ) + `); + + // Structured session summaries + this.db.exec(` + CREATE TABLE IF NOT EXISTS session_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + request TEXT, + completed TEXT, + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + next_steps TEXT, + notes TEXT, + decisions TEXT DEFAULT '[]', + errors TEXT DEFAULT '[]', + prompt_number INTEGER, + created_at INTEGER NOT NULL, + embedding BLOB, FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) `); + // Task queue: holds pending background tasks (embedding, enrichment) + // processed by single-instance workers with lock files + this.db.exec(` + CREATE TABLE IF NOT EXISTS task_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_type TEXT NOT NULL, + target_table TEXT NOT NULL, + target_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + status TEXT DEFAULT 'pending', + retry_count INTEGER DEFAULT 0 + ) + `); + + // Session digests — compressed session-level summaries + this.db.exec(` + CREATE TABLE IF NOT EXISTS session_digests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + project TEXT NOT NULL, + digest TEXT NOT NULL, + observation_count INTEGER, + created_at INTEGER NOT NULL, + embedding BLOB + ) + `); + + // Base indexes (columns that exist in original schema) this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_session ON observations(session_id)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_project ON observations(project)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_timestamp ON observations(timestamp)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_prompts_session ON user_prompts(session_id)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_summaries_session ON session_summaries(session_id)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_summaries_project ON session_summaries(project)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_digests_project ON session_digests(project)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_task_queue_status ON task_queue(status, task_type)'); + + // Migration: add new columns to existing tables + this.migrateSchema(); + + // Indexes on migrated columns (must run AFTER migration) + this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_content_hash ON observations(content_hash)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_prompts_hash ON user_prompts(content_hash)'); + } + + /** + * Migrate schema for existing databases (add new columns) + */ + private migrateSchema(): void { + if (!this.db) return; + + try { + const obsColumns = this.db.prepare("PRAGMA table_info(observations)").all() as Array<{ name: string }>; + const columnNames = new Set(obsColumns.map(c => c.name)); + + const migrations: Array<[string, string]> = [ + ['prompt_number', 'ALTER TABLE observations ADD COLUMN prompt_number INTEGER'], + ['files_read', "ALTER TABLE observations ADD COLUMN files_read TEXT DEFAULT '[]'"], + ['files_modified', "ALTER TABLE observations ADD COLUMN files_modified TEXT DEFAULT '[]'"], + ['subtitle', 'ALTER TABLE observations ADD COLUMN subtitle TEXT'], + ['narrative', 'ALTER TABLE observations ADD COLUMN narrative TEXT'], + ['facts', "ALTER TABLE observations ADD COLUMN facts TEXT DEFAULT '[]'"], + ['concepts', "ALTER TABLE observations ADD COLUMN concepts TEXT DEFAULT '[]'"], + ['content_hash', 'ALTER TABLE observations ADD COLUMN content_hash TEXT'], + ['compressed_summary', 'ALTER TABLE observations ADD COLUMN compressed_summary TEXT'], + ['is_compressed', 'ALTER TABLE observations ADD COLUMN is_compressed INTEGER DEFAULT 0'], + ]; + + for (const [column, sql] of migrations) { + if (!columnNames.has(column)) { + this.db.exec(sql); + } + } + + // Migrate user_prompts: add content_hash + try { + const promptCols = this.db.prepare("PRAGMA table_info(user_prompts)").all() as Array<{ name: string }>; + if (!promptCols.some(c => c.name === 'content_hash')) { + this.db.exec('ALTER TABLE user_prompts ADD COLUMN content_hash TEXT'); + } + } catch { /* ignore */ } + + // Migrate sessions: add parent_session_id + try { + const sessionCols = this.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>; + if (!sessionCols.some(c => c.name === 'parent_session_id')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN parent_session_id TEXT'); + } + } catch { /* ignore */ } + + // Migrate session_summaries: add decisions + errors columns + try { + const summaryCols = this.db.prepare("PRAGMA table_info(session_summaries)").all() as Array<{ name: string }>; + if (!summaryCols.some(c => c.name === 'decisions')) { + this.db.exec("ALTER TABLE session_summaries ADD COLUMN decisions TEXT DEFAULT '[]'"); + } + if (!summaryCols.some(c => c.name === 'errors')) { + this.db.exec("ALTER TABLE session_summaries ADD COLUMN errors TEXT DEFAULT '[]'"); + } + } catch { /* ignore */ } + + // Migrate task_queue: add retry_count column + try { + const queueCols = this.db.prepare("PRAGMA table_info(task_queue)").all() as Array<{ name: string }>; + if (!queueCols.some(c => c.name === 'retry_count')) { + this.db.exec('ALTER TABLE task_queue ADD COLUMN retry_count INTEGER DEFAULT 0'); + } + } catch { /* ignore */ } + + // Add embedding column to all session tables + for (const table of ['observations', 'user_prompts', 'session_summaries']) { + const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'embedding')) { + this.db.exec(`ALTER TABLE ${table} ADD COLUMN embedding BLOB`); + } + } + } catch { + // Ignore migration errors on fresh databases + } } private rowToSession(row: Record): SessionRecord { @@ -466,6 +2052,7 @@ export class MemoryHookService { observationCount: row.observation_count as number, summary: row.summary as string | undefined, status: row.status as 'active' | 'completed' | 'abandoned', + parentSessionId: row.parent_session_id as string | undefined, }; } @@ -481,6 +2068,16 @@ export class MemoryHookService { timestamp: row.timestamp as number, type: row.type as Observation['type'], title: row.title as string | undefined, + promptNumber: row.prompt_number as number | undefined, + filesRead: JSON.parse((row.files_read as string) || '[]'), + filesModified: JSON.parse((row.files_modified as string) || '[]'), + subtitle: row.subtitle as string | undefined, + narrative: row.narrative as string | undefined, + facts: JSON.parse((row.facts as string) || '[]'), + concepts: JSON.parse((row.concepts as string) || '[]'), + contentHash: row.content_hash as string | undefined, + compressedSummary: row.compressed_summary as string | undefined, + isCompressed: (row.is_compressed as number) === 1, }; } @@ -500,6 +2097,12 @@ export class MemoryHookService { return new Date(timestamp).toLocaleDateString(); } + private formatIntentBadge(concepts: string[]): string { + const intents = extractIntents(concepts); + if (intents.length === 0) return ''; + return ` [${intents.join(', ')}]`; + } + private getObservationIcon(type: string): string { switch (type) { case 'read': return '📖'; @@ -518,4 +2121,58 @@ export function createHookService(cwd: string): MemoryHookService { return new MemoryHookService(cwd); } +/** + * Extract the last assistant message from a Claude Code transcript JSONL file. + * Reads the file, iterates lines in reverse, finds the last 'assistant' type entry, + * extracts text content, and strips tags. + */ +export function extractLastAssistantMessage(transcriptPath: string): string | null { + if (!transcriptPath || !existsSync(transcriptPath)) return null; + + try { + const content = readFileSync(transcriptPath, 'utf-8').trim(); + if (!content) return null; + + const lines = content.split('\n'); + + // Iterate in reverse to find the last assistant message + for (let i = lines.length - 1; i >= 0; i--) { + try { + const line = JSON.parse(lines[i]); + if (line.type !== 'assistant') continue; + + const msgContent = line.message?.content; + if (!msgContent) continue; + + let text = ''; + if (typeof msgContent === 'string') { + text = msgContent; + } else if (Array.isArray(msgContent)) { + // Extract text blocks from content array (skip tool_use blocks) + text = msgContent + .filter((c: { type: string }) => c.type === 'text') + .map((c: { text: string }) => c.text) + .join('\n'); + } + + if (!text) continue; + + // Strip tags + text = text.replace(/[\s\S]*?<\/system-reminder>/g, ''); + text = text.replace(/\n{3,}/g, '\n\n').trim(); + + // Return truncated to avoid excessive tokens + return text.substring(0, 5000); + } catch { + // Skip unparseable lines + continue; + } + } + + return null; + } catch { + return null; + } +} + export default MemoryHookService; diff --git a/src/hooks/session-init.ts b/src/hooks/session-init.ts index 8f09a9c..fe61e86 100644 --- a/src/hooks/session-init.ts +++ b/src/hooks/session-init.ts @@ -53,6 +53,15 @@ export class SessionInitHook implements EventHandler { input.prompt ); + // Save every user prompt (not just the first) + if (input.prompt) { + await this.service.saveUserPrompt( + input.sessionId, + input.project, + input.prompt + ); + } + return { continue: true, suppressOutput: true, diff --git a/src/hooks/summarize.ts b/src/hooks/summarize.ts index f73425b..b58d109 100644 --- a/src/hooks/summarize.ts +++ b/src/hooks/summarize.ts @@ -7,12 +7,15 @@ * @module @agentkits/memory/hooks/summarize */ +import { spawn } from 'node:child_process'; +import * as path from 'node:path'; import { NormalizedHookInput, HookResult, EventHandler, } from './types.js'; import { MemoryHookService } from './service.js'; +import { isAIEnrichmentEnabled } from './ai-enrichment.js'; /** * Summarize Hook - Stop Event @@ -56,11 +59,43 @@ export class SummarizeHook implements EventHandler { }; } - // Generate summary from observations - const summary = await this.service.generateSummary(input.sessionId); + // Generate structured summary from observations + prompts + const structured = await this.service.generateStructuredSummary(input.sessionId); - // Complete the session with summary - await this.service.completeSession(input.sessionId, summary); + // Save structured summary to session_summaries table (same DB as memories) + await this.service.saveSessionSummary(structured); + + // Complete the session with text summary (legacy field) + const textSummary = await this.service.generateSummary(input.sessionId); + await this.service.completeSession(input.sessionId, textSummary); + + // Spawn background workers to process queued tasks (one per type, gated by lock file) + this.service.ensureWorkerRunning(input.cwd, 'embed-session', 'embed-worker.lock'); + if (isAIEnrichmentEnabled()) { + this.service.ensureWorkerRunning(input.cwd, 'enrich-session', 'enrich-worker.lock'); + + // Queue compression for this session's observations (runs after enrichment) + this.service.queueTask('compress', 'sessions', input.sessionId); + this.service.ensureWorkerRunning(input.cwd, 'compress-session', 'compress-worker.lock'); + } + + // Summary enrichment needs transcript path — handled separately (not via queue) + if (isAIEnrichmentEnabled() && input.transcriptPath) { + try { + const cliPath = path.resolve(input.cwd, 'dist/hooks/cli.js'); + const child = spawn('node', [ + cliPath, 'enrich-summary', input.sessionId, input.cwd, input.transcriptPath, + ], { + detached: true, + stdio: 'ignore', + env: { ...process.env }, + }); + child.on('error', () => { /* spawn failure — silently ignore */ }); + child.unref(); + } catch { + // Silently ignore + } + } // Shutdown service await this.service.shutdown(); diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 2c5ed05..e6b7381 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -2,11 +2,13 @@ * Hook Types for AgentKits Memory * * Lightweight hook system for auto-capturing Claude Code sessions. - * Based on claude-mem patterns but simplified for project-scoped storage. + * Project-scoped storage. * * @module @agentkits/memory/hooks/types */ +import { createHash } from 'node:crypto'; + // ===== Claude Code Hook Input Types ===== /** @@ -163,6 +165,36 @@ export interface Observation { /** Brief title (auto-generated) */ title?: string; + + /** Which prompt number this observation belongs to */ + promptNumber?: number; + + /** Files read in this observation (auto-extracted) */ + filesRead?: string[]; + + /** Files modified in this observation (auto-extracted) */ + filesModified?: string[]; + + /** Brief subtitle describing the action context */ + subtitle?: string; + + /** Narrative explanation of what happened */ + narrative?: string; + + /** Extracted facts from the observation */ + facts?: string[]; + + /** Extracted concepts/topics */ + concepts?: string[]; + + /** Content hash for deduplication */ + contentHash?: string; + + /** Compressed single-sentence summary (AI-generated) */ + compressedSummary?: string; + + /** Whether raw data has been replaced by compressed summary */ + isCompressed?: boolean; } /** @@ -175,6 +207,228 @@ export type ObservationType = | 'search' // WebSearch, WebFetch | 'other'; // Unknown tools +/** + * Observation intent — what the developer is trying to accomplish. + * Stored as `intent:` prefixed tags in the concepts array (no schema change). + */ +export type ObservationIntent = + | 'bugfix' + | 'feature' + | 'refactor' + | 'investigation' + | 'testing' + | 'documentation' + | 'configuration' + | 'optimization'; + +/** + * Detect the developer's intent from tool usage context. + * Pattern-matches on prompt text, tool name, and tool input. + * Returns one or more intents (usually 1-2). + */ +export function detectIntent( + toolName: string, + toolInput: unknown, + _toolResponse: unknown, + prompt?: string +): ObservationIntent[] { + const intents: Set = new Set(); + + // Pattern-match on latest prompt text (strongest signal) + if (prompt) { + const p = prompt.toLowerCase(); + + // Bugfix signals + if (/\b(fix|bug|broken|crash|error|issue|wrong|fail|regress|patch|hotfix)\b/.test(p)) { + intents.add('bugfix'); + } + // Feature signals + if (/\b(add|create|implement|new|feature|build|introduce|enable)\b/.test(p)) { + intents.add('feature'); + } + // Refactor signals + if (/\b(refactor|rename|restructure|reorganize|clean\s*up|simplify|extract|move|split|merge|dedup)\b/.test(p)) { + intents.add('refactor'); + } + // Testing signals + if (/\b(test|spec|coverage|assert|expect|mock|stub|vitest|jest|pytest)\b/.test(p)) { + intents.add('testing'); + } + // Documentation signals + if (/\b(doc|readme|comment|jsdoc|typedoc|changelog|annotation)\b/.test(p)) { + intents.add('documentation'); + } + // Configuration signals + if (/\b(config|setting|env|environment|setup|install|dependency|package|deploy)\b/.test(p)) { + intents.add('configuration'); + } + // Optimization signals + if (/\b(optimiz\w*|perf\w*|speed|slow|fast\w*|cach\w*|lazy|memory\s*leak|bundl\w*|minif\w*|compress\w*)\b/.test(p)) { + intents.add('optimization'); + } + } + + // Pattern-match on tool name (secondary signal) + const readTools = ['Read', 'Glob', 'Grep', 'LS']; + const writeTools = ['Write', 'Edit', 'MultiEdit']; + const searchTools = ['WebSearch', 'WebFetch']; + + if (readTools.includes(toolName) || searchTools.includes(toolName)) { + // Reading/searching without other intent → investigation + if (intents.size === 0) { + intents.add('investigation'); + } + } + + // Pattern-match on tool input (tertiary signal) + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + + if (toolName === 'Bash') { + const cmd = ((input?.command as string) || '').toLowerCase(); + if (/\b(test|vitest|jest|pytest|mocha|tap)\b/.test(cmd)) { + intents.add('testing'); + } + if (/\b(build|tsc|webpack|vite|esbuild|rollup)\b/.test(cmd)) { + if (intents.size === 0) intents.add('feature'); + } + if (/\b(lint|eslint|prettier|format)\b/.test(cmd)) { + intents.add('refactor'); + } + } + + // File path hints + const filePath = ((input?.file_path || input?.path || '') as string).toLowerCase(); + if (filePath) { + if (/\.(test|spec)\.(ts|js|tsx|jsx)$/.test(filePath) || filePath.includes('__tests__')) { + intents.add('testing'); + } + if (/readme|changelog|\.md$/.test(filePath) && writeTools.includes(toolName)) { + intents.add('documentation'); + } + if (/config|\.env|tsconfig|package\.json|\.eslintrc/.test(filePath) && writeTools.includes(toolName)) { + intents.add('configuration'); + } + } + } catch { + // Ignore parse errors + } + + // Fallback: if no intent detected, default to investigation + if (intents.size === 0) { + intents.add('investigation'); + } + + return Array.from(intents); +} + +/** + * Extract intent tags from a concepts array. + * Filters concepts starting with 'intent:' and strips the prefix. + */ +export function extractIntents(concepts: string[]): ObservationIntent[] { + return concepts + .filter(c => c.startsWith('intent:')) + .map(c => c.slice(7) as ObservationIntent); +} + +// ===== Code Diff Types ===== + +/** + * Structured code diff from Edit/MultiEdit operations. + * Captures before/after snippets for understanding what changed. + */ +/** + * Change type classification for code diffs + */ +export type DiffChangeType = 'addition' | 'deletion' | 'modification' | 'replacement'; + +export interface CodeDiff { + /** File path that was edited */ + file: string; + /** Code before the change (truncated) */ + before: string; + /** Code after the change (truncated) */ + after: string; + /** Net line count change (positive=added, negative=removed) */ + changeLines: number; + /** Classified change type */ + changeType: DiffChangeType; +} + +/** + * Classify the type of change in a diff + */ +export function classifyChangeType(before: string, after: string): DiffChangeType { + if (!before.trim() && after.trim()) return 'addition'; + if (before.trim() && !after.trim()) return 'deletion'; + // If structure is similar (same first token), it's a modification; otherwise replacement + const beforeFirst = before.trim().split(/[\s({\[]/)[0]; + const afterFirst = after.trim().split(/[\s({\[]/)[0]; + if (beforeFirst === afterFirst) return 'modification'; + return 'replacement'; +} + +/** + * Extract structured code diffs from Edit/MultiEdit tool input. + * Returns compact before/after snippets (truncated to 200 chars each). + * For MultiEdit, captures up to 5 edits. + */ +export function extractCodeDiffs(toolName: string, toolInput: unknown): CodeDiff[] { + if (toolName !== 'Edit' && toolName !== 'MultiEdit') return []; + + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const diffs: CodeDiff[] = []; + const file = (input?.file_path || input?.path || '') as string; + + if (toolName === 'Edit') { + const oldStr = (input?.old_string || '') as string; + const newStr = (input?.new_string || '') as string; + if (oldStr || newStr) { + diffs.push({ + file, + before: oldStr.substring(0, 200), + after: newStr.substring(0, 200), + changeLines: newStr.split('\n').length - oldStr.split('\n').length, + changeType: classifyChangeType(oldStr, newStr), + }); + } + } else if (toolName === 'MultiEdit') { + const edits = (input?.edits || []) as Array<{ old_string?: string; new_string?: string }>; + for (const edit of edits.slice(0, 5)) { + const oldStr = (edit?.old_string || '') as string; + const newStr = (edit?.new_string || '') as string; + if (oldStr || newStr) { + diffs.push({ + file, + before: oldStr.substring(0, 200), + after: newStr.substring(0, 200), + changeLines: newStr.split('\n').length - oldStr.split('\n').length, + changeType: classifyChangeType(oldStr, newStr), + }); + } + } + } + + return diffs; + } catch { + return []; + } +} + +/** + * Format a code diff as a compact fact string. + * Example: `DIFF src/auth.ts: "function auth(user)" → "function auth(user, opts)"` + */ +export function formatDiffFact(diff: CodeDiff): string { + const fileName = diff.file.split(/[/\\]/).pop() || diff.file; + const beforeLine = diff.before.split('\n')[0].trim().substring(0, 60); + const afterLine = diff.after.split('\n')[0].trim().substring(0, 60); + const tag = diff.changeType !== 'modification' ? ` [${diff.changeType}]` : ''; + return `DIFF ${fileName}${tag}: "${beforeLine}" → "${afterLine}"`; +} + /** * Session record for tracking */ @@ -205,6 +459,76 @@ export interface SessionRecord { /** Status */ status: 'active' | 'completed' | 'abandoned'; + + /** Parent session ID for session resume/continuation tracking */ + parentSessionId?: string; +} + +/** + * User prompt record - tracks ALL prompts in a session + */ +export interface UserPrompt { + /** Database ID */ + id: number; + + /** Claude's session ID */ + sessionId: string; + + /** Prompt number within session (1, 2, 3...) */ + promptNumber: number; + + /** User's prompt text */ + promptText: string; + + /** Timestamp */ + createdAt: number; + + /** Content hash for deduplication */ + contentHash?: string; +} + +/** + * Structured session summary + */ +export interface SessionSummary { + /** Database ID */ + id: number; + + /** Claude's session ID */ + sessionId: string; + + /** Project name */ + project: string; + + /** What user requested */ + request: string; + + /** What was completed */ + completed: string; + + /** Files read during session */ + filesRead: string[]; + + /** Files modified during session */ + filesModified: string[]; + + /** Remaining work / next steps */ + nextSteps: string; + + /** Additional notes */ + notes: string; + + /** Decision rationale — why key changes were made */ + decisions: string[]; + + /** Errors encountered during session */ + errors: string[]; + + /** Which prompt triggered this summary */ + promptNumber: number; + + /** Timestamp */ + createdAt: number; } // ===== Context Types ===== @@ -219,6 +543,12 @@ export interface MemoryContext { /** Previous sessions */ previousSessions: SessionRecord[]; + /** User prompts from recent sessions */ + userPrompts: UserPrompt[]; + + /** Structured session summaries */ + sessionSummaries: SessionSummary[]; + /** Project-specific patterns */ patterns?: string[]; @@ -229,8 +559,181 @@ export interface MemoryContext { markdown: string; } +// ===== Export/Import Types ===== + +/** + * Export data format + */ +export interface ExportData { + version: string; + exportedAt: number; + project: string; + sessions: ExportSession[]; +} + +/** + * Exported session with all related data + */ +export interface ExportSession { + sessionId: string; + project: string; + prompt: string; + startedAt: number; + endedAt?: number; + status: string; + parentSessionId?: string; + observations: ExportObservation[]; + prompts: ExportPrompt[]; + summary?: ExportSummary; +} + +/** + * Exported observation + */ +export interface ExportObservation { + id: string; + toolName: string; + timestamp: number; + type: string; + title?: string; + subtitle?: string; + narrative?: string; + facts: string[]; + concepts: string[]; + contentHash?: string; + compressedSummary?: string; + isCompressed: boolean; +} + +/** + * Exported user prompt + */ +export interface ExportPrompt { + promptNumber: number; + promptText: string; + createdAt: number; + contentHash?: string; +} + +/** + * Exported session summary + */ +export interface ExportSummary { + request: string; + completed: string; + filesRead: string[]; + filesModified: string[]; + nextSteps: string; + notes: string; + decisions: string[]; + errors: string[]; +} + +/** + * Import result + */ +export interface ImportResult { + imported: { sessions: number; observations: number; prompts: number }; + skipped: { observations: number; prompts: number }; +} + // ===== Utility Functions ===== +/** + * Context configuration for controlling what gets injected + */ +export interface ContextConfig { + showSummaries: boolean; + showPrompts: boolean; + showObservations: boolean; + showToolGuidance: boolean; + maxSummaries: number; + maxPrompts: number; + maxObservations: number; +} + +/** + * Lifecycle configuration for memory decay/archival + */ +export interface LifecycleConfig { + /** Auto-compress old observations */ + autoCompress: boolean; + /** Days after which to compress observations */ + compressAfterDays: number; + /** Auto-archive old sessions */ + autoArchive: boolean; + /** Days after which to archive sessions */ + archiveAfterDays: number; + /** Auto-delete archived sessions (opt-in, disabled by default) */ + autoDelete: boolean; + /** Days after which to delete archived sessions */ + deleteAfterDays: number; + /** Auto-vacuum after deletes */ + autoVacuum: boolean; +} + +/** Default lifecycle configuration */ +export const DEFAULT_LIFECYCLE_CONFIG: LifecycleConfig = { + autoCompress: true, + compressAfterDays: 7, + autoArchive: true, + archiveAfterDays: 30, + autoDelete: false, // opt-in: destructive + deleteAfterDays: 90, + autoVacuum: true, +}; + +/** + * Lifecycle task results + */ +export interface LifecycleResult { + compressed: number; + archived: number; + deleted: number; + vacuumed: boolean; +} + +/** + * Lifecycle statistics + */ +export interface LifecycleStats { + totalSessions: number; + activeSessions: number; + completedSessions: number; + archivedSessions: number; + totalObservations: number; + compressedObservations: number; + uncompressedObservations: number; + totalPrompts: number; + dbSizeBytes: number; +} + +/** Default context configuration */ +export const DEFAULT_CONTEXT_CONFIG: ContextConfig = { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: true, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, +}; + +/** + * Persistent memory settings stored in .claude/memory/settings.json + */ +export interface MemorySettings { + /** Context injection configuration */ + context: ContextConfig; + /** AI provider configuration (for enrichment/compression) */ + aiProvider?: import('./ai-provider.js').AIProviderConfig; +} + +/** Default memory settings */ +export const DEFAULT_MEMORY_SETTINGS: MemorySettings = { + context: DEFAULT_CONTEXT_CONFIG, +}; + /** * Generate observation ID */ @@ -240,6 +743,19 @@ export function generateObservationId(): string { return `obs_${timestamp}_${random}`; } +/** + * Compute content hash for deduplication. + * Uses SHA-256 truncated to 16 hex chars (64 bits) — sufficient for dedup. + * Computation: ~0.01ms. + */ +export function computeContentHash(...parts: string[]): string { + const hash = createHash('sha256'); + for (const part of parts) { + hash.update(part); + } + return hash.digest('hex').substring(0, 16); +} + /** * Get project name from cwd */ @@ -253,7 +769,7 @@ export function getProjectName(cwd: string): string { */ export function getObservationType(toolName: string): ObservationType { const readTools = ['Read', 'Glob', 'Grep', 'LS']; - const writeTools = ['Write', 'Edit', 'NotebookEdit']; + const writeTools = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']; const executeTools = ['Bash', 'Task', 'Skill']; const searchTools = ['WebSearch', 'WebFetch']; @@ -264,6 +780,32 @@ export function getObservationType(toolName: string): ObservationType { return 'other'; } +/** + * Extract file paths from tool input, classified as read or modified + */ +export function extractFilePaths(toolName: string, toolInput: unknown): { filesRead: string[]; filesModified: string[] } { + const filesRead: string[] = []; + const filesModified: string[] = []; + + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const filePath = input?.file_path || input?.path || ''; + + if (!filePath) return { filesRead, filesModified }; + + const type = getObservationType(toolName); + if (type === 'write') { + filesModified.push(filePath); + } else if (type === 'read') { + filesRead.push(filePath); + } + } catch { + // Ignore parse errors + } + + return { filesRead, filesModified }; +} + /** * Generate observation title from tool usage */ @@ -277,6 +819,7 @@ export function generateObservationTitle(toolName: string, toolInput: unknown): case 'Write': return `Write ${input?.file_path || input?.path || 'file'}`; case 'Edit': + case 'MultiEdit': return `Edit ${input?.file_path || input?.path || 'file'}`; case 'Bash': const cmd = input?.command || ''; @@ -299,6 +842,277 @@ export function generateObservationTitle(toolName: string, toolInput: unknown): } } +/** + * Generate observation subtitle from tool usage context + */ +export function generateObservationSubtitle(toolName: string, toolInput: unknown, _toolResponse?: unknown): string { + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const filePath = input?.file_path || input?.path || ''; + const fileName = filePath ? filePath.split(/[/\\]/).pop() : ''; + + switch (toolName) { + case 'Read': + return fileName ? `Examining ${fileName}` : 'Reading file contents'; + case 'Write': + return fileName ? `Creating/updating ${fileName}` : 'Writing file'; + case 'Edit': + case 'MultiEdit': + return fileName ? `Modifying ${fileName}` : 'Editing file'; + case 'Bash': { + const cmd = (input?.command || '').split(/\s+/)[0]; + const cmdMap: Record = { + npm: 'Running npm command', node: 'Running Node.js', git: 'Git operation', + cd: 'Changing directory', ls: 'Listing files', mkdir: 'Creating directory', + rm: 'Removing files', cp: 'Copying files', mv: 'Moving files', + docker: 'Docker operation', python: 'Running Python', cargo: 'Cargo operation', + }; + return cmdMap[cmd] || `Executing ${cmd || 'command'}`; + } + case 'Glob': + return `Searching for ${input?.pattern || 'files'} pattern`; + case 'Grep': + return `Searching code for "${input?.pattern || 'pattern'}"`; + case 'Task': + return `Delegating to ${input?.subagent_type || 'sub-agent'}`; + case 'WebSearch': + return `Researching: ${(input?.query || '').substring(0, 60)}`; + case 'WebFetch': + return `Fetching web content`; + default: + return `Using ${toolName} tool`; + } + } catch { + return `Using ${toolName}`; + } +} + +/** + * Generate observation narrative from tool usage + */ +export function generateObservationNarrative( + toolName: string, toolInput: unknown, _toolResponse?: unknown +): string { + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const filePath = input?.file_path || input?.path || ''; + + switch (toolName) { + case 'Read': + return `Read the contents of ${filePath || 'a file'} to understand the existing code structure.`; + case 'Write': + return `Wrote ${filePath || 'a file'} with new or updated content.`; + case 'Edit': + case 'MultiEdit': { + const diffs = extractCodeDiffs(toolName, toolInput); + if (diffs.length > 0) { + const diffDescs = diffs.map(d => { + const bLine = d.before.split('\n')[0].trim().substring(0, 50); + const aLine = d.after.split('\n')[0].trim().substring(0, 50); + return `"${bLine}" → "${aLine}"`; + }); + return `Edited ${filePath || 'a file'}: ${diffDescs.join('; ')}.`; + } + const oldStr = input?.old_string ? `"${input.old_string.substring(0, 40)}..."` : 'code'; + return `Edited ${filePath || 'a file'}, replacing ${oldStr} with updated content.`; + } + case 'Bash': { + const cmd = input?.command || ''; + if (cmd.startsWith('npm test') || cmd.startsWith('npx vitest')) + return `Ran tests to verify changes: \`${cmd.substring(0, 80)}\`.`; + if (cmd.startsWith('npm run build') || cmd.startsWith('tsc')) + return `Built the project to check for compilation errors.`; + if (cmd.startsWith('git ')) + return `Performed git operation: \`${cmd.substring(0, 80)}\`.`; + return `Executed command: \`${cmd.substring(0, 80)}\`.`; + } + case 'Glob': + return `Searched the filesystem for files matching pattern "${input?.pattern || ''}".`; + case 'Grep': + return `Searched code for pattern "${input?.pattern || ''}"${input?.path ? ` in ${input.path}` : ''}.`; + case 'Task': + return `Delegated work to a ${input?.subagent_type || 'sub'}-agent: ${input?.description || 'task'}.`; + case 'WebSearch': + return `Searched the web for: ${input?.query || 'information'}.`; + case 'WebFetch': + return `Fetched content from ${input?.url || 'a URL'}.`; + default: + return `Used ${toolName} tool.`; + } + } catch { + return `Used ${toolName} tool.`; + } +} + +/** + * Extract facts from tool input/response + */ +export function extractFacts(toolName: string, toolInput: unknown, toolResponse: unknown): string[] { + const facts: string[] = []; + + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const response = typeof toolResponse === 'string' ? JSON.parse(toolResponse) : toolResponse; + const filePath = input?.file_path || input?.path || ''; + + switch (toolName) { + case 'Read': + if (filePath) facts.push(`File read: ${filePath}`); + break; + case 'Write': + if (filePath) facts.push(`File created/updated: ${filePath}`); + break; + case 'Edit': + case 'MultiEdit': { + if (filePath) facts.push(`File modified: ${filePath}`); + // Extract structured code diffs + const diffs = extractCodeDiffs(toolName, toolInput); + for (const diff of diffs) { + facts.push(formatDiffFact(diff)); + } + if (diffs.length === 0 && input?.old_string) { + facts.push(`Code replaced in ${filePath.split(/[/\\]/).pop() || 'file'}`); + } + break; + } + case 'Bash': { + const cmd = input?.command || ''; + facts.push(`Command executed: ${cmd.substring(0, 100)}`); + // Extract test results + const stdout = response?.stdout || response?.output || ''; + if (typeof stdout === 'string') { + if (stdout.includes('passed') || stdout.includes('✓')) facts.push('Tests passed'); + if (stdout.includes('failed') || stdout.includes('✗')) facts.push('Tests failed'); + if (stdout.includes('error') || stdout.includes('Error')) facts.push('Errors encountered'); + } + break; + } + case 'Glob': + if (input?.pattern) facts.push(`Pattern searched: ${input.pattern}`); + break; + case 'Grep': + if (input?.pattern) facts.push(`Code pattern searched: ${input.pattern}`); + if (input?.path) facts.push(`Search scope: ${input.path}`); + break; + case 'WebSearch': + if (input?.query) facts.push(`Web search: ${input.query}`); + break; + case 'WebFetch': + if (input?.url) facts.push(`URL fetched: ${input.url}`); + break; + case 'Task': + if (input?.description) facts.push(`Sub-task: ${input.description}`); + if (input?.subagent_type) facts.push(`Agent type: ${input.subagent_type}`); + break; + } + } catch { + // Ignore parse errors + } + + return facts; +} + +/** + * Extract concepts/topics from tool usage + */ +export function extractConcepts(toolName: string, toolInput: unknown, _toolResponse?: unknown): string[] { + const concepts: Set = new Set(); + + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const filePath = (input?.file_path || input?.path || '') as string; + + // Extract concepts from file paths + if (filePath) { + // Directory-based concepts + const parts = filePath.split(/[/\\]/); + for (const part of parts) { + if (['src', 'lib', 'dist', 'node_modules', '.', '..'].includes(part)) continue; + if (part.includes('.')) { + // File extension concepts + const ext = part.split('.').pop(); + const extMap: Record = { + ts: 'typescript', tsx: 'react', js: 'javascript', jsx: 'react', + py: 'python', rs: 'rust', go: 'golang', css: 'styling', scss: 'styling', + html: 'html', json: 'configuration', yaml: 'configuration', yml: 'configuration', + md: 'documentation', test: 'testing', spec: 'testing', sql: 'database', + }; + if (ext && extMap[ext]) concepts.add(extMap[ext]); + } + // Directory-based concepts + const dirMap: Record = { + tests: 'testing', __tests__: 'testing', test: 'testing', spec: 'testing', + hooks: 'hooks', api: 'api', auth: 'authentication', db: 'database', + components: 'components', pages: 'pages', routes: 'routing', utils: 'utilities', + services: 'services', middleware: 'middleware', models: 'models', types: 'types', + cli: 'cli', config: 'configuration', migrations: 'database', schemas: 'schemas', + }; + if (dirMap[part]) concepts.add(dirMap[part]); + } + } + + // Extract function/class names from Edit/MultiEdit for code-specific searchability + if (toolName === 'Edit' || toolName === 'MultiEdit') { + const oldStr = (input?.old_string || '') as string; + const newStr = (input?.new_string || '') as string; + const combined = oldStr + '\n' + newStr; + + // Extract function names + const funcMatches = combined.match(/(?:function|async function|const|let|var)\s+(\w{3,})/g); + if (funcMatches) { + for (const m of funcMatches.slice(0, 3)) { + const name = m.replace(/(?:function|async function|const|let|var)\s+/, ''); + concepts.add(`fn:${name}`); + } + } + + // Extract class names + const classMatches = combined.match(/class\s+(\w{3,})/g); + if (classMatches) { + for (const m of classMatches.slice(0, 2)) { + concepts.add(`class:${m.replace('class ', '')}`); + } + } + + // Extract patterns: import, export, interface, type, enum + if (/\bimport\b/.test(combined)) concepts.add('pattern:import'); + if (/\bexport\b/.test(combined)) concepts.add('pattern:export'); + if (/\binterface\b/.test(combined)) concepts.add('pattern:interface'); + if (/\benum\b/.test(combined)) concepts.add('pattern:enum'); + if (/\btry\s*\{/.test(combined)) concepts.add('pattern:error-handling'); + if (/\basync\b/.test(combined)) concepts.add('pattern:async'); + } + + // Tool-based concepts + switch (toolName) { + case 'Bash': { + const cmd = (input?.command || '') as string; + if (cmd.includes('test') || cmd.includes('vitest') || cmd.includes('jest')) concepts.add('testing'); + if (cmd.includes('build') || cmd.includes('tsc')) concepts.add('build'); + if (cmd.includes('git')) concepts.add('version-control'); + if (cmd.includes('npm') || cmd.includes('yarn') || cmd.includes('pnpm')) concepts.add('package-management'); + if (cmd.includes('docker')) concepts.add('containerization'); + if (cmd.includes('lint') || cmd.includes('eslint')) concepts.add('linting'); + break; + } + case 'WebSearch': + concepts.add('research'); + break; + case 'WebFetch': + concepts.add('web-content'); + break; + case 'Task': + concepts.add('delegation'); + if (input?.subagent_type) concepts.add(input.subagent_type as string); + break; + } + } catch { + // Ignore parse errors + } + + return Array.from(concepts); +} + /** * Truncate string to max length */ diff --git a/src/hooks/user-message.ts b/src/hooks/user-message.ts new file mode 100644 index 0000000..abd213a --- /dev/null +++ b/src/hooks/user-message.ts @@ -0,0 +1,103 @@ +/** + * User Message Hook Handler (SessionStart - parallel) + * + * Displays memory status info to user via stderr. + * Runs alongside context hook but only writes to stderr + * (visible to user in Claude Code UI) without injecting + * into Claude's conversation context. + * + * @module @agentkits/memory/hooks/user-message + */ + +import { + NormalizedHookInput, + HookResult, + EventHandler, +} from './types.js'; +import { MemoryHookService } from './service.js'; + +/** + * User Message Hook - SessionStart Event + * + * Shows memory system status to user in terminal. + * Does NOT inject anything into Claude's context. + */ +export class UserMessageHook implements EventHandler { + private service: MemoryHookService; + private ownsService: boolean; + + constructor(service: MemoryHookService, ownsService = false) { + this.service = service; + this.ownsService = ownsService; + } + + /** + * Shutdown the hook (closes database if owned) + */ + async shutdown(): Promise { + if (this.ownsService) { + await this.service.shutdown(); + } + } + + /** + * Execute the user message hook + */ + async execute(input: NormalizedHookInput): Promise { + try { + // Initialize service + await this.service.initialize(); + + // Get context to count observations + const context = await this.service.getContext(input.project); + const obsCount = context.recentObservations.length; + const sessionCount = context.sessionSummaries.length || context.previousSessions.length; + const promptCount = context.userPrompts.length; + + // Build status display + const parts: string[] = []; + parts.push(''); + parts.push(' AgentKits Memory Loaded'); + + if (obsCount > 0 || sessionCount > 0 || promptCount > 0) { + const stats: string[] = []; + if (sessionCount > 0) stats.push(`${sessionCount} session${sessionCount > 1 ? 's' : ''}`); + if (obsCount > 0) stats.push(`${obsCount} observation${obsCount > 1 ? 's' : ''}`); + if (promptCount > 0) stats.push(`${promptCount} prompt${promptCount > 1 ? 's' : ''}`); + parts.push(` Context: ${stats.join(', ')}`); + parts.push(' Use: memory_search → memory_timeline → memory_details'); + } else { + parts.push(' Fresh memory — use memory_save to start building context'); + } + + parts.push(''); + + // Write to stderr for user visibility + console.error(parts.join('\n')); + + return { + continue: true, + suppressOutput: true, + }; + } catch (error) { + // Log error but don't block session + console.error('[AgentKits Memory] User message hook error:', error); + + return { + continue: true, + suppressOutput: true, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } +} + +/** + * Create user message hook handler + */ +export function createUserMessageHook(cwd: string): UserMessageHook { + const service = new MemoryHookService(cwd); + return new UserMessageHook(service, true); +} + +export default UserMessageHook; diff --git a/src/mcp/__tests__/server.test.ts b/src/mcp/__tests__/server.test.ts index fee3884..cff7774 100644 --- a/src/mcp/__tests__/server.test.ts +++ b/src/mcp/__tests__/server.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ProjectMemoryService, DEFAULT_NAMESPACES } from '../../index.js'; -import { MEMORY_TOOLS } from '../tools.js'; +import { MEMORY_TOOLS, SEARCH_STRATEGY_TIPS } from '../tools.js'; import type { MemorySaveArgs, MemorySearchArgs, @@ -16,6 +16,8 @@ import type { MemoryListArgs, MemoryTimelineArgs, MemoryDetailsArgs, + MemoryDeleteArgs, + MemoryUpdateArgs, } from '../types.js'; // Mock ProjectMemoryService for isolated testing @@ -32,15 +34,22 @@ describe('MCP Server', () => { it('should export all required tools', () => { const toolNames = MEMORY_TOOLS.map(t => t.name); + expect(toolNames).toContain('__IMPORTANT'); expect(toolNames).toContain('memory_save'); expect(toolNames).toContain('memory_search'); expect(toolNames).toContain('memory_timeline'); expect(toolNames).toContain('memory_details'); + expect(toolNames).toContain('memory_delete'); + expect(toolNames).toContain('memory_update'); expect(toolNames).toContain('memory_recall'); expect(toolNames).toContain('memory_list'); expect(toolNames).toContain('memory_status'); }); + it('should export exactly 10 tools', () => { + expect(MEMORY_TOOLS).toHaveLength(10); + }); + it('should have valid input schemas for all tools', () => { for (const tool of MEMORY_TOOLS) { expect(tool.inputSchema).toBeDefined(); @@ -152,6 +161,84 @@ describe('MCP Server', () => { }); }); + describe('__IMPORTANT meta-tool', () => { + const importantTool = MEMORY_TOOLS.find(t => t.name === '__IMPORTANT')!; + + it('should exist as the first tool', () => { + expect(MEMORY_TOOLS[0].name).toBe('__IMPORTANT'); + }); + + it('should describe 3-layer workflow', () => { + expect(importantTool.description).toContain('MEMORY WORKFLOW'); + expect(importantTool.description).toContain('memory_search'); + expect(importantTool.description).toContain('memory_timeline'); + expect(importantTool.description).toContain('memory_details'); + expect(importantTool.description).toContain('Do NOT call memory_search'); + }); + + it('should mention all available tools', () => { + expect(importantTool.description).toContain('memory_save'); + expect(importantTool.description).toContain('memory_delete'); + expect(importantTool.description).toContain('memory_update'); + expect(importantTool.description).toContain('memory_recall'); + expect(importantTool.description).toContain('memory_list'); + expect(importantTool.description).toContain('memory_status'); + }); + + it('should have empty properties (not callable)', () => { + expect(Object.keys(importantTool.inputSchema.properties)).toHaveLength(0); + }); + }); + + describe('memory_delete tool', () => { + const deleteTool = MEMORY_TOOLS.find(t => t.name === 'memory_delete')!; + + it('should require ids parameter', () => { + expect(deleteTool.inputSchema.required).toContain('ids'); + }); + + it('should have ids as array type', () => { + const idsProp = deleteTool.inputSchema.properties.ids; + expect(idsProp.type).toBe('array'); + expect(idsProp.items).toEqual({ type: 'string' }); + }); + }); + + describe('memory_update tool', () => { + const updateTool = MEMORY_TOOLS.find(t => t.name === 'memory_update')!; + + it('should require id parameter', () => { + expect(updateTool.inputSchema.required).toContain('id'); + }); + + it('should have optional content and tags', () => { + expect(updateTool.inputSchema.properties.content).toBeDefined(); + expect(updateTool.inputSchema.properties.tags).toBeDefined(); + const required = updateTool.inputSchema.required || []; + expect(required).not.toContain('content'); + expect(required).not.toContain('tags'); + }); + }); + + describe('memory_search advanced params', () => { + const searchTool = MEMORY_TOOLS.find(t => t.name === 'memory_search')!; + + it('should have dateStart filter option', () => { + expect(searchTool.inputSchema.properties.dateStart).toBeDefined(); + expect(searchTool.inputSchema.properties.dateStart.type).toBe('string'); + }); + + it('should have dateEnd filter option', () => { + expect(searchTool.inputSchema.properties.dateEnd).toBeDefined(); + expect(searchTool.inputSchema.properties.dateEnd.type).toBe('string'); + }); + + it('should have orderBy option with valid enum', () => { + expect(searchTool.inputSchema.properties.orderBy).toBeDefined(); + expect(searchTool.inputSchema.properties.orderBy.enum).toEqual(['relevance', 'date_asc', 'date_desc']); + }); + }); + describe('memory_status tool', () => { const statusTool = MEMORY_TOOLS.find(t => t.name === 'memory_status')!; @@ -165,6 +252,24 @@ describe('MCP Server', () => { }); }); + describe('SEARCH_STRATEGY_TIPS', () => { + it('should contain 3-layer workflow steps', () => { + expect(SEARCH_STRATEGY_TIPS).toContain('memory_search'); + expect(SEARCH_STRATEGY_TIPS).toContain('memory_timeline'); + expect(SEARCH_STRATEGY_TIPS).toContain('memory_details'); + }); + + it('should mention token savings', () => { + expect(SEARCH_STRATEGY_TIPS).toContain('87%'); + }); + + it('should mention filtering tips', () => { + expect(SEARCH_STRATEGY_TIPS).toContain('category'); + expect(SEARCH_STRATEGY_TIPS).toContain('dateStart'); + expect(SEARCH_STRATEGY_TIPS).toContain('orderBy'); + }); + }); + describe('Tool Argument Types', () => { it('MemorySaveArgs should accept valid arguments', () => { const args: MemorySaveArgs = { @@ -255,5 +360,52 @@ describe('MCP Server', () => { expect(args.ids).toContain('memory-2'); expect(args.ids).toContain('memory-3'); }); + + it('MemoryDeleteArgs should accept valid arguments', () => { + const args: MemoryDeleteArgs = { + ids: ['memory-1', 'memory-2'], + }; + + expect(args.ids).toHaveLength(2); + expect(args.ids).toContain('memory-1'); + }); + + it('MemoryUpdateArgs should accept valid arguments', () => { + const args: MemoryUpdateArgs = { + id: 'memory-1', + content: 'Updated content', + tags: 'tag1,tag2', + }; + + expect(args.id).toBe('memory-1'); + expect(args.content).toBe('Updated content'); + expect(args.tags).toBe('tag1,tag2'); + }); + + it('MemoryUpdateArgs should work with minimal arguments', () => { + const args: MemoryUpdateArgs = { + id: 'memory-1', + }; + + expect(args.id).toBe('memory-1'); + expect(args.content).toBeUndefined(); + expect(args.tags).toBeUndefined(); + }); + + it('MemorySearchArgs should accept advanced filter arguments', () => { + const args: MemorySearchArgs = { + query: 'search term', + limit: 20, + category: 'decision', + dateStart: '2025-01-01', + dateEnd: '2025-12-31', + orderBy: 'date_desc', + }; + + expect(args.query).toBe('search term'); + expect(args.dateStart).toBe('2025-01-01'); + expect(args.dateEnd).toBe('2025-12-31'); + expect(args.orderBy).toBe('date_desc'); + }); }); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index dca8f7b..b0ad815 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -4,6 +4,7 @@ * * Model Context Protocol server for Claude Code memory access. * Provides tools for saving, searching, and recalling memories. + * Implements 3-layer progressive disclosure for token efficiency. * * Usage: * Add to .mcp.json: @@ -21,8 +22,23 @@ import * as readline from 'node:readline'; import * as path from 'node:path'; -import { ProjectMemoryService, MemoryEntry, MemoryQuery, DEFAULT_NAMESPACES, LocalEmbeddingsService } from '../index.js'; -import { MEMORY_TOOLS } from './tools.js'; + +// CRITICAL: Redirect console.log to stderr BEFORE other imports. +// MCP uses stdio transport — stdout is reserved for JSON-RPC protocol messages. +// Any stray console.log (from libraries, debug code) breaks the protocol. +const _originalConsoleLog = console.log; +console.log = (...args: unknown[]) => { + // Only allow JSON-RPC messages (start with '{') + if (args.length === 1 && typeof args[0] === 'string' && args[0].startsWith('{')) { + _originalConsoleLog.apply(console, args); + } else { + console.error('[MCP stdout intercepted]', ...args); + } +}; + +import { ProjectMemoryService, MemoryEntry, MemoryQuery, DEFAULT_NAMESPACES } from '../index.js'; +import { EmbeddingSubprocess } from '../embeddings/embedding-subprocess.js'; +import { MEMORY_TOOLS, SEARCH_STRATEGY_TIPS } from './tools.js'; import type { JSONRPCRequest, JSONRPCResponse, @@ -34,6 +50,8 @@ import type { MemoryListArgs, MemoryTimelineArgs, MemoryDetailsArgs, + MemoryDeleteArgs, + MemoryUpdateArgs, } from './types.js'; // Map category names to namespaces @@ -50,7 +68,7 @@ const CATEGORY_TO_NAMESPACE: Record = { */ class MemoryMCPServer { private service: ProjectMemoryService | null = null; - private embeddingsService: LocalEmbeddingsService | null = null; + private embeddingSubprocess: EmbeddingSubprocess | null = null; private projectDir: string; private initialized = false; @@ -59,23 +77,22 @@ class MemoryMCPServer { } /** - * Initialize the memory service with embeddings support + * Initialize the memory service with subprocess embeddings. + * The embedding model loads in a background child process — + * requests are queued until the worker is ready, with mock fallback on timeout. */ private async ensureInitialized(): Promise { if (!this.service || !this.initialized) { const baseDir = path.join(this.projectDir, '.claude/memory'); - // Initialize embeddings service - this.embeddingsService = new LocalEmbeddingsService({ + // Spawn embedding worker process (returns immediately, loads model in background) + this.embeddingSubprocess = new EmbeddingSubprocess({ cacheDir: path.join(baseDir, 'embeddings-cache'), }); - await this.embeddingsService.initialize(); + this.embeddingSubprocess.spawn(); - // Create embedding generator function - const embeddingGenerator = async (text: string): Promise => { - const result = await this.embeddingsService!.embed(text); - return result.embedding; - }; + // Get embedding generator (queues requests until worker is ready) + const embeddingGenerator = this.embeddingSubprocess.getGenerator(); this.service = new ProjectMemoryService({ baseDir, @@ -143,7 +160,7 @@ class MemoryMCPServer { }, serverInfo: { name: 'agentkits-memory', - version: '1.0.0', + version: '2.1.0', }, }, }; @@ -184,6 +201,11 @@ class MemoryMCPServer { args: Record ): Promise { try { + // __IMPORTANT is a meta-tool, no service needed + if (name === '__IMPORTANT') { + return this.toolImportant(); + } + const service = await this.ensureInitialized(); switch (name) { @@ -199,6 +221,12 @@ class MemoryMCPServer { case 'memory_details': return this.toolDetails(service, args as unknown as MemoryDetailsArgs); + case 'memory_delete': + return this.toolDelete(service, args as unknown as MemoryDeleteArgs); + + case 'memory_update': + return this.toolUpdate(service, args as unknown as MemoryUpdateArgs); + case 'memory_recall': return this.toolRecall(service, args as unknown as MemoryRecallArgs); @@ -225,6 +253,42 @@ class MemoryMCPServer { } } + /** + * __IMPORTANT meta-tool: returns workflow instructions + */ + private toolImportant(): ToolCallResult { + return { + content: [{ + type: 'text', + text: `# Memory Tool Workflow + +## Step 0: Check before searching +Use \`memory_status()\` to check if memories exist. +**Do NOT call memory_search, memory_timeline, or memory_details on empty memory.** +If no memories exist, use \`memory_save\` first to build the knowledge base. + +## Saving memories +\`memory_save(content, category, tags, importance)\` — Store decisions, patterns, errors, context. +Categories: decision, pattern, error, context, observation. + +## 3-Layer Progressive Disclosure (for searching AFTER memories exist): + +1. **Search** — \`memory_search(query)\` → index with IDs (~50 tokens/result) +2. **Timeline** — \`memory_timeline(anchor="ID")\` → temporal context +3. **Details** — \`memory_details(ids=["ID1","ID2"])\` → full content + +**Why:** 10x token savings. Never fetch details without filtering first. + +## Other tools +- \`memory_recall(topic)\` — Quick topic summary +- \`memory_list(category, limit)\` — List recent memories +- \`memory_update(id, content, tags)\` — Update existing +- \`memory_delete(ids)\` — Remove by ID +- \`memory_status()\` — Health check`, + }], + }; + } + /** * Save memory tool */ @@ -256,7 +320,8 @@ class MemoryMCPServer { return { content: [{ type: 'text', - text: `Saved to memory (${category}): "${args.content.slice(0, 100)}${args.content.length > 100 ? '...' : ''}"`, + text: `Saved to memory (${category}): "${args.content.slice(0, 100)}${args.content.length > 100 ? '...' : ''}" +ID: ${entry.id}`, }], }; } @@ -264,6 +329,7 @@ class MemoryMCPServer { /** * Search memory tool (Progressive Disclosure Layer 1) * Returns lightweight index: id, title, category, score + * Supports advanced filters: dateStart, dateEnd, orderBy */ private async toolSearch( service: ProjectMemoryService, @@ -282,23 +348,41 @@ class MemoryMCPServer { content: args.query, }; - const results = await service.query(query); + let results = await service.query(query); + + // Apply date filters + if (args.dateStart) { + const startTime = new Date(args.dateStart).getTime(); + results = results.filter((e: MemoryEntry) => new Date(e.createdAt).getTime() >= startTime); + } + if (args.dateEnd) { + const endTime = new Date(args.dateEnd).getTime(); + results = results.filter((e: MemoryEntry) => new Date(e.createdAt).getTime() <= endTime); + } + + // Apply ordering + if (args.orderBy === 'date_asc') { + results.sort((a: MemoryEntry, b: MemoryEntry) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + } else if (args.orderBy === 'date_desc') { + results.sort((a: MemoryEntry, b: MemoryEntry) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + // 'relevance' = default hybrid search ordering if (results.length === 0) { return { content: [{ type: 'text', - text: `No memories found for: "${args.query}"`, + text: `No memories found for: "${args.query}"${SEARCH_STRATEGY_TIPS}`, }], }; } // Progressive Disclosure Layer 1: Return lightweight index only - // Full content requires memory_details(ids) - const index = results.map((entry: MemoryEntry, i: number) => { + const index = results.map((entry: MemoryEntry) => { const category = entry.tags.find(t => Object.keys(CATEGORY_TO_NAMESPACE).includes(t)) || entry.namespace; const date = new Date(entry.createdAt).toLocaleDateString(); - // Extract title from content (first line or first 60 chars) const title = entry.content.split('\n')[0].slice(0, 60) + (entry.content.length > 60 ? '...' : ''); const score = (entry as MemoryEntry & { score?: number }).score; @@ -306,7 +390,7 @@ class MemoryMCPServer { id: entry.id, title, category, - tags: entry.tags.slice(0, 3), // Limit tags + tags: entry.tags.slice(0, 3), date, score: score ? Math.round(score * 100) : undefined, }; @@ -323,11 +407,7 @@ class MemoryMCPServer { text: `## Search Results (${results.length} memories) ${formatted} - ---- -**Next steps:** -- \`memory_timeline(anchor: "ID")\` - Get context around a memory -- \`memory_details(ids: ["ID1", "ID2"])\` - Get full content`, +${SEARCH_STRATEGY_TIPS}`, }], }; } @@ -379,6 +459,9 @@ ${formatted} new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); + // Collect IDs for convenience + const nearbyIds = nearby.map((e: MemoryEntry) => e.id); + // Format timeline const category = anchor.tags.find(t => Object.keys(CATEGORY_TO_NAMESPACE).includes(t)) || anchor.namespace; const anchorTitle = anchor.content.split('\n')[0].slice(0, 60); @@ -398,13 +481,15 @@ ${formatted} **Anchor:** ${anchorTitle} **Category:** ${category} **Time range:** ${before}min before → ${after}min after +**Entries:** ${nearby.length} \`\`\` ${timeline} \`\`\` --- -**Next:** \`memory_details(ids: ["${anchor.id}"])\` - Get full content`, +**Next:** \`memory_details(ids: ${JSON.stringify(nearbyIds.slice(0, 5))})\` — Get full content for these entries +${SEARCH_STRATEGY_TIPS}`, }], }; } @@ -429,9 +514,6 @@ ${timeline} // Limit to prevent token explosion const ids = args.ids.slice(0, 5); - if (args.ids.length > 5) { - // Will add note about limit - } const memories: MemoryEntry[] = []; for (const id of ids) { @@ -475,6 +557,97 @@ ${entry.content}`; }; } + /** + * Delete memories by ID + */ + private async toolDelete( + service: ProjectMemoryService, + args: MemoryDeleteArgs + ): Promise { + if (!args.ids || args.ids.length === 0) { + return { + content: [{ + type: 'text', + text: 'No memory IDs provided. Use memory_search first to find IDs.', + }], + isError: true, + }; + } + + const deleted: string[] = []; + const notFound: string[] = []; + + for (const id of args.ids) { + const entry = await service.get(id); + if (entry) { + await service.delete(id); + deleted.push(id); + } else { + notFound.push(id); + } + } + + let output = `Deleted ${deleted.length} memor${deleted.length === 1 ? 'y' : 'ies'}.`; + if (deleted.length > 0) { + output += `\nRemoved: ${deleted.join(', ')}`; + } + if (notFound.length > 0) { + output += `\nNot found: ${notFound.join(', ')}`; + } + + return { + content: [{ type: 'text', text: output }], + }; + } + + /** + * Update an existing memory + */ + private async toolUpdate( + service: ProjectMemoryService, + args: MemoryUpdateArgs + ): Promise { + if (!args.id) { + return { + content: [{ + type: 'text', + text: 'No memory ID provided. Use memory_search first to find the ID.', + }], + isError: true, + }; + } + + // Get existing entry + const existing = await service.get(args.id); + if (!existing) { + return { + content: [{ + type: 'text', + text: `Memory not found: ${args.id}`, + }], + isError: true, + }; + } + + // Build update + const updates: Partial = {}; + if (args.content) { + updates.content = args.content; + } + if (args.tags) { + updates.tags = args.tags.split(',').map((t: string) => t.trim()); + } + + await service.update(args.id, updates); + + return { + content: [{ + type: 'text', + text: `Updated memory: ${args.id}\n${args.content ? 'Content updated.' : ''}${args.tags ? ' Tags updated.' : ''}`, + }], + }; + } + /** * Recall topic tool */ @@ -495,29 +668,35 @@ ${entry.content}`; return { content: [{ type: 'text', - text: `No memories found about: "${args.topic}"`, + text: `No memories found about: "${args.topic}"\n\nTry \`memory_search(query="${args.topic}")\` for a more detailed search with filters.`, }], }; } // Group by namespace - const byNamespace: Record = {}; + const byNamespace: Record = {}; for (const entry of results) { const ns = entry.namespace || 'general'; if (!byNamespace[ns]) byNamespace[ns] = []; - byNamespace[ns].push(entry.content); + byNamespace[ns].push(entry); } - // Format output + // Format output with IDs for follow-up let output = `## Memory Recall: ${args.topic}\n\n`; - for (const [namespace, items] of Object.entries(byNamespace)) { + const allIds: string[] = []; + + for (const [namespace, entries] of Object.entries(byNamespace)) { output += `### ${namespace.charAt(0).toUpperCase() + namespace.slice(1)}\n`; - items.forEach((item: string) => { - output += `- ${item}\n`; - }); + for (const entry of entries) { + const title = entry.content.split('\n')[0].slice(0, 80); + output += `- [${entry.id}] ${title}\n`; + allIds.push(entry.id); + } output += '\n'; } + output += `---\n**For full details:** \`memory_details(ids: ${JSON.stringify(allIds.slice(0, 5))})\``; + return { content: [{ type: 'text', text: output }], }; @@ -548,7 +727,7 @@ ${entry.content}`; return { content: [{ type: 'text', - text: 'No memories stored yet.', + text: 'No memories stored yet. Use `memory_save(content, category, tags)` to store information.', }], }; } @@ -556,13 +735,13 @@ ${entry.content}`; const formatted = results.map((entry: MemoryEntry, i: number) => { const date = new Date(entry.createdAt).toLocaleString(); const category = entry.tags.find(t => Object.keys(CATEGORY_TO_NAMESPACE).includes(t)) || entry.namespace; - return `${i + 1}. [${category}] ${entry.content.slice(0, 80)}${entry.content.length > 80 ? '...' : ''}\n Created: ${date}`; + return `${i + 1}. [${category}] ${entry.content.slice(0, 80)}${entry.content.length > 80 ? '...' : ''}\n ID: ${entry.id} | Created: ${date}`; }).join('\n\n'); return { content: [{ type: 'text', - text: `Recent memories (${results.length}):\n\n${formatted}`, + text: `## Recent Memories (${results.length})\n\n${formatted}\n\n---\n**For full details:** \`memory_details(ids: ["ID"])\` | **To search:** \`memory_search(query="...")\``, }], }; } @@ -582,6 +761,12 @@ ${entry.content}`; ### Namespace Breakdown ${Object.entries(stats.entriesByNamespace || {}).map(([ns, count]) => `- ${ns}: ${count}`).join('\n') || '- No entries yet'} + +### Available Tools +- \`memory_search(query)\` — Search with 3-layer progressive disclosure +- \`memory_save(content, category)\` — Store new memories +- \`memory_delete(ids)\` — Remove memories +- \`memory_update(id, content)\` — Modify existing memories `; return { @@ -623,6 +808,9 @@ ${Object.entries(stats.entriesByNamespace || {}).map(([ns, count]) => `- ${ns}: }); rl.on('close', () => { + if (this.embeddingSubprocess) { + this.embeddingSubprocess.shutdown(); + } process.exit(0); }); } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index f99dc10..ab2227d 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2,16 +2,47 @@ * MCP Memory Tools * * Tool definitions for the memory MCP server. + * Includes __IMPORTANT meta-tool that teaches LLMs the 3-layer workflow. * * @module @agentkits/memory/mcp/tools */ import type { MCPTool } from './types.js'; +/** + * Search strategy tips appended to search/timeline results. + * Guides LLM through progressive disclosure workflow. + */ +export const SEARCH_STRATEGY_TIPS = ` +--- +**Memory Search Strategy (3-Layer Progressive Disclosure):** +1. \`memory_search(query)\` - Get index with IDs (~50 tokens/result) +2. \`memory_timeline(anchor: "ID")\` - Get context around interesting results +3. \`memory_details(ids: ["ID1", "ID2"])\` - Fetch full content ONLY for filtered IDs + +**Tips:** Filter by category, dateStart/dateEnd, or orderBy for precise results. +NEVER fetch full details without filtering first — saves ~87% tokens.`; + /** * All available memory tools */ export const MEMORY_TOOLS: MCPTool[] = [ + // Meta-tool: teaches LLM the correct workflow (save-first, then search) + { + name: '__IMPORTANT', + description: `MEMORY WORKFLOW (ALWAYS FOLLOW): +0. memory_status() → Check if memories exist BEFORE searching +1. memory_save(content, category, tags) → Save decisions, patterns, errors, context +2. memory_search(query) → Get index with IDs (~50 tokens/result) +3. memory_timeline(anchor="ID") → Get context around interesting results +4. memory_details(ids=["ID1","ID2"]) → Fetch full content ONLY for filtered IDs +IMPORTANT: Do NOT call memory_search/timeline/details on empty memory — save first. +Also available: memory_recall, memory_list, memory_update, memory_delete.`, + inputSchema: { + type: 'object', + properties: {}, + }, + }, { name: 'memory_save', description: 'Save information to project memory. Use this to store decisions, patterns, error solutions, or important context that should persist across sessions.', @@ -61,6 +92,19 @@ This 3-step workflow saves ~87% tokens vs fetching everything.`, description: 'Filter by category', enum: ['decision', 'pattern', 'error', 'context', 'observation'], }, + dateStart: { + type: 'string', + description: 'Filter: only memories after this date (ISO 8601, e.g., "2025-01-01")', + }, + dateEnd: { + type: 'string', + description: 'Filter: only memories before this date (ISO 8601, e.g., "2025-12-31")', + }, + orderBy: { + type: 'string', + description: 'Sort order for results', + enum: ['relevance', 'date_asc', 'date_desc'], + }, }, required: ['query'], }, @@ -104,9 +148,47 @@ Only fetches memories you need, saving context tokens.`, required: ['ids'], }, }, + { + name: 'memory_delete', + description: 'Delete specific memories by ID. Use to clean up duplicates, outdated, or incorrect entries.', + inputSchema: { + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'string' }, + description: 'Memory IDs to delete', + }, + }, + required: ['ids'], + }, + }, + { + name: 'memory_update', + description: 'Update an existing memory. Replaces content and/or tags of an existing entry without creating duplicates.', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Memory ID to update', + }, + content: { + type: 'string', + description: 'New content (replaces existing)', + }, + tags: { + type: 'string', + description: 'New comma-separated tags (replaces existing)', + }, + }, + required: ['id'], + }, + }, { name: 'memory_recall', - description: 'Recall specific topic from memory. Gets a summary of everything known about a topic.', + description: `Recall specific topic from memory. Gets a summary of everything known about a topic. +Use for quick topic overview. For detailed investigation, use memory_search → memory_timeline → memory_details instead.`, inputSchema: { type: 'object', properties: { @@ -125,7 +207,7 @@ Only fetches memories you need, saving context tokens.`, }, { name: 'memory_list', - description: 'List recent memories. Shows what has been saved recently.', + description: 'List recent memories. Shows what has been saved recently. Use memory_search for targeted lookup.', inputSchema: { type: 'object', properties: { diff --git a/src/mcp/types.ts b/src/mcp/types.ts index f1edd6a..f481d2b 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -64,13 +64,16 @@ export interface MemorySaveArgs { } /** - * Memory search arguments + * Memory search arguments (with advanced filters) */ export interface MemorySearchArgs { query: string; limit?: number; category?: string; tags?: string[]; + dateStart?: string; // ISO 8601 + dateEnd?: string; // ISO 8601 + orderBy?: 'relevance' | 'date_asc' | 'date_desc'; } /** @@ -106,6 +109,22 @@ export interface MemoryDetailsArgs { ids: string[]; // Memory IDs from search/timeline } +/** + * Memory delete arguments + */ +export interface MemoryDeleteArgs { + ids: string[]; // Memory IDs to delete +} + +/** + * Memory update arguments + */ +export interface MemoryUpdateArgs { + id: string; // Memory ID to update + content?: string; // New content (replaces existing) + tags?: string; // New comma-separated tags (replaces existing) +} + /** * JSON-RPC request */ diff --git a/vitest.config.ts b/vitest.config.ts index 42336a4..7d4fa5e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,9 +8,10 @@ export default defineConfig({ coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], - exclude: ['node_modules', 'dist', '**/*.test.ts'], + exclude: ['node_modules', 'dist', '**/*.test.ts', 'src/__tests__/setup.ts', 'src/embeddings/index.ts', 'src/search/index.ts'], }, testTimeout: 30000, hookTimeout: 30000, + setupFiles: ['./src/__tests__/setup.ts'], }, });