Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
128 changes: 128 additions & 0 deletions DROID-SETUP-NOTES.md
Original file line number Diff line number Diff line change
@@ -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___<tool>` (server name + triple
underscore + tool name), not `mcp__engram__<tool>`. 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=<the repo matching the current user task> 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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
29 changes: 29 additions & 0 deletions docs/AGENT-SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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:
Expand Down
69 changes: 22 additions & 47 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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=<one of available_projects>, 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 {
Expand Down
79 changes: 79 additions & 0 deletions internal/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"} {
Expand Down
Loading