Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5e86ad4ab | |||
| d93bde059e | |||
| d60ae14a9b | |||
| 272391ec9d | |||
| 0e502dbd21 | |||
| 9ab119932a | |||
| 50d1dfb7ee | |||
| 0b034af98b | |||
| b43ad00f8b |
@@ -10,7 +10,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.4.3",
|
||||
"version": "10.5.1",
|
||||
"source": "./plugin",
|
||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ datasets/
|
||||
node_modules/
|
||||
dist/
|
||||
!installer/dist/
|
||||
**/_tree-sitter/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"MD013": false
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
# Plan: NPX Distribution + Universal IDE/CLI Coverage for claude-mem
|
||||
|
||||
## Problem
|
||||
|
||||
1. **Installation is slow and fragile**: Current install clones the full git repo, runs `npm install`, and builds from source. The npm package already ships pre-built artifacts.
|
||||
|
||||
2. **IDE coverage is limited**: claude-mem only supports Claude Code (plugin) and Cursor (hooks installer). The AI coding tools landscape has exploded — Gemini CLI (95k stars), OpenCode (110k stars), Windsurf (~1M users), Codex CLI, Antigravity, Goose, Crush, Copilot CLI, and more all support extensibility.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- **npm package already has everything**: `plugin/` directory ships pre-built. No git clone or build needed.
|
||||
- **Transcript watcher already exists**: `src/services/transcripts/` has a fully built schema-based JSONL tailer. It just needs schemas for more tools.
|
||||
- **3 integration tiers exist**: (1) Hook/plugin-based (Claude Code, Gemini CLI, OpenCode, Windsurf, Codex CLI, OpenClaw), (2) MCP-based (Cursor, Copilot CLI, Antigravity, Goose, Crush, Roo Code), (3) Transcript-based (anything with structured log files).
|
||||
- **OpenClaw plugin already built**: Full plugin at `openclaw/src/index.ts` (1000+ lines). Needs to be wired into the npx installer.
|
||||
- **Gemini CLI is architecturally near-identical to Claude Code**: 11 lifecycle hooks, JSON via stdin/stdout, exit code 0/2 convention, `GEMINI.md` context files, `~/.gemini/settings.json`. This is the easiest high-value integration.
|
||||
- **OpenCode has the richest plugin system**: 20+ hook events across 12 categories, JS/TS plugin modules, custom tool creation, MCP support. 110k stars — largest open-source AI CLI.
|
||||
- **`npx skills` by Vercel supports 41 agents** — proving the multi-IDE installer UX works. Their agent detection pattern (check if config dir exists) is the right model.
|
||||
- **All IDEs share a single worker on port 37777**: One worker serves all integrations. Session source (which IDE) is tracked via the `source` field in hook payloads. No per-IDE worker instances.
|
||||
- **This npx CLI fully replaces the old `claude-mem-installer`**: Not a supplement — the complete replacement.
|
||||
|
||||
## Solution
|
||||
|
||||
`npx claude-mem` becomes a unified CLI: install, configure any IDE, manage the worker, search memory.
|
||||
|
||||
```
|
||||
npx claude-mem # Interactive install + IDE selection
|
||||
npx claude-mem install # Same as above
|
||||
npx claude-mem install --ide windsurf # Direct IDE setup
|
||||
npx claude-mem start / stop / status # Worker management
|
||||
npx claude-mem search <query> # Search memory from terminal
|
||||
npx claude-mem transcript watch # Start transcript watcher
|
||||
```
|
||||
|
||||
## Platform Support
|
||||
|
||||
**Windows, macOS, and Linux are all first-class targets.** Platform-specific considerations:
|
||||
|
||||
- **Config paths**: Use `os.homedir()` and `path.join()` everywhere — never hardcode `/` or `~`
|
||||
- **Shebangs**: `#!/usr/bin/env node` for the CLI entry point (cross-platform via Node)
|
||||
- **Bun detection**: Check `PATH`, common install locations per platform (`%USERPROFILE%\.bun\bin\bun.exe` on Windows, `~/.bun/bin/bun` on Unix)
|
||||
- **File permissions**: `fs.chmod` is a no-op on Windows; don't gate on it
|
||||
- **Process management**: Worker start/stop uses signals on Unix, taskkill on Windows — match existing `worker-service.ts` patterns
|
||||
- **VS Code paths**: `~/Library/Application Support/Code/` (macOS), `~/.config/Code/` (Linux), `%APPDATA%/Code/` (Windows)
|
||||
- **Shell config**: `.bashrc`/`.zshrc` on Unix, PowerShell profile on Windows (for PATH modifications if needed)
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Research Findings
|
||||
|
||||
### IDE Integration Tiers
|
||||
|
||||
**Tier 1 — Native Hook/Plugin Systems** (highest fidelity, real-time capture):
|
||||
|
||||
| Tool | Hooks | Config Location | Context Injection | Stars/Users |
|
||||
|------|-------|----------------|-------------------|-------------|
|
||||
| Claude Code | 5 lifecycle hooks | `~/.claude/settings.json` | CLAUDE.md, plugins | ~25% market |
|
||||
| Gemini CLI | 11 lifecycle hooks | `~/.gemini/settings.json` | GEMINI.md | ~95k stars |
|
||||
| OpenCode | 20+ event hooks + plugin SDK | `~/.config/opencode/opencode.json` | AGENTS.md + rules dirs | ~110k stars |
|
||||
| Windsurf | 11 Cascade hooks | `.windsurf/hooks.json` | `.windsurf/rules/*.md` | ~1M users |
|
||||
| Codex CLI | `notify` hook | `~/.codex/config.toml` | `.codex/AGENTS.md`, MCP | Growing (OpenAI) |
|
||||
| OpenClaw | 8 event hooks + plugin SDK | `~/.openclaw/openclaw.json` | MEMORY.md sync | ~196k stars |
|
||||
|
||||
**Tier 2 — MCP Integration** (tool-based, search + context injection):
|
||||
|
||||
| Tool | MCP Support | Config Location | Context Injection |
|
||||
|------|------------|----------------|-------------------|
|
||||
| Cursor | First-class | `.cursor/mcp.json` | `.cursor/rules/*.mdc` |
|
||||
| Copilot CLI | First-class (default MCP) | `~/.copilot/config` | `.github/copilot-instructions.md` |
|
||||
| Antigravity | First-class + MCP Store | `~/.gemini/antigravity/mcp_config.json` | `.agent/rules/`, GEMINI.md |
|
||||
| Goose | Native MCP (co-developed protocol) | `~/.config/goose/config.yaml` | MCP context |
|
||||
| Crush | MCP + Skills | JSON config (charm.land schema) | Skills system |
|
||||
| Roo Code | First-class | `.roo/` | `.roo/rules/*.md`, `AGENTS.md` |
|
||||
| Warp | MCP + Warp Drive | `WARP.md` + Warp Drive UI | `WARP.md` |
|
||||
|
||||
**Tier 3 — Transcript File Watching** (passive, file-based):
|
||||
|
||||
| Tool | Transcript Location | Format |
|
||||
|------|-------------------|--------|
|
||||
| Claude Code | `~/.claude/projects/<proj>/<session>.jsonl` | JSONL |
|
||||
| Codex CLI | `~/.codex/sessions/**/*.jsonl` | JSONL |
|
||||
| Gemini CLI | `~/.gemini/tmp/<hash>/chats/` | JSON |
|
||||
| OpenCode | `.opencode/` (SQLite) | SQLite — needs export |
|
||||
|
||||
### What claude-mem Already Has
|
||||
|
||||
| Component | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| Claude Code plugin | Complete | `plugin/hooks/hooks.json` |
|
||||
| Cursor hooks installer | Complete | `src/services/integrations/CursorHooksInstaller.ts` |
|
||||
| Platform adapters | Claude Code + Cursor + raw | `src/cli/adapters/` |
|
||||
| Transcript watcher | Complete (schema-based JSONL) | `src/services/transcripts/` |
|
||||
| Codex transcript schema | Sample exists | `src/services/transcripts/config.ts` |
|
||||
| OpenClaw plugin | Complete (1000+ lines) | `openclaw/src/index.ts` |
|
||||
| MCP server | Complete | `plugin/scripts/mcp-server.cjs` |
|
||||
| Gemini CLI support | Not started | — |
|
||||
| OpenCode support | Not started | — |
|
||||
| Windsurf support | Not started | — |
|
||||
|
||||
### Patterns to Copy
|
||||
|
||||
- **Agent detection from `npx skills`** (`vercel-labs/skills/src/agents.ts`): Check if config directory exists
|
||||
- **Existing installer logic** (`installer/src/steps/install.ts:29-83`): registerMarketplace, registerPlugin, enablePluginInClaudeSettings — **extract shared logic** from existing installer into reusable modules (DRY with the new CLI)
|
||||
- **Bun resolution** (`plugin/scripts/bun-runner.js`): PATH lookup + common locations per platform
|
||||
- **CursorHooksInstaller** (`src/services/integrations/CursorHooksInstaller.ts`): Reference implementation for IDE hooks installation
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: NPX CLI Entry Point
|
||||
|
||||
### What to implement
|
||||
|
||||
1. **Add `bin` field to `package.json`**:
|
||||
```json
|
||||
"bin": {
|
||||
"claude-mem": "./dist/cli/index.js"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Create `src/npx-cli/index.ts`** — a Node.js CLI router (NOT Bun) with command categories:
|
||||
|
||||
**Install commands** (pure Node.js, no Bun required):
|
||||
- `npx claude-mem` or `npx claude-mem install` → interactive install (IDE multi-select)
|
||||
- `npx claude-mem install --ide <name>` → direct IDE setup (only for implemented IDEs; unimplemented ones error with "Support for <name> coming soon")
|
||||
- `npx claude-mem update` → update to latest version
|
||||
- `npx claude-mem uninstall` → remove plugin and IDE configs
|
||||
- `npx claude-mem version` → print version
|
||||
|
||||
**Runtime commands** (delegate to Bun via installed plugin):
|
||||
- `npx claude-mem start` → spawns `bun worker-service.cjs start`
|
||||
- `npx claude-mem stop` → spawns `bun worker-service.cjs stop`
|
||||
- `npx claude-mem restart` → spawns `bun worker-service.cjs restart`
|
||||
- `npx claude-mem status` → spawns `bun worker-service.cjs status`
|
||||
- `npx claude-mem search <query>` → hits `GET http://localhost:37777/api/search?q=<query>`
|
||||
- `npx claude-mem transcript watch` → starts transcript watcher
|
||||
|
||||
**Runtime commands must check for installation first**: If plugin directory doesn't exist at `~/.claude/plugins/marketplaces/thedotmack/`, print "claude-mem is not installed. Run: npx claude-mem install" and exit.
|
||||
|
||||
3. **The install flow** (fully replaces git clone + build):
|
||||
- Detect the npm package's own location (`import.meta.url` or `__dirname`)
|
||||
- Copy `plugin/` from the npm package to `~/.claude/plugins/marketplaces/thedotmack/`
|
||||
- Copy `plugin/` to `~/.claude/plugins/cache/thedotmack/claude-mem/<version>/`
|
||||
- Register marketplace in `~/.claude/plugins/known_marketplaces.json`
|
||||
- Register plugin in `~/.claude/plugins/installed_plugins.json`
|
||||
- Enable in `~/.claude/settings.json`
|
||||
- Run `npm install` in the marketplace dir (for `@chroma-core/default-embed` — native ONNX binaries, can't be bundled)
|
||||
- Trigger smart-install.js for Bun/uv setup
|
||||
- Run IDE-specific setup for each selected IDE
|
||||
|
||||
4. **Interactive IDE selection** (auto-detect + prompt):
|
||||
- Auto-detect installed IDEs by checking config directories
|
||||
- Present multi-select with detected IDEs pre-selected
|
||||
- Detection map:
|
||||
- Claude Code: `~/.claude/` exists
|
||||
- Gemini CLI: `~/.gemini/` exists
|
||||
- OpenCode: `~/.config/opencode/` exists OR `opencode` in PATH
|
||||
- OpenClaw: `~/.openclaw/` exists
|
||||
- Windsurf: `~/.codeium/windsurf/` exists
|
||||
- Codex CLI: `~/.codex/` exists
|
||||
- Cursor: `~/.cursor/` exists
|
||||
- Copilot CLI: `copilot` in PATH (it's a CLI tool, not a config dir)
|
||||
- Antigravity: `~/.gemini/antigravity/` exists
|
||||
- Goose: `~/.config/goose/` exists OR `goose` in PATH
|
||||
- Crush: `crush` in PATH
|
||||
- Roo Code: check for VS Code extension directory containing `roo-code`
|
||||
- Warp: `~/.warp/` exists OR `warp` in PATH
|
||||
|
||||
5. **The runtime command routing**:
|
||||
- Locate the installed plugin directory
|
||||
- Find Bun binary (same logic as `bun-runner.js`, platform-aware)
|
||||
- Spawn `bun worker-service.cjs <command>` and pipe stdio through
|
||||
- For `search`: HTTP request to running worker
|
||||
|
||||
### Patterns to follow
|
||||
|
||||
- `installer/src/steps/install.ts:29-83` for marketplace registration — **extract to shared module**
|
||||
- `plugin/scripts/bun-runner.js` for Bun resolution
|
||||
- `vercel-labs/skills/src/agents.ts` for IDE auto-detection pattern
|
||||
|
||||
### Verification
|
||||
|
||||
- `npx claude-mem install` copies plugin to correct directories on macOS, Linux, and Windows
|
||||
- Auto-detection finds installed IDEs
|
||||
- `npx claude-mem start/stop/status` work after install
|
||||
- `npx claude-mem search "test"` returns results
|
||||
- `npx claude-mem start` before install prints helpful error message
|
||||
- `npx claude-mem update` and `npx claude-mem uninstall` work correctly
|
||||
- `npx claude-mem version` prints version
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- Do NOT require Bun for install commands — pure Node.js
|
||||
- Do NOT clone the git repo
|
||||
- Do NOT build from source at install time
|
||||
- Do NOT depend on `bun:sqlite` in the CLI entry point
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Build Pipeline Integration
|
||||
|
||||
### What to implement
|
||||
|
||||
1. **Add CLI build step to `scripts/build-hooks.js`**:
|
||||
- Compile `src/npx-cli/index.ts` → `dist/cli/index.js`
|
||||
- Bundle `@clack/prompts` and `picocolors` into the output (self-contained)
|
||||
- Shebang: `#!/usr/bin/env node`
|
||||
- Set executable permissions (no-op on Windows, that's fine)
|
||||
|
||||
2. **Move `@clack/prompts` and `picocolors`** to main package.json as dev dependencies (bundled by esbuild into dist/cli/index.js)
|
||||
|
||||
3. **Verify `package.json` `files` field**: Currently `["dist", "plugin"]`. `dist/cli/index.js` is already included since it's under `dist/`. No change needed.
|
||||
|
||||
4. **Update `prepublishOnly`** to ensure CLI is built before npm publish (already covered — `npm run build` calls `build-hooks.js`)
|
||||
|
||||
5. **Pre-build OpenClaw plugin**: Add an esbuild step that compiles `openclaw/src/index.ts` → `openclaw/dist/index.js` so it ships ready-to-use. No `tsc` at install time.
|
||||
|
||||
6. **Add `openclaw/dist/` to `package.json` `files` field** (or add `openclaw` if the whole directory should ship)
|
||||
|
||||
### Verification
|
||||
|
||||
- `npm run build` produces `dist/cli/index.js` with correct shebang
|
||||
- `npm run build` produces `openclaw/dist/index.js` pre-built
|
||||
- `npm pack` includes both `dist/cli/index.js` and `openclaw/dist/`
|
||||
- `node dist/cli/index.js --help` works without Bun
|
||||
- Package size is reasonable (check with `npm pack --dry-run`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Gemini CLI Integration (Tier 1 — Hook-Based)
|
||||
|
||||
**Why first among new IDEs**: Near-identical architecture to Claude Code. 11 lifecycle hooks with JSON stdin/stdout, same exit code conventions (0=success, 2=block), `GEMINI.md` context files. 95k GitHub stars. Lowest effort, highest confidence.
|
||||
|
||||
### Gemini CLI Hook Events
|
||||
|
||||
| Event | Map to claude-mem | Use |
|
||||
|-------|-------------------|-----|
|
||||
| `SessionStart` | `session-init` | Start tracking session |
|
||||
| `BeforeAgent` | `user-prompt` | Capture user prompt |
|
||||
| `AfterAgent` | `observation` | Capture full agent response |
|
||||
| `BeforeTool` | — | Skip (pre-execution, no result yet) |
|
||||
| `AfterTool` | `observation` | Capture tool name + input + response |
|
||||
| `BeforeModel` | — | Skip (too low-level, LLM request details) |
|
||||
| `AfterModel` | — | Skip (raw LLM response, redundant with AfterAgent) |
|
||||
| `BeforeToolSelection` | — | Skip (internal planning step) |
|
||||
| `PreCompress` | `summary` | Trigger summary before context compression |
|
||||
| `Notification` | — | Skip (system alerts, not session data) |
|
||||
| `SessionEnd` | `session-end` | Finalize session |
|
||||
|
||||
**Mapped**: 5 of 11 events. **Skipped**: 6 events that are either too low-level (BeforeModel/AfterModel), pre-execution (BeforeTool, BeforeToolSelection), or system-level (Notification).
|
||||
|
||||
### Verified Stdin Payload Schemas (from `packages/core/src/hooks/types.ts`)
|
||||
|
||||
**Base input (all hooks receive):**
|
||||
```typescript
|
||||
{ session_id: string, transcript_path: string, cwd: string, hook_event_name: string, timestamp: string }
|
||||
```
|
||||
|
||||
**Event-specific fields:**
|
||||
| Event | Additional Fields |
|
||||
|-------|-------------------|
|
||||
| `SessionStart` | `source: "startup" \| "resume" \| "clear"` |
|
||||
| `SessionEnd` | `reason: "exit" \| "clear" \| "logout" \| "prompt_input_exit" \| "other"` |
|
||||
| `BeforeAgent` | `prompt: string` |
|
||||
| `AfterAgent` | `prompt: string, prompt_response: string, stop_hook_active: boolean` |
|
||||
| `BeforeTool` | `tool_name: string, tool_input: Record<string, unknown>, mcp_context?: McpToolContext, original_request_name?: string` |
|
||||
| `AfterTool` | `tool_name: string, tool_input: Record<string, unknown>, tool_response: Record<string, unknown>, mcp_context?: McpToolContext` |
|
||||
| `PreCompress` | `trigger: "auto" \| "manual"` |
|
||||
| `Notification` | `notification_type: "ToolPermission", message: string, details: Record<string, unknown>` |
|
||||
|
||||
**Output (all hooks can return):**
|
||||
```typescript
|
||||
{ continue?: boolean, stopReason?: string, suppressOutput?: boolean, systemMessage?: string, decision?: "allow" | "deny" | "block" | "approve" | "ask", reason?: string, hookSpecificOutput?: Record<string, unknown> }
|
||||
```
|
||||
|
||||
**Advisory (non-blocking) hooks:** SessionStart, SessionEnd, PreCompress, Notification — `continue` and `decision` fields are ignored.
|
||||
|
||||
**Environment variables provided:** `GEMINI_PROJECT_DIR`, `GEMINI_SESSION_ID`, `GEMINI_CWD`, `CLAUDE_PROJECT_DIR` (compat alias)
|
||||
|
||||
### What to implement
|
||||
|
||||
1. **Create Gemini CLI platform adapter** at `src/cli/adapters/gemini-cli.ts`:
|
||||
- Normalize Gemini CLI's hook JSON to `NormalizedHookInput`
|
||||
- Base fields always present: `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `timestamp`
|
||||
- Map per event:
|
||||
- `SessionStart`: `source` → session init metadata
|
||||
- `BeforeAgent`: `prompt` → user prompt text
|
||||
- `AfterAgent`: `prompt` + `prompt_response` → full conversation turn
|
||||
- `AfterTool`: `tool_name` + `tool_input` + `tool_response` → observation
|
||||
- `PreCompress`: `trigger` → summary trigger
|
||||
- `SessionEnd`: `reason` → session finalization
|
||||
|
||||
2. **Create Gemini CLI hooks installer** at `src/services/integrations/GeminiCliHooksInstaller.ts`:
|
||||
- Write hooks to `~/.gemini/settings.json` under the `hooks` key
|
||||
- Must **merge** with existing settings (read → parse → deep merge → write)
|
||||
- Hook config format (verified against official docs):
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"AfterTool": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{ "name": "claude-mem", "type": "command", "command": "<path-to-hook-script>", "timeout": 5000 }]
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
- Note: `matcher` uses regex for tool events, exact string for lifecycle events. `"*"` or `""` matches all.
|
||||
- Hook groups support `sequential: boolean` (default false = parallel execution)
|
||||
- Security: Project-level hooks are fingerprinted — if name/command changes, user is warned
|
||||
- Context injection via `~/.gemini/GEMINI.md` (append claude-mem section with `<claude-mem-context>` tags, same pattern as CLAUDE.md)
|
||||
- Settings hierarchy: project `.gemini/settings.json` > user `~/.gemini/settings.json` > system `/etc/gemini-cli/settings.json`
|
||||
|
||||
3. **Register `gemini-cli` in `getPlatformAdapter()`** at `src/cli/adapters/index.ts`
|
||||
|
||||
4. **Add Gemini CLI to installer IDE selection**
|
||||
|
||||
### Verification
|
||||
|
||||
- `npx claude-mem install --ide gemini-cli` merges hooks into `~/.gemini/settings.json`
|
||||
- Gemini CLI sessions are captured by the worker
|
||||
- `AfterTool` events produce observations with correct `tool_name`, `tool_input`, `tool_response`
|
||||
- `GEMINI.md` gets claude-mem context section
|
||||
- Existing Gemini CLI settings are preserved (merge, not overwrite)
|
||||
- Verify `session_id` from base input is used for session tracking
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- Do NOT overwrite `~/.gemini/settings.json` — must deep merge
|
||||
- Do NOT map all 11 events — the 6 skipped events would produce noise, not signal
|
||||
- Do NOT use `type: "runtime"` — that's for internal extensions only; use `type: "command"`
|
||||
- Advisory hooks (SessionStart, SessionEnd, PreCompress, Notification) cannot block — don't set `decision` or `continue` fields on them
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: OpenCode Integration (Tier 1 — Plugin-Based)
|
||||
|
||||
**Why next**: 110k stars, richest plugin ecosystem. OpenCode plugins are JS/TS modules auto-loaded from plugin directories. OpenCode also has a Claude Code compatibility fallback (reads `~/.claude/CLAUDE.md` if no global `AGENTS.md` exists, controllable via `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1`).
|
||||
|
||||
### Verified Plugin API (from `packages/plugin/src/index.ts`)
|
||||
|
||||
**Plugin signature:**
|
||||
```typescript
|
||||
import { type Plugin, tool } from "@opencode-ai/plugin"
|
||||
|
||||
export const ClaudeMemPlugin: Plugin = async (ctx) => {
|
||||
// ctx: { client, project, directory, worktree, serverUrl, $ }
|
||||
return { /* hooks object */ }
|
||||
}
|
||||
```
|
||||
|
||||
**PluginInput type (6 properties, not 4):**
|
||||
```typescript
|
||||
type PluginInput = {
|
||||
client: ReturnType<typeof createOpencodeClient> // OpenCode SDK client
|
||||
project: Project // Current project info
|
||||
directory: string // Current working directory
|
||||
worktree: string // Git worktree path
|
||||
serverUrl: URL // Server URL
|
||||
$: BunShell // Bun shell API
|
||||
}
|
||||
```
|
||||
|
||||
**Two hook mechanisms (important distinction):**
|
||||
|
||||
1. **Direct interceptor hooks** — keys on the returned `Hooks` object, receive `(input, output)` allowing mutation:
|
||||
- `tool.execute.before`: `(input: { tool, sessionID, callID }, output: { args })`
|
||||
- `tool.execute.after`: `(input: { tool, sessionID, callID, args }, output: { title, output, metadata })`
|
||||
- `shell.env`, `chat.message`, `chat.params`, `chat.headers`, `permission.ask`, `command.execute.before`
|
||||
- Experimental: `experimental.session.compacting`, `experimental.chat.messages.transform`, `experimental.chat.system.transform`
|
||||
|
||||
2. **Bus event catch-all** — generic `event` hook, receives `{ event }` where `event.type` is the event name:
|
||||
- `session.created`, `session.compacted`, `session.deleted`, `session.idle`, `session.error`, `session.status`, `session.updated`, `session.diff`
|
||||
- `message.updated`, `message.part.updated`, `message.part.removed`, `message.removed`
|
||||
- `file.edited`, `file.watcher.updated`
|
||||
- `command.executed`, `todo.updated`, `installation.updated`, `server.connected`
|
||||
- `permission.asked`, `permission.replied`
|
||||
- `lsp.client.diagnostics`, `lsp.updated`
|
||||
- `tui.prompt.append`, `tui.command.execute`, `tui.toast.show`
|
||||
- Total: **27 bus events** across **12 categories**
|
||||
|
||||
**Custom tool registration (CORRECTED — name is the key, not positional arg):**
|
||||
```typescript
|
||||
return {
|
||||
tool: {
|
||||
claude_mem_search: tool({
|
||||
description: "Search claude-mem memory database",
|
||||
args: { query: tool.schema.string() },
|
||||
async execute(args, context) {
|
||||
// context: { sessionID, messageID, agent, directory, worktree, abort, metadata, ask }
|
||||
const response = await fetch(`http://localhost:37777/api/search?q=${encodeURIComponent(args.query)}`)
|
||||
return await response.text()
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### What to implement
|
||||
|
||||
1. **Create OpenCode plugin** at `src/integrations/opencode-plugin/index.ts`:
|
||||
- Export a `Plugin` function receiving full `PluginInput` context
|
||||
- Use **direct interceptor** `tool.execute.after` for tool observation capture (gives `tool`, `args`, `output`)
|
||||
- Use **bus event catch-all** `event` for session lifecycle:
|
||||
|
||||
| Mechanism | Event | Map to claude-mem |
|
||||
|-----------|-------|-------------------|
|
||||
| interceptor | `tool.execute.after` | `observation` (tool name + args + output) |
|
||||
| bus event | `session.created` | `session-init` |
|
||||
| bus event | `message.updated` | `observation` (assistant messages) |
|
||||
| bus event | `session.compacted` | `summary` |
|
||||
| bus event | `file.edited` | `observation` (file changes) |
|
||||
| bus event | `session.deleted` | `session-end` |
|
||||
|
||||
- Register `claude_mem_search` custom tool using correct `tool({ description, args, execute })` API
|
||||
- Hit `localhost:37777` API endpoints from the plugin
|
||||
|
||||
2. **Build the plugin** in the esbuild pipeline → `dist/opencode-plugin/index.js`
|
||||
|
||||
3. **Create OpenCode setup in installer** (two options, prefer file-based):
|
||||
- **Option A (file-based):** Copy plugin to `~/.config/opencode/plugins/claude-mem.ts` (auto-loaded at startup)
|
||||
- **Option B (npm-based):** Add to `~/.config/opencode/opencode.json` under `"plugin"` array: `["claude-mem"]`
|
||||
- Config also supports JSONC (`opencode.jsonc`) and legacy `config.json`
|
||||
- Context injection: Append to `~/.config/opencode/AGENTS.md` (or create it) with `<claude-mem-context>` tags
|
||||
- Additional context via `"instructions"` config key (supports file paths, globs, remote URLs)
|
||||
|
||||
4. **Add OpenCode to installer IDE selection**
|
||||
|
||||
### OpenCode Verification
|
||||
|
||||
- `npx claude-mem install --ide opencode` registers the plugin (file or npm)
|
||||
- OpenCode loads the plugin on next session
|
||||
- `tool.execute.after` interceptor produces observations with `tool`, `args`, `output`
|
||||
- Bus events (`session.created`, `session.deleted`) handle session lifecycle
|
||||
- `claude_mem_search` custom tool works in OpenCode sessions
|
||||
- Context is injected via AGENTS.md
|
||||
|
||||
### OpenCode Anti-patterns
|
||||
|
||||
- Do NOT try to use OpenCode's `session.diff` for full capture — it's a summary diff, not raw data
|
||||
- Do NOT use `tool('name', schema, handler)` — wrong signature. Name is the key in the `tool:{}` map
|
||||
- Do NOT assume bus events have the same `(input, output)` mutation pattern — they only receive `{ event }`
|
||||
- OpenCode plugins run in Bun — the plugin CAN use Bun APIs (unlike the npx CLI itself)
|
||||
- Do NOT hardcode `~/.config/opencode/` — respect `OPENCODE_CONFIG_DIR` env var if set
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Windsurf Integration (Tier 1 — Hook-Based)
|
||||
|
||||
**Why next**: 11 Cascade hooks, ~1M users. Hook architecture uses JSON stdin with a consistent envelope format.
|
||||
|
||||
### Verified Windsurf Hook Events (from docs.windsurf.com/windsurf/cascade/hooks)
|
||||
|
||||
**Naming pattern**: `pre_`/`post_` prefix + 5 action categories, plus 2 standalone post-only events.
|
||||
|
||||
| Event | Can Block? | Map to claude-mem | Use |
|
||||
|-------|-----------|-------------------|-----|
|
||||
| `pre_user_prompt` | Yes | `session-init` + `context` | Start session, inject context |
|
||||
| `pre_read_code` | Yes | — | Skip (pre-execution, can block file reads) |
|
||||
| `post_read_code` | No | — | Skip (too noisy, file reads are frequent) |
|
||||
| `pre_write_code` | Yes | — | Skip (pre-execution, can block writes) |
|
||||
| `post_write_code` | No | `observation` | Code generation |
|
||||
| `pre_run_command` | Yes | — | Skip (pre-execution, can block commands) |
|
||||
| `post_run_command` | No | `observation` | Shell command execution |
|
||||
| `pre_mcp_tool_use` | Yes | — | Skip (pre-execution, can block MCP calls) |
|
||||
| `post_mcp_tool_use` | No | `observation` | MCP tool results |
|
||||
| `post_cascade_response` | No | `observation` | Full AI response |
|
||||
| `post_setup_worktree` | No | — | Skip (informational) |
|
||||
|
||||
**Mapped**: 5 of 11 events (all post-action). **Skipped**: 4 pre-hooks (blocking-capable, pre-execution) + 2 low-value post-hooks.
|
||||
|
||||
### Verified Stdin Payload Schema
|
||||
|
||||
**Common envelope (all hooks):**
|
||||
```json
|
||||
{
|
||||
"agent_action_name": "string",
|
||||
"trajectory_id": "string",
|
||||
"execution_id": "string",
|
||||
"timestamp": "ISO 8601 string",
|
||||
"tool_info": { /* event-specific payload */ }
|
||||
}
|
||||
```
|
||||
|
||||
**Event-specific `tool_info` payloads:**
|
||||
|
||||
| Event | `tool_info` fields |
|
||||
|-------|-------------------|
|
||||
| `pre_user_prompt` | `{ user_prompt: string }` |
|
||||
| `pre_read_code` / `post_read_code` | `{ file_path: string }` |
|
||||
| `pre_write_code` / `post_write_code` | `{ file_path: string, edits: [{ old_string: string, new_string: string }] }` |
|
||||
| `pre_run_command` / `post_run_command` | `{ command_line: string, cwd: string }` |
|
||||
| `pre_mcp_tool_use` | `{ mcp_server_name: string, mcp_tool_name: string, mcp_tool_arguments: {} }` |
|
||||
| `post_mcp_tool_use` | `{ mcp_server_name: string, mcp_tool_name: string, mcp_tool_arguments: {}, mcp_result: string }` |
|
||||
| `post_cascade_response` | `{ response: string }` (markdown) |
|
||||
| `post_setup_worktree` | `{ worktree_path: string, root_workspace_path: string }` |
|
||||
|
||||
**Exit codes:** `0` = success, `2` = block (pre-hooks only; stderr shown to agent), any other = non-blocking warning.
|
||||
|
||||
### What to implement
|
||||
|
||||
1. **Create Windsurf platform adapter** at `src/cli/adapters/windsurf.ts`:
|
||||
- Normalize Windsurf's hook input format to `NormalizedHookInput`
|
||||
- Common envelope: `agent_action_name`, `trajectory_id`, `execution_id`, `timestamp`, `tool_info`
|
||||
- Map: `trajectory_id` → `sessionId`, `tool_info` fields per event type
|
||||
- For `post_write_code`: `tool_info.file_path` + `tool_info.edits` → file change observation
|
||||
- For `post_run_command`: `tool_info.command_line` + `tool_info.cwd` → command observation
|
||||
- For `post_mcp_tool_use`: `tool_info.mcp_tool_name` + `tool_info.mcp_tool_arguments` + `tool_info.mcp_result` → tool observation
|
||||
- For `post_cascade_response`: `tool_info.response` → full AI response observation
|
||||
|
||||
2. **Create Windsurf hooks installer** at `src/services/integrations/WindsurfHooksInstaller.ts`:
|
||||
- Write hooks to `~/.codeium/windsurf/hooks.json` (user-level, for global coverage)
|
||||
- Per-workspace override at `.windsurf/hooks.json` if user chooses workspace-level install
|
||||
- Config format (verified):
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"post_write_code": [{
|
||||
"command": "<path-to-hook-script>",
|
||||
"show_output": false,
|
||||
"working_directory": "<optional>"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
- Note: Tilde expansion (`~`) is NOT supported in `working_directory` — use absolute paths
|
||||
- Merge order: cloud → system → user → workspace (all hooks at all levels execute)
|
||||
- Context injection via `.windsurf/rules/claude-mem-context.md` (workspace-level; Windsurf rules are workspace-scoped)
|
||||
- Rule limits: 6,000 chars per file, 12,000 chars total across all rules
|
||||
|
||||
3. **Register `windsurf` in `getPlatformAdapter()`** at `src/cli/adapters/index.ts`
|
||||
|
||||
4. **Add Windsurf to installer IDE selection**
|
||||
|
||||
### Windsurf Verification
|
||||
|
||||
- `npx claude-mem install --ide windsurf` creates hooks config at `~/.codeium/windsurf/hooks.json`
|
||||
- Windsurf sessions are captured by the worker via post-action hooks
|
||||
- `trajectory_id` is used as session identifier
|
||||
- Context is injected via `.windsurf/rules/claude-mem-context.md` (under 6K char limit)
|
||||
- Existing hooks.json is preserved (merge, not overwrite)
|
||||
|
||||
### Windsurf Anti-patterns
|
||||
|
||||
- Do NOT use fabricated event names (`post_search_code`, `post_lint_code`, `on_error`, `pre_tool_execution`) — they don't exist
|
||||
- Do NOT assume Windsurf's stdin JSON matches Claude Code's — it uses `tool_info` envelope, not flat fields
|
||||
- Do NOT use tilde (`~`) in `working_directory` — not supported, use absolute paths
|
||||
- Do NOT exceed 6K chars in the context rule file — Windsurf truncates beyond that
|
||||
- Pre-hooks can block actions (exit 2) — only use post-hooks for observation capture
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Codex CLI Integration (Tier 1 — Hook + Transcript)
|
||||
|
||||
### Dedup strategy
|
||||
|
||||
Codex has both a `notify` hook (real-time) and transcript files (complete history). Use **transcript watching only** — it's more complete and avoids the complexity of dual capture paths. The `notify` hook is a simpler mechanism that doesn't provide enough granularity to justify maintaining two integration paths. If transcript watching proves insufficient, add the notify hook later.
|
||||
|
||||
### What to implement
|
||||
|
||||
1. **Create Codex transcript schema** — the sample in `src/services/transcripts/config.ts` is already production-quality. Verify against current Codex CLI JSONL format and update if needed.
|
||||
|
||||
2. **Create Codex setup in installer**:
|
||||
- Write transcript-watch config to `~/.claude-mem/transcript-watch.json`
|
||||
- Set up watch for `~/.codex/sessions/**/*.jsonl` using existing CODEX_SAMPLE_SCHEMA
|
||||
- Context injection via `.codex/AGENTS.md` (Codex reads this natively)
|
||||
- Must merge with existing `config.toml` if it exists (read → parse → merge → write)
|
||||
|
||||
3. **Add Codex CLI to installer IDE selection**
|
||||
|
||||
### Verification
|
||||
|
||||
- `npx claude-mem install --ide codex` creates transcript watch config
|
||||
- Codex sessions appear in claude-mem database
|
||||
- `AGENTS.md` updated with context after sessions
|
||||
- Existing `config.toml` is preserved
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: OpenClaw Integration (Tier 1 — Plugin-Based)
|
||||
|
||||
**Plugin is already fully built** at `openclaw/src/index.ts` (~1000 lines). Has event hooks, SSE observation feed, MEMORY.md sync, slash commands. Only wiring into the installer is needed.
|
||||
|
||||
### What to implement
|
||||
|
||||
1. **Wire OpenClaw into the npx installer**:
|
||||
- Detect `~/.openclaw/` directory
|
||||
- Copy pre-built plugin from `openclaw/dist/` (built in Phase 2) to OpenClaw plugins location
|
||||
- Register in `~/.openclaw/openclaw.json` under `plugins.claude-mem`
|
||||
- Configure worker port, project name, syncMemoryFile
|
||||
- Optionally prompt for observation feed setup (channel type + target ID)
|
||||
|
||||
2. **Add OpenClaw to IDE selection TUI** with hint about messaging channel support
|
||||
|
||||
### Verification
|
||||
|
||||
- `npx claude-mem install --ide openclaw` registers the plugin
|
||||
- OpenClaw gateway loads the plugin on restart
|
||||
- Observations are recorded from OpenClaw sessions
|
||||
- MEMORY.md syncs to agent workspaces
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- Do NOT rebuild the OpenClaw plugin from source at install time — it ships pre-built from Phase 2
|
||||
- Do NOT modify the plugin's event handling — it's battle-tested
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: MCP-Based Integrations (Tier 2)
|
||||
|
||||
**These get the MCP server for free** — it already exists at `plugin/scripts/mcp-server.cjs`. The installer just needs to write the right config files per IDE.
|
||||
|
||||
MCP-only integrations provide: search tools + context injection. They do NOT capture transcripts or tool usage in real-time.
|
||||
|
||||
### What to implement
|
||||
|
||||
1. **Copilot CLI MCP setup**:
|
||||
- Write MCP config to `~/.copilot/config` (merge, not overwrite)
|
||||
- Context injection: `.github/copilot-instructions.md`
|
||||
- Detection: `copilot` command in PATH
|
||||
|
||||
2. **Antigravity MCP setup**:
|
||||
- Write MCP config to `~/.gemini/antigravity/mcp_config.json` (merge, not overwrite)
|
||||
- Context injection: `~/.gemini/GEMINI.md` (shared with Gemini CLI) and/or `.agent/rules/claude-mem-context.md`
|
||||
- Detection: `~/.gemini/antigravity/` exists
|
||||
- Note: Antigravity has NO hook system — MCP is the only integration path
|
||||
|
||||
3. **Goose MCP setup**:
|
||||
- Write MCP config to `~/.config/goose/config.yaml` (YAML merge — use a lightweight YAML parser or write the block manually if config doesn't exist)
|
||||
- Detection: `~/.config/goose/` exists OR `goose` in PATH
|
||||
- Note: Goose co-developed MCP with Anthropic, so MCP support is excellent
|
||||
|
||||
4. **Crush MCP setup**:
|
||||
- Write MCP config to Crush's JSON config
|
||||
- Detection: `crush` in PATH
|
||||
|
||||
5. **Roo Code MCP setup**:
|
||||
- Write MCP config to `.roo/` or workspace settings
|
||||
- Context injection: `.roo/rules/claude-mem-context.md`
|
||||
- Detection: Check for VS Code extension directory containing `roo-code`
|
||||
|
||||
6. **Warp MCP setup**:
|
||||
- Warp uses `WARP.md` in project root for context injection (similar to CLAUDE.md)
|
||||
- MCP servers configured via Warp Drive UI, but also via config files
|
||||
- Detection: `~/.warp/` exists OR `warp` in PATH
|
||||
- Note: Warp is a terminal replacement (~26k stars), not just a CLI tool — multi-agent orchestration with management UI
|
||||
|
||||
7. **For each**: Add to installer IDE detection and selection
|
||||
|
||||
### Config merging strategy
|
||||
|
||||
JSON configs: Read → parse → deep merge → write back. YAML configs (Goose): If file exists, read and append the MCP block. If not, create from template. Avoid pulling in a full YAML parser library — write the MCP block as a string append with proper indentation if the format is predictable.
|
||||
|
||||
### Verification
|
||||
|
||||
- Each IDE can search claude-mem via MCP tools
|
||||
- Context files are written to IDE-specific locations
|
||||
- Existing configs are preserved
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- MCP-only integrations do NOT capture transcripts — don't claim "full integration"
|
||||
- Do NOT overwrite existing config files — always merge
|
||||
- Do NOT add a heavy YAML parser dependency for one integration
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Remove Old Installer
|
||||
|
||||
This is a **full replacement**, not a deprecation.
|
||||
|
||||
### What to implement
|
||||
|
||||
1. Remove `claude-mem-installer` npm package (unpublish or mark deprecated with message pointing to `npx claude-mem`)
|
||||
2. Update `install/public/install.sh` → redirect to `npx claude-mem`
|
||||
3. Remove `installer/` directory from the repository (it's replaced by `src/npx-cli/`)
|
||||
4. Update docs site to reflect the new install command
|
||||
5. Update README.md install instructions
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Final Verification
|
||||
|
||||
### All platforms (macOS, Linux, Windows)
|
||||
|
||||
1. `npm run build` succeeds, produces `dist/cli/index.js` and `openclaw/dist/index.js`
|
||||
2. `node dist/cli/index.js install` works clean (no prior install)
|
||||
3. Auto-detects installed IDEs correctly per platform
|
||||
4. `npx claude-mem start/stop/status/search` all work
|
||||
5. `npx claude-mem update` updates correctly
|
||||
6. `npx claude-mem uninstall` cleans up all IDE configs
|
||||
7. `npx claude-mem version` prints version
|
||||
8. `npx claude-mem start` before install shows helpful error
|
||||
9. No Bun dependency at install time
|
||||
|
||||
### Per-integration verification
|
||||
|
||||
| Integration | Type | Captures Sessions | Search via MCP | Context Injection |
|
||||
|-------------|------|-------------------|----------------|-------------------|
|
||||
| Claude Code | Plugin | Yes (hooks) | Yes | CLAUDE.md |
|
||||
| Gemini CLI | Hooks | Yes (AfterTool, AfterAgent) | Yes (via hook) | GEMINI.md |
|
||||
| OpenCode | Plugin | Yes (tool.execute.after, message.updated) | Yes (custom tool) | AGENTS.md / rules |
|
||||
| Windsurf | Hooks | Yes (post_cascade_response, etc.) | Yes (via hook) | .windsurf/rules/ |
|
||||
| Codex CLI | Transcript | Yes (JSONL watcher) | No (passive only) | .codex/AGENTS.md |
|
||||
| OpenClaw | Plugin | Yes (event hooks) | Yes (slash commands) | MEMORY.md |
|
||||
| Copilot CLI | MCP | No | Yes | copilot-instructions.md |
|
||||
| Antigravity | MCP | No | Yes | .agent/rules/ |
|
||||
| Goose | MCP | No | Yes | MCP context |
|
||||
| Crush | MCP | No | Yes | Skills |
|
||||
| Roo Code | MCP | No | Yes | .roo/rules/ |
|
||||
| Warp | MCP | No | Yes | WARP.md |
|
||||
|
||||
---
|
||||
|
||||
## Priority Order & Impact
|
||||
|
||||
| Phase | IDE/Tool | Integration Type | Stars/Users | Effort |
|
||||
|-------|----------|-----------------|-------------|--------|
|
||||
| 1-2 | (infrastructure) | npx CLI + build pipeline | All users | Medium |
|
||||
| 3 | Gemini CLI | Hooks (Tier 1) | ~95k stars | Medium (near-identical to Claude Code) |
|
||||
| 4 | OpenCode | Plugin (Tier 1) | ~110k stars | Medium (rich plugin SDK) |
|
||||
| 5 | Windsurf | Hooks (Tier 1) | ~1M users | Medium |
|
||||
| 6 | Codex CLI | Transcript (Tier 3) | Growing (OpenAI) | Low (schema already exists) |
|
||||
| 7 | OpenClaw | Plugin (Tier 1) — pre-built | ~196k stars | Low (wire into installer) |
|
||||
| 8 | Copilot CLI, Antigravity, Goose, Crush, Warp, Roo Code | MCP (Tier 2) | 20M+ combined | Low per IDE |
|
||||
| 9 | (remove old installer) | — | — | Low |
|
||||
| 10 | (final verification) | — | — | Low |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- **Removing Bun as runtime dependency**: Worker still requires Bun for `bun:sqlite`. Runtime commands delegate to Bun; install commands don't need it.
|
||||
- **JetBrains plugin**: Requires Kotlin/Java development — different ecosystem entirely.
|
||||
- **Zed extension**: WASM sandbox limits feasibility.
|
||||
- **Neovim/Emacs plugins**: Niche audiences, complex plugin ecosystems (Lua/Elisp). Could be added later via MCP (gptel supports it).
|
||||
- **Amazon Q / Kiro**: Amazon Q Developer CLI has been sunsetted in favor of Kiro (proprietary, no public extensibility API yet). Revisit when Kiro opens up.
|
||||
- **Aider**: Niche audience, writes Markdown transcripts (not JSONL), would require a markdown parser mode in the watcher. Add if demand materializes.
|
||||
- **Continue.dev**: Small user base relative to other MCP tools. Can be added as a Tier 2 MCP integration later if requested.
|
||||
- **Toad / Qwen Code / Oh-my-pi**: Too early-stage or too niche. Monitor for growth.
|
||||
- **OpenClaw plugin development**: The plugin is already complete. Only installer wiring is in scope.
|
||||
+60
-225
@@ -2,6 +2,66 @@
|
||||
|
||||
All notable changes to claude-mem.
|
||||
|
||||
## [v10.5.0] - 2026-02-26
|
||||
|
||||
## Smart Explore: AST-Powered Code Navigation
|
||||
|
||||
This release introduces **Smart Explore**, a token-optimized structural code search system built on tree-sitter AST parsing. It applies the same progressive disclosure pattern used in human-readable code outlines — but programmatically, for AI agents.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
The standard exploration cycle (Glob → Grep → Read) forces agents to consume entire files to understand code structure. A typical 800-line file costs ~12,000 tokens to read. Smart Explore replaces this with a 3-layer progressive disclosure workflow that delivers the same understanding at **6-12x lower token cost**.
|
||||
|
||||
### 3 New MCP Tools
|
||||
|
||||
- **`smart_search`** — Walks directories, parses all code files via tree-sitter, and returns ranked symbols with signatures and line numbers. Replaces the Glob → Grep discovery cycle in a single call (~2-6k tokens).
|
||||
- **`smart_outline`** — Returns the complete structural skeleton of a file: all functions, classes, methods, properties, imports (~1-2k tokens vs ~12k for a full Read).
|
||||
- **`smart_unfold`** — Expands a single symbol to its full source code including JSDoc, decorators, and implementation (~1-7k tokens).
|
||||
|
||||
### Token Economics
|
||||
|
||||
| Approach | Tokens | Savings |
|
||||
|----------|--------|---------|
|
||||
| smart_outline + smart_unfold | ~3,100 | 8x vs Read |
|
||||
| smart_search (cross-file) | ~2,000-6,000 | 6-12x vs Explore agent |
|
||||
| Read (full file) | ~12,000+ | baseline |
|
||||
| Explore agent | ~20,000-40,000 | baseline |
|
||||
|
||||
### Language Support
|
||||
|
||||
10 languages via tree-sitter grammars: TypeScript, JavaScript, Python, Rust, Go, Java, C, C++, Ruby, PHP.
|
||||
|
||||
### Other Changes
|
||||
|
||||
- Simplified hooks configuration
|
||||
- Removed legacy setup.sh script
|
||||
- Security fix: replaced `execSync` with `execFileSync` to prevent command injection in file path handling
|
||||
|
||||
## [v10.4.4] - 2026-02-26
|
||||
|
||||
## Fix
|
||||
|
||||
- **Remove `save_observation` from MCP tool surface** — This tool was exposed as an MCP tool available to Claude, but it's an internal API-only feature. Removing it from the MCP server prevents unintended tool invocation and keeps the tool surface clean.
|
||||
|
||||
## [v10.4.3] - 2026-02-25
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Fix PostToolUse hook crashes and 5-second latency (#1220)**: Added missing `break` statements to all 7 switch cases in `worker-service.ts` preventing fall-through execution, added `.catch()` on `main()` to handle unhandled promise rejections, and removed redundant `start` commands from hook groups that triggered the 5-second `collectStdin()` timeout
|
||||
- **Fix CLAUDE_PLUGIN_ROOT fallback for Stop hooks (#1215)**: Added POSIX shell-level `CLAUDE_PLUGIN_ROOT` fallback in `hooks.json` for environments where the variable isn't injected, added script-level self-resolution via `import.meta.url` in `bun-runner.js`, and regression test added in `plugin-distribution.test.ts`
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Synced all version files (plugin.json was stuck at 10.4.0)
|
||||
|
||||
## [v10.4.2] - 2026-02-25
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Fix PostToolUse hook crashes and 5-second latency (#1220)**: Added missing `break` statements to all 7 switch cases in `worker-service.ts` preventing fall-through execution, added `.catch()` on `main()` to handle unhandled promise rejections, and removed redundant `start` commands from hook groups that triggered the 5-second `collectStdin()` timeout
|
||||
- **Fix CLAUDE_PLUGIN_ROOT fallback for Stop hooks (#1215)**: Added POSIX shell-level `CLAUDE_PLUGIN_ROOT` fallback in `hooks.json` for environments where the variable isn't injected, added script-level self-resolution via `import.meta.url` in `bun-runner.js`, and regression test added in `plugin-distribution.test.ts`
|
||||
- **Sync plugin.json version**: Fixed `plugin.json` being stuck at 10.4.0 while other version files were at 10.4.1
|
||||
|
||||
## [v10.4.1] - 2026-02-24
|
||||
|
||||
### Refactor
|
||||
@@ -1172,228 +1232,3 @@ Comprehensive test suite in a new PR, targeting **v8.6.0**
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
## [v8.5.5] - 2026-01-03
|
||||
|
||||
## Improved Error Handling and Logging
|
||||
|
||||
This patch release enhances error handling and logging across all worker services for better debugging and reliability.
|
||||
|
||||
### Changes
|
||||
- **Enhanced Error Logging**: Improved error context across SessionStore, SearchManager, SDKAgent, GeminiAgent, and OpenRouterAgent
|
||||
- **SearchManager**: Restored error handling for Chroma calls with improved logging
|
||||
- **SessionStore**: Enhanced error logging throughout database operations
|
||||
- **Bug Fix**: Fixed critical bug where `memory_session_id` could incorrectly equal `content_session_id`
|
||||
- **Hooks**: Streamlined error handling and loading states for better maintainability
|
||||
|
||||
### Investigation Reports
|
||||
- Added detailed analysis documents for generator failures and observation duplication regressions
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v8.5.4...v8.5.5
|
||||
|
||||
## [v8.5.4] - 2026-01-02
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Chroma Connection Error Handling
|
||||
Fixed a critical bug in ChromaSync where connection-related errors were misinterpreted as missing collections. The `ensureCollection()` method previously caught ALL errors and assumed they meant the collection doesn't exist, which caused connection errors to trigger unnecessary collection creation attempts. Now connection-related errors like "Not connected" are properly distinguished and re-thrown immediately, preventing false error handling paths and inappropriate fallback behavior.
|
||||
|
||||
### Removed Dead last_user_message Code
|
||||
Cleaned up dead code related to `last_user_message` handling in the summary flow. This field was being extracted from transcripts but never used anywhere - in Claude Code transcripts, "user" type messages are mostly tool_results rather than actual user input, and the user's original request is already stored in the user_prompts table. Removing this unused field eliminates confusing warnings like "Missing last_user_message when queueing summary". Changes span summary-hook, SessionRoutes, SessionManager, interface definitions, and all agent implementations.
|
||||
|
||||
## Improvements
|
||||
|
||||
### Enhanced Error Handling Across Services
|
||||
Comprehensive improvement to error handling across 8 core services:
|
||||
- **BranchManager** - Now logs recovery checkout failures
|
||||
- **PaginationHelper** - Logs when file paths are plain strings instead of valid JSON
|
||||
- **SDKAgent** - Enhanced logging for Claude executable detection failures
|
||||
- **SearchManager** - Logs plain string handling for files read and edited
|
||||
- **paths.ts** - Improved logging for git root detection failures
|
||||
- **timeline-formatting** - Enhanced JSON parsing errors with input previews
|
||||
- **transcript-parser** - Logs summary of parse errors after processing
|
||||
- **ChromaSync** - Logs full error context before attempting collection creation
|
||||
|
||||
### Error Handling Documentation & Tooling
|
||||
- Created `error-handling-baseline.txt` establishing baseline error handling practices
|
||||
- Documented error handling anti-pattern rules in CLAUDE.md
|
||||
- Added `detect-error-handling-antipatterns.ts` script to identify empty catch blocks, improper logging practices, and oversized try-catch blocks
|
||||
|
||||
## New Features
|
||||
|
||||
### Console Filter Bar with Log Parsing
|
||||
Implemented interactive log filtering in the viewer UI:
|
||||
- **Structured Log Parsing** - Extracts timestamp, level, component, correlation ID, and message content using regex pattern matching
|
||||
- **Level Filtering** - Toggle visibility for DEBUG, INFO, WARN, ERROR log levels
|
||||
- **Component Filtering** - Filter by 9 component types: HOOK, WORKER, SDK, PARSER, DB, SYSTEM, HTTP, SESSION, CHROMA
|
||||
- **Color-Coded Rendering** - Visual distinction with component-specific icons and log level colors
|
||||
- **Special Message Detection** - Recognizes markers like → (dataIn), ← (dataOut), ✓ (success), ✗ (failure), ⏱ (timing), [HAPPY-PATH]
|
||||
- **Smart Auto-Scroll** - Maintains scroll position when reviewing older logs
|
||||
- **Responsive Design** - Filter bar adapts to smaller screens
|
||||
|
||||
## [v8.5.3] - 2026-01-02
|
||||
|
||||
# 🛡️ Error Handling Hardening & Developer Tools
|
||||
|
||||
Version 8.5.3 introduces comprehensive error handling improvements that prevent silent failures and reduce debugging time from hours to minutes. This release also adds new developer tools for queue management and log monitoring.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Critical Error Handling Improvements
|
||||
|
||||
### The Problem
|
||||
A single overly-broad try-catch block caused a **10-hour debugging session** by silently swallowing errors. This pattern was pervasive throughout the codebase, creating invisible failure modes.
|
||||
|
||||
### The Solution
|
||||
|
||||
**Automated Anti-Pattern Detection** (`scripts/detect-error-handling-antipatterns.ts`)
|
||||
- Detects 7 categories of error handling anti-patterns
|
||||
- Enforces zero-tolerance policy for empty catch blocks
|
||||
- Identifies large try-catch blocks (>10 lines) that mask specific errors
|
||||
- Flags missing error logging that causes silent failures
|
||||
- Supports approved overrides with justification comments
|
||||
- Exit code 1 if critical issues detected (enforceable in CI)
|
||||
|
||||
**New Error Handling Standards** (Added to `CLAUDE.md`)
|
||||
- **5-Question Pre-Flight Checklist**: Required before writing any try-catch
|
||||
1. What SPECIFIC error am I catching?
|
||||
2. Show documentation proving this error can occur
|
||||
3. Why can't this error be prevented?
|
||||
4. What will the catch block DO?
|
||||
5. Why shouldn't this error propagate?
|
||||
- **Forbidden Patterns**: Empty catch, catch without logging, large try blocks, promise catch without handlers
|
||||
- **Allowed Patterns**: Specific errors, logged failures, minimal scope, explicit recovery
|
||||
- **Meta-Rule**: Uncertainty triggers research, NOT try-catch
|
||||
|
||||
### Fixes Applied
|
||||
|
||||
**Wave 1: Empty Catch Blocks** (5 files)
|
||||
- `import-xml-observations.ts` - Log skipped invalid JSON
|
||||
- `bun-path.ts` - Log when bun not in PATH
|
||||
- `cursor-utils.ts` - Log failed registry reads & corrupt MCP config
|
||||
- `worker-utils.ts` - Log failed health checks
|
||||
|
||||
**Wave 2: Promise Catches on Critical Paths** (8 locations)
|
||||
- `worker-service.ts` - Background initialization failures
|
||||
- `SDKAgent.ts` - Session processor errors (2 locations)
|
||||
- `GeminiAgent.ts` - Finalization failures (2 locations)
|
||||
- `OpenRouterAgent.ts` - Finalization failures (2 locations)
|
||||
- `SessionManager.ts` - Generator promise failures
|
||||
|
||||
**Wave 3: Comprehensive Audit** (29 catch blocks)
|
||||
- Added logging to 16 catch blocks (UI, servers, worker, routes, services)
|
||||
- Documented 13 intentional exceptions with justification comments
|
||||
- All patterns now follow error handling guidelines with appropriate log levels
|
||||
|
||||
### Approved Override System
|
||||
|
||||
For justified exceptions (performance-critical paths, expected failures), use:
|
||||
```typescript
|
||||
// [APPROVED OVERRIDE]: Brief technical justification
|
||||
try {
|
||||
// code
|
||||
} catch {
|
||||
// allowed exception
|
||||
}
|
||||
```
|
||||
|
||||
**Progress**: 163 anti-patterns → 26 approved overrides (84% reduction in silent failures)
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Queue Management Features
|
||||
|
||||
**New Commands**
|
||||
- `npm run queue:clear` - Interactive removal of failed messages
|
||||
- `npm run queue:clear -- --all` - Clear all messages (pending, processing, failed)
|
||||
- `npm run queue:clear -- --force` - Non-interactive mode
|
||||
|
||||
**HTTP API Endpoints**
|
||||
- `DELETE /api/pending-queue/failed` - Remove failed messages
|
||||
- `DELETE /api/pending-queue/all` - Complete queue reset
|
||||
|
||||
Failed messages exceed max retry count and remain for debugging. These commands provide clean queue maintenance.
|
||||
|
||||
---
|
||||
|
||||
## 🪵 Developer Console (Chrome DevTools Style)
|
||||
|
||||
**UI Improvements**
|
||||
- Bottom drawer console (slides up from bottom-left corner)
|
||||
- Draggable resize handle for height adjustment
|
||||
- Auto-refresh toggle (2s interval)
|
||||
- Clear logs button with confirmation
|
||||
- Monospace font (SF Mono/Monaco/Consolas)
|
||||
- Minimum height: 150px, adjustable to window height - 100px
|
||||
|
||||
**API Endpoints**
|
||||
- `GET /api/logs` - Fetch last 1000 lines of current day's log
|
||||
- `DELETE /api/logs` - Clear current log file
|
||||
|
||||
Logs viewer accessible via floating console button in UI.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Architecture Documentation
|
||||
|
||||
**Session ID Architecture** (`docs/SESSION_ID_ARCHITECTURE.md`)
|
||||
- Comprehensive documentation of 1:1 session mapping guarantees
|
||||
- 19 validation tests proving UNIQUE constraints and resume consistency
|
||||
- Documents single-transition vulnerability (application-level enforcement)
|
||||
- Complete reference for session lifecycle management
|
||||
|
||||
---
|
||||
|
||||
## 📊 Impact Summary
|
||||
|
||||
- **Debugging Time**: 10 hours → minutes (proper error visibility)
|
||||
- **Test Coverage**: +19 critical architecture validation tests
|
||||
- **Silent Failures**: 84% reduction (163 → 26 approved exceptions)
|
||||
- **Protection**: Automated detection prevents regression
|
||||
- **Developer UX**: Console logs, queue management, comprehensive docs
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical Details
|
||||
|
||||
**Files Changed**: 25+ files across error handling, queue management, UI, and documentation
|
||||
|
||||
**Critical Path Protection**
|
||||
These files now have strict error propagation (no catch-and-continue):
|
||||
- `SDKAgent.ts`
|
||||
- `GeminiAgent.ts`
|
||||
- `OpenRouterAgent.ts`
|
||||
- `SessionStore.ts`
|
||||
- `worker-service.ts`
|
||||
|
||||
**Build Verification**: All changes tested, build successful
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v8.5.2...v8.5.3
|
||||
|
||||
## [v8.5.2] - 2025-12-31
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Fixed SDK Agent Memory Leak (#499)
|
||||
|
||||
Fixed a critical memory leak where Claude SDK child processes were never terminated after sessions completed. Over extended usage, this caused hundreds of orphaned processes consuming 40GB+ of RAM.
|
||||
|
||||
**Root Cause:**
|
||||
- When the SDK agent generator completed naturally (no more messages to process), the `AbortController` was never aborted
|
||||
- Child processes spawned by the Agent SDK remained running indefinitely
|
||||
- Sessions stayed in memory (by design for future events) but underlying processes were never cleaned up
|
||||
|
||||
**Fix:**
|
||||
- Added proper cleanup to SessionRoutes finally block
|
||||
- Now calls `abortController.abort()` when generator completes with no pending work
|
||||
- Creates new `AbortController` when crash recovery restarts generators
|
||||
- Ensures cleanup happens even if recovery logic fails
|
||||
|
||||
**Impact:**
|
||||
- Prevents orphaned `claude` processes from accumulating
|
||||
- Eliminates multi-gigabyte memory leaks during normal usage
|
||||
- Maintains crash recovery functionality with proper resource cleanup
|
||||
|
||||
Thanks to @yonnock for the detailed bug report and investigation in #499!
|
||||
|
||||
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.4.3",
|
||||
"version": "10.5.1",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
@@ -117,6 +117,16 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"esbuild": "^0.27.2",
|
||||
"np": "^11.0.2",
|
||||
"tree-sitter-c": "^0.24.1",
|
||||
"tree-sitter-cli": "^0.26.5",
|
||||
"tree-sitter-cpp": "^0.23.4",
|
||||
"tree-sitter-go": "^0.25.0",
|
||||
"tree-sitter-java": "^0.23.5",
|
||||
"tree-sitter-javascript": "^0.25.0",
|
||||
"tree-sitter-python": "^0.25.0",
|
||||
"tree-sitter-ruby": "^0.23.1",
|
||||
"tree-sitter-rust": "^0.24.0",
|
||||
"tree-sitter-typescript": "^0.23.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Fix: SessionStart Hook "startup hook error" — Worker Not Waiting
|
||||
|
||||
## Root Cause
|
||||
|
||||
The **installed plugin** (`~/.claude/plugins/marketplaces/thedotmack/`) is version **10.2.5** and has **none** of the recent fixes:
|
||||
|
||||
| Fix | Repo Status | Installed Status |
|
||||
|-----|-------------|-----------------|
|
||||
| Hook group split (smart-install isolated from worker start) | In `plugin/hooks/hooks.json` | **Missing** — all 3 hooks in one group, smart-install failure blocks worker |
|
||||
| `waitForReadiness()` after spawn | In `src/services/infrastructure/HealthMonitor.ts` | **Missing** — 0 occurrences in installed `worker-service.cjs` |
|
||||
| Early `initializationCompleteFlag` (after DB+search, not MCP) | In `src/services/worker-service.ts` | **Missing** — flag set after MCP connection (5+ minute wait) |
|
||||
|
||||
The changes exist in source code but were **never built and synced** to the installed location.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Build and Sync
|
||||
|
||||
```bash
|
||||
npm run build-and-sync
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
# 1. Confirm waitForReadiness exists in installed build
|
||||
grep -c "waitForReadiness" ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs
|
||||
# Expected: > 0
|
||||
|
||||
# 2. Confirm hooks.json has two SessionStart groups (the split)
|
||||
python3 -c "import json; d=json.load(open('$(echo $HOME)/.claude/plugins/marketplaces/thedotmack/plugin/hooks/hooks.json')); print('SessionStart groups:', len(d['hooks']['SessionStart']))"
|
||||
# Expected: 2
|
||||
|
||||
# 3. Confirm initializationCompleteFlag is set before MCP connection
|
||||
grep -n "Core initialization complete" ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs | head -1
|
||||
# Expected: appears BEFORE "MCP server connected"
|
||||
```
|
||||
|
||||
## Phase 2: Restart Worker and Test
|
||||
|
||||
```bash
|
||||
# Stop existing worker
|
||||
bun plugin/scripts/worker-service.cjs stop
|
||||
|
||||
# Verify stopped
|
||||
curl -s http://127.0.0.1:37777/api/health && echo "STILL RUNNING" || echo "STOPPED"
|
||||
```
|
||||
|
||||
Then start a new Claude Code session and verify:
|
||||
- No "SessionStart:startup hook error" messages
|
||||
- Worker is running: `curl http://127.0.0.1:37777/api/health`
|
||||
- Readiness endpoint works: `curl http://127.0.0.1:37777/api/readiness`
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.4.3",
|
||||
"version": "10.5.1",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
|
||||
+11
-2
@@ -1,11 +1,20 @@
|
||||
{
|
||||
"name": "claude-mem-plugin",
|
||||
"version": "10.4.3",
|
||||
"version": "10.5.1",
|
||||
"private": true,
|
||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@chroma-core/default-embed": "^0.1.9"
|
||||
"tree-sitter-cli": "^0.26.5",
|
||||
"tree-sitter-c": "^0.24.1",
|
||||
"tree-sitter-cpp": "^0.23.4",
|
||||
"tree-sitter-go": "^0.25.0",
|
||||
"tree-sitter-java": "^0.23.5",
|
||||
"tree-sitter-javascript": "^0.25.0",
|
||||
"tree-sitter-python": "^0.25.0",
|
||||
"tree-sitter-ruby": "^0.23.1",
|
||||
"tree-sitter-rust": "^0.24.0",
|
||||
"tree-sitter-typescript": "^0.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
Never read built source files in this directory. These are compiled outputs — read the source files in `src/` instead.
|
||||
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,228 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# claude-mem Setup Hook
|
||||
# Ensures dependencies are installed before plugin runs
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Use CLAUDE_PLUGIN_ROOT if available, otherwise detect from script location
|
||||
if [[ -z "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
else
|
||||
ROOT="$CLAUDE_PLUGIN_ROOT"
|
||||
fi
|
||||
|
||||
MARKER="$ROOT/.install-version"
|
||||
PKG_JSON="$ROOT/package.json"
|
||||
|
||||
# Colors (when terminal supports it)
|
||||
if [[ -t 2 ]]; then
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
else
|
||||
RED='' GREEN='' YELLOW='' BLUE='' NC=''
|
||||
fi
|
||||
|
||||
log_info() { echo -e "${BLUE}ℹ${NC} $*" >&2; }
|
||||
log_ok() { echo -e "${GREEN}✓${NC} $*" >&2; }
|
||||
log_warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; }
|
||||
log_error() { echo -e "${RED}✗${NC} $*" >&2; }
|
||||
|
||||
#
|
||||
# Detect Bun - check PATH and common locations
|
||||
#
|
||||
find_bun() {
|
||||
# Try PATH first
|
||||
if command -v bun &>/dev/null; then
|
||||
echo "bun"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check common install locations
|
||||
local paths=(
|
||||
"$HOME/.bun/bin/bun"
|
||||
"/usr/local/bin/bun"
|
||||
"/opt/homebrew/bin/bun"
|
||||
)
|
||||
|
||||
for p in "${paths[@]}"; do
|
||||
if [[ -x "$p" ]]; then
|
||||
echo "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
#
|
||||
# Detect uv - check PATH and common locations
|
||||
#
|
||||
find_uv() {
|
||||
# Try PATH first
|
||||
if command -v uv &>/dev/null; then
|
||||
echo "uv"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check common install locations
|
||||
local paths=(
|
||||
"$HOME/.local/bin/uv"
|
||||
"$HOME/.cargo/bin/uv"
|
||||
"/usr/local/bin/uv"
|
||||
"/opt/homebrew/bin/uv"
|
||||
)
|
||||
|
||||
for p in "${paths[@]}"; do
|
||||
if [[ -x "$p" ]]; then
|
||||
echo "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
#
|
||||
# Get package.json version
|
||||
#
|
||||
get_pkg_version() {
|
||||
if [[ -f "$PKG_JSON" ]]; then
|
||||
# Simple grep-based extraction (no jq dependency)
|
||||
grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$PKG_JSON" | head -1 | sed 's/.*"\([^"]*\)"$/\1/'
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# Get marker version (if exists)
|
||||
#
|
||||
get_marker_version() {
|
||||
if [[ -f "$MARKER" ]]; then
|
||||
grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$MARKER" | head -1 | sed 's/.*"\([^"]*\)"$/\1/'
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# Get marker's recorded bun version
|
||||
#
|
||||
get_marker_bun() {
|
||||
if [[ -f "$MARKER" ]]; then
|
||||
grep -o '"bun"[[:space:]]*:[[:space:]]*"[^"]*"' "$MARKER" | head -1 | sed 's/.*"\([^"]*\)"$/\1/'
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# Check if install is needed
|
||||
#
|
||||
needs_install() {
|
||||
# No node_modules? Definitely need install
|
||||
if [[ ! -d "$ROOT/node_modules" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# No marker? Need install
|
||||
if [[ ! -f "$MARKER" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pkg_ver marker_ver bun_ver marker_bun
|
||||
pkg_ver=$(get_pkg_version)
|
||||
marker_ver=$(get_marker_version)
|
||||
|
||||
# Version mismatch? Need install
|
||||
if [[ "$pkg_ver" != "$marker_ver" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Bun version changed? Need install
|
||||
if BUN_PATH=$(find_bun); then
|
||||
bun_ver=$("$BUN_PATH" --version 2>/dev/null || echo "")
|
||||
marker_bun=$(get_marker_bun)
|
||||
if [[ -n "$bun_ver" && "$bun_ver" != "$marker_bun" ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# All good, no install needed
|
||||
return 1
|
||||
}
|
||||
|
||||
#
|
||||
# Write version marker after successful install
|
||||
#
|
||||
write_marker() {
|
||||
local bun_ver uv_ver pkg_ver
|
||||
pkg_ver=$(get_pkg_version)
|
||||
bun_ver=$("$BUN_PATH" --version 2>/dev/null || echo "unknown")
|
||||
|
||||
if UV_PATH=$(find_uv); then
|
||||
uv_ver=$("$UV_PATH" --version 2>/dev/null | head -1 || echo "unknown")
|
||||
else
|
||||
uv_ver="not-installed"
|
||||
fi
|
||||
|
||||
cat > "$MARKER" <<EOF
|
||||
{
|
||||
"version": "$pkg_ver",
|
||||
"bun": "$bun_ver",
|
||||
"uv": "$uv_ver",
|
||||
"installedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
#
|
||||
# Main
|
||||
#
|
||||
|
||||
# 1. Check for Bun
|
||||
BUN_PATH=$(find_bun) || true
|
||||
if [[ -z "$BUN_PATH" ]]; then
|
||||
log_error "Bun runtime not found!"
|
||||
echo "" >&2
|
||||
echo "claude-mem requires Bun to run. Please install it:" >&2
|
||||
echo "" >&2
|
||||
echo " curl -fsSL https://bun.sh/install | bash" >&2
|
||||
echo "" >&2
|
||||
echo "Or on macOS with Homebrew:" >&2
|
||||
echo "" >&2
|
||||
echo " brew install oven-sh/bun/bun" >&2
|
||||
echo "" >&2
|
||||
echo "Then restart your terminal and try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUN_VERSION=$("$BUN_PATH" --version 2>/dev/null || echo "unknown")
|
||||
log_ok "Bun $BUN_VERSION found at $BUN_PATH"
|
||||
|
||||
# 2. Check for uv (optional - for Python/Chroma support)
|
||||
UV_PATH=$(find_uv) || true
|
||||
if [[ -z "$UV_PATH" ]]; then
|
||||
log_warn "uv not found (optional - needed for Python/Chroma vector search)"
|
||||
echo " To install: curl -LsSf https://astral.sh/uv/install.sh | sh" >&2
|
||||
else
|
||||
UV_VERSION=$("$UV_PATH" --version 2>/dev/null | head -1 || echo "unknown")
|
||||
log_ok "uv $UV_VERSION found"
|
||||
fi
|
||||
|
||||
# 3. Install dependencies if needed
|
||||
if needs_install; then
|
||||
log_info "Installing dependencies with Bun..."
|
||||
|
||||
if ! "$BUN_PATH" install --cwd "$ROOT"; then
|
||||
log_error "Failed to install dependencies"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
write_marker
|
||||
log_ok "Dependencies installed ($(get_pkg_version))"
|
||||
else
|
||||
log_ok "Dependencies up to date ($(get_marker_version))"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: smart-explore
|
||||
description: Token-optimized structural code search using tree-sitter AST parsing. Use instead of reading full files when you need to understand code structure, find functions, or explore a codebase efficiently.
|
||||
---
|
||||
|
||||
# Smart Explore
|
||||
|
||||
Structural code exploration using AST parsing. **This skill overrides your default exploration behavior.** While this skill is active, use smart_search/smart_outline/smart_unfold as your primary tools instead of Read, Grep, and Glob.
|
||||
|
||||
## Your Next Tool Call
|
||||
|
||||
This skill only loads instructions. You must call the MCP tools yourself. Your next action should be one of:
|
||||
|
||||
```
|
||||
smart_search(query="<topic>", path="./src") -- discover files + symbols across a directory
|
||||
smart_outline(file_path="<file>") -- structural skeleton of one file
|
||||
smart_unfold(file_path="<file>", symbol_name="<name>") -- full source of one symbol
|
||||
```
|
||||
|
||||
Do NOT run Grep, Glob, Read, or find to discover files first. `smart_search` walks directories, parses all code files, and returns ranked symbols in one call. It replaces the Glob → Grep → Read discovery cycle.
|
||||
|
||||
## 3-Layer Workflow
|
||||
|
||||
### Step 1: Search -- Discover Files and Symbols
|
||||
|
||||
```
|
||||
smart_search(query="shutdown", path="./src", max_results=15)
|
||||
```
|
||||
|
||||
**Returns:** Ranked symbols with signatures, line numbers, match reasons, plus folded file views (~2-6k tokens)
|
||||
|
||||
```
|
||||
-- Matching Symbols --
|
||||
function performGracefulShutdown (services/infrastructure/GracefulShutdown.ts:56)
|
||||
function httpShutdown (services/infrastructure/HealthMonitor.ts:92)
|
||||
method WorkerService.shutdown (services/worker-service.ts:846)
|
||||
|
||||
-- Folded File Views --
|
||||
services/infrastructure/GracefulShutdown.ts (7 symbols)
|
||||
services/worker-service.ts (12 symbols)
|
||||
```
|
||||
|
||||
This is your discovery tool. It finds relevant files AND shows their structure. No Glob/find pre-scan needed.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `query` (string, required) -- What to search for (function name, concept, class name)
|
||||
- `path` (string) -- Root directory to search (defaults to cwd)
|
||||
- `max_results` (number) -- Max matching symbols, default 20, max 50
|
||||
- `file_pattern` (string, optional) -- Filter to specific files/paths
|
||||
|
||||
### Step 2: Outline -- Get File Structure
|
||||
|
||||
```
|
||||
smart_outline(file_path="services/worker-service.ts")
|
||||
```
|
||||
|
||||
**Returns:** Complete structural skeleton -- all functions, classes, methods, properties, imports (~1-2k tokens per file)
|
||||
|
||||
**Skip this step** when Step 1's folded file views already provide enough structure. Most useful for files not covered by the search results.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `file_path` (string, required) -- Path to the file
|
||||
|
||||
### Step 3: Unfold -- See Implementation
|
||||
|
||||
Review symbols from Steps 1-2. Pick the ones you need. Unfold only those:
|
||||
|
||||
```
|
||||
smart_unfold(file_path="services/worker-service.ts", symbol_name="shutdown")
|
||||
```
|
||||
|
||||
**Returns:** Full source code of the specified symbol including JSDoc, decorators, and complete implementation (~1-7k tokens depending on symbol size)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `file_path` (string, required) -- Path to the file (as returned by search/outline)
|
||||
- `symbol_name` (string, required) -- Name of the function/class/method to expand
|
||||
|
||||
## When to Use Standard Tools Instead
|
||||
|
||||
Use these only when smart_* tools are the wrong fit:
|
||||
|
||||
- **Grep:** Exact string/regex search ("find all TODO comments", "where is `ensureWorkerStarted` defined?")
|
||||
- **Read:** Small files under ~100 lines, non-code files (JSON, markdown, config)
|
||||
- **Glob:** File path patterns ("find all test files")
|
||||
|
||||
For code files over ~100 lines, prefer smart_outline + smart_unfold over Read.
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
**Discover how a feature works (cross-cutting):**
|
||||
|
||||
```
|
||||
1. smart_search(query="shutdown", path="./src")
|
||||
-> 14 symbols across 7 files, full picture in one call
|
||||
2. smart_unfold(file_path="services/infrastructure/GracefulShutdown.ts", symbol_name="performGracefulShutdown")
|
||||
-> See the core implementation
|
||||
```
|
||||
|
||||
**Navigate a large file:**
|
||||
|
||||
```
|
||||
1. smart_outline(file_path="services/worker-service.ts")
|
||||
-> 1,466 tokens: 12 functions, WorkerService class with 24 members
|
||||
2. smart_unfold(file_path="services/worker-service.ts", symbol_name="startSessionProcessor")
|
||||
-> 1,610 tokens: the specific method you need
|
||||
Total: ~3,076 tokens vs ~12,000 to Read the full file
|
||||
```
|
||||
|
||||
**Write documentation about code (hybrid workflow):**
|
||||
|
||||
```
|
||||
1. smart_search(query="feature name", path="./src") -- discover all relevant files and symbols
|
||||
2. smart_outline on key files -- understand structure
|
||||
3. smart_unfold on important functions -- get implementation details
|
||||
4. Read on small config/markdown/plan files -- get non-code context
|
||||
```
|
||||
|
||||
Use smart_* tools for code exploration, Read for non-code files. Mix freely.
|
||||
|
||||
**Exploration then precision:**
|
||||
|
||||
```
|
||||
1. smart_search(query="session", path="./src", max_results=10)
|
||||
-> 10 ranked symbols: SessionMetadata, SessionQueueProcessor, SessionSummary...
|
||||
2. Pick the relevant one, unfold it
|
||||
```
|
||||
|
||||
## Token Economics
|
||||
|
||||
| Approach | Tokens | Use Case |
|
||||
|----------|--------|----------|
|
||||
| smart_outline | ~1,500 | "What's in this file?" |
|
||||
| smart_unfold | ~1,600 | "Show me this function" |
|
||||
| smart_search | ~2,000-6,000 | "How does X work?" |
|
||||
| Read (full file) | ~12,000+ | When you truly need everything |
|
||||
| Explore agent | ~20,000-40,000 | Same as smart_search, 6-12x more expensive |
|
||||
|
||||
**8x savings** on file understanding (outline + unfold vs Read). **6-12x savings** on exploration vs Explore agent.
|
||||
@@ -0,0 +1 @@
|
||||
Never read built source files in this directory. These are compiled outputs — read the source files in `src/` instead.
|
||||
+24
-3
@@ -59,8 +59,16 @@ async function buildHooks() {
|
||||
description: 'Runtime dependencies for claude-mem bundled hooks',
|
||||
type: 'module',
|
||||
dependencies: {
|
||||
// Chroma embedding function with native ONNX binaries (can't be bundled)
|
||||
'@chroma-core/default-embed': '^0.1.9'
|
||||
'tree-sitter-cli': '^0.26.5',
|
||||
'tree-sitter-c': '^0.24.1',
|
||||
'tree-sitter-cpp': '^0.23.4',
|
||||
'tree-sitter-go': '^0.25.0',
|
||||
'tree-sitter-java': '^0.23.5',
|
||||
'tree-sitter-javascript': '^0.25.0',
|
||||
'tree-sitter-python': '^0.25.0',
|
||||
'tree-sitter-ruby': '^0.23.1',
|
||||
'tree-sitter-rust': '^0.24.0',
|
||||
'tree-sitter-typescript': '^0.23.2',
|
||||
},
|
||||
engines: {
|
||||
node: '>=18.0.0',
|
||||
@@ -128,7 +136,19 @@ async function buildHooks() {
|
||||
outfile: `${hooksDir}/${MCP_SERVER.name}.cjs`,
|
||||
minify: true,
|
||||
logLevel: 'error',
|
||||
external: ['bun:sqlite'],
|
||||
external: [
|
||||
'bun:sqlite',
|
||||
'tree-sitter-cli',
|
||||
'tree-sitter-javascript',
|
||||
'tree-sitter-typescript',
|
||||
'tree-sitter-python',
|
||||
'tree-sitter-go',
|
||||
'tree-sitter-rust',
|
||||
'tree-sitter-ruby',
|
||||
'tree-sitter-java',
|
||||
'tree-sitter-c',
|
||||
'tree-sitter-cpp',
|
||||
],
|
||||
define: {
|
||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||
},
|
||||
@@ -166,6 +186,7 @@ async function buildHooks() {
|
||||
console.log('\n📋 Verifying distribution files...');
|
||||
const requiredDistributionFiles = [
|
||||
'plugin/skills/mem-search/SKILL.md',
|
||||
'plugin/skills/smart-explore/SKILL.md',
|
||||
'plugin/hooks/hooks.json',
|
||||
'plugin/.claude-plugin/plugin.json',
|
||||
];
|
||||
|
||||
@@ -76,7 +76,7 @@ try {
|
||||
const gitignoreExcludes = getGitignoreExcludes(rootDir);
|
||||
|
||||
execSync(
|
||||
`rsync -av --delete --exclude=.git --exclude=/.mcp.json --exclude=bun.lock --exclude=package-lock.json ${gitignoreExcludes} ./ ~/.claude/plugins/marketplaces/thedotmack/`,
|
||||
`rsync -av --delete --exclude=.git --exclude=bun.lock --exclude=package-lock.json ${gitignoreExcludes} ./ ~/.claude/plugins/marketplaces/thedotmack/`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
|
||||
|
||||
+101
-10
@@ -28,6 +28,10 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
|
||||
import { searchCodebase, formatSearchResults } from '../services/smart-file-read/search.js';
|
||||
import { parseFile, formatFoldedView, unfoldSymbol } from '../services/smart-file-read/parser.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* Worker HTTP API configuration
|
||||
@@ -235,28 +239,115 @@ NEVER fetch full details without filtering first. 10x token savings.`,
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'save_observation',
|
||||
description: 'Save an observation to the database. Params: text (required), title, project',
|
||||
name: 'smart_search',
|
||||
description: 'Search codebase for symbols, functions, classes using tree-sitter AST parsing. Returns folded structural views with token counts. Use path parameter to scope the search.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Content to remember (required)'
|
||||
description: 'Search term — matches against symbol names, file names, and file content'
|
||||
},
|
||||
title: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'Short title (auto-generated from text if omitted)'
|
||||
description: 'Root directory to search (default: current working directory)'
|
||||
},
|
||||
project: {
|
||||
max_results: {
|
||||
type: 'number',
|
||||
description: 'Maximum results to return (default: 20)'
|
||||
},
|
||||
file_pattern: {
|
||||
type: 'string',
|
||||
description: 'Project name (uses "claude-mem" if omitted)'
|
||||
description: 'Substring filter for file paths (e.g. ".ts", "src/services")'
|
||||
}
|
||||
},
|
||||
required: ['text']
|
||||
required: ['query']
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
return await callWorkerAPIPost('/api/memory/save', args);
|
||||
const rootDir = resolve(args.path || process.cwd());
|
||||
const result = await searchCodebase(rootDir, args.query, {
|
||||
maxResults: args.max_results || 20,
|
||||
filePattern: args.file_pattern
|
||||
});
|
||||
const formatted = formatSearchResults(result, args.query);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: formatted }]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'smart_unfold',
|
||||
description: 'Expand a specific symbol (function, class, method) from a file. Returns the full source code of just that symbol. Use after smart_search or smart_outline to read specific code.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Path to the source file'
|
||||
},
|
||||
symbol_name: {
|
||||
type: 'string',
|
||||
description: 'Name of the symbol to unfold (function, class, method, etc.)'
|
||||
}
|
||||
},
|
||||
required: ['file_path', 'symbol_name']
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
const filePath = resolve(args.file_path);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const unfolded = unfoldSymbol(content, filePath, args.symbol_name);
|
||||
if (unfolded) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: unfolded }]
|
||||
};
|
||||
}
|
||||
// Symbol not found — show available symbols
|
||||
const parsed = parseFile(content, filePath);
|
||||
if (parsed.symbols.length > 0) {
|
||||
const available = parsed.symbols.map(s => ` - ${s.name} (${s.kind})`).join('\n');
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Symbol "${args.symbol_name}" not found in ${args.file_path}.\n\nAvailable symbols:\n${available}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Could not parse ${args.file_path}. File may be unsupported or empty.`
|
||||
}]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'smart_outline',
|
||||
description: 'Get structural outline of a file — shows all symbols (functions, classes, methods, types) with signatures but bodies folded. Much cheaper than reading the full file.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Path to the source file'
|
||||
}
|
||||
},
|
||||
required: ['file_path']
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
const filePath = resolve(args.file_path);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const parsed = parseFile(content, filePath);
|
||||
if (parsed.symbols.length > 0) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: formatFoldedView(parsed) }]
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Could not parse ${args.file_path}. File may use an unsupported language or be empty.`
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
/**
|
||||
* Code structure parser — shells out to tree-sitter CLI for AST-based extraction.
|
||||
*
|
||||
* No native bindings. No WASM. Just the CLI binary + query patterns.
|
||||
*
|
||||
* Supported: JS, TS, Python, Go, Rust, Ruby, Java, C, C++
|
||||
*
|
||||
* by Copter Labs
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFileSync, mkdtempSync, rmSync, existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
// CJS-safe require for resolving external packages at runtime.
|
||||
// In ESM: import.meta.url works. In CJS bundle (esbuild): __filename works.
|
||||
// typeof check avoids ReferenceError in ESM where __filename doesn't exist.
|
||||
const _require = typeof __filename !== 'undefined'
|
||||
? createRequire(__filename)
|
||||
: createRequire(import.meta.url);
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface CodeSymbol {
|
||||
name: string;
|
||||
kind: "function" | "class" | "method" | "interface" | "type" | "const" | "variable" | "export" | "struct" | "enum" | "trait" | "impl" | "property" | "getter" | "setter";
|
||||
signature: string;
|
||||
jsdoc?: string;
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
parent?: string;
|
||||
exported: boolean;
|
||||
children?: CodeSymbol[];
|
||||
}
|
||||
|
||||
export interface FoldedFile {
|
||||
filePath: string;
|
||||
language: string;
|
||||
symbols: CodeSymbol[];
|
||||
imports: string[];
|
||||
totalLines: number;
|
||||
foldedTokenEstimate: number;
|
||||
}
|
||||
|
||||
// --- Language detection ---
|
||||
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
".js": "javascript",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".jsx": "tsx",
|
||||
".ts": "typescript",
|
||||
".tsx": "tsx",
|
||||
".py": "python",
|
||||
".pyw": "python",
|
||||
".go": "go",
|
||||
".rs": "rust",
|
||||
".rb": "ruby",
|
||||
".java": "java",
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".cpp": "cpp",
|
||||
".cc": "cpp",
|
||||
".cxx": "cpp",
|
||||
".hpp": "cpp",
|
||||
".hh": "cpp",
|
||||
};
|
||||
|
||||
export function detectLanguage(filePath: string): string {
|
||||
const ext = filePath.slice(filePath.lastIndexOf("."));
|
||||
return LANG_MAP[ext] || "unknown";
|
||||
}
|
||||
|
||||
// --- Grammar path resolution ---
|
||||
|
||||
const GRAMMAR_PACKAGES: Record<string, string> = {
|
||||
javascript: "tree-sitter-javascript",
|
||||
typescript: "tree-sitter-typescript/typescript",
|
||||
tsx: "tree-sitter-typescript/tsx",
|
||||
python: "tree-sitter-python",
|
||||
go: "tree-sitter-go",
|
||||
rust: "tree-sitter-rust",
|
||||
ruby: "tree-sitter-ruby",
|
||||
java: "tree-sitter-java",
|
||||
c: "tree-sitter-c",
|
||||
cpp: "tree-sitter-cpp",
|
||||
};
|
||||
|
||||
function resolveGrammarPath(language: string): string | null {
|
||||
const pkg = GRAMMAR_PACKAGES[language];
|
||||
if (!pkg) return null;
|
||||
try {
|
||||
const packageJsonPath = _require.resolve(pkg + "/package.json");
|
||||
return dirname(packageJsonPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Query patterns (declarative symbol extraction) ---
|
||||
|
||||
const QUERIES: Record<string, string> = {
|
||||
jsts: `
|
||||
(function_declaration name: (identifier) @name) @func
|
||||
(lexical_declaration (variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)])) @const_func
|
||||
(class_declaration name: (type_identifier) @name) @cls
|
||||
(method_definition name: (property_identifier) @name) @method
|
||||
(interface_declaration name: (type_identifier) @name) @iface
|
||||
(type_alias_declaration name: (type_identifier) @name) @tdef
|
||||
(enum_declaration name: (identifier) @name) @enm
|
||||
(import_statement) @imp
|
||||
(export_statement) @exp
|
||||
`,
|
||||
|
||||
python: `
|
||||
(function_definition name: (identifier) @name) @func
|
||||
(class_definition name: (identifier) @name) @cls
|
||||
(import_statement) @imp
|
||||
(import_from_statement) @imp
|
||||
`,
|
||||
|
||||
go: `
|
||||
(function_declaration name: (identifier) @name) @func
|
||||
(method_declaration name: (field_identifier) @name) @method
|
||||
(type_declaration (type_spec name: (type_identifier) @name)) @tdef
|
||||
(import_declaration) @imp
|
||||
`,
|
||||
|
||||
rust: `
|
||||
(function_item name: (identifier) @name) @func
|
||||
(struct_item name: (type_identifier) @name) @struct_def
|
||||
(enum_item name: (type_identifier) @name) @enm
|
||||
(trait_item name: (type_identifier) @name) @trait_def
|
||||
(impl_item type: (type_identifier) @name) @impl_def
|
||||
(use_declaration) @imp
|
||||
`,
|
||||
|
||||
ruby: `
|
||||
(method name: (identifier) @name) @func
|
||||
(class name: (constant) @name) @cls
|
||||
(module name: (constant) @name) @cls
|
||||
(call method: (identifier) @name) @imp
|
||||
`,
|
||||
|
||||
java: `
|
||||
(method_declaration name: (identifier) @name) @method
|
||||
(class_declaration name: (identifier) @name) @cls
|
||||
(interface_declaration name: (identifier) @name) @iface
|
||||
(enum_declaration name: (identifier) @name) @enm
|
||||
(import_declaration) @imp
|
||||
`,
|
||||
|
||||
generic: `
|
||||
(function_declaration name: (identifier) @name) @func
|
||||
(function_definition name: (identifier) @name) @func
|
||||
(class_declaration name: (identifier) @name) @cls
|
||||
(class_definition name: (identifier) @name) @cls
|
||||
(import_statement) @imp
|
||||
(import_declaration) @imp
|
||||
`,
|
||||
};
|
||||
|
||||
function getQueryKey(language: string): string {
|
||||
switch (language) {
|
||||
case "javascript":
|
||||
case "typescript":
|
||||
case "tsx":
|
||||
return "jsts";
|
||||
case "python": return "python";
|
||||
case "go": return "go";
|
||||
case "rust": return "rust";
|
||||
case "ruby": return "ruby";
|
||||
case "java": return "java";
|
||||
default: return "generic";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Temp file management ---
|
||||
|
||||
let queryTmpDir: string | null = null;
|
||||
const queryFileCache = new Map<string, string>();
|
||||
|
||||
function getQueryFile(queryKey: string): string {
|
||||
if (queryFileCache.has(queryKey)) return queryFileCache.get(queryKey)!;
|
||||
|
||||
if (!queryTmpDir) {
|
||||
queryTmpDir = mkdtempSync(join(tmpdir(), "smart-read-queries-"));
|
||||
}
|
||||
|
||||
const filePath = join(queryTmpDir, `${queryKey}.scm`);
|
||||
writeFileSync(filePath, QUERIES[queryKey]);
|
||||
queryFileCache.set(queryKey, filePath);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// --- CLI execution ---
|
||||
|
||||
let cachedBinPath: string | null = null;
|
||||
|
||||
function getTreeSitterBin(): string {
|
||||
if (cachedBinPath) return cachedBinPath;
|
||||
|
||||
// Try direct binary from tree-sitter-cli package
|
||||
try {
|
||||
const pkgPath = _require.resolve("tree-sitter-cli/package.json");
|
||||
const binPath = join(dirname(pkgPath), "tree-sitter");
|
||||
if (existsSync(binPath)) {
|
||||
cachedBinPath = binPath;
|
||||
return binPath;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Fallback: assume it's on PATH
|
||||
cachedBinPath = "tree-sitter";
|
||||
return cachedBinPath;
|
||||
}
|
||||
|
||||
interface RawCapture {
|
||||
tag: string;
|
||||
startRow: number;
|
||||
startCol: number;
|
||||
endRow: number;
|
||||
endCol: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface RawMatch {
|
||||
pattern: number;
|
||||
captures: RawCapture[];
|
||||
}
|
||||
|
||||
function runQuery(queryFile: string, sourceFile: string, grammarPath: string): RawMatch[] {
|
||||
const result = runBatchQuery(queryFile, [sourceFile], grammarPath);
|
||||
return result.get(sourceFile) || [];
|
||||
}
|
||||
|
||||
function runBatchQuery(queryFile: string, sourceFiles: string[], grammarPath: string): Map<string, RawMatch[]> {
|
||||
if (sourceFiles.length === 0) return new Map();
|
||||
|
||||
const bin = getTreeSitterBin();
|
||||
const execArgs = ["query", "-p", grammarPath, queryFile, ...sourceFiles];
|
||||
|
||||
let output: string;
|
||||
try {
|
||||
output = execFileSync(bin, execArgs, { encoding: "utf-8", timeout: 30000, stdio: ["pipe", "pipe", "pipe"] });
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
return parseMultiFileQueryOutput(output);
|
||||
}
|
||||
|
||||
function parseMultiFileQueryOutput(output: string): Map<string, RawMatch[]> {
|
||||
const fileMatches = new Map<string, RawMatch[]>();
|
||||
let currentFile: string | null = null;
|
||||
let currentMatch: RawMatch | null = null;
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
// File header: a line that doesn't start with whitespace and isn't empty
|
||||
if (line.length > 0 && !line.startsWith(" ") && !line.startsWith("\t")) {
|
||||
currentFile = line.trim();
|
||||
if (!fileMatches.has(currentFile)) {
|
||||
fileMatches.set(currentFile, []);
|
||||
}
|
||||
currentMatch = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentFile) continue;
|
||||
|
||||
const patternMatch = line.match(/^\s+pattern:\s+(\d+)/);
|
||||
if (patternMatch) {
|
||||
currentMatch = { pattern: parseInt(patternMatch[1]), captures: [] };
|
||||
fileMatches.get(currentFile)!.push(currentMatch);
|
||||
continue;
|
||||
}
|
||||
|
||||
const captureMatch = line.match(
|
||||
/^\s+capture:\s+(?:\d+\s*-\s*)?(\w+),\s*start:\s*\((\d+),\s*(\d+)\),\s*end:\s*\((\d+),\s*(\d+)\)(?:,\s*text:\s*`([^`]*)`)?/
|
||||
);
|
||||
if (captureMatch && currentMatch) {
|
||||
currentMatch.captures.push({
|
||||
tag: captureMatch[1],
|
||||
startRow: parseInt(captureMatch[2]),
|
||||
startCol: parseInt(captureMatch[3]),
|
||||
endRow: parseInt(captureMatch[4]),
|
||||
endCol: parseInt(captureMatch[5]),
|
||||
text: captureMatch[6],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fileMatches;
|
||||
}
|
||||
|
||||
// --- Symbol building ---
|
||||
|
||||
const KIND_MAP: Record<string, CodeSymbol["kind"]> = {
|
||||
func: "function",
|
||||
const_func: "function",
|
||||
cls: "class",
|
||||
method: "method",
|
||||
iface: "interface",
|
||||
tdef: "type",
|
||||
enm: "enum",
|
||||
struct_def: "struct",
|
||||
trait_def: "trait",
|
||||
impl_def: "impl",
|
||||
};
|
||||
|
||||
const CONTAINER_KINDS = new Set(["class", "struct", "impl", "trait"]);
|
||||
|
||||
function extractSignatureFromLines(lines: string[], startRow: number, endRow: number, maxLen: number = 200): string {
|
||||
const firstLine = lines[startRow] || "";
|
||||
let sig = firstLine;
|
||||
|
||||
if (!sig.trimEnd().endsWith("{") && !sig.trimEnd().endsWith(":")) {
|
||||
const chunk = lines.slice(startRow, Math.min(startRow + 10, endRow + 1)).join("\n");
|
||||
const braceIdx = chunk.indexOf("{");
|
||||
if (braceIdx !== -1 && braceIdx < 500) {
|
||||
sig = chunk.slice(0, braceIdx).replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
}
|
||||
|
||||
sig = sig.replace(/\s*[{:]\s*$/, "").trim();
|
||||
if (sig.length > maxLen) sig = sig.slice(0, maxLen - 3) + "...";
|
||||
return sig;
|
||||
}
|
||||
|
||||
function findCommentAbove(lines: string[], startRow: number): string | undefined {
|
||||
const commentLines: string[] = [];
|
||||
let foundComment = false;
|
||||
|
||||
for (let i = startRow - 1; i >= 0; i--) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed === "") {
|
||||
if (foundComment) break;
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("/**") || trimmed.startsWith("*") || trimmed.startsWith("*/") ||
|
||||
trimmed.startsWith("//") || trimmed.startsWith("///") || trimmed.startsWith("//!") ||
|
||||
trimmed.startsWith("#") || trimmed.startsWith("@")) {
|
||||
commentLines.unshift(lines[i]);
|
||||
foundComment = true;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return commentLines.length > 0 ? commentLines.join("\n").trim() : undefined;
|
||||
}
|
||||
|
||||
function findPythonDocstringFromLines(lines: string[], startRow: number, endRow: number): string | undefined {
|
||||
for (let i = startRow + 1; i <= Math.min(startRow + 3, endRow); i++) {
|
||||
const trimmed = lines[i]?.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith('"""') || trimmed.startsWith("'''")) return trimmed;
|
||||
break;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isExported(
|
||||
name: string, startRow: number, endRow: number,
|
||||
exportRanges: Array<{ startRow: number; endRow: number }>,
|
||||
lines: string[], language: string
|
||||
): boolean {
|
||||
switch (language) {
|
||||
case "javascript":
|
||||
case "typescript":
|
||||
case "tsx":
|
||||
return exportRanges.some(r => startRow >= r.startRow && endRow <= r.endRow);
|
||||
case "python":
|
||||
return !name.startsWith("_");
|
||||
case "go":
|
||||
return name.length > 0 && name[0] === name[0].toUpperCase() && name[0] !== name[0].toLowerCase();
|
||||
case "rust":
|
||||
return lines[startRow]?.trimStart().startsWith("pub") ?? false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSymbols(matches: RawMatch[], lines: string[], language: string): { symbols: CodeSymbol[]; imports: string[] } {
|
||||
const symbols: CodeSymbol[] = [];
|
||||
const imports: string[] = [];
|
||||
const exportRanges: Array<{ startRow: number; endRow: number }> = [];
|
||||
const containers: Array<{ sym: CodeSymbol; startRow: number; endRow: number }> = [];
|
||||
|
||||
// Collect exports and imports
|
||||
for (const match of matches) {
|
||||
for (const cap of match.captures) {
|
||||
if (cap.tag === "exp") {
|
||||
exportRanges.push({ startRow: cap.startRow, endRow: cap.endRow });
|
||||
}
|
||||
if (cap.tag === "imp") {
|
||||
imports.push(cap.text || lines[cap.startRow]?.trim() || "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build symbols
|
||||
for (const match of matches) {
|
||||
const kindCapture = match.captures.find(c => KIND_MAP[c.tag]);
|
||||
const nameCapture = match.captures.find(c => c.tag === "name");
|
||||
if (!kindCapture) continue;
|
||||
|
||||
const name = nameCapture?.text || "anonymous";
|
||||
const startRow = kindCapture.startRow;
|
||||
const endRow = kindCapture.endRow;
|
||||
const kind = KIND_MAP[kindCapture.tag];
|
||||
|
||||
const comment = findCommentAbove(lines, startRow);
|
||||
const docstring = language === "python" ? findPythonDocstringFromLines(lines, startRow, endRow) : undefined;
|
||||
|
||||
const sym: CodeSymbol = {
|
||||
name,
|
||||
kind,
|
||||
signature: extractSignatureFromLines(lines, startRow, endRow),
|
||||
jsdoc: comment || docstring,
|
||||
lineStart: startRow,
|
||||
lineEnd: endRow,
|
||||
exported: isExported(name, startRow, endRow, exportRanges, lines, language),
|
||||
};
|
||||
|
||||
if (CONTAINER_KINDS.has(kind)) {
|
||||
sym.children = [];
|
||||
containers.push({ sym, startRow, endRow });
|
||||
}
|
||||
|
||||
symbols.push(sym);
|
||||
}
|
||||
|
||||
// Nest methods inside containers
|
||||
const nested = new Set<CodeSymbol>();
|
||||
for (const container of containers) {
|
||||
for (const sym of symbols) {
|
||||
if (sym === container.sym) continue;
|
||||
if (sym.lineStart > container.startRow && sym.lineEnd <= container.endRow) {
|
||||
if (sym.kind === "function") sym.kind = "method";
|
||||
container.sym.children!.push(sym);
|
||||
nested.add(sym);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { symbols: symbols.filter(s => !nested.has(s)), imports };
|
||||
}
|
||||
|
||||
// --- Main parse functions ---
|
||||
|
||||
export function parseFile(content: string, filePath: string): FoldedFile {
|
||||
const language = detectLanguage(filePath);
|
||||
const lines = content.split("\n");
|
||||
|
||||
const grammarPath = resolveGrammarPath(language);
|
||||
if (!grammarPath) {
|
||||
return {
|
||||
filePath, language, symbols: [], imports: [],
|
||||
totalLines: lines.length, foldedTokenEstimate: 50,
|
||||
};
|
||||
}
|
||||
|
||||
const queryKey = getQueryKey(language);
|
||||
const queryFile = getQueryFile(queryKey);
|
||||
|
||||
// Write content to temp file with correct extension for language detection
|
||||
const ext = filePath.slice(filePath.lastIndexOf(".")) || ".txt";
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "smart-src-"));
|
||||
const tmpFile = join(tmpDir, `source${ext}`);
|
||||
writeFileSync(tmpFile, content);
|
||||
|
||||
try {
|
||||
const matches = runQuery(queryFile, tmpFile, grammarPath);
|
||||
const result = buildSymbols(matches, lines, language);
|
||||
|
||||
const folded = formatFoldedView({
|
||||
filePath, language,
|
||||
symbols: result.symbols, imports: result.imports,
|
||||
totalLines: lines.length, foldedTokenEstimate: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
filePath, language,
|
||||
symbols: result.symbols, imports: result.imports,
|
||||
totalLines: lines.length,
|
||||
foldedTokenEstimate: Math.ceil(folded.length / 4),
|
||||
};
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch parse multiple on-disk files. Groups by language for one CLI call per language.
|
||||
* Much faster than calling parseFile() per file (one process spawn per language vs per file).
|
||||
*/
|
||||
export function parseFilesBatch(
|
||||
files: Array<{ absolutePath: string; relativePath: string; content: string }>
|
||||
): Map<string, FoldedFile> {
|
||||
const results = new Map<string, FoldedFile>();
|
||||
|
||||
// Group files by language (and thus by query + grammar)
|
||||
const languageGroups = new Map<string, typeof files>();
|
||||
for (const file of files) {
|
||||
const language = detectLanguage(file.relativePath);
|
||||
if (!languageGroups.has(language)) languageGroups.set(language, []);
|
||||
languageGroups.get(language)!.push(file);
|
||||
}
|
||||
|
||||
for (const [language, groupFiles] of languageGroups) {
|
||||
const grammarPath = resolveGrammarPath(language);
|
||||
if (!grammarPath) {
|
||||
// No grammar — return empty results for these files
|
||||
for (const file of groupFiles) {
|
||||
const lines = file.content.split("\n");
|
||||
results.set(file.relativePath, {
|
||||
filePath: file.relativePath, language, symbols: [], imports: [],
|
||||
totalLines: lines.length, foldedTokenEstimate: 50,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const queryKey = getQueryKey(language);
|
||||
const queryFile = getQueryFile(queryKey);
|
||||
|
||||
// Run one batch query for all files of this language
|
||||
const absolutePaths = groupFiles.map(f => f.absolutePath);
|
||||
const batchResults = runBatchQuery(queryFile, absolutePaths, grammarPath);
|
||||
|
||||
// Build FoldedFile for each file using the batch results
|
||||
for (const file of groupFiles) {
|
||||
const lines = file.content.split("\n");
|
||||
const matches = batchResults.get(file.absolutePath) || [];
|
||||
const symbolResult = buildSymbols(matches, lines, language);
|
||||
|
||||
const folded = formatFoldedView({
|
||||
filePath: file.relativePath, language,
|
||||
symbols: symbolResult.symbols, imports: symbolResult.imports,
|
||||
totalLines: lines.length, foldedTokenEstimate: 0,
|
||||
});
|
||||
|
||||
results.set(file.relativePath, {
|
||||
filePath: file.relativePath, language,
|
||||
symbols: symbolResult.symbols, imports: symbolResult.imports,
|
||||
totalLines: lines.length,
|
||||
foldedTokenEstimate: Math.ceil(folded.length / 4),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// --- Formatting ---
|
||||
|
||||
export function formatFoldedView(file: FoldedFile): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`📁 ${file.filePath} (${file.language}, ${file.totalLines} lines)`);
|
||||
parts.push("");
|
||||
|
||||
if (file.imports.length > 0) {
|
||||
parts.push(` 📦 Imports: ${file.imports.length} statements`);
|
||||
for (const imp of file.imports.slice(0, 10)) {
|
||||
parts.push(` ${imp}`);
|
||||
}
|
||||
if (file.imports.length > 10) {
|
||||
parts.push(` ... +${file.imports.length - 10} more`);
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
for (const sym of file.symbols) {
|
||||
parts.push(formatSymbol(sym, " "));
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function formatSymbol(sym: CodeSymbol, indent: string): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
const icon = getSymbolIcon(sym.kind);
|
||||
const exportTag = sym.exported ? " [exported]" : "";
|
||||
const lineRange = sym.lineStart === sym.lineEnd
|
||||
? `L${sym.lineStart + 1}`
|
||||
: `L${sym.lineStart + 1}-${sym.lineEnd + 1}`;
|
||||
|
||||
parts.push(`${indent}${icon} ${sym.name}${exportTag} (${lineRange})`);
|
||||
parts.push(`${indent} ${sym.signature}`);
|
||||
|
||||
if (sym.jsdoc) {
|
||||
const jsdocLines = sym.jsdoc.split("\n");
|
||||
const firstLine = jsdocLines.find(l => {
|
||||
const t = l.replace(/^[\s*/]+/, "").replace(/^['"`]{3}/, "").trim();
|
||||
return t.length > 0 && !t.startsWith("/**");
|
||||
});
|
||||
if (firstLine) {
|
||||
const cleaned = firstLine.replace(/^[\s*/]+/, "").replace(/^['"`]{3}/, "").replace(/['"`]{3}$/, "").trim();
|
||||
if (cleaned) {
|
||||
parts.push(`${indent} 💬 ${cleaned}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sym.children && sym.children.length > 0) {
|
||||
for (const child of sym.children) {
|
||||
parts.push(formatSymbol(child, indent + " "));
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function getSymbolIcon(kind: CodeSymbol["kind"]): string {
|
||||
const icons: Record<string, string> = {
|
||||
function: "ƒ", method: "ƒ", class: "◆", interface: "◇",
|
||||
type: "◇", const: "●", variable: "○", export: "→",
|
||||
struct: "◆", enum: "▣", trait: "◇", impl: "◈",
|
||||
property: "○", getter: "⇢", setter: "⇠",
|
||||
};
|
||||
return icons[kind] || "·";
|
||||
}
|
||||
|
||||
// --- Unfold ---
|
||||
|
||||
export function unfoldSymbol(content: string, filePath: string, symbolName: string): string | null {
|
||||
const file = parseFile(content, filePath);
|
||||
|
||||
const findSymbol = (symbols: CodeSymbol[]): CodeSymbol | null => {
|
||||
for (const sym of symbols) {
|
||||
if (sym.name === symbolName) return sym;
|
||||
if (sym.children) {
|
||||
const found = findSymbol(sym.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const symbol = findSymbol(file.symbols);
|
||||
if (!symbol) return null;
|
||||
|
||||
const lines = content.split("\n");
|
||||
|
||||
// Include preceding comments/decorators
|
||||
let start = symbol.lineStart;
|
||||
for (let i = symbol.lineStart - 1; i >= 0; i--) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed === "" || trimmed.startsWith("*") || trimmed.startsWith("/**") ||
|
||||
trimmed.startsWith("///") || trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("#") || trimmed.startsWith("@") ||
|
||||
trimmed === "*/") {
|
||||
start = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = lines.slice(start, symbol.lineEnd + 1).join("\n");
|
||||
return `// 📍 ${filePath} L${start + 1}-${symbol.lineEnd + 1}\n${extracted}`;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Search module — finds code files and symbols matching a query.
|
||||
*
|
||||
* Two search modes:
|
||||
* 1. Grep-style: find files/lines containing the query string
|
||||
* 2. Structural: parse files and match against symbol names/signatures
|
||||
*
|
||||
* Both return folded views, not raw content.
|
||||
*
|
||||
* Uses batch parsing (one CLI call per language) for fast multi-file search.
|
||||
*/
|
||||
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
import { parseFilesBatch, formatFoldedView, type FoldedFile } from "./parser.js";
|
||||
|
||||
const CODE_EXTENSIONS = new Set([
|
||||
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
||||
".py", ".pyw",
|
||||
".go",
|
||||
".rs",
|
||||
".rb",
|
||||
".java",
|
||||
".cs",
|
||||
".cpp", ".c", ".h", ".hpp",
|
||||
".swift",
|
||||
".kt",
|
||||
".php",
|
||||
".vue", ".svelte",
|
||||
]);
|
||||
|
||||
const IGNORE_DIRS = new Set([
|
||||
"node_modules", ".git", "dist", "build", ".next", "__pycache__",
|
||||
".venv", "venv", "env", ".env", "target", "vendor",
|
||||
".cache", ".turbo", "coverage", ".nyc_output",
|
||||
".claude", ".smart-file-read",
|
||||
]);
|
||||
|
||||
const MAX_FILE_SIZE = 512 * 1024; // 512KB — skip huge files
|
||||
|
||||
export interface SearchResult {
|
||||
foldedFiles: FoldedFile[];
|
||||
matchingSymbols: SymbolMatch[];
|
||||
totalFilesScanned: number;
|
||||
totalSymbolsFound: number;
|
||||
tokenEstimate: number;
|
||||
}
|
||||
|
||||
export interface SymbolMatch {
|
||||
filePath: string;
|
||||
symbolName: string;
|
||||
kind: string;
|
||||
signature: string;
|
||||
jsdoc?: string;
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
matchReason: string; // why this matched
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a directory recursively, yielding file paths.
|
||||
*/
|
||||
async function* walkDir(dir: string, rootDir: string, maxDepth: number = 20): AsyncGenerator<string> {
|
||||
if (maxDepth <= 0) return;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // permission denied, etc.
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") && entry.name !== ".") continue;
|
||||
if (IGNORE_DIRS.has(entry.name)) continue;
|
||||
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
yield* walkDir(fullPath, rootDir, maxDepth - 1);
|
||||
} else if (entry.isFile()) {
|
||||
const ext = entry.name.slice(entry.name.lastIndexOf("."));
|
||||
if (CODE_EXTENSIONS.has(ext)) {
|
||||
yield fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file safely, skipping if too large or binary.
|
||||
*/
|
||||
async function safeReadFile(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
if (stats.size > MAX_FILE_SIZE) return null;
|
||||
if (stats.size === 0) return null;
|
||||
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
|
||||
// Quick binary check — if first 1000 chars have null bytes, skip
|
||||
if (content.slice(0, 1000).includes("\0")) return null;
|
||||
|
||||
return content;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a codebase for symbols matching a query.
|
||||
*
|
||||
* Phase 1: Collect files and read content
|
||||
* Phase 2: Batch parse all files (one CLI call per language)
|
||||
* Phase 3: Match query against parsed symbols
|
||||
*/
|
||||
export async function searchCodebase(
|
||||
rootDir: string,
|
||||
query: string,
|
||||
options: {
|
||||
maxResults?: number;
|
||||
includeImports?: boolean;
|
||||
filePattern?: string;
|
||||
} = {}
|
||||
): Promise<SearchResult> {
|
||||
const maxResults = options.maxResults || 20;
|
||||
const queryLower = query.toLowerCase();
|
||||
const queryParts = queryLower.split(/[\s_\-./]+/).filter(p => p.length > 0);
|
||||
|
||||
// Phase 1: Collect files
|
||||
const filesToParse: Array<{ absolutePath: string; relativePath: string; content: string }> = [];
|
||||
|
||||
for await (const filePath of walkDir(rootDir, rootDir)) {
|
||||
if (options.filePattern) {
|
||||
const relPath = relative(rootDir, filePath);
|
||||
if (!relPath.toLowerCase().includes(options.filePattern.toLowerCase())) continue;
|
||||
}
|
||||
|
||||
const content = await safeReadFile(filePath);
|
||||
if (!content) continue;
|
||||
|
||||
filesToParse.push({
|
||||
absolutePath: filePath,
|
||||
relativePath: relative(rootDir, filePath),
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: Batch parse (one CLI call per language)
|
||||
const parsedFiles = parseFilesBatch(filesToParse);
|
||||
|
||||
// Phase 3: Match query against symbols
|
||||
const foldedFiles: FoldedFile[] = [];
|
||||
const matchingSymbols: SymbolMatch[] = [];
|
||||
let totalSymbolsFound = 0;
|
||||
|
||||
for (const [relPath, parsed] of parsedFiles) {
|
||||
totalSymbolsFound += countSymbols(parsed);
|
||||
|
||||
const pathMatch = matchScore(relPath.toLowerCase(), queryParts);
|
||||
let fileHasMatch = pathMatch > 0;
|
||||
const fileSymbolMatches: SymbolMatch[] = [];
|
||||
|
||||
const checkSymbols = (symbols: typeof parsed.symbols, parent?: string) => {
|
||||
for (const sym of symbols) {
|
||||
let score = 0;
|
||||
let reason = "";
|
||||
|
||||
const nameScore = matchScore(sym.name.toLowerCase(), queryParts);
|
||||
if (nameScore > 0) {
|
||||
score += nameScore * 3;
|
||||
reason = "name match";
|
||||
}
|
||||
|
||||
if (sym.signature.toLowerCase().includes(queryLower)) {
|
||||
score += 2;
|
||||
reason = reason ? `${reason} + signature` : "signature match";
|
||||
}
|
||||
|
||||
if (sym.jsdoc && sym.jsdoc.toLowerCase().includes(queryLower)) {
|
||||
score += 1;
|
||||
reason = reason ? `${reason} + jsdoc` : "jsdoc match";
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
fileHasMatch = true;
|
||||
fileSymbolMatches.push({
|
||||
filePath: relPath,
|
||||
symbolName: parent ? `${parent}.${sym.name}` : sym.name,
|
||||
kind: sym.kind,
|
||||
signature: sym.signature,
|
||||
jsdoc: sym.jsdoc,
|
||||
lineStart: sym.lineStart,
|
||||
lineEnd: sym.lineEnd,
|
||||
matchReason: reason,
|
||||
});
|
||||
}
|
||||
|
||||
if (sym.children) {
|
||||
checkSymbols(sym.children, sym.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkSymbols(parsed.symbols);
|
||||
|
||||
if (fileHasMatch) {
|
||||
foldedFiles.push(parsed);
|
||||
matchingSymbols.push(...fileSymbolMatches);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by relevance and trim
|
||||
matchingSymbols.sort((a, b) => {
|
||||
const aScore = matchScore(a.symbolName.toLowerCase(), queryParts);
|
||||
const bScore = matchScore(b.symbolName.toLowerCase(), queryParts);
|
||||
return bScore - aScore;
|
||||
});
|
||||
|
||||
const trimmedSymbols = matchingSymbols.slice(0, maxResults);
|
||||
const relevantFiles = new Set(trimmedSymbols.map(s => s.filePath));
|
||||
const trimmedFiles = foldedFiles.filter(f => relevantFiles.has(f.filePath)).slice(0, maxResults);
|
||||
|
||||
const tokenEstimate = trimmedFiles.reduce((sum, f) => sum + f.foldedTokenEstimate, 0);
|
||||
|
||||
return {
|
||||
foldedFiles: trimmedFiles,
|
||||
matchingSymbols: trimmedSymbols,
|
||||
totalFilesScanned: filesToParse.length,
|
||||
totalSymbolsFound,
|
||||
tokenEstimate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Score how well query parts match a string.
|
||||
* Returns 0 for no match, higher for better matches.
|
||||
*/
|
||||
function matchScore(text: string, queryParts: string[]): number {
|
||||
let score = 0;
|
||||
for (const part of queryParts) {
|
||||
if (text === part) {
|
||||
score += 10; // exact match
|
||||
} else if (text.includes(part)) {
|
||||
score += 5; // substring match
|
||||
} else {
|
||||
// Fuzzy: check if all chars appear in order
|
||||
let ti = 0;
|
||||
let matched = 0;
|
||||
for (const ch of part) {
|
||||
const idx = text.indexOf(ch, ti);
|
||||
if (idx !== -1) {
|
||||
matched++;
|
||||
ti = idx + 1;
|
||||
}
|
||||
}
|
||||
if (matched === part.length) {
|
||||
score += 1; // loose fuzzy match
|
||||
}
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function countSymbols(file: FoldedFile): number {
|
||||
let count = file.symbols.length;
|
||||
for (const sym of file.symbols) {
|
||||
if (sym.children) count += sym.children.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results for LLM consumption.
|
||||
*/
|
||||
export function formatSearchResults(result: SearchResult, query: string): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`🔍 Smart Search: "${query}"`);
|
||||
parts.push(` Scanned ${result.totalFilesScanned} files, found ${result.totalSymbolsFound} symbols`);
|
||||
parts.push(` ${result.matchingSymbols.length} matches across ${result.foldedFiles.length} files (~${result.tokenEstimate} tokens for folded view)`);
|
||||
parts.push("");
|
||||
|
||||
if (result.matchingSymbols.length === 0) {
|
||||
parts.push(" No matching symbols found.");
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// Show matching symbols first (compact)
|
||||
parts.push("── Matching Symbols ──");
|
||||
parts.push("");
|
||||
for (const match of result.matchingSymbols) {
|
||||
parts.push(` ${match.kind} ${match.symbolName} (${match.filePath}:${match.lineStart + 1})`);
|
||||
parts.push(` ${match.signature}`);
|
||||
if (match.jsdoc) {
|
||||
const firstLine = match.jsdoc.split("\n").find(l => l.replace(/^[\s*/]+/, "").trim().length > 0);
|
||||
if (firstLine) {
|
||||
parts.push(` 💬 ${firstLine.replace(/^[\s*/]+/, "").trim()}`);
|
||||
}
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
// Show folded file views
|
||||
parts.push("── Folded File Views ──");
|
||||
parts.push("");
|
||||
for (const file of result.foldedFiles) {
|
||||
parts.push(formatFoldedView(file));
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
parts.push("── Actions ──");
|
||||
parts.push(' To see full implementation: use smart_unfold with file path and symbol name');
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user