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