diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f6002b1a..df546b459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,3 +97,7 @@ Returns detection result including `project`, `project_source`, `project_path`, - **feat(mcp):** auto-detect project at MCP startup via `--project` flag, `ENGRAM_PROJECT` env, or git remote - **feat(mcp):** similar-project warnings when saving to a new project that resembles an existing one - **fix(sync):** use git remote detection instead of `filepath.Base(cwd)` for project name + +### MCP read-tool errors + +- **fix(mcp):** read tools (`mem_search`, `mem_context`, `mem_stats`, `mem_doctor`, `mem_timeline`, `mem_review`) now return structured `ambiguous_project` and `unknown_project` errors with `available_projects` and a read-specific hint, matching the existing write-tool behavior. Previously these errors were returned as plain text, preventing agents from recovering programmatically. diff --git a/DROID-SETUP-NOTES.md b/DROID-SETUP-NOTES.md new file mode 100644 index 000000000..537fc771b --- /dev/null +++ b/DROID-SETUP-NOTES.md @@ -0,0 +1,128 @@ +# Droid Setup Implementation Notes + +## Overview + +Implemented `engram setup droid` to integrate Engram with Factory's Droid CLI. +The installer registers the Engram MCP server, installs the Engram plugin via +Droid's marketplace translation, and writes a user-level `UserPromptSubmit` hook +to work around a Droid plugin-hook limitation. + +## What `engram setup droid` does + +1. **MCP registration** — writes `mcpServers.engram` to `~/.factory/mcp.json` + using the absolute path to the `engram` binary. +2. **Hook script extraction** — copies embedded hook scripts to + `~/.factory/hooks/engram/` so they live at a stable path. +3. **User-level UserPromptSubmit hook** — writes the hook to + `~/.factory/hooks.json` in Droid's standalone format (event names as + top-level keys). +4. **Plugin installation** — runs `droid plugin marketplace add` and + `droid plugin install engram@engram --scope user` so Droid gets the + `SessionStart`, `Stop`, `PreCompact`, `SubagentStop` hooks and the + `engram-memory` skill. + +## Key findings from validation + +### Plugin translation works, but UserPromptSubmit plugin hooks do not fire + +Droid translates the existing Claude Code plugin (`.claude-plugin/`) into a +native Droid plugin (`.factory-plugin/`) and loads it. Lifecycle hooks such as +`SessionStart`, `Stop`, `PreCompact`, and `SubagentStop` execute correctly. + +However, `UserPromptSubmit` hooks declared **inside** a plugin are registered +and matched but never executed. This matches the known Claude Code issue +[anthropics/claude-code#10225](https://github.com/anthropics/claude-code/issues/10225). +The workaround is to declare the `UserPromptSubmit` hook at user scope in +`~/.factory/hooks.json`. + +### Droid MCP tool naming + +Droid exposes Engram MCP tools as `engram___` (server name + triple +underscore + tool name), not `mcp__engram__`. The first-message +`ToolSearch` instruction emitted by the user-level hook uses the correct Droid +pattern: + +```text +select:engram___mem_save,engram___mem_search,engram___mem_context,... +``` + +### Multi-repo cwd handling + +When Droid starts in a directory that contains multiple git repositories (e.g. +`/Users/aj/scratch`, which holds both `engram-droid` and `iqair-airvisual-pro`), +cwd-based project detection returns `ambiguous_project` and read tools fail +until the caller retries with an explicit `project=`. + +The `UserPromptSubmit` hook scans immediate child git repos on the first +message of each session. If it finds more than one, it injects the candidate +list and a hard rule into the first-message system prompt: + +```text +IMPORTANT — multi-repo cwd detected: [engram-droid, iqair-airvisual-pro]. +When calling ANY engram read tool (mem_search, mem_context, ...), ALWAYS pass +project= explicitly. Never omit the +project parameter from read tools — cwd auto-detection will fail with +ambiguous_project. Only use a project name from the list above. +``` + +This is a prompt-side fix: it eliminates the `ambiguous_project` round-trip by +telling the agent to always pass `project=` on read tools, while still allowing +the agent to pick the correct project for the user's task. If cwd is a single +repo or not a git parent, the first-message prompt is unchanged. + +### `droid exec` vs interactive `droid` + +`UserPromptSubmit` hooks fire in interactive Droid sessions. They do **not** +fire in `droid exec` sessions. In exec mode the agent still receives the Memory +Protocol from the `SessionStart` hook and sees Engram tools in the deferred +list, but it must choose to load them itself. + +## Files added/changed + +- `internal/setup/droid.go` — installer implementation +- `internal/setup/droid_test.go` — installer tests +- `internal/setup/plugins/droid/scripts/_helpers.sh` — shared hook helpers, + including `list_child_projects()` for multi-repo cwd detection +- `internal/setup/plugins/droid/scripts/user-prompt-submit.sh` — first-message + tool loader, save nudge, and multi-repo `project=` instruction injection +- `internal/setup/agents.go` — registry entry for `droid` +- `internal/setup/setup.go` — seam variables for testing +- `internal/setup/setup_test.go` — reset seams for Droid +- `internal/setup/registry_test.go` — include `droid` in expected agents +- `README.md` — add Droid to the setup table +- `docs/AGENT-SETUP.md` — Droid setup section + +## Current user configuration (this machine) + +- Binary: `/Users/aj/.local/bin/engram` (development build from this branch) +- MCP config: `~/.factory/mcp.json` → `mcpServers.engram` +- Hook scripts: `~/.factory/hooks/engram/` +- User hooks: `~/.factory/hooks.json` → `UserPromptSubmit` +- Plugin: `engram@engram` installed at user scope + +## How to verify + +1. Restart Droid (or the Droid daemon) so it reloads `~/.factory/hooks.json`. +2. Start an interactive Droid session in any project. +3. Check the session transcript for a `UserPromptSubmit` hook result. +4. Confirm the assistant calls `ToolSearch` with the Engram tools and then + loads them. + +## Testing + +```bash +# Run only the Droid installer tests +go test ./internal/setup/ -run Droid -v + +# Run the full setup package tests +go test ./internal/setup/ + +# Run the entire repository test suite +go test ./... +``` + +All tests pass. + +## Fork + +Changes are pushed to `main` on https://github.com/ahjota/engram. diff --git a/README.md b/README.md index 6d0c04107..25c7ffabd 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Windows, Linux, and other install methods → [docs/INSTALLATION.md](docs/INSTAL | Agent | One-liner | | --------------------------- | -------------------------------------------------------------------------------------------- | | Claude Code | `claude plugin marketplace add Gentleman-Programming/engram && claude plugin install engram` | +| Droid | `engram setup droid` | | Pi | `engram setup pi` | | OpenCode | `engram setup opencode` | | Gemini CLI | `engram setup gemini-cli` | diff --git a/docs/AGENT-SETUP.md b/docs/AGENT-SETUP.md index 9fc742509..e8c28a88c 100644 --- a/docs/AGENT-SETUP.md +++ b/docs/AGENT-SETUP.md @@ -17,6 +17,7 @@ Engram works with **any MCP-compatible agent**. Pick your agent below. | Agent | One-liner | Manual Config | | ------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------- | | Claude Code | `claude plugin marketplace add Gentleman-Programming/engram && claude plugin install engram` | [Details](#claude-code) | +| Droid | `engram setup droid` | [Details](#droid) | | Pi | `engram setup pi` | [Details](#pi) | | OpenCode | `engram setup opencode` | [Details](#opencode) | | Gemini CLI | `engram setup gemini-cli` | [Details](#gemini-cli) | @@ -353,6 +354,34 @@ Then reload your shell (`source ~/.bashrc`) and re-run the install. --- +## Droid + +> **Prerequisite**: Install the `engram` binary first (via [Homebrew](INSTALLATION.md#homebrew-macos--linux), [Windows binary](INSTALLATION.md#windows), [binary download](INSTALLATION.md#download-binary-all-platforms), or [source](INSTALLATION.md#install-from-source-macos--linux)). + +```bash +engram setup droid +``` + +`engram setup droid` does four things: + +1. Registers `mcpServers.engram` in `~/.factory/mcp.json` with the absolute path to the `engram` binary. +2. Extracts Engram's `UserPromptSubmit` hook scripts to `~/.factory/hooks/engram/`. +3. Writes a `UserPromptSubmit` entry to `~/.factory/hooks.json` that calls the extracted script. +4. Installs the Engram plugin from the GitHub marketplace so Droid gets the `SessionStart`, `Stop`, `PreCompact`, `SubagentStop` hooks and the Memory Protocol skill. + +The `UserPromptSubmit` hook is written at user scope because Droid (like Claude Code) does not execute `UserPromptSubmit` hooks that are declared inside a plugin, even though it registers and matches them. The user-level hook works around this limitation and ensures first-message tool loading and prompt capture function correctly. + +If the plugin install step fails (for example, due to network issues), setup continues and prints a warning with the manual install commands: + +```bash +droid plugin marketplace add https://github.com/Gentleman-Programming/engram +droid plugin install engram@engram --scope user +``` + +After setup, restart Droid so the new MCP config and hooks are loaded. + +--- + ## Gemini CLI Recommended: one command to set up MCP + compaction recovery instructions: diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index e1fb4d161..22b0dec14 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -989,17 +989,10 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv if allProjects { detRes = projectpkg.DetectionResult{Source: projectpkg.SourceAllProjects} } else { - // Resolve project: validate override or auto-detect (REQ-310, REQ-311) + // Resolve project: validate override or auto-detect (REQ-310, REQ-311, REQ-314) res, err := resolveReadProjectWithProcessOverride(s, projectOverride, cfg.DefaultProject) if err != nil { - var upe *unknownProjectError - if errors.As(err, &upe) { - return errorWithMeta("unknown_project", - fmt.Sprintf("Project %q not found in store", upe.Name), - upe.AvailableProjects, - ), nil - } - return mcp.NewToolResultError(fmt.Sprintf("Project resolution failed: %s", err)), nil + return readProjectErrorResult(res, err), nil } detRes = res project = detRes.Project @@ -1452,14 +1445,7 @@ func handleReview(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { var err error detRes, err = resolveReadProject(s, projectFilter) if err != nil { - var upe *unknownProjectError - if errors.As(err, &upe) { - return errorWithMeta("unknown_project", - fmt.Sprintf("Project %q not found in store", upe.Name), - upe.AvailableProjects, - ), nil - } - return mcp.NewToolResultError(fmt.Sprintf("Project resolution failed: %s", err)), nil + return readProjectErrorResult(detRes, err), nil } projectFilter = detRes.Project } else if res, err := resolveReadProjectWithProcessOverride(s, "", cfg.DefaultProject); err == nil { @@ -1615,17 +1601,10 @@ func handleContext(s *store.Store, cfg MCPConfig, activity *SessionActivity) ser projectOverride, _ := req.GetArguments()["project"].(string) scope, _ := req.GetArguments()["scope"].(string) - // Resolve project: validate override or auto-detect (REQ-310, REQ-311) + // Resolve project: validate override or auto-detect (REQ-310, REQ-311, REQ-314) detRes, err := resolveReadProjectWithProcessOverride(s, projectOverride, cfg.DefaultProject) if err != nil { - var upe *unknownProjectError - if errors.As(err, &upe) { - return errorWithMeta("unknown_project", - fmt.Sprintf("Project %q not found in store", upe.Name), - upe.AvailableProjects, - ), nil - } - return mcp.NewToolResultError(fmt.Sprintf("Project resolution failed: %s", err)), nil + return readProjectErrorResult(detRes, err), nil } project := detRes.Project project, _ = store.NormalizeProject(project) @@ -1678,14 +1657,7 @@ func handleStats(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { // Resolve project: validate override or auto-detect (REQ-310, REQ-311, REQ-314) detRes, err := resolveReadProjectWithProcessOverride(s, projectOverride, cfg.DefaultProject) if err != nil { - var upe *unknownProjectError - if errors.As(err, &upe) { - return errorWithMeta("unknown_project", - fmt.Sprintf("Project %q not found in store", upe.Name), - upe.AvailableProjects, - ), nil - } - return mcp.NewToolResultError(fmt.Sprintf("Project resolution failed: %s", err)), nil + return readProjectErrorResult(detRes, err), nil } stats, err := loadMCPStats(s) @@ -1718,11 +1690,7 @@ func handleDoctor(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { check, _ := req.GetArguments()["check"].(string) detRes, err := resolveReadProjectWithProcessOverride(s, projectOverride, cfg.DefaultProject) if err != nil { - var upe *unknownProjectError - if errors.As(err, &upe) { - return errorWithMeta("unknown_project", fmt.Sprintf("Project %q not found in store", upe.Name), upe.AvailableProjects), nil - } - return mcp.NewToolResultError(fmt.Sprintf("Project resolution failed: %s", err)), nil + return readProjectErrorResult(detRes, err), nil } project := detRes.Project project, _ = store.NormalizeProject(project) @@ -1762,14 +1730,7 @@ func handleTimeline(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { // Resolve project: validate override or auto-detect (REQ-310, REQ-311, REQ-314) detRes, err := resolveReadProjectWithProcessOverride(s, projectOverride, cfg.DefaultProject) if err != nil { - var upe *unknownProjectError - if errors.As(err, &upe) { - return errorWithMeta("unknown_project", - fmt.Sprintf("Project %q not found in store", upe.Name), - upe.AvailableProjects, - ), nil - } - return mcp.NewToolResultError(fmt.Sprintf("Project resolution failed: %s", err)), nil + return readProjectErrorResult(detRes, err), nil } result, err := s.Timeline(observationID, before, after) @@ -2908,6 +2869,20 @@ func addErrorMetadata(result *mcp.CallToolResult, metadata map[string]any) { result.Content[0] = mcp.NewTextContent(string(out)) } +// readProjectErrorResult returns a structured project-resolution error for read +// tools. It reuses writeProjectErrorResult but replaces the write-specific +// ambiguous-project hint and never issues a recovery_token, because read tools +// only need an explicit project override, not a project_choice_reason. +func readProjectErrorResult(res projectpkg.DetectionResult, err error) *mcp.CallToolResult { + result := writeProjectErrorResult(nil, "", res, err) + if errors.Is(err, projectpkg.ErrAmbiguousProject) { + addErrorMetadata(result, map[string]any{ + "hint": "Retry this read tool with project=, or call mem_current_project to see the resolved project and available projects. Alternatively cd into the target repo or add repo .engram/config.json.", + }) + } + return result +} + // errorWithMeta returns a structured tool error result with error_code, // message, available_projects, and a hint for resolution. func errorWithMeta(code, msg string, availableProjects []string) *mcp.CallToolResult { diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 89fc21ed8..d867cca86 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -4043,6 +4043,85 @@ func TestMemSave_AmbiguousEnvelope(t *testing.T) { } } +// TestReadTools_AmbiguousEnvelope asserts that read tools return a structured +// ambiguous_project error with available_projects and a read-specific hint, and +// no recovery_token, when cwd is a parent of multiple git repos (REQ-314). +func TestReadTools_AmbiguousEnvelope(t *testing.T) { + parent := t.TempDir() + names := []string{"repo-a", "repo-b"} + for _, name := range names { + child := filepath.Join(parent, name) + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatal(err) + } + initTestGitRepo(t, child) + } + t.Chdir(parent) + + s := newMCPTestStore(t) + activity := NewSessionActivity(10 * time.Minute) + + cases := []struct { + name string + h func(context.Context, mcppkg.CallToolRequest) (*mcppkg.CallToolResult, error) + req mcppkg.CallToolRequest + }{ + { + name: "mem_search", + h: handleSearch(s, MCPConfig{}, activity), + req: mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{"query": "test"}}}, + }, + { + name: "mem_context", + h: handleContext(s, MCPConfig{}, activity), + req: mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{}}}, + }, + { + name: "mem_stats", + h: handleStats(s, MCPConfig{}), + req: mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{}}}, + }, + { + name: "mem_doctor", + h: handleDoctor(s, MCPConfig{}), + req: mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{}}}, + }, + { + name: "mem_timeline", + h: handleTimeline(s, MCPConfig{}), + req: mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{"observation_id": float64(1)}}}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, err := tc.h(context.Background(), tc.req) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if !res.IsError { + t.Fatal("expected error for ambiguous cwd") + } + text := callResultText(t, res) + if !strings.Contains(text, "\"error_code\":\"ambiguous_project\"") { + t.Errorf("expected error_code ambiguous_project, got: %q", text) + } + body := callResultJSON(t, res) + projects, ok := body["available_projects"].([]any) + if !ok || len(projects) != 2 { + t.Errorf("expected available_projects with 2 entries, got: %v", body["available_projects"]) + } + hint, ok := body["hint"].(string) + if !ok || !strings.Contains(hint, "read tool") { + t.Errorf("expected read-specific hint, got: %q", hint) + } + if _, has := body["recovery_token"]; has { + t.Errorf("read tools must not include recovery_token; got: %v", body) + } + }) + } +} + func TestMemSave_AmbiguousWithValidUserChoiceSucceeds(t *testing.T) { parent := t.TempDir() for _, name := range []string{"repo-choice-a", "repo-choice-b"} { diff --git a/internal/setup/agents.go b/internal/setup/agents.go index 51e619a7f..3a69c56b9 100644 --- a/internal/setup/agents.go +++ b/internal/setup/agents.go @@ -36,6 +36,18 @@ func agentAdapters() []agentAdapter { custom: installClaudeCode, installDir: func() string { return "managed by claude plugin system" }, }, + { + slug: "droid", + description: "Droid — MCP registration, user-level UserPromptSubmit hook, and plugin via marketplace", + custom: installDroid, + installDir: func() string { return "managed by droid plugin system" }, + postInstall: []string{ + "Restart Droid so MCP config and hooks are reloaded", + "Verify ~/.factory/mcp.json includes mcpServers.engram", + "Verify ~/.factory/hooks.json has a UserPromptSubmit entry for engram", + "Verify the plugin is installed with: droid plugin list --scope user", + }, + }, { slug: "gemini-cli", description: "Gemini CLI — MCP registration plus system prompt compaction recovery", diff --git a/internal/setup/droid.go b/internal/setup/droid.go new file mode 100644 index 000000000..4db94d1f4 --- /dev/null +++ b/internal/setup/droid.go @@ -0,0 +1,256 @@ +package setup + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +//go:embed plugins/droid/scripts/* +var droidScriptsFS embed.FS + +const droidMarketplace = "Gentleman-Programming/engram" + +// droidMCPPath returns the user-level Droid MCP config path. +func droidMCPPath() string { + home, _ := userHomeDir() + return filepath.Join(home, ".factory", "mcp.json") +} + +// droidHooksPath returns the user-level Droid hooks config path. +func droidHooksPath() string { + home, _ := userHomeDir() + return filepath.Join(home, ".factory", "hooks.json") +} + +// droidHooksDir returns the directory where engram hook scripts are extracted. +func droidHooksDir() string { + home, _ := userHomeDir() + return filepath.Join(home, ".factory", "hooks", "engram") +} + +// installDroid sets up Engram for the Droid CLI. +// +// It performs four steps: +// 1. Registers the engram MCP server in ~/.factory/mcp.json with the absolute +// binary path so the subprocess never depends on PATH. +// 2. Extracts the UserPromptSubmit hook scripts to ~/.factory/hooks/engram/ so +// they live at a stable, user-controlled path. +// 3. Writes a UserPromptSubmit entry to ~/.factory/hooks.json. This is required +// because Droid (like Claude Code) does not execute UserPromptSubmit hooks +// that are declared inside a plugin; they must be user-level hooks. +// 4. Installs the Engram plugin from the GitHub marketplace so Droid gets the +// SessionStart, Stop, PreCompact, SubagentStop hooks and the Memory Protocol +// skill. +func installDroid() (*Result, error) { + if _, err := lookPathFn("droid"); err != nil { + return nil, fmt.Errorf("droid CLI not found in PATH — install Droid first: https://docs.factory.ai/droid-cli/overview") + } + + files := 0 + + // Step 1: MCP registration. + if err := injectDroidMCPFn(); err != nil { + return nil, fmt.Errorf("register engram MCP server: %w", err) + } + files++ + + // Step 2: Extract hook scripts to a stable user-level path. + if err := extractDroidHookScriptsFn(); err != nil { + return nil, fmt.Errorf("extract hook scripts: %w", err) + } + files++ + + // Step 3: User-level UserPromptSubmit hook. + if err := writeDroidUserPromptSubmitHookFn(); err != nil { + return nil, fmt.Errorf("write UserPromptSubmit hook: %w", err) + } + files++ + + // Step 4: Install the plugin via Droid marketplace. + // This is best-effort: the plugin provides SessionStart/Stop/PreCompact/ + // SubagentStop hooks and the Memory Protocol skill, but the core memory + // functionality already works via the MCP registration and user hook above. + if err := installDroidPluginFn(); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not install Engram Droid plugin: %v\n", err) + fmt.Fprintf(os.Stderr, " Memory tools are still available via MCP. You can install the plugin manually later with:\n") + fmt.Fprintf(os.Stderr, " droid plugin marketplace add https://github.com/%s\n", droidMarketplace) + fmt.Fprintf(os.Stderr, " droid plugin install engram@engram --scope user\n") + } + + return &Result{ + Agent: "droid", + Destination: filepath.Dir(droidMCPPath()), + Files: files, + }, nil +} + +// injectDroidMCP registers the engram MCP server in ~/.factory/mcp.json. +// Droid's user-level MCP config uses a top-level "mcpServers" object with +// "type": "stdio" entries. +func injectDroidMCP() error { + path := droidMCPPath() + config, err := readJSONConfig(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + servers := make(map[string]json.RawMessage) + if raw, ok := config["mcpServers"]; ok { + if err := json.Unmarshal(raw, &servers); err != nil { + return fmt.Errorf("parse mcpServers block in %s: %w", path, err) + } + if servers == nil { + servers = make(map[string]json.RawMessage) + } + } + + cmd := resolveEngramCommand() + entry := map[string]any{ + "type": "stdio", + "command": cmd, + "args": []string{"mcp", "--tools=agent"}, + } + entryJSON, err := jsonMarshalFn(entry) + if err != nil { + return fmt.Errorf("marshal engram entry: %w", err) + } + servers["engram"] = json.RawMessage(entryJSON) + + blockJSON, err := jsonMarshalFn(servers) + if err != nil { + return fmt.Errorf("marshal mcpServers block: %w", err) + } + config["mcpServers"] = json.RawMessage(blockJSON) + + return writeJSONConfig(path, config) +} + +// extractDroidHookScripts copies the embedded Droid hook scripts to +// ~/.factory/hooks/engram/ so the user-level hooks.json can reference a stable +// absolute path. +func extractDroidHookScripts() error { + dir := droidHooksDir() + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create hooks dir: %w", err) + } + + return fs.WalkDir(droidScriptsFS, "plugins/droid/scripts", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + data, err := droidScriptsFS.ReadFile(path) + if err != nil { + return fmt.Errorf("read embedded %s: %w", path, err) + } + + dest := filepath.Join(dir, filepath.Base(path)) + if err := writeFileFn(dest, data, 0755); err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + return nil + }) +} + +// writeDroidUserPromptSubmitHook writes (or updates) the UserPromptSubmit hook +// in ~/.factory/hooks.json to call the extracted engram script. +// +// Droid's user-level hooks file uses the standalone format: event names are +// top-level keys. Some existing installs may wrap events under a legacy +// "hooks" key (the format used inside settings.json). This function normalizes +// the file to the standalone format while preserving any existing hooks. +func writeDroidUserPromptSubmitHook() error { + path := droidHooksPath() + + events := make(map[string]json.RawMessage) + + data, err := readFileFn(path) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("read hooks config: %w", err) + } + } else { + var rawConfig map[string]json.RawMessage + if err := json.Unmarshal(data, &rawConfig); err != nil { + return fmt.Errorf("parse hooks config: %w", err) + } + + // If the file uses the legacy "hooks" wrapper (settings.json format), + // unwrap the known event keys into the standalone map. + if rawHooks, exists := rawConfig["hooks"]; exists { + var wrapped map[string]json.RawMessage + if err := json.Unmarshal(rawHooks, &wrapped); err != nil { + return fmt.Errorf("parse wrapped hooks block: %w", err) + } + for event, hookList := range wrapped { + events[event] = hookList + } + // Preserve any non-event top-level keys (unlikely, but safe). + for key, value := range rawConfig { + if key == "hooks" { + continue + } + // Non-event keys are not part of the hooks schema; drop them + // since the standalone file only contains event keys. + _ = value + } + } else { + // Already standalone: copy all top-level keys as events. + for event, hookList := range rawConfig { + events[event] = hookList + } + } + } + + scriptPath := filepath.Join(droidHooksDir(), "user-prompt-submit.sh") + engramHook := []map[string]any{ + { + "hooks": []map[string]any{ + { + "type": "command", + "command": scriptPath, + "timeout": 10, + }, + }, + }, + } + hookJSON, err := jsonMarshalFn(engramHook) + if err != nil { + return fmt.Errorf("marshal UserPromptSubmit hook: %w", err) + } + events["UserPromptSubmit"] = json.RawMessage(hookJSON) + + return writeJSONConfig(path, events) +} + +// installDroidPlugin adds the Engram marketplace and installs the plugin. +func installDroidPlugin() error { + // Add marketplace (idempotent). + addOut, err := runCommand("droid", "plugin", "marketplace", "add", "https://github.com/"+droidMarketplace) + addOutputStr := strings.TrimSpace(string(addOut)) + if err != nil { + // If marketplace is already added, that's fine. + if !strings.Contains(addOutputStr, "already") && !strings.Contains(addOutputStr, "exists") { + return fmt.Errorf("marketplace add failed: %s", addOutputStr) + } + } + + // Install the plugin. + installOut, err := runCommand("droid", "plugin", "install", "engram@engram", "--scope", "user") + installOutputStr := strings.TrimSpace(string(installOut)) + if err != nil { + if !strings.Contains(installOutputStr, "already") && !strings.Contains(installOutputStr, "installed") { + return fmt.Errorf("plugin install failed: %s", installOutputStr) + } + } + + return nil +} diff --git a/internal/setup/droid_test.go b/internal/setup/droid_test.go new file mode 100644 index 000000000..4c27961d1 --- /dev/null +++ b/internal/setup/droid_test.go @@ -0,0 +1,267 @@ +package setup + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInstallDroidRequiresDroidCLI(t *testing.T) { + resetSetupSeams(t) + home := useTestHome(t) + lookPathFn = func(name string) (string, error) { + if name == "droid" { + return "", errors.New("not found") + } + return "", errors.New("not found") + } + + _, err := Install("droid") + if err == nil { + t.Fatalf("expected error when droid CLI is missing") + } + if !strings.Contains(err.Error(), "droid CLI not found") { + t.Fatalf("expected droid CLI error, got: %v", err) + } + _ = home +} + +func TestInstallDroidWritesMCPAndHooks(t *testing.T) { + resetSetupSeams(t) + home := useTestHome(t) + + lookPathFn = func(name string) (string, error) { + if name == "droid" { + return "/usr/local/bin/droid", nil + } + return "", errors.New("not found") + } + + // Mock the plugin install so the test does not hit the network. + installDroidPluginFn = func() error { return nil } + + result, err := Install("droid") + if err != nil { + t.Fatalf("install droid: %v", err) + } + + if result.Agent != "droid" { + t.Fatalf("unexpected agent: %q", result.Agent) + } + if result.Files != 3 { + t.Fatalf("expected 3 files written, got %d", result.Files) + } + + // Verify MCP config. + mcpPath := filepath.Join(home, ".factory", "mcp.json") + mcpRaw, err := os.ReadFile(mcpPath) + if err != nil { + t.Fatalf("read mcp config: %v", err) + } + var mcpCfg map[string]any + if err := json.Unmarshal(mcpRaw, &mcpCfg); err != nil { + t.Fatalf("parse mcp config: %v", err) + } + mcpServers, ok := mcpCfg["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("expected mcpServers object") + } + engram, ok := mcpServers["engram"].(map[string]any) + if !ok { + t.Fatalf("expected mcpServers.engram object") + } + if engram["type"] != "stdio" { + t.Fatalf("expected type stdio, got %v", engram["type"]) + } + cmd, ok := engram["command"].(string) + if !ok || cmd == "" { + t.Fatalf("expected non-empty command string") + } + args, ok := engram["args"].([]any) + if !ok || len(args) != 2 || args[0] != "mcp" || args[1] != "--tools=agent" { + t.Fatalf("expected args [mcp --tools=agent], got %#v", engram["args"]) + } + + // Verify hook scripts were extracted. + hooksDir := filepath.Join(home, ".factory", "hooks", "engram") + if _, err := os.Stat(filepath.Join(hooksDir, "user-prompt-submit.sh")); err != nil { + t.Fatalf("user-prompt-submit.sh not extracted: %v", err) + } + if _, err := os.Stat(filepath.Join(hooksDir, "_helpers.sh")); err != nil { + t.Fatalf("_helpers.sh not extracted: %v", err) + } + + // Verify hooks.json uses standalone format (events at top level). + hooksPath := filepath.Join(home, ".factory", "hooks.json") + hooksRaw, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatalf("read hooks config: %v", err) + } + var hooksCfg map[string]any + if err := json.Unmarshal(hooksRaw, &hooksCfg); err != nil { + t.Fatalf("parse hooks config: %v", err) + } + if _, exists := hooksCfg["hooks"]; exists { + t.Fatalf("expected standalone hooks.json format, found top-level 'hooks' wrapper") + } + ups, ok := hooksCfg["UserPromptSubmit"].([]any) + if !ok || len(ups) != 1 { + t.Fatalf("expected one UserPromptSubmit matcher group") + } + group, ok := ups[0].(map[string]any) + if !ok { + t.Fatalf("expected matcher group map") + } + hookList, ok := group["hooks"].([]any) + if !ok || len(hookList) != 1 { + t.Fatalf("expected one hook command") + } + hookCmd, ok := hookList[0].(map[string]any) + if !ok { + t.Fatalf("expected hook command map") + } + if hookCmd["type"] != "command" { + t.Fatalf("expected command type, got %v", hookCmd["type"]) + } + if !strings.HasSuffix(hookCmd["command"].(string), "user-prompt-submit.sh") { + t.Fatalf("expected command to end with user-prompt-submit.sh, got %v", hookCmd["command"]) + } +} + +func TestWriteDroidUserPromptSubmitHookPreservesWrappedHooks(t *testing.T) { + resetSetupSeams(t) + home := useTestHome(t) + + // Pre-populate hooks.json with the legacy "hooks" wrapper format. + hooksPath := filepath.Join(home, ".factory", "hooks.json") + if err := os.MkdirAll(filepath.Dir(hooksPath), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + original := `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"/existing.sh"}]}],"UserPromptSubmit":[{"hooks":[{"type":"command","command":"/old.sh"}]}]}}` + if err := os.WriteFile(hooksPath, []byte(original), 0644); err != nil { + t.Fatalf("write initial hooks: %v", err) + } + + lookPathFn = func(name string) (string, error) { + if name == "droid" { + return "/usr/local/bin/droid", nil + } + return "", errors.New("not found") + } + installDroidPluginFn = func() error { return nil } + + if _, err := Install("droid"); err != nil { + t.Fatalf("install droid: %v", err) + } + + raw, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatalf("read hooks: %v", err) + } + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("parse hooks: %v", err) + } + if _, exists := cfg["hooks"]; exists { + t.Fatalf("expected standalone format after install, found 'hooks' wrapper") + } + + // Existing SessionStart hook should be preserved. + ss, ok := cfg["SessionStart"].([]any) + if !ok || len(ss) != 1 { + t.Fatalf("expected preserved SessionStart hook") + } + ssGroup := ss[0].(map[string]any) + ssCmds := ssGroup["hooks"].([]any) + if ssCmds[0].(map[string]any)["command"] != "/existing.sh" { + t.Fatalf("expected existing SessionStart command to be preserved") + } + + // UserPromptSubmit should be updated to the engram script. + ups, ok := cfg["UserPromptSubmit"].([]any) + if !ok || len(ups) != 1 { + t.Fatalf("expected one UserPromptSubmit group") + } + upsGroup := ups[0].(map[string]any) + upsCmds := upsGroup["hooks"].([]any) + cmd := upsCmds[0].(map[string]any)["command"].(string) + if !strings.HasSuffix(cmd, "user-prompt-submit.sh") { + t.Fatalf("expected engram user-prompt-submit.sh, got %s", cmd) + } +} + +func TestInstallDroidContinuesIfPluginInstallFails(t *testing.T) { + resetSetupSeams(t) + home := useTestHome(t) + + lookPathFn = func(name string) (string, error) { + if name == "droid" { + return "/usr/local/bin/droid", nil + } + return "", errors.New("not found") + } + installDroidPluginFn = func() error { return errors.New("network unavailable") } + + result, err := Install("droid") + if err != nil { + t.Fatalf("install droid should not fail when plugin install fails: %v", err) + } + if result.Files != 3 { + t.Fatalf("expected 3 files written, got %d", result.Files) + } + + // MCP and hooks should still be written. + if _, err := os.Stat(filepath.Join(home, ".factory", "mcp.json")); err != nil { + t.Fatalf("mcp.json not written: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".factory", "hooks.json")); err != nil { + t.Fatalf("hooks.json not written: %v", err) + } +} + +func TestInstallDroidIsIdempotent(t *testing.T) { + resetSetupSeams(t) + home := useTestHome(t) + + lookPathFn = func(name string) (string, error) { + if name == "droid" { + return "/usr/local/bin/droid", nil + } + return "", errors.New("not found") + } + installDroidPluginFn = func() error { return nil } + + if _, err := Install("droid"); err != nil { + t.Fatalf("first install: %v", err) + } + firstMCP, err := os.ReadFile(filepath.Join(home, ".factory", "mcp.json")) + if err != nil { + t.Fatalf("read first mcp: %v", err) + } + firstHooks, err := os.ReadFile(filepath.Join(home, ".factory", "hooks.json")) + if err != nil { + t.Fatalf("read first hooks: %v", err) + } + + if _, err := Install("droid"); err != nil { + t.Fatalf("second install should be idempotent: %v", err) + } + secondMCP, err := os.ReadFile(filepath.Join(home, ".factory", "mcp.json")) + if err != nil { + t.Fatalf("read second mcp: %v", err) + } + secondHooks, err := os.ReadFile(filepath.Join(home, ".factory", "hooks.json")) + if err != nil { + t.Fatalf("read second hooks: %v", err) + } + + if string(firstMCP) != string(secondMCP) { + t.Fatalf("mcp.json changed on second install") + } + if string(firstHooks) != string(secondHooks) { + t.Fatalf("hooks.json changed on second install") + } +} diff --git a/internal/setup/plugins/droid/scripts/_helpers.sh b/internal/setup/plugins/droid/scripts/_helpers.sh new file mode 100644 index 000000000..c20640666 --- /dev/null +++ b/internal/setup/plugins/droid/scripts/_helpers.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Engram — Shared helpers for Droid hooks +# WARNING: Do not read from stdin here — scripts source this before reading their hook input. + +# Detect project name from git remote, with fallbacks. +# Priority: git remote origin repo name > git root basename > cwd basename +detect_project() { + local dir="$1" + + # Try git remote origin URL + local url + url=$(git -C "$dir" remote get-url origin 2>/dev/null) + if [ -n "$url" ]; then + # Handles both SSH (git@github.com:user/repo.git) and HTTPS (https://github.com/user/repo.git) + local name + name=$(echo "$url" | sed 's/\.git$//' | sed 's|.*[/:]||' | tr '[:upper:]' '[:lower:]') + if [ -n "$name" ]; then + echo "$name" + return + fi + fi + + # Fallback: git root directory name (works in worktrees) + local root + root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) + if [ -n "$root" ]; then + basename "$root" | tr '[:upper:]' '[:lower:]' + return + fi + + # Final fallback: cwd basename (current behavior) + basename "$dir" | tr '[:upper:]' '[:lower:]' +} + +# List lowercase names of immediate child git repositories under $dir, +# skipping hidden and noise directories. Mirrors the Go scanChildren logic in +# internal/project/detect.go so the injected candidate list matches the +# available_projects returned by ambiguous_project errors. +# +# Prints a single line with space-separated names; empty if none. +list_child_projects() { + local dir="$1" + [ -d "$dir" ] || return + + local child name + for child in "$dir"/*/; do + [ -d "${child%/}/.git" ] || continue + name=$(basename "${child%/}") + case "$name" in + node_modules|vendor|.venv|__pycache__|target|dist|build|.idea|.vscode) continue ;; + esac + printf '%s ' "$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + done +} diff --git a/internal/setup/plugins/droid/scripts/user-prompt-submit.sh b/internal/setup/plugins/droid/scripts/user-prompt-submit.sh new file mode 100644 index 000000000..f450c0290 --- /dev/null +++ b/internal/setup/plugins/droid/scripts/user-prompt-submit.sh @@ -0,0 +1,306 @@ +#!/bin/bash +# Engram — UserPromptSubmit hook for Droid +# +# On the FIRST message of a session: injects a ToolSearch instruction to force +# Droid to load all engram memory tools (which are deferred by default). +# +# On subsequent messages: checks when the last mem_save was for the current +# project. If it's been > 15 minutes AND the session has been active > 5 +# minutes, injects a nudge reminding the agent to save. +# +# The nudge is debounced per session: once shown, it stays quiet for +# ENGRAM_NUDGE_COOLDOWN_SECS (default 900s) before it can fire again. Without +# this, an agent that genuinely has nothing to save never resets the last-save +# clock, so the reminder would fire on every single message forever. +# +# MUST exit 0 always and output valid JSON — otherwise Droid blocks the message. + +ENGRAM_PORT="${ENGRAM_PORT:-7437}" +ENGRAM_URL="http://127.0.0.1:${ENGRAM_PORT}" + +# Windows Git Bash/MSYS2 can fail while forking helper processes under +# enterprise Defender/EDR, which makes Droid wait on prompt submission. +# Keep the Windows path bash-builtin-only: no jq, git, curl, date, dirname, cat, +# touch, or command substitutions. It preserves first-message tool loading and +# degrades subsequent save nudges to a fast no-op instead of risking a hang. +is_windows_bash() { + case "${OSTYPE:-}" in + msys*|cygwin*|win32*) return 0 ;; + esac + [ -n "${MSYSTEM:-}" ] || [ -n "${MINGW_PREFIX:-}" ] +} + +set_json_string_value() { + local key="$1" + local json="$2" + local pattern='"'"$key"'"[[:space:]]*:[[:space:]]*"([^"]*)"' + JSON_VALUE="" + if [[ "$json" =~ $pattern ]]; then + JSON_VALUE="${BASH_REMATCH[1]}" + fi +} + +sanitize_session_key_part() { + local raw="$1" + local safe="" + local i char + for (( i=0; i<${#raw}; i++ )); do + char="${raw:i:1}" + if [[ "$char" =~ [a-zA-Z0-9_-] ]]; then + safe+="$char" + else + safe+="_" + fi + done + JSON_VALUE="$safe" +} + +print_toolsearch_message() { + local projects_list="$1" + local project_count=0 + local p + for p in $projects_list; do + project_count=$((project_count + 1)) + done + + if [ "$project_count" -gt 1 ]; then + local formatted_list="" + local first=1 + for p in $projects_list; do + if [ "$first" -eq 1 ]; then + formatted_list="$p" + first=0 + else + formatted_list="${formatted_list}, ${p}" + fi + done + printf '%s\n' '{"systemMessage":"CRITICAL FIRST ACTION — Execute this ToolSearch NOW before responding to the user:\nselect:engram___mem_save,engram___mem_search,engram___mem_context,engram___mem_session_summary,engram___mem_session_start,engram___mem_session_end,engram___mem_get_observation,engram___mem_suggest_topic_key,engram___mem_capture_passive,engram___mem_save_prompt,engram___mem_update,engram___mem_current_project,engram___mem_judge\n\nAfter loading tools, call mem_context to check for prior session history before responding.\n\nIMPORTANT — multi-repo cwd detected: ['"${formatted_list}"']. When calling ANY engram read tool (mem_search, mem_context, mem_get_observation, mem_timeline, mem_doctor, mem_review, mem_judge, mem_compare), ALWAYS pass project= explicitly. Never omit the project parameter from read tools — cwd auto-detection will fail with ambiguous_project. Only use a project name from the list above.\n\nWhen calling mem_save, content is REQUIRED — structure it as **What** / **Why** / **Where** / **Learned**. A save with only title+type is rejected."}' + else + printf '%s\n' '{"systemMessage":"CRITICAL FIRST ACTION — Execute this ToolSearch NOW before responding to the user:\nselect:engram___mem_save,engram___mem_search,engram___mem_context,engram___mem_session_summary,engram___mem_session_start,engram___mem_session_end,engram___mem_get_observation,engram___mem_suggest_topic_key,engram___mem_capture_passive,engram___mem_save_prompt,engram___mem_update,engram___mem_current_project,engram___mem_judge\n\nAfter loading tools, call mem_context to check for prior session history before responding.\n\nIf a memory tool returns an ambiguous_project error (cwd spans multiple git repos), retry with project= or call mem_current_project.\n\nWhen calling mem_save, content is REQUIRED — structure it as **What** / **Why** / **Where** / **Learned**. A save with only title+type is rejected."}' + fi +} + +if is_windows_bash && [ "${ENGRAM_DROID_WINDOWS_BASH_SAFE_MODE:-auto}" != "0" ]; then + INPUT="" + while IFS= read -r LINE || [ -n "$LINE" ]; do + INPUT+="${LINE}"$'\n' + done + + set_json_string_value "session_id" "$INPUT" + SESSION_ID="$JSON_VALUE" + if [ -n "$SESSION_ID" ]; then + sanitize_session_key_part "$SESSION_ID" + SESSION_KEY="engram-droid-${JSON_VALUE}-tools-loaded" + else + SESSION_KEY="engram-droid-windows-$$-tools-loaded" + fi + STATE_DIR="${TMPDIR:-/tmp}" + STATE_FILE="${STATE_DIR}/${SESSION_KEY}" + + if [ ! -f "$STATE_FILE" ]; then + : > "$STATE_FILE" 2>/dev/null || true + print_toolsearch_message + exit 0 + fi + + printf '%s\n' '{}' + exit 0 +fi + +# Load shared helpers after the Windows-safe fast path so Git Bash does not fork +# for dirname/pwd before deciding whether the safe path applies. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/_helpers.sh" + +# Read hook input from stdin +INPUT=$(cat) +CWD=$(echo "$INPUT" | jq -r '.cwd // empty') +SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty') + +# ────────────────────────────────────────────────────────────────────────────── +# PROMPT PERSIST +# +# Every user message is captured to POST /prompts so mem_save can attach the +# originating prompt via SessionActivity. Fire-and-forget: never blocks and +# never fails the hook. +# ────────────────────────────────────────────────────────────────────────────── +PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty') +if [ -n "$PROMPT" ] && [ -n "$SESSION_ID" ]; then + # Detached subshell so the POST never stalls the hook. The server derives the + # prompt's project from the session, so project lookup stays off the hot path + # here (the hook keys by session_id first and only resolves the project later). + ( + curl -sf -X POST "${ENGRAM_URL}/prompts" --max-time 2 \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg s "$SESSION_ID" --arg c "$PROMPT" \ + '{session_id:$s, content:$c}')" >/dev/null 2>&1 || true + ) & +fi + +parse_epoch() { + TS="$1" + if [ -z "$TS" ]; then + return 1 + fi + + # Drop fractional seconds without dropping timezone information. + if [[ "$TS" == *.* ]]; then + TS_PREFIX="${TS%%.*}" + TS_SUFFIX="${TS#*.}" + case "$TS_SUFFIX" in + *Z) TS="${TS_PREFIX}Z" ;; + *+*) TS="${TS_PREFIX}+${TS_SUFFIX#*+}" ;; + *-*) TS="${TS_PREFIX}-${TS_SUFFIX#*-}" ;; + *) TS="$TS_PREFIX" ;; + esac + fi + + # BSD date accepts numeric RFC3339 offsets with %z, but requires +HHMM. + if [[ "$TS" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})([+-][0-9]{2}):([0-9]{2})$ ]]; then + TZ_TS="${BASH_REMATCH[1]}${BASH_REMATCH[2]}${BASH_REMATCH[3]}" + date -j -f "%Y-%m-%dT%H:%M:%S%z" "$TZ_TS" "+%s" 2>/dev/null && return 0 + fi + if [[ "$TS" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}[+-][0-9]{4}$ ]]; then + date -j -f "%Y-%m-%dT%H:%M:%S%z" "$TS" "+%s" 2>/dev/null && return 0 + fi + + if [[ "$TS" == *Z ]]; then + Z_TS="${TS%Z}" + date -j -u -f "%Y-%m-%dT%H:%M:%S" "$Z_TS" "+%s" 2>/dev/null && return 0 + fi + + date -j -f "%Y-%m-%dT%H:%M:%S" "$TS" "+%s" 2>/dev/null \ + || date -j -f "%Y-%m-%d %H:%M:%S" "$TS" "+%s" 2>/dev/null \ + || date -d "$TS" "+%s" 2>/dev/null +} + +# Default: no injection +OUTPUT="{}" + +# ────────────────────────────────────────────────────────────────────────────── +# FIRST-MESSAGE DETECTION +# +# Use a state file per session to determine if this is the first user message. +# State file lives in /tmp and is keyed by session_id (falls back to project+pid). +# ────────────────────────────────────────────────────────────────────────────── + +# Build a stable session key — prefer SESSION_ID, fall back to project name +if [ -n "$SESSION_ID" ]; then + SESSION_KEY="engram-droid-${SESSION_ID}-tools-loaded" +else + # No session ID available — only then detect project for the fallback state key. + PROJECT=$(detect_project "$CWD") + SAFE_PROJECT=$(printf '%s' "${PROJECT:-unknown}" | tr -cs 'a-zA-Z0-9_-' '_') + SESSION_KEY="engram-droid-${SAFE_PROJECT}-$$-tools-loaded" +fi + +STATE_FILE="/tmp/${SESSION_KEY}" + +if [ ! -f "$STATE_FILE" ]; then + # ── FIRST MESSAGE ──────────────────────────────────────────────────────────── + # Create the state file immediately to prevent repeat injections + touch "$STATE_FILE" 2>/dev/null || true + + # Detect available child projects so the ToolSearch instruction can force the + # agent to pass project= explicitly when cwd is a multi-repo parent. If cwd + # is not a multi-repo parent (empty or single-repo), this is a no-op and the + # existing message is emitted unchanged. + CHILD_PROJECTS=$(list_child_projects "$CWD") + + # Inject ToolSearch + mem_context instruction. + print_toolsearch_message "$CHILD_PROJECTS" + exit 0 +fi + +# ────────────────────────────────────────────────────────────────────────────── +# SUBSEQUENT MESSAGES — existing save-nudge logic +# ────────────────────────────────────────────────────────────────────────────── + +# Detect project only after the first-message path has had a chance to return. +if [ -z "${PROJECT:-}" ]; then + PROJECT=$(detect_project "$CWD") +fi + +# Bail early if we can't determine the project +if [ -z "$PROJECT" ]; then + echo "$OUTPUT" + exit 0 +fi + +# Get session start time to check if session is > 5 minutes old +SESSION_START="" +if [ -n "$SESSION_ID" ]; then + SESSION_START=$(curl -sf "${ENGRAM_URL}/sessions/${SESSION_ID}" --max-time 0.2 2>/dev/null \ + | jq -r '.started_at // empty' 2>/dev/null) +fi + +# Check session age — skip nudge if session is new (< 5 minutes) +if [ -n "$SESSION_START" ]; then + SESSION_START_EPOCH=$(parse_epoch "$SESSION_START") + if [ -z "$SESSION_START_EPOCH" ]; then + echo "$OUTPUT" + exit 0 + fi + NOW_EPOCH=$(date "+%s") + SESSION_AGE_SECS=$(( NOW_EPOCH - SESSION_START_EPOCH )) + + if [ "$SESSION_AGE_SECS" -lt 300 ]; then + # Session < 5 minutes old — no nudge yet + echo "$OUTPUT" + exit 0 + fi +fi + +# Fetch the most recent observation for this project (any type) +ENCODED_PROJECT=$(printf '%s' "$PROJECT" | jq -sRr @uri) +LAST_SAVE_JSON=$(curl -sf \ + "${ENGRAM_URL}/observations?project=${ENCODED_PROJECT}&limit=1&sort=created_at:desc" \ + --max-time 0.2 2>/dev/null) + +if [ -z "$LAST_SAVE_JSON" ]; then + # Server not responding or slow — fail silently, no nudge + echo "$OUTPUT" + exit 0 +fi + +LAST_SAVE_AT=$(echo "$LAST_SAVE_JSON" | jq -r '.[0].created_at // empty' 2>/dev/null) + +if [ -z "$LAST_SAVE_AT" ]; then + # No observations yet — no nudge (session might just be starting) + echo "$OUTPUT" + exit 0 +fi + +# Parse last save timestamp and compare to now +LAST_EPOCH=$(parse_epoch "$LAST_SAVE_AT") +if [ -z "$LAST_EPOCH" ]; then + echo "$OUTPUT" + exit 0 +fi +NOW_EPOCH=$(date "+%s") +ELAPSED=$(( NOW_EPOCH - LAST_EPOCH )) + +# Nudge if last save was > 15 minutes ago (900 seconds), but debounce so we do +# not repeat the reminder on every message while the agent has nothing to save. +if [ "$ELAPSED" -gt 900 ]; then + NUDGE_COOLDOWN="${ENGRAM_NUDGE_COOLDOWN_SECS:-900}" + NUDGE_STATE_FILE="${STATE_FILE%-tools-loaded}-last-nudge" + + LAST_NUDGE_EPOCH="" + if [ -f "$NUDGE_STATE_FILE" ]; then + read -r LAST_NUDGE_EPOCH < "$NUDGE_STATE_FILE" 2>/dev/null || LAST_NUDGE_EPOCH="" + fi + # Ignore a corrupt/non-numeric state file — treat as "never nudged". + case "$LAST_NUDGE_EPOCH" in + ''|*[!0-9]*) LAST_NUDGE_EPOCH="" ;; + esac + + if [ -z "$LAST_NUDGE_EPOCH" ] || [ "$(( NOW_EPOCH - LAST_NUDGE_EPOCH ))" -ge "$NUDGE_COOLDOWN" ]; then + printf '%s' "$NOW_EPOCH" > "$NUDGE_STATE_FILE" 2>/dev/null || true + OUTPUT=$(jq -n \ + '{"systemMessage": "MEMORY REMINDER: It'\''s been over 15 minutes since your last save. If you'\''ve made decisions, discoveries, or completed significant work, call mem_save now."}') + fi +fi + +echo "$OUTPUT" +exit 0 diff --git a/internal/setup/registry_test.go b/internal/setup/registry_test.go index ad23d7552..be32547b3 100644 --- a/internal/setup/registry_test.go +++ b/internal/setup/registry_test.go @@ -56,7 +56,7 @@ func TestSupportedAgentsIncludesAllRegistryAgents(t *testing.T) { } want := []string{ - "opencode", "pi", "claude-code", "gemini-cli", "codex", + "opencode", "pi", "claude-code", "droid", "gemini-cli", "codex", "antigravity-cli", "windsurf", "qwen", "kiro", "cursor", "vscode-copilot", "kilocode", } diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 31333c1ae..50501daab 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -53,6 +53,10 @@ var ( injectCodexMemoryConfigFn = injectCodexMemoryConfig addClaudeCodeAllowlistFn = AddClaudeCodeAllowlist writeClaudeCodeUserMCPFn = writeClaudeCodeUserMCP + injectDroidMCPFn = injectDroidMCP + extractDroidHookScriptsFn = extractDroidHookScripts + writeDroidUserPromptSubmitHookFn = writeDroidUserPromptSubmitHook + installDroidPluginFn = installDroidPlugin // resolveMiseNodeVersionFn resolves the active Node version managed by mise. // It runs "mise current node" and returns the result as a "node@X.Y.Z" specifier. @@ -173,6 +177,10 @@ Also search memory PROACTIVELY when: - Starting work on something that might have been done before - The user mentions a topic you have no context on — check if past sessions covered it +### AMBIGUOUS PROJECT (cwd spans multiple repos) + +If a memory tool returns an ambiguous_project error, read available_projects from the JSON error and retry with project= using one of the listed values, or call mem_current_project to see the resolved project and available projects. For cross-project recall, mem_search accepts all_projects=true. + ### SESSION CLOSE PROTOCOL (mandatory) Before ending a session or saying "done" / "listo" / "that's it", you MUST: diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 07208f5af..4a263ba75 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -34,6 +34,10 @@ func resetSetupSeams(t *testing.T) { oldAddClaudeCodeAllowlistFn := addClaudeCodeAllowlistFn oldOsExecutable := osExecutable oldWriteClaudeCodeUserMCPFn := writeClaudeCodeUserMCPFn + oldInjectDroidMCPFn := injectDroidMCPFn + oldExtractDroidHookScriptsFn := extractDroidHookScriptsFn + oldWriteDroidUserPromptSubmitHookFn := writeDroidUserPromptSubmitHookFn + oldInstallDroidPluginFn := installDroidPluginFn oldResolveMiseNodeVersionFn := resolveMiseNodeVersionFn t.Cleanup(func() { @@ -58,6 +62,10 @@ func resetSetupSeams(t *testing.T) { addClaudeCodeAllowlistFn = oldAddClaudeCodeAllowlistFn osExecutable = oldOsExecutable writeClaudeCodeUserMCPFn = oldWriteClaudeCodeUserMCPFn + injectDroidMCPFn = oldInjectDroidMCPFn + extractDroidHookScriptsFn = oldExtractDroidHookScriptsFn + writeDroidUserPromptSubmitHookFn = oldWriteDroidUserPromptSubmitHookFn + installDroidPluginFn = oldInstallDroidPluginFn resolveMiseNodeVersionFn = oldResolveMiseNodeVersionFn }) } diff --git a/plugin/claude-code/scripts/post-compaction.sh b/plugin/claude-code/scripts/post-compaction.sh index a767bfc9c..c7002eb7a 100755 --- a/plugin/claude-code/scripts/post-compaction.sh +++ b/plugin/claude-code/scripts/post-compaction.sh @@ -69,11 +69,20 @@ Call `mem_save` IMMEDIATELY after ANY of these: **Self-check after EVERY task**: "Did I just make a decision, fix a bug, learn something, or establish a convention? If yes → mem_save NOW." +### FORMAT for mem_save (content is required) +- **title**: short, searchable +- **type**: decision | bugfix | architecture | discovery | pattern | config +- **content** (required): **What** / **Why** / **Where** / **Learned** + ### SEARCH MEMORY when: - User asks to recall anything ("remember", "what did we do", or the equivalent in the user's language) - Starting work on something that might have been done before - User mentions a topic you have no context on +### AMBIGUOUS PROJECT (cwd spans multiple repos) + +If a memory tool returns an `ambiguous_project` error, read `available_projects` from the JSON error and retry with `project=` using one of the listed values, or call `mem_current_project` to see the resolved project and available projects. For cross-project recall, `mem_search` accepts `all_projects=true`. + ### SESSION CLOSE — before saying "done": Call `mem_session_summary` with: Goal, Discoveries, Accomplished, Next Steps, Relevant Files. diff --git a/plugin/claude-code/scripts/session-start.sh b/plugin/claude-code/scripts/session-start.sh index 150a42651..a532a46c9 100755 --- a/plugin/claude-code/scripts/session-start.sh +++ b/plugin/claude-code/scripts/session-start.sh @@ -175,12 +175,21 @@ Call `mem_save` IMMEDIATELY after ANY of these: **Self-check after EVERY task**: "Did I or the user just make a decision, confirm a recommendation, express a preference, fix a bug, learn something, or establish a convention? If yes → mem_save NOW." +### FORMAT for mem_save (content is required) +- **title**: short, searchable +- **type**: decision | bugfix | architecture | discovery | pattern | config +- **content** (required): **What** / **Why** / **Where** / **Learned** + ### SEARCH MEMORY when: - User asks to recall anything ("remember", "what did we do", or the equivalent in the user's language) - Starting work on something that might have been done before - User mentions a topic you have no context on - User's FIRST message references the project, a feature, or a problem — call `mem_search` with keywords from their message to check for prior work before responding +### AMBIGUOUS PROJECT (cwd spans multiple repos) + +If a memory tool returns an `ambiguous_project` error, read `available_projects` from the JSON error and retry with `project=` using one of the listed values, or call `mem_current_project` to see the resolved project and available projects. For cross-project recall, `mem_search` accepts `all_projects=true`. + ### SESSION CLOSE — before saying "done": Call `mem_session_summary` with: Goal, Discoveries, Accomplished, Next Steps, Relevant Files. PROTOCOL diff --git a/plugin/claude-code/skills/memory/SKILL.md b/plugin/claude-code/skills/memory/SKILL.md index 9ef42d5bd..28623a901 100644 --- a/plugin/claude-code/skills/memory/SKILL.md +++ b/plugin/claude-code/skills/memory/SKILL.md @@ -88,6 +88,10 @@ Also search memory PROACTIVELY when: - The user mentions a topic you have no context on — check if past sessions covered it - The user's FIRST message references the project, a feature, or a problem — call `mem_search` with keywords from their message to check for prior work before responding +## AMBIGUOUS PROJECT (cwd spans multiple repos) + +If a memory tool returns an `ambiguous_project` error, read `available_projects` from the JSON error and retry with `project=` using one of the listed values, or call `mem_current_project` to see the resolved project and available projects. For cross-project recall, `mem_search` accepts `all_projects=true`. + ## SESSION CLOSE PROTOCOL (mandatory) Before ending a session or saying "done" / "that's it", you MUST: diff --git a/plugin/codex/scripts/post-compaction.sh b/plugin/codex/scripts/post-compaction.sh index 363d6be61..5a11fa906 100755 --- a/plugin/codex/scripts/post-compaction.sh +++ b/plugin/codex/scripts/post-compaction.sh @@ -60,6 +60,10 @@ Call `mem_save` IMMEDIATELY after ANY of these: - Starting work on something that might have been done before - User mentions a topic you have no context on +### AMBIGUOUS PROJECT (cwd spans multiple repos) + +If a memory tool returns an `ambiguous_project` error, read `available_projects` from the JSON error and retry with `project=` using one of the listed values, or call `mem_current_project` to see the resolved project and available projects. For cross-project recall, `mem_search` accepts `all_projects=true`. + ### SESSION CLOSE — before saying "done": Call `mem_session_summary` with: Goal, Discoveries, Accomplished, Next Steps, Relevant Files. diff --git a/plugin/codex/scripts/session-start.sh b/plugin/codex/scripts/session-start.sh index 19a859727..5a297a3b3 100755 --- a/plugin/codex/scripts/session-start.sh +++ b/plugin/codex/scripts/session-start.sh @@ -170,6 +170,10 @@ Call `mem_save` IMMEDIATELY after ANY of these: - User mentions a topic you have no context on - User's FIRST message references the project, a feature, or a problem — call `mem_search` with keywords from their message to check for prior work before responding +### AMBIGUOUS PROJECT (cwd spans multiple repos) + +If a memory tool returns an `ambiguous_project` error, read `available_projects` from the JSON error and retry with `project=` using one of the listed values, or call `mem_current_project` to see the resolved project and available projects. For cross-project recall, `mem_search` accepts `all_projects=true`. + ### SESSION CLOSE — before saying "done": Call `mem_session_summary` with: Goal, Discoveries, Accomplished, Next Steps, Relevant Files. PROTOCOL diff --git a/plugin/codex/skills/memory/SKILL.md b/plugin/codex/skills/memory/SKILL.md index d0ce3f500..4aa62971b 100644 --- a/plugin/codex/skills/memory/SKILL.md +++ b/plugin/codex/skills/memory/SKILL.md @@ -87,6 +87,10 @@ Also search memory PROACTIVELY when: - The user mentions a topic you have no context on — check if past sessions covered it - The user's FIRST message references the project, a feature, or a problem — call `mem_search` with keywords from their message to check for prior work before responding +## AMBIGUOUS PROJECT (cwd spans multiple repos) + +If a memory tool returns an `ambiguous_project` error, read `available_projects` from the JSON error and retry with `project=` using one of the listed values, or call `mem_current_project` to see the resolved project and available projects. For cross-project recall, `mem_search` accepts `all_projects=true`. + ## SESSION CLOSE PROTOCOL (mandatory) Before ending a session or saying "done" / "that's it", you MUST: diff --git a/plugin/opencode/engram.ts b/plugin/opencode/engram.ts index c5567087a..f2cda696b 100644 --- a/plugin/opencode/engram.ts +++ b/plugin/opencode/engram.ts @@ -86,6 +86,10 @@ Also search memory PROACTIVELY when: - The user mentions a topic you have no context on — check if past sessions covered it - The user's FIRST message references the project, a feature, or a problem — call \`mem_search\` with keywords from their message to check for prior work before responding +### AMBIGUOUS PROJECT (cwd spans multiple repos) + +If a memory tool returns an \`ambiguous_project\` error, read \`available_projects\` from the JSON error and retry with \`project=\` using one of the listed values, or call \`mem_current_project\` to see the resolved project and available projects. For cross-project recall, \`mem_search\` accepts \`all_projects=true\`. + ### SESSION CLOSE PROTOCOL (mandatory) Before ending a session or saying "done" / "that's it", you MUST: diff --git a/plugin/pi/index.ts b/plugin/pi/index.ts index 33a5523d2..2138095bd 100644 --- a/plugin/pi/index.ts +++ b/plugin/pi/index.ts @@ -82,6 +82,10 @@ Format for \`mem_save\`: When the user asks to recall past work, first call \`mem_context\`. If not found, call \`mem_search\`, then \`mem_get_observation\` for full content. +### AMBIGUOUS PROJECT (cwd spans multiple repos) + +If a memory tool returns an \`ambiguous_project\` error, read \`available_projects\` from the JSON error and retry with \`project=\` using one of the listed values, or call \`mem_current_project\` to see the resolved project and available projects. For cross-project recall, \`mem_search\` accepts \`all_projects=true\`. + ### SESSION CLOSE PROTOCOL Before ending a session or saying "done", call \`mem_session_summary\`