From 7f0f68649fa947aaf440da1b3333e70d15e43101 Mon Sep 17 00:00:00 2001 From: zuwasi Date: Sun, 12 Jul 2026 14:19:11 +0300 Subject: [PATCH] feat: add Amp (Sourcegraph) integration with auto-capture plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add native Amp support via two layers: 1. MCP connectivity — gentmemory connect amp writes the canonical MCP block into mp.mcpServers in ~/.config/amp/settings.json (or %APPDATA%/amp/settings.json on Windows). Amp uses the mp.mcpServers wrapper key, not the standard mcpServers. 2. Native plugin — gentmemory connect amp --with-hooks also copies integrations/amp/agentmemory.ts to ~/.config/amp/plugins/. The plugin hooks into Amp's session.start, agent.start, tool.call, tool.result, and agent.end events to automatically capture observations (prompts, tool calls, tool results, failures) and POST them to the agentmemory REST API. It also registers four memory tools (memory_recall, memory_save, memory_smart_search, memory_sessions) and three command palette commands (agentmemory-recall, agentmemory-remember, agentmemory-session-history). The adapter is Windows-safe and added to the Windows allowlist alongside copilot-cli. Amp's plugin API runs via Bun, so the plugin file uses fetch() (available globally in Bun) and Bun.spawnSync for git toplevel resolution — no Node-specific dependencies. Tests: 8 new tests in test/connect-amp.test.ts covering detect, install, already-wired, force, preserve-existing, dry-run, and adapter registration. Updated test/cli-connect.test.ts to include amp in the supported agent list (19 adapters). --- README.md | 15 ++ integrations/amp/README.md | 101 ++++++++++ integrations/amp/agentmemory.ts | 316 ++++++++++++++++++++++++++++++++ package.json | 1 + src/cli/connect/amp.ts | 186 +++++++++++++++++++ src/cli/connect/index.ts | 5 +- test/cli-connect.test.ts | 3 +- test/connect-amp.test.ts | 155 ++++++++++++++++ 8 files changed, 780 insertions(+), 2 deletions(-) create mode 100644 integrations/amp/README.md create mode 100644 integrations/amp/agentmemory.ts create mode 100644 src/cli/connect/amp.ts create mode 100644 test/connect-amp.test.ts diff --git a/README.md b/README.md index d04da3712..0bdbbc2f2 100644 --- a/README.md +++ b/README.md @@ -573,6 +573,21 @@ copilot plugin install rohitg00/agentmemory:plugin `agentmemory connect copilot-cli` merges `mcpServers.agentmemory` into `~/.copilot/mcp-config.json` (or `$COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set) and preserves existing servers. This adapter is Windows-safe even though other `connect` adapters still require manual Windows setup. Copilot picks up the MCP server on next launch or after `/mcp`. Install the plugin as well when you want the full hook/skill experience. +### Amp (Sourcegraph) + +```bash +# 1. start the memory server in a separate terminal +npx @agentmemory/agentmemory + +# 2. wire MCP + install the auto-capture plugin +agentmemory connect amp --with-hooks +``` + +`agentmemory connect amp` merges `amp.mcpServers.agentmemory` into `~/.config/amp/settings.json` (or `%APPDATA%\amp\settings.json` on Windows). With `--with-hooks`, it also copies the native plugin (`integrations/amp/agentmemory.ts`) to `~/.config/amp/plugins/agentmemory.ts`, which hooks into Amp's `session.start`, `agent.start`, `tool.call`, `tool.result`, and `agent.end` events for automatic observation capture. The plugin also registers `memory_recall`, `memory_save`, `memory_smart_search`, and `memory_sessions` tools plus `agentmemory-recall`, `agentmemory-remember`, and `agentmemory-session-history` commands. This adapter is Windows-safe. Restart Amp (or run `plugins: reload` from the command palette) to pick up the changes. + +Full guide: [`integrations/amp/`](integrations/amp/) + +
OpenClaw (paste this prompt) diff --git a/integrations/amp/README.md b/integrations/amp/README.md new file mode 100644 index 000000000..35c6a8ef7 --- /dev/null +++ b/integrations/amp/README.md @@ -0,0 +1,101 @@ +# agentmemory for Amp (Sourcegraph) + +Persistent memory for [Amp](https://ampcode.com) via the [agentmemory](https://github.com/rohitg00/agentmemory) memory engine. + +## What this gives you + +- **Auto-capture**: Every tool call, tool result, and user prompt is automatically POSTed to the agentmemory REST API (`localhost:3111`). No manual `memory_save` calls needed. +- **Cross-session recall**: Relevant memories from past sessions are available at the start of new threads via `memory_recall` / `memory_smart_search` tools. +- **Context injection** (optional): Set `AGENTMEMORY_INJECT_CONTEXT=true` to have recalled context automatically injected into the agent's first turn. +- **Command palette**: `/recall`, `/remember`, `/session-history` commands for explicit memory operations. + +## Prerequisites + +1. **Amp** installed (`amp` on PATH) — https://ampcode.com/install +2. **agentmemory server** running: + ```bash + npx @agentmemory/agentmemory + ``` + Verify: `curl http://localhost:3111/agentmemory/health` + +## Install + +### Option A: One command (MCP + plugin) + +```bash +agentmemory connect amp --with-hooks +``` + +This writes the MCP server config to `~/.config/amp/settings.json` (or `%APPDATA%\amp\settings.json` on Windows) AND copies the plugin file to `~/.config/amp/plugins/agentmemory.ts`. + +Restart Amp (or run `plugins: reload` from the command palette) to pick up the changes. + +### Option B: Manual + +1. Copy `agentmemory.ts` to `~/.config/amp/plugins/agentmemory.ts` (or `.amp/plugins/` for project scope). + +2. Add the MCP server to your Amp settings: + ```json + { + "amp.mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } + } + ``` + +3. Run `plugins: reload` from the Amp command palette (`Ctrl+O`). + +## How it works + +### Event mapping + +| Amp event | agentmemory REST endpoint | hookType | +|---|---|---| +| `session.start` | `POST /agentmemory/session/start` | — | +| `agent.start` | `POST /agentmemory/observe` | `prompt_submit` | +| `tool.call` | (allowed, no interception) | — | +| `tool.result` (done) | `POST /agentmemory/observe` | `post_tool_use` | +| `tool.result` (error) | `POST /agentmemory/observe` | `post_tool_failure` | +| `agent.end` | `POST /agentmemory/summarize` + `/session/end` | — | + +### Registered tools + +- `memory_recall` — keyword search of past observations +- `memory_save` — save an insight, decision, or fact +- `memory_smart_search` — hybrid semantic + keyword search +- `memory_sessions` — list recent sessions + +When the MCP server is also wired, all 53 agentmemory MCP tools are available in addition to these plugin tools. + +### Registered commands + +- `agentmemory-recall` — search memories from the command palette +- `agentmemory-remember` — save a memory from the command palette +- `agentmemory-session-history` — list recent sessions + +## Configuration + +| Environment variable | Default | Description | +|---|---|---| +| `AGENTMEMORY_URL` | `http://localhost:3111` | agentmemory REST server URL | +| `AGENTMEMORY_SECRET` | (none) | Bearer token for authenticated deployments | +| `AGENTMEMORY_INJECT_CONTEXT` | `false` | Set to `true` to auto-inject recalled context | +| `AGENTMEMORY_PROJECT_NAME` | git toplevel basename | Override project name | +| `AGENTMEMORY_AMP_DEBUG` | `0` | Set to `1` for debug logging | + +## Remote deployments + +Point the plugin at a remote agentmemory instance: + +```bash +export AGENTMEMORY_URL=https://agentmemory.my-company.com +export AGENTMEMORY_SECRET=my-token +``` + +The plugin and MCP shim both respect these environment variables. \ No newline at end of file diff --git a/integrations/amp/agentmemory.ts b/integrations/amp/agentmemory.ts new file mode 100644 index 000000000..779afca48 --- /dev/null +++ b/integrations/amp/agentmemory.ts @@ -0,0 +1,316 @@ +import type { PluginAPI } from '@ampcode/plugin' + +// --- Configuration ------------------------------------------------ +const API_URL = process.env.AGENTMEMORY_URL || 'http://localhost:3111' +const SECRET = process.env.AGENTMEMORY_SECRET || '' +const INJECT_CONTEXT = process.env.AGENTMEMORY_INJECT_CONTEXT === 'true' +const DEBUG = process.env.AGENTMEMORY_AMP_DEBUG === '1' + +// --- REST helpers ------------------------------------------------- +function authHeaders(): Record { + const h: Record = { 'Content-Type': 'application/json' } + if (SECRET) h['Authorization'] = `Bearer ${SECRET}` + return h +} + +async function post( + path: string, + body: Record, + timeoutMs = 5000, +): Promise { + try { + await fetch(`${API_URL}/agentmemory${path}`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }) + } catch (e) { + if (DEBUG) logger?.log(`POST ${path} failed: ${(e as Error).message}`) + } +} + +async function postJson( + path: string, + body: Record, + timeoutMs = 5000, +): Promise | null> { + try { + const res = await fetch(`${API_URL}/agentmemory${path}`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }) + return res.ok ? ((await res.json()) as Record) : null + } catch (e) { + if (DEBUG) logger?.log(`POST ${path} failed: ${(e as Error).message}`) + return null + } +} + +// --- Project resolution ------------------------------------------- +function resolveProject(): string { + const explicit = process.env.AGENTMEMORY_PROJECT_NAME + if (explicit && explicit.trim()) return explicit.trim() + const cwd = process.cwd() + try { + const top = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { + cwd, + stdout: 'pipe', + stderr: 'ignore', + timeout: 500, + }).stdout?.toString().trim() + if (top) return top.split(/[/\\]/).pop()! + } catch {} + return cwd.split(/[/\\]/).pop()! +} + +// --- Observation helper ------------------------------------------- +async function observe( + sessionId: string, + hookType: string, + data: Record, +): Promise { + await post('/observe', { + hookType, + sessionId, + project: resolveProject(), + cwd: process.cwd(), + timestamp: new Date().toISOString(), + data, + }) +} + +function safeSlice(v: unknown, max: number): string { + if (typeof v === 'string') return v.slice(0, max) + if (v == null) return '' + try { + return JSON.stringify(v).slice(0, max) + } catch { + return '' + } +} + +// --- Plugin-level state ------------------------------------------- +let logger: { log: (msg: string) => void } | undefined +const contextCache = new Map() + +const INSTRUCTIONS = ` +You have access to agentmemory for persistent cross-session memory via the +memory_recall, memory_save, memory_smart_search, and memory_sessions tools. +Use them proactively. + +memory_save - Save an insight, decision, or fact to long-term memory. + Required: content (text), concepts (2-5 comma-separated keywords), type (pattern/preference/architecture/bug/workflow/fact) + Optional: files (comma-separated paths) + Use when: user says "remember this", after discovering a bug, after making + an architectural decision, or after learning a project convention. + +memory_recall - Search past observations by keywords. + Use when: user says "recall", "what did we do", "do you remember", or + needs context from past sessions. + +memory_smart_search - Hybrid semantic + keyword search with progressive disclosure. + Use when: you need the most relevant past context, fuzzy/conceptual searches, + or memory_recall does not find what you need. + +memory_sessions - List recent sessions with status and observation counts. + Use when: user asks about session/past history, "what did we work on". +` + +export default function (amp: PluginAPI) { + logger = amp.logger + amp.logger.log('agentmemory plugin loaded') + + // -- session.start: register the session with the memory server -- + amp.on('session.start', async (event) => { + const sessionId = event.thread.id + const startResult = await postJson('/session/start', { + sessionId, + project: resolveProject(), + cwd: process.cwd(), + }) + const ctx = startResult?.['context'] + if (typeof ctx === 'string' && ctx.length > 0) { + contextCache.set(sessionId, ctx) + } + }) + + // -- agent.start: capture the user's prompt and inject context ---- + amp.on('agent.start', async (event) => { + const sessionId = event.thread.id + + observe(sessionId, 'prompt_submit', { + prompt: safeSlice(event.message, 8000), + message_id: event.id, + }) + + if (INJECT_CONTEXT) { + let ctx = contextCache.get(sessionId) + if (!ctx) { + const result = await postJson('/context', { + sessionId, + project: resolveProject(), + }) + ctx = result?.['context'] as string | undefined + } else { + contextCache.delete(sessionId) + } + if (typeof ctx === 'string' && ctx.length > 0) { + return { + message: { + content: `\n${INSTRUCTIONS}\n\n${ctx}`, + display: false, + }, + } + } + } + + return { + message: { + content: `\n${INSTRUCTIONS}`, + display: false, + }, + } + }) + + // -- tool.call: allow all tool calls (no interception) ---------- + amp.on('tool.call', async (_event) => { + return { action: 'allow' as const } + }) + + // -- tool.result: capture observations for every tool execution -- + amp.on('tool.result', async (event) => { + const sessionId = event.thread.id + + if (event.status === 'error') { + await observe(sessionId, 'post_tool_failure', { + tool_name: event.tool, + call_id: event.toolUseID, + tool_input: safeSlice(event.input, 4000), + tool_output: safeSlice(event.error, 4000), + }) + } else { + await observe(sessionId, 'post_tool_use', { + tool_name: event.tool, + call_id: event.toolUseID, + tool_input: safeSlice(event.input, 4000), + tool_output: safeSlice(event.output, 8000), + }) + } + }) + + // -- agent.end: summarize and end the session -------------------- + amp.on('agent.end', async (event) => { + const sessionId = event.thread.id + const toolCalls = amp.helpers.toolCallsInMessages(event.messages) + await observe(sessionId, 'agent_end', { + status: event.status, + tool_call_count: toolCalls.length, + }) + post('/summarize', { sessionId }, 120000) + post('/session/end', { sessionId }, 5000) + contextCache.delete(sessionId) + }) + + // -- Register memory tools --------------------------------------- + amp.registerTool({ + name: 'memory_recall', + description: 'Search past agentmemory observations by keywords. Returns matching memories from previous sessions.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Keywords to search for in past observations.' }, + limit: { type: 'number', description: 'Maximum number of results (default 10).' }, + }, + required: ['query'], + }, + async execute(input) { + const result = await postJson('/recall', { query: input['query'], limit: input['limit'] ?? 10 }) + return JSON.stringify(result ?? { results: [] }, null, 2) + }, + }) + + amp.registerTool({ + name: 'memory_save', + description: 'Save an insight, decision, pattern, or fact to long-term memory. Use when the user says "remember this" or after discovering something worth keeping.', + inputSchema: { + type: 'object', + properties: { + content: { type: 'string', description: 'The memory content to save.' }, + concepts: { type: 'string', description: '2-5 comma-separated keywords for retrieval (e.g. "auth,jwt,middleware").' }, + type: { type: 'string', description: 'Memory type: pattern, preference, architecture, bug, workflow, or fact.' }, + files: { type: 'string', description: 'Comma-separated file paths related to this memory.' }, + }, + required: ['content', 'concepts'], + }, + async execute(input) { + const result = await postJson('/save', { content: input['content'], concepts: input['concepts'], type: input['type'] ?? 'fact', files: input['files'] ?? '' }) + return JSON.stringify(result ?? { saved: false }, null, 2) + }, + }) + + amp.registerTool({ + name: 'memory_smart_search', + description: 'Hybrid semantic + keyword search across all memories. Use when memory_recall does not find what you need or for fuzzy/conceptual searches.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'The search query (natural language or keywords).' }, + limit: { type: 'number', description: 'Maximum number of results (default 10).' }, + }, + required: ['query'], + }, + async execute(input) { + const result = await postJson('/smart-search', { query: input['query'], limit: input['limit'] ?? 10 }) + return JSON.stringify(result ?? { results: [] }, null, 2) + }, + }) + + amp.registerTool({ + name: 'memory_sessions', + description: 'List recent agentmemory sessions with status and observation counts. Use when the user asks about past history or what was worked on.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Maximum number of sessions to list (default 20).' }, + }, + }, + async execute(input) { + const result = await postJson('/sessions', { limit: input['limit'] ?? 20 }) + return JSON.stringify(result ?? { sessions: [] }, null, 2) + }, + }) + + // -- Register commands ------------------------------------------- + amp.registerCommand('agentmemory-recall', { + title: 'Recall memories', category: 'agentmemory', + description: 'Search agentmemory for past observations matching a query.', + }, async (ctx) => { + const query = await ctx.ui.input({ prompt: 'Search query:', placeholder: 'e.g. JWT auth setup' }) + if (!query) return + const result = await postJson('/smart-search', { query, limit: 10 }) + await ctx.ui.notify(JSON.stringify(result ?? { results: [] }, null, 2).slice(0, 2000)) + }) + + amp.registerCommand('agentmemory-remember', { + title: 'Save a memory', category: 'agentmemory', + description: 'Save an insight, decision, or fact to agentmemory.', + }, async (ctx) => { + const content = await ctx.ui.input({ prompt: 'Memory content:', placeholder: 'e.g. We chose jose over jsonwebtoken for JWT' }) + if (!content) return + const concepts = await ctx.ui.input({ prompt: 'Keywords (comma-separated):', placeholder: 'e.g. auth, jwt, jose' }) + if (!concepts) return + const result = await postJson('/save', { content, concepts, type: 'fact' }) + await ctx.ui.notify(result?.['saved'] ? 'Memory saved.' : 'Save failed -- is agentmemory running?') + }) + + amp.registerCommand('agentmemory-session-history', { + title: 'Session history', category: 'agentmemory', + description: 'List recent agentmemory sessions.', + }, async (ctx) => { + const result = await postJson('/sessions', { limit: 20 }) + await ctx.ui.notify(JSON.stringify(result ?? { sessions: [] }, null, 2).slice(0, 2000)) + }) +} \ No newline at end of file diff --git a/package.json b/package.json index 4e2bc8c4c..b152f9fc2 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "files": [ "dist/", "plugin/", + "integrations/", "iii-config.yaml", "iii-config.docker.yaml", "docker-compose.yml", diff --git a/src/cli/connect/amp.ts b/src/cli/connect/amp.ts new file mode 100644 index 000000000..2d811422d --- /dev/null +++ b/src/cli/connect/amp.ts @@ -0,0 +1,186 @@ +import { existsSync, mkdirSync, copyFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; +import { + AGENTMEMORY_MCP_BLOCK, + backupFile, + logAlreadyWired, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; + +// Amp settings live under: +// macOS/Linux: ~/.config/amp/settings.json +// Windows: %APPDATA%\amp\settings.json +// Amp plugins live under: +// system: ~/.config/amp/plugins/ (or %APPDATA%\amp\plugins\ on Windows) +// project: .amp/plugins/ +// +// The MCP wrapper key in settings.json is "amp.mcpServers" (not the +// standard "mcpServers" used by Claude Code / Cursor / etc). + +function ampConfigDir(): string { + if (process.platform === "win32") { + const appdata = + process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"); + return join(appdata, "amp"); + } + return join(homedir(), ".config", "amp"); +} + +const CONFIG_PATH = join(ampConfigDir(), "settings.json"); +const DETECT_DIR = ampConfigDir(); +const PLUGINS_DIR = join(ampConfigDir(), "plugins"); +const PLUGIN_FILE = join(PLUGINS_DIR, "agentmemory.ts"); + +// Amp uses "amp.mcpServers" as the wrapper key (not "mcpServers"). +const WRAPPER_KEY = "amp.mcpServers"; + +type AmpMcpEntry = typeof AGENTMEMORY_MCP_BLOCK; +type AmpConfig = Record; + +function entryMatches(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return false; + const e = entry as Record; + if (e["command"] !== "npx") return false; + const args = Array.isArray(e["args"]) ? (e["args"] as string[]) : []; + return args.includes("@agentmemory/mcp"); +} + +// Walk upward from this file to find the bundled plugin source at +// integrations/amp/agentmemory.ts. The published package includes +// integrations/ alongside plugin/ (see "files" in package.json). +function findPluginSource(): string { + let dir = dirname(new URL(import.meta.url).pathname.replace(/^\//, "")); + for (let i = 0; i < 15; i++) { + const candidate = join(dir, "integrations", "amp", "agentmemory.ts"); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error( + "Could not locate integrations/amp/agentmemory.ts — is the package installed correctly?", + ); +} + +export const adapter: ConnectAdapter = { + name: "amp", + displayName: "Amp (Sourcegraph)", + category: "native", + docs: "https://github.com/rohitg00/agentmemory/tree/main/integrations/amp", + protocolNote: + "Using MCP via amp.mcpServers in settings.json + native plugin for auto-capture hooks. Run `agentmemory connect amp --with-hooks` to install both.", + + detect(): boolean { + // Detect Amp config dir OR an .amp/ directory in the current project. + return existsSync(DETECT_DIR) || existsSync(".amp"); + }, + + async install(opts: ConnectOptions): Promise { + const existing = readJsonSafe(CONFIG_PATH); + const next: AmpConfig = existing ? { ...existing } : {}; + const servers: Record = { + ...((next[WRAPPER_KEY] as Record) ?? {}), + }; + + const alreadyHas = entryMatches(servers["agentmemory"]); + if (alreadyHas && !opts.force && !opts.withHooks) { + logAlreadyWired(this.displayName, CONFIG_PATH); + return { kind: "already-wired", mutatedPath: CONFIG_PATH }; + } + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${alreadyHas ? "overwrite" : "add"} ${WRAPPER_KEY}.agentmemory in ${CONFIG_PATH}`, + ); + if (opts.withHooks) { + p.log.info(`[dry-run] Would also copy plugin to ${PLUGIN_FILE}`); + } + return { kind: "installed", mutatedPath: CONFIG_PATH }; + } + + let backupPath: string | undefined; + if (existsSync(CONFIG_PATH)) { + backupPath = backupFile(CONFIG_PATH, this.name); + logBackup(backupPath); + } else { + mkdirSync(dirname(CONFIG_PATH), { recursive: true }); + } + + // Write MCP config (always, even with --with-hooks) + if (!alreadyHas || opts.force) { + servers["agentmemory"] = AGENTMEMORY_MCP_BLOCK; + next[WRAPPER_KEY] = servers; + writeJsonAtomic(CONFIG_PATH, next); + + const verify = readJsonSafe(CONFIG_PATH); + const verifyServers = verify?.[WRAPPER_KEY] as + | Record + | undefined; + if (!entryMatches(verifyServers?.["agentmemory"])) { + p.log.error( + `Verification failed: ${CONFIG_PATH} did not contain ${WRAPPER_KEY}.agentmemory after write.`, + ); + return { kind: "skipped", reason: "verification-failed" }; + } + + logInstalled(this.displayName, CONFIG_PATH); + } + + // Install the plugin file when --with-hooks is passed + if (opts.withHooks) { + let pluginSource: string; + try { + pluginSource = findPluginSource(); + } catch (err) { + p.log.warn( + `Plugin file not installed: ${err instanceof Error ? err.message : String(err)}. MCP wiring still applied.`, + ); + return { + kind: "installed", + mutatedPath: CONFIG_PATH, + ...(backupPath !== undefined && { backupPath }), + }; + } + + mkdirSync(PLUGINS_DIR, { recursive: true }); + + // Only copy if the source is different from what's already there + let shouldCopy = true; + if (existsSync(PLUGIN_FILE) && !opts.force) { + try { + const srcStat = statSync(pluginSource); + const dstStat = statSync(PLUGIN_FILE); + if (srcStat.size === dstStat.size && srcStat.mtimeMs <= dstStat.mtimeMs) { + shouldCopy = false; + } + } catch {} + } + + if (shouldCopy) { + copyFileSync(pluginSource, PLUGIN_FILE); + logInstalled("agentmemory plugin (auto-capture)", PLUGIN_FILE); + p.log.info( + "Run `plugins: reload` from the Amp command palette (Ctrl+O) to activate.", + ); + } else { + p.log.info(`Plugin already up to date at ${PLUGIN_FILE}`); + } + } + + p.log.info( + "Restart Amp (or run `plugins: reload` from the command palette) to pick up agentmemory.", + ); + + return { + kind: "installed", + mutatedPath: CONFIG_PATH, + ...(backupPath !== undefined && { backupPath }), + }; + }, +}; \ No newline at end of file diff --git a/src/cli/connect/index.ts b/src/cli/connect/index.ts index 0d9412ca6..215839983 100644 --- a/src/cli/connect/index.ts +++ b/src/cli/connect/index.ts @@ -3,6 +3,7 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; import { adapter as antigravity } from "./antigravity.js"; +import { adapter as amp } from "./amp.js"; import { adapter as claudeCode } from "./claude-code.js"; import { adapter as cline } from "./cline.js"; import { adapter as copilotCli } from "./copilot-cli.js"; @@ -25,6 +26,7 @@ export const ADAPTERS: readonly ConnectAdapter[] = [ claudeCode, copilotCli, codex, + amp, cursor, geminiCli, qwen, @@ -100,7 +102,8 @@ export async function runAdapter( export async function runConnect(args: string[]): Promise { const { dryRun, force, all, withHooks, positional } = parseFlags(args); const allowWindowsAdapter = - positional.length === 1 && positional[0]?.toLowerCase() === "copilot-cli"; + positional.length === 1 && + ["copilot-cli", "amp"].includes(positional[0]?.toLowerCase() ?? ""); if (platform() === "win32" && !allowWindowsAdapter) { p.intro("agentmemory connect"); p.log.warn( diff --git a/test/cli-connect.test.ts b/test/cli-connect.test.ts index 46a1f240b..3e3a0ce14 100644 --- a/test/cli-connect.test.ts +++ b/test/cli-connect.test.ts @@ -43,6 +43,7 @@ describe("agentmemory connect — dispatcher", () => { it("ships the supported agent list", () => { expect(knownAgents().sort()).toEqual( [ + "amp", "antigravity", "claude-code", "cline", @@ -63,7 +64,7 @@ describe("agentmemory connect — dispatcher", () => { "zed", ].sort(), ); - expect(ADAPTERS.length).toBe(18); + expect(ADAPTERS.length).toBe(19); }); it("every adapter exposes detect() and install()", () => { diff --git a/test/connect-amp.test.ts b/test/connect-amp.test.ts new file mode 100644 index 000000000..893aedb37 --- /dev/null +++ b/test/connect-amp.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir, platform } from "node:os"; +import { join } from "node:path"; + +// Connect adapter for Amp (Sourcegraph). Writes the canonical MCP +// block into amp.mcpServers (Amp's wrapper key, not the standard +// mcpServers) in the Amp settings.json. On Windows the config dir is +// %APPDATA%/amp; on macOS/Linux it is ~/.config/amp. + +function freshHome(): string { + return mkdtempSync(join(tmpdir(), "am-connect-amp-")); +} + +// Returns the config directory that the adapter will actually use, +// mirroring ampConfigDir() in the adapter source. +function ampConfigDir(home: string): string { + if (platform() === "win32") { + const appdata = process.env["APPDATA"] ?? join(home, "AppData", "Roaming"); + return join(appdata, "amp"); + } + return join(home, ".config", "amp"); +} + +describe("connect: Amp", () => { + let home: string; + const ORIG_HOME = process.env["HOME"]; + const ORIG_APPDATA = process.env["APPDATA"]; + + beforeEach(() => { + home = freshHome(); + vi.resetModules(); + process.env["HOME"] = home; + process.env["APPDATA"] = join(home, "AppData", "Roaming"); + }); + + afterEach(() => { + process.env["HOME"] = ORIG_HOME; + if (ORIG_APPDATA !== undefined) { + process.env["APPDATA"] = ORIG_APPDATA; + } else { + delete process.env["APPDATA"]; + } + rmSync(home, { recursive: true, force: true }); + }); + + it("does not detect when amp config dir is absent", async () => { + const { adapter } = await import("../src/cli/connect/amp.js"); + expect(adapter.detect()).toBe(false); + }); + + it("writes amp.mcpServers.agentmemory to settings.json", async () => { + const configDir = ampConfigDir(home); + mkdirSync(configDir, { recursive: true }); + const { adapter } = await import("../src/cli/connect/amp.js"); + expect(adapter.detect()).toBe(true); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + const cfgPath = join(configDir, "settings.json"); + expect(existsSync(cfgPath)).toBe(true); + const cfg = JSON.parse(readFileSync(cfgPath, "utf-8")); + // Amp uses "amp.mcpServers" as the wrapper key + expect(cfg["amp.mcpServers"].agentmemory.command).toBe("npx"); + expect(cfg["amp.mcpServers"].agentmemory.args).toContain("@agentmemory/mcp"); + expect(cfg["amp.mcpServers"].agentmemory.env.AGENTMEMORY_URL).toMatch( + /\$\{AGENTMEMORY_URL:-/, + ); + // Standard mcpServers should NOT be present (Amp uses amp.mcpServers) + expect(cfg.mcpServers).toBeUndefined(); + }); + + it("returns already-wired when agentmemory is already configured", async () => { + const configDir = ampConfigDir(home); + mkdirSync(configDir, { recursive: true }); + const { writeFileSync } = await import("node:fs"); + writeFileSync( + join(configDir, "settings.json"), + JSON.stringify({ + "amp.mcpServers": { + agentmemory: { + command: "npx", + args: ["-y", "@agentmemory/mcp"], + env: { AGENTMEMORY_URL: "${AGENTMEMORY_URL:-http://localhost:3111}" }, + }, + }, + }), + ); + const { adapter } = await import("../src/cli/connect/amp.js"); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("already-wired"); + }); + + it("overwrites when --force is passed", async () => { + const configDir = ampConfigDir(home); + mkdirSync(configDir, { recursive: true }); + const { writeFileSync } = await import("node:fs"); + writeFileSync( + join(configDir, "settings.json"), + JSON.stringify({ + "amp.mcpServers": { + agentmemory: { command: "old", args: [], env: {} }, + }, + }), + ); + const { adapter } = await import("../src/cli/connect/amp.js"); + const result = await adapter.install({ dryRun: false, force: true }); + expect(result.kind).toBe("installed"); + const cfg = JSON.parse(readFileSync(join(configDir, "settings.json"), "utf-8")); + expect(cfg["amp.mcpServers"].agentmemory.command).toBe("npx"); + }); + + it("preserves existing settings when writing MCP config", async () => { + const configDir = ampConfigDir(home); + mkdirSync(configDir, { recursive: true }); + const { writeFileSync } = await import("node:fs"); + writeFileSync( + join(configDir, "settings.json"), + JSON.stringify({ amp: { someSetting: true } }), + ); + const { adapter } = await import("../src/cli/connect/amp.js"); + await adapter.install({ dryRun: false, force: false }); + const cfg = JSON.parse(readFileSync(join(configDir, "settings.json"), "utf-8")); + expect(cfg.amp.someSetting).toBe(true); + expect(cfg["amp.mcpServers"].agentmemory).toBeDefined(); + }); + + it("dry-run does not write any file", async () => { + const configDir = ampConfigDir(home); + mkdirSync(configDir, { recursive: true }); + const { adapter } = await import("../src/cli/connect/amp.js"); + const result = await adapter.install({ dryRun: true, force: false }); + expect(result.kind).toBe("installed"); + expect(existsSync(join(configDir, "settings.json"))).toBe(false); + }); +}); + +describe("connect: Amp registered in ADAPTERS", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("knownAgents includes amp", async () => { + const { knownAgents } = await import("../src/cli/connect/index.js"); + const agents = knownAgents(); + expect(agents).toContain("amp"); + }); + + it("resolveAdapter finds amp", async () => { + const { resolveAdapter } = await import("../src/cli/connect/index.js"); + const adapter = resolveAdapter("amp"); + expect(adapter).not.toBeNull(); + expect(adapter?.name).toBe("amp"); + expect(adapter?.category).toBe("native"); + }); +}); \ No newline at end of file