diff --git a/README.md b/README.md index a716d8f1c..767e7aeb5 100644 --- a/README.md +++ b/README.md @@ -679,9 +679,11 @@ The agentmemory entry is the **same MCP server block** across every host that us | **Continue.dev** | `~/.continue/config.yaml` (preferred) or `config.json` (legacy) | `agentmemory connect continue` creates `config.yaml` from scratch when neither exists, or modifies existing `config.json`. **If you already have `config.yaml`** the adapter prints the exact block to paste under `mcpServers:` — it won't silently rewrite your yaml because preserving comments and anchors safely needs a YAML parser the package doesn't ship. Continue uses array form (not object) for `mcpServers`. | | **Zed** | `~/.config/zed/settings.json` | `agentmemory connect zed` writes under `context_servers` (Zed's key, NOT `mcpServers`). Remote MCP servers can be wired via `{"url": "..."}` instead. | | **Droid (Factory.ai)** | `~/.factory/mcp.json` | `agentmemory connect droid` writes the standard `mcpServers` block. Project-scoped overrides go in `/.factory/mcp.json`. Pass `--with-hooks` for native auto-capture. | +| **DeepSeek Harness (dsh)** | `~/.dsh/profiles//cordis.patch.yml` | `agentmemory connect dsh` appends the MCP entry for `@deepseek-ai/dsh-mcp-client` (stdio), writes the memory-usage guideline into `~/.dsh/AGENTS.md` (auto-injected every session), and installs the `agentmemory-sync` skill under `~/.dsh/skills/`. HMR hot-reloads the patch — no restart. | +| **DeepSeek Harness (full plugin)** | `plugin/dsh/` | `dsh plugin --profile web add @agentmemory/dsh` — cordis plugin: session/created → `/session/start`, first-step context+instructions injection (`agent/pre-step`), `user/message`/`tool/call`/`approval/asked` observations, `compaction/summary` bridge, `session/disposed` → `/session/end` summarization. See [`plugin/dsh/README.md`](plugin/dsh/README.md). | | **Goose** | Goose MCP settings UI | Same `mcpServers` block — use `goose configure` → Add Extension → MCP. Direct YAML edit at `~/.config/goose/config.yaml` is supported but the schema uses `extensions:` + `cmd` (not `mcpServers:` + `command`). | | **Aider** | n/a | Talk to the REST API directly: `curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`. | -| **Any agent (32+)** | n/a | `npx skillkit install agentmemory` auto-detects the host and merges. | +| **Any agent (33+)** | n/a | `npx skillkit install agentmemory` auto-detects the host and merges. | **Sandboxed MCP clients** (Flatpak / Snap / restrictive containers) that can't reach the host's `localhost`: also set `"AGENTMEMORY_FORCE_PROXY": "1"` in the `env` block, and point `AGENTMEMORY_URL` at a route the sandbox can actually reach (e.g. your LAN IP). diff --git a/docs/dsh-integration.md b/docs/dsh-integration.md new file mode 100644 index 000000000..48dd259ad --- /dev/null +++ b/docs/dsh-integration.md @@ -0,0 +1,52 @@ +# agentmemory × DeepSeek Harness (dsh) — Integration Design + +## Background + +dsh (DeepSeek Harness) is a cordis-based agent harness with no long-term memory: session transcripts and compaction summaries stay per-session and are not semantically retrievable. This PR closes the gap using agentmemory's existing surfaces — the REST lifecycle endpoints (`/session/start`, `/observe`, `/context`, `/session/end`, `/remember`) and the MCP shim (`@agentmemory/mcp`) — mirroring the OpenCode plugin pattern (`plugin/opencode/`). + +## Architecture (four layers) + +| Layer | What | Where | +|---|---|---| +| L1 MCP bridge | `mcp__agentmemory__*` tools via `@deepseek-ai/dsh-mcp-client` (stdio → `@agentmemory/mcp` shim, proxies to the daemon; reduced local fallback when unreachable) | `~/.dsh/profiles/

/cordis.patch.yml` entry, written by `agentmemory connect dsh` | +| L2 Behavior guidance | memory-usage guideline injected into every session (`~/.dsh/AGENTS.md`, read by dsh-agent-instructions) + `agentmemory-sync` skill (`~/.dsh/skills/`) | `src/cli/connect/guidelines.ts` + adapter | +| L3 Auto-capture plugin | `@agentmemory/dsh` cordis plugin: session lifecycle → REST | `plugin/dsh/` | +| L4 Deep collaboration | per-agent isolation (`agentId`), compaction bridge, team memory | plugin config / future | + +## Event mapping: agentmemory hooks ↔ dsh events + +The plugin mirrors the official Claude Code hook semantics 1:1 on dsh's event stream: + +| agentmemory hook (Claude Code) | dsh event | Form | +|---|---|---| +| SessionStart | `session/created` → `POST /agentmemory/session/start` | injecting (await + timeout + fail silent) | +| SessionStart stdout injection | `agent/pre-step` (step 1) middleware → first-step batch fold (idempotent per session) | injecting | +| UserPromptSubmit | `session/event` (`user/message`) → `/observe prompt_submit` | telemetry (fire-and-forget) | +| PreToolUse (matcher) | `session/event` (`tool/call`) → `/observe post_tool_use` (`mcp__agentmemory__*` self-calls filtered) | telemetry | +| Notification | `approval/asked` → `/observe notification` (allowlisted fields) | telemetry | +| Stop / SessionEnd | `session/disposed` → `POST /agentmemory/session/end` | telemetry (30s, promise-tracked) | +| PreCompact | `compaction/summary` → `POST /agentmemory/remember` (compaction bridge) | telemetry | + +Event names are taken from `@deepseek-ai/dsh-session` / `@deepseek-ai/dsh-agent` (`session/created`, `session/disposed`, `session/event` stream types `user/message`/`tool/call`/`tool/result`, `agent/pre-step` middleware with `(_assembly, _context, next)`-style chaining, `approval/asked`, `compaction/summary`). + +## Design contract (same as the official hooks) + +- Injecting handlers **await + time out + fail silently**; they never throw into cordis (`signal.aborted` returns the unmodified decision). +- Telemetry handlers are **fire-and-forget** and never block the agent loop; in-flight promises are tracked so nothing is dropped at teardown. +- Project resolution (`git rev-parse`) is cached per cwd — no child process per event; per-session `{cwd, project}` captured at `session/created`. +- Zero runtime dependencies: the plugin is a thin REST bridge (Node built-ins only), so dsh `file:` consumers need no build step; `lib/` is committed (repo convention, cf. `plugin/scripts/*.mjs`). + +## Verification (live, macOS) + +- `npm test`: 1620 passed, 6 failed (all pre-existing environment failures in `embedding-provider.test.ts`, unrelated to this change), 1 skipped out of 1627 total. +- `@agentmemory/mcp` stdio handshake: initialize + `tools/list` → 53 tools; `memory_sessions` returns real daemon data. +- dsh `session.create` → daemon registers a session with `agentId=dsh`; observations captured during active sessions. +- `~/.dsh/AGENTS.md` guideline injected into live dsh sessions; `agentmemory-sync` skill picked up by dsh's skill registry. + +## Known environment caveat + +A root-owned `~/.npm/_cacache` (npm historical bug) makes `npx -y @agentmemory/mcp` fail with EPERM, so the dsh MCP bridge cannot spawn the shim. Fix: `sudo chown -R "$(id -u):$(id -g)" ~/.npm` (never hard-code UID/GID), or point `--cache` at a private per-user directory in the entry's `args` (the installer generates `~/.cache/npmcache-dsh`; both documented in `plugin/dsh/install/cordis.patch.yml`). + +## Installer + +`scripts/dsh-install.cjs` applies L1+L2 and declares the L3 plugin dependency idempotently (`--dry-run` preview, `--no-plugin` for config-only). diff --git a/plugin/dsh/README.md b/plugin/dsh/README.md new file mode 100644 index 000000000..f84769e86 --- /dev/null +++ b/plugin/dsh/README.md @@ -0,0 +1,99 @@ +# @agentmemory/dsh — agentmemory for DeepSeek Harness + +A cordis plugin that connects agentmemory's long-term memory to DeepSeek Harness (dsh). It registers sessions on start, injects recalled context into the first step, captures user messages and tool calls, bridges compaction summaries into the memory store, and summarizes sessions on dispose — mirroring the official agentmemory hooks for Claude Code on dsh's event stream. + +| agentmemory hook (Claude Code) | This plugin (dsh event) | Form | +|---|---|---| +| SessionStart | `session/created` → `POST /agentmemory/session/start` | injecting (await + timeout + fail silent) | +| SessionStart stdout injection | `agent/pre-step` (step 1) middleware → first-step batch fold | injecting | +| UserPromptSubmit | `session/event` (`user/message`) → `/observe prompt_submit` | telemetry (fire-and-forget) | +| PreToolUse (matcher) | `session/event` (`tool/call`) → `/observe post_tool_use` (filters `mcp__agentmemory__*` self-calls) | telemetry | +| Notification | `approval/asked` → `/observe notification` (allowlisted fields) | telemetry | +| Stop / SessionEnd | `session/disposed` → `POST /agentmemory/session/end` | telemetry (30s, tracked) | +| PreCompact | `compaction/summary` → `POST /agentmemory/remember` (compaction bridge) | telemetry | + +## Install + +Prerequisite: the agentmemory daemon is running (`npx @agentmemory/agentmemory`, REST `http://localhost:3111`). + +### 1. MCP bridge (tools, optional but recommended) + +Append the `mcp-agentmemory` entry from `install/cordis.patch.yml` to `~/.dsh/profiles//cordis.patch.yml` (HMR hot-reloads it — no restart). dsh agents then get `mcp__agentmemory__*` tools. + +### 2. Plugin (auto-capture) + +```bash +dsh plugin --profile add @agentmemory/dsh +``` + +Development (local repo): + +```bash +# add to ~/.dsh/profiles/web/package.json dependencies: +# "@agentmemory/dsh": "file:/path/to/agentmemory/plugin/dsh" +cd ~/.dsh/profiles/web && pnpm install +``` + +Then append to `cordis.patch.yml` (defaults shown; override as needed): + +```yaml +- insert: + - id: agentmemory + name: '@agentmemory/dsh' + config: + url: http://localhost:3111 # agentmemory REST + secret: '' # match the daemon AGENTMEMORY_SECRET + agentId: dsh # per-agent memory isolation + injectInstructions: true # inject memory-tool guidance on first turn + injectContext: true # inject recalled project context on first turn + injectMaxChars: 6000 # injection budget (≈2k tokens) + observeToolCalls: true # capture tool calls as observations + compactionBridge: true # persist compaction summaries as memories + summarizeOnDispose: true # LLM summary on session dispose +``` + +Restart dsh (or wait for HMR to load the new plugin). + +### 3. Behavior guidance (optional) + +- Global guidance: `install/AGENTS.md` → `~/.dsh/AGENTS.md` (auto-injected into every session) +- Memory skill: `install/skills/agentmemory-sync/` → `~/.dsh/skills/agentmemory-sync/` + +## Verify + +1. The first turn of a new session should show injected recalled context/guidance. +2. `curl http://localhost:3111/agentmemory/sessions` lists the dsh sessions. +3. After ending a session, `curl http://localhost:3111/agentmemory/search -H 'Content-Type: application/json' -d '{"query":""}'` recalls the new memory (add `-H "Authorization: Bearer $AGENTMEMORY_SECRET"` when the daemon requires auth). + +## Development + +```bash +npm run build # tsdown → lib/index.js (zero runtime dependencies) +npm test # vitest (20 cases: REST client / event mapping / injection / self-call filter / fail-open) +``` + +Design contract (same as the official hooks): injecting handlers await + time out + fail silently; telemetry handlers fire-and-forget and never block the agent loop; REST failures never throw into cordis (with `AGENTMEMORY_DSH_DEBUG=1` they are logged via the REST client). + +## Config + +| Field | Default | Description | +|---|---|---| +| `url` | `http://localhost:3111` | agentmemory REST base URL | +| `secret` | empty | Bearer auth (only if the daemon sets `AGENTMEMORY_SECRET`) | +| `agentId` | `dsh` | memory owner agent; isolation key under `AGENTMEMORY_AGENT_SCOPE=isolated` | +| `injectInstructions` | `true` | inject memory-tool guidance on the first turn | +| `injectContext` | `true` | inject `/context` recalled project context on the first turn | +| `injectMaxChars` | `6000` | total injection budget in characters | +| `observeToolCalls` | `true` | `tool/call` → `post_tool_use` observations | +| `compactionBridge` | `true` | `compaction/summary` → `/remember` | +| `summarizeOnDispose` | `true` | `session/disposed` → `/session/end` (LLM summary) | + +## Build artifacts + +`lib/index.js` (and the hand-written `lib/index.d.ts`) are committed alongside the source: dsh `file:` consumers load `lib/` directly with no publish-time build. After changing `src/index.ts`, run `npm run build` and commit the new `lib/` (the repo already commits build output, cf. `plugin/scripts/*.mjs`). + +## Limitations + +- Event payload fields follow the running dsh version (`session/created`/`disposed` carry the session object; `agent/pre-step` is middleware; `session/event` stream events are `{type, data}`). +- `approval/asked` observations are skipped when the payload has no `sessionId`. +- The plugin only bridges REST; the MCP tools still need step 1's bridge. diff --git a/plugin/dsh/install/AGENTS.md b/plugin/dsh/install/AGENTS.md new file mode 100644 index 000000000..846062a16 --- /dev/null +++ b/plugin/dsh/install/AGENTS.md @@ -0,0 +1,7 @@ +## Agent memory (agentmemory) + +You have persistent long-term memory via the agentmemory MCP server. Tools: `mcp__agentmemory__memory_recall`, `memory_smart_search`, `memory_save`, `memory_sessions`. + +- At the START of a task, call `memory_recall` (or `memory_smart_search`) to load relevant past decisions, fixes, and user preferences; do not re-ask. +- When you learn something durable (a decision, a fix, a gotcha, a preference, a project convention), call `memory_save` to persist it. +- Prefer recall over re-deriving; save concise reusable facts, not transcripts. diff --git a/plugin/dsh/install/cordis.patch.yml b/plugin/dsh/install/cordis.patch.yml new file mode 100644 index 000000000..d28689225 --- /dev/null +++ b/plugin/dsh/install/cordis.patch.yml @@ -0,0 +1,38 @@ +# ── agentmemory L1: MCP bridge (append into ~/.dsh/profiles//cordis.patch.yml) ── +# Provides mcp__agentmemory__* tools to every DSH agent. The stdio shim +# proxies to the agentmemory daemon (AGENTMEMORY_URL) and falls back to a +# reduced local tool set when the daemon is unreachable. +- insert: + - id: mcp-agentmemory + name: '@deepseek-ai/dsh-mcp-client' + config: + transport: stdio + serverName: agentmemory + command: npx + # If npx fails with EPERM (broken ~/.npm cache), point --cache at a + # private per-user dir, e.g. '~/.cache/npmcache-dsh' expanded to an + # absolute path by the installer (never /tmp — predictable + shared): + # args: ['--cache', '/home//.cache/npmcache-dsh', '-y', '@agentmemory/mcp'] + args: ['-y', '@agentmemory/mcp'] + env: + AGENTMEMORY_URL: http://localhost:3111 + # AGENTMEMORY_SECRET: '' + # AGENTMEMORY_TOOLS: all # default: 8 core tools only (saves tokens) + toolCallTimeoutMs: 60000 + failOnStartupError: false + +# ── agentmemory L3: cordis plugin (add after 'dsh plugin --profile

add @agentmemory/dsh') ── +# Note: new entries must be wrapped in an insert list; a top-level `- id:` only +# overrides the config of an existing entry. +- insert: + - id: agentmemory + name: '@agentmemory/dsh' + config: + url: http://localhost:3111 + secret: '' + agentId: dsh + injectInstructions: true + injectContext: true + observeToolCalls: true + compactionBridge: true + summarizeOnDispose: true diff --git a/plugin/dsh/install/skills/agentmemory-sync/SKILL.md b/plugin/dsh/install/skills/agentmemory-sync/SKILL.md new file mode 100644 index 000000000..87fee13c2 --- /dev/null +++ b/plugin/dsh/install/skills/agentmemory-sync/SKILL.md @@ -0,0 +1,10 @@ +--- +name: agentmemory-sync +description: Sync with the agentmemory long-term memory at task start/end. Use when starting a new task, recalling past work, finishing a task, needing cross-session context, or reviewing what was done. +--- +# agentmemory memory sync + +1. At task start: call mcp__agentmemory__memory_recall with the task keywords + project path, format=compact, to bring relevant history into context. +2. During the task: when you learn something durable (decision/fix/preference/convention), call memory_save immediately (type=fact, concepts: 2-5 keywords). +3. At task end: call memory_save with a short outcome summary (type=insight), and confirm the session is registered via memory_sessions. +4. For large handoffs: call memory_smart_search with expandIds for graph-diffusion recall, or use memory_lesson_save/memory_lesson_recall for lessons. diff --git a/plugin/dsh/lib/index.d.ts b/plugin/dsh/lib/index.d.ts new file mode 100644 index 000000000..b2cdb3b01 --- /dev/null +++ b/plugin/dsh/lib/index.d.ts @@ -0,0 +1,45 @@ +export interface PluginContext { + on(event: string, listener: (...args: any[]) => unknown): void; + effect(callback: () => void | (() => void), label?: string): void; + logger: { info(...args: unknown[]): void; warn(...args: unknown[]): void; error(...args: unknown[]): void }; +} + +export interface SessionLike { + id: string; + header?: { cwd?: string }; +} + +// Event payloads vary by event type; consumers narrow data at runtime. +export interface SessionEvent { + type: string; + seq?: number; + data: any; +} + +export interface AgentmemoryConfig { + url: string; + secret: string; + agentId: string; + injectInstructions: boolean; + injectContext: boolean; + injectMaxChars: number; + observeToolCalls: boolean; + compactionBridge: boolean; + summarizeOnDispose: boolean; +} + +export interface RestClient { + post(path: string, body: Record, timeoutMs?: number): Promise; + fire(path: string, body: Record, timeoutMs?: number): void; +} + +export function makeRestClient(url: string, secret: string, debug?: boolean): RestClient; +export function resolveProjectName(cwd: string, env?: Record): string; +export function isAgentmemoryTool(name: string): boolean; +export function eventTextContent(content: unknown): string; +export function userMessagePrompt(event: SessionEvent, maxChars: number): string | null; +export function toolCallObservation(event: SessionEvent, maxChars: number): Record | null; +export function compactionSummary(event: SessionEvent, maxChars: number): string | null; + +export const name: string; +export function apply(ctx: PluginContext, config?: Partial): void; diff --git a/plugin/dsh/lib/index.js b/plugin/dsh/lib/index.js new file mode 100644 index 000000000..1362099cc --- /dev/null +++ b/plugin/dsh/lib/index.js @@ -0,0 +1,316 @@ +import { execFileSync } from "node:child_process"; +import { basename } from "node:path"; +import { randomUUID } from "node:crypto"; +//#region src/index.ts +const DEFAULTS = { + url: "http://localhost:3111", + secret: "", + agentId: "dsh", + injectInstructions: true, + injectContext: true, + injectMaxChars: 6e3, + observeToolCalls: true, + compactionBridge: true, + summarizeOnDispose: true +}; +const PROMPT_MAX_CHARS = 8e3; +const TOOL_INPUT_MAX_CHARS = 4e3; +const COMPACTION_MAX_CHARS = 6e3; +const OBSERVE_TIMEOUT_MS = 1500; +const SESSION_START_TIMEOUT_MS = 2e3; +const CONTEXT_TIMEOUT_MS = 3e3; +const REMEMBER_TIMEOUT_MS = 5e3; +const SESSION_END_TIMEOUT_MS = 3e4; +const INSTRUCTIONS = [ + "", + "You have persistent cross-session long-term memory via agentmemory. Tools are namespaced `mcp__agentmemory__*` (memory_recall / memory_save / memory_smart_search / memory_sessions / ...).", + "- At the START of a task, call `mcp__agentmemory__memory_recall` (or `memory_smart_search`) to load relevant past decisions, fixes, and user preferences; do not re-ask.", + "- When you learn something durable (a decision, a fix, a gotcha, a preference, a project convention), call `mcp__agentmemory__memory_save` (concepts: 2-5 comma-separated keywords).", + "- Prefer recall over re-deriving; save concise reusable facts, not transcripts.", + "- If the user asks to forget/delete something, use `memory_governance_delete` (comma-separated memoryIds).", + "- Tool results are JSON — inspect them before presenting to the user.", + "" +].join("\n"); +function makeRestClient(url, secret, debug = false) { + function headers() { + const h = { "Content-Type": "application/json" }; + if (secret) h["Authorization"] = `Bearer ${secret}`; + return h; + } + async function post(path, body, timeoutMs = 3e3) { + try { + const res = await fetch(`${url}/agentmemory${path}`, { + method: "POST", + headers: headers(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs) + }); + if (!res.ok) return null; + return await res.json(); + } catch (err) { + if (debug) console.error(`[agentmemory] POST ${path} failed:`, err.message); + return null; + } + } + function fire(path, body, timeoutMs = 1500) { + post(path, body, timeoutMs); + } + return { + post, + fire + }; +} +function resolveProjectName(cwd, env = process.env) { + const explicit = env["AGENTMEMORY_PROJECT_NAME"]?.trim(); + if (explicit) return explicit; + try { + const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + encoding: "utf8" + }).trim(); + if (top) return basename(top); + } catch {} + return basename(cwd) || cwd; +} +function isAgentmemoryTool(name) { + return name.startsWith("mcp__agentmemory__") || name.startsWith("agentmemory_"); +} +function eventTextContent(content) { + if (typeof content === "string") return content; + if (Array.isArray(content)) return content.map((block) => { + if (typeof block === "string") return block; + if (block && typeof block === "object") { + const b = block; + if (b.type === "text" && typeof b.text === "string") return b.text; + } + return ""; + }).join("\n"); + return ""; +} +function userMessagePrompt(event, maxChars) { + if (event.type !== "user/message") return null; + const text = eventTextContent(event.data?.content); + if (!text.trim()) return null; + return text.slice(0, maxChars); +} +function toolCallObservation(event, maxChars) { + if (event.type !== "tool/call") return null; + const data = event.data ?? {}; + const name = typeof data.name === "string" ? data.name : ""; + if (!name || isAgentmemoryTool(name)) return null; + let input; + try { + input = JSON.stringify(data.arguments ?? {}).slice(0, maxChars); + } catch { + input = String(data.arguments ?? "").slice(0, maxChars); + } + return { + tool_name: name, + call_id: typeof data.callId === "string" ? data.callId : null, + tool_input: input + }; +} +function compactionSummary(event, maxChars) { + if (event.type !== "compaction/summary") return null; + const data = event.data ?? {}; + const summary = (typeof data.summary === "string" && data.summary ? data.summary : void 0) ?? (typeof data.text === "string" && data.text ? data.text : void 0) ?? (typeof data.content === "string" && data.content ? data.content : void 0); + if (!summary) return null; + return summary.slice(0, maxChars); +} +const name = "agentmemory"; +function apply(ctx, rawConfig = {}) { + const cfg = { + ...DEFAULTS, + ...rawConfig + }; + const debug = process.env["AGENTMEMORY_DSH_DEBUG"] === "1"; + const rest = makeRestClient(cfg.url, cfg.secret, debug); + const logger = ctx.logger; + const startContextCache = /* @__PURE__ */ new Map(); + const injectedSessions = /* @__PURE__ */ new Set(); + const sessionInfos = /* @__PURE__ */ new Map(); + const projectNameCache = /* @__PURE__ */ new Map(); + const pendingCalls = /* @__PURE__ */ new Set(); + function sessionCwd(session) { + return session.header?.cwd || process.cwd(); + } + function cachedProjectName(cwd) { + let project = projectNameCache.get(cwd); + if (project === void 0) { + project = resolveProjectName(cwd); + projectNameCache.set(cwd, project); + } + return project; + } + function trackSession(session) { + if (!session || typeof session.id !== "string") return void 0; + const cwd = sessionCwd(session); + const info = { + cwd, + project: cachedProjectName(cwd) + }; + sessionInfos.set(session.id, info); + return info; + } + function fireObserve(sid, info, hookType, data) { + const call = rest.post("/observe", { + hookType, + sessionId: sid, + project: info.project, + cwd: info.cwd, + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data + }, OBSERVE_TIMEOUT_MS).catch(() => {}); + pendingCalls.add(call); + call.then(() => pendingCalls.delete(call)); + } + ctx.on("session/created", (session) => { + if (!session || typeof session.id !== "string") return; + const sid = session.id; + const info = trackSession(session); + if (!info) return; + const call = rest.post("/session/start", { + sessionId: sid, + project: info.project, + cwd: info.cwd, + agentId: cfg.agentId + }, SESSION_START_TIMEOUT_MS).then((result) => { + const context = result?.context; + if (typeof context === "string" && context.length > 0) startContextCache.set(sid, context); + }).catch(() => {}); + pendingCalls.add(call); + call.then(() => pendingCalls.delete(call)); + }); + ctx.on("session/event", (session, event) => { + if (!session || !event || typeof event.type !== "string") return; + const sid = session.id; + if (!sid) return; + let info = sessionInfos.get(sid); + if (!info) { + info = trackSession(session); + if (!info) return; + } + const prompt = userMessagePrompt(event, PROMPT_MAX_CHARS); + if (prompt !== null) { + fireObserve(sid, info, "prompt_submit", { userPrompt: prompt }); + return; + } + const tool = toolCallObservation(event, TOOL_INPUT_MAX_CHARS); + if (tool !== null && cfg.observeToolCalls) { + fireObserve(sid, info, "post_tool_use", tool); + return; + } + if (cfg.compactionBridge) { + const summary = compactionSummary(event, COMPACTION_MAX_CHARS); + if (summary !== null) { + const call = rest.post("/remember", { + content: `[dsh compaction] ${summary}`, + type: "fact", + concepts: ["compaction"], + project: info.project + }, REMEMBER_TIMEOUT_MS).catch(() => {}); + pendingCalls.add(call); + call.then(() => pendingCalls.delete(call)); + } + } + }); + ctx.on("agent/pre-step", async ({ agent, messages, step, signal }, next) => { + const decision = await next(); + const session = agent?.session; + if (!session || typeof session.id !== "string") return decision; + const sid = session.id; + if (step !== 1 || injectedSessions.has(sid)) return decision; + if (decision.kind !== "enter" || !Array.isArray(decision.messages) || decision.messages.length === 0) return decision; + if (signal.aborted) return decision; + const project = (sessionInfos.get(sid) ?? trackSession(session))?.project ?? cachedProjectName(sessionCwd(session)); + const parts = []; + if (cfg.injectInstructions) parts.push(INSTRUCTIONS); + if (cfg.injectContext) { + let context = startContextCache.get(sid); + if (!context) { + const result = await rest.post("/context", { + sessionId: sid, + project + }, CONTEXT_TIMEOUT_MS); + context = typeof result?.context === "string" ? result.context : ""; + } else startContextCache.delete(sid); + if (context) parts.push(context); + } + if (parts.length === 0) return decision; + const text = parts.join("\n\n").slice(0, cfg.injectMaxChars); + const message = { + id: randomUUID(), + role: "user", + content: [{ + type: "text", + text + }], + source: { + kind: "agentmemory", + form: "memory-context" + } + }; + injectedSessions.add(sid); + const lastClaimedIndex = decision.messages.length - 1 - [...decision.messages].reverse().findIndex((m) => messages.includes(m)); + const nextMessages = decision.messages.slice(); + nextMessages.splice(lastClaimedIndex + 1, 0, message); + return { + ...decision, + messages: nextMessages + }; + }); + const APPROVAL_FIELDS = [ + "permission", + "pattern", + "title", + "tool_call_id", + "metadata" + ]; + ctx.on("approval/asked", (req) => { + const data = req ?? {}; + const sid = typeof data.sessionId === "string" ? data.sessionId : void 0; + if (!sid) return; + const info = sessionInfos.get(sid); + if (!info) return; + const payload = { notification_type: "permission_prompt" }; + for (const key of APPROVAL_FIELDS) { + const value = data[key]; + if (value === void 0) continue; + let text; + if (typeof value === "string") text = value; + else try { + text = JSON.stringify(value) ?? String(value); + } catch { + text = String(value); + } + payload[key] = text.slice(0, 2e3); + } + fireObserve(sid, info, "notification", payload); + }); + ctx.on("session/disposed", (session) => { + if (!session || typeof session.id !== "string") return; + const sid = session.id; + if (cfg.summarizeOnDispose) { + const call = rest.post("/session/end", { sessionId: sid }, SESSION_END_TIMEOUT_MS).catch(() => {}); + pendingCalls.add(call); + call.then(() => pendingCalls.delete(call)); + } + startContextCache.delete(sid); + injectedSessions.delete(sid); + sessionInfos.delete(sid); + }); + ctx.effect(() => () => { + startContextCache.clear(); + injectedSessions.clear(); + sessionInfos.clear(); + projectNameCache.clear(); + }, "agentmemory.dsh.memory"); + if (debug) logger.info(`[agentmemory] dsh plugin active: url=${cfg.url} agentId=${cfg.agentId} inject=${cfg.injectContext} observe=${cfg.observeToolCalls} compactionBridge=${cfg.compactionBridge}`); +} +//#endregion +export { apply, compactionSummary, eventTextContent, isAgentmemoryTool, makeRestClient, name, resolveProjectName, toolCallObservation, userMessagePrompt }; diff --git a/plugin/dsh/package.json b/plugin/dsh/package.json new file mode 100644 index 000000000..aa56697ab --- /dev/null +++ b/plugin/dsh/package.json @@ -0,0 +1,47 @@ +{ + "name": "@agentmemory/dsh", + "version": "0.1.0", + "description": "agentmemory cordis plugin for DeepSeek Harness — auto-captures DSH session lifecycle into long-term memory (session start registration, first-step context injection, tool/prompt observations, compaction bridge, session-end summarization).", + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib", + "install", + "README.md" + ], + "keywords": [ + "dsh", + "deepseek-harness", + "agentmemory", + "memory", + "cordis", + "plugin" + ], + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1" + }, + "devDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@types/node": "^25.9.1", + "tsdown": "^0.21.10", + "typescript": "^6.0.3", + "vitest": "^4.1.6" + }, + "scripts": { + "build": "tsdown", + "test": "vitest run", + "typecheck": "tsc --noEmit" + } +} diff --git a/plugin/dsh/src/index.ts b/plugin/dsh/src/index.ts new file mode 100644 index 000000000..c3532cc8e --- /dev/null +++ b/plugin/dsh/src/index.ts @@ -0,0 +1,479 @@ +// @agentmemory/dsh — cordis plugin for DeepSeek Harness. +// +// Bridges DSH session lifecycle events to the agentmemory REST API, mirroring +// the official agentmemory hooks for Claude Code: +// +// session/created -> POST /agentmemory/session/start (register) +// agent/pre-step (step1) -> inject instructions + recalled context +// session/event -> user/message -> /observe prompt_submit +// tool/call -> /observe post_tool_use +// compaction/summary -> /remember (compaction bridge) +// approval/asked -> /observe notification +// session/disposed -> /agentmemory/session/end (summarize) +// +// Design contract (same as the official hooks): +// - Injecting handlers await + time out + fail silently. +// - Telemetry handlers are fire-and-forget; they never block the agent loop. +// - Anything REST-related failing logs once and never throws into cordis. + +import { execFileSync } from "node:child_process"; +import { basename } from "node:path"; +import { randomUUID } from "node:crypto"; + +// ─────────────────────────── minimal cordis surface ─────────────────────────── +// The plugin intentionally does not import @deepseek-ai/cordis so it builds and +// tests standalone; the running dsh profile supplies the real Context. +// `any` at these boundaries is deliberate: cordis dispatches heterogeneous +// payloads per event and the plugin narrows at runtime; importing the real +// types would couple the package to @deepseek-ai/cordis at build time. + +type AnyListener = (...args: any[]) => unknown; + +interface PluginLogger { + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; +} + +export interface PluginContext { + on(event: string, listener: AnyListener): void; + effect(callback: () => void | (() => void), label?: string): void; + logger: PluginLogger; +} + +export interface SessionLike { + id: string; + header?: { cwd?: string }; +} + +// Event payloads vary by event type (user/message, tool/call, compaction/...); +// consumers narrow data at runtime, so it stays `any` at this boundary. +export interface SessionEvent { + type: string; + seq?: number; + data: any; +} + +interface PreStepPayload { + agent: { session: SessionLike; inbox?: { nextStep?: unknown[] } }; + messages: unknown[]; + step: number; + signal: AbortSignal; +} + +// decision.messages mirrors the agent's claimed message batch — heterogeneous +// cordis message shapes, narrowed by the caller. +type PreStepDecision = { + kind: string; + messages: any[]; +}; + +type PreStepNext = () => Promise; + +// ─────────────────────────────── config ─────────────────────────────── + +export interface AgentmemoryConfig { + url: string; + secret: string; + agentId: string; + injectInstructions: boolean; + injectContext: boolean; + injectMaxChars: number; + observeToolCalls: boolean; + compactionBridge: boolean; + summarizeOnDispose: boolean; +} + +const DEFAULTS: AgentmemoryConfig = { + url: "http://localhost:3111", + secret: "", + agentId: "dsh", + injectInstructions: true, + injectContext: true, + injectMaxChars: 6000, + observeToolCalls: true, + compactionBridge: true, + summarizeOnDispose: true, +}; + +// Telemetry/truncation budgets and timeouts (kept in one place so operators +// can tune behavior without hunting magic numbers). +const PROMPT_MAX_CHARS = 8000; +const TOOL_INPUT_MAX_CHARS = 4000; +const COMPACTION_MAX_CHARS = 6000; +const OBSERVE_TIMEOUT_MS = 1500; +const SESSION_START_TIMEOUT_MS = 2000; +const CONTEXT_TIMEOUT_MS = 3000; +const REMEMBER_TIMEOUT_MS = 5000; +const SESSION_END_TIMEOUT_MS = 30000; + +// ─────────────────────────── static instructions ─────────────────────────── + +const INSTRUCTIONS = [ + "", + "You have persistent cross-session long-term memory via agentmemory. Tools are namespaced `mcp__agentmemory__*` (memory_recall / memory_save / memory_smart_search / memory_sessions / ...).", + "- At the START of a task, call `mcp__agentmemory__memory_recall` (or `memory_smart_search`) to load relevant past decisions, fixes, and user preferences; do not re-ask.", + "- When you learn something durable (a decision, a fix, a gotcha, a preference, a project convention), call `mcp__agentmemory__memory_save` (concepts: 2-5 comma-separated keywords).", + "- Prefer recall over re-deriving; save concise reusable facts, not transcripts.", + "- If the user asks to forget/delete something, use `memory_governance_delete` (comma-separated memoryIds).", + "- Tool results are JSON — inspect them before presenting to the user.", + "", +].join("\n"); + +// ─────────────────────────── REST client ─────────────────────────── + +export interface RestClient { + post(path: string, body: Record, timeoutMs?: number): Promise; + fire(path: string, body: Record, timeoutMs?: number): void; +} + +export function makeRestClient(url: string, secret: string, debug = false): RestClient { + function headers(): Record { + const h: Record = { "Content-Type": "application/json" }; + if (secret) h["Authorization"] = `Bearer ${secret}`; + return h; + } + + async function post(path: string, body: Record, timeoutMs = 3000): Promise { + try { + const res = await fetch(`${url}/agentmemory${path}`, { + method: "POST", + headers: headers(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch (err) { + if (debug) console.error(`[agentmemory] POST ${path} failed:`, (err as Error).message); + return null; + } + } + + function fire(path: string, body: Record, timeoutMs = 1500): void { + void post(path, body, timeoutMs); + } + + return { post, fire }; +} + +// ─────────────────────────── project resolution ─────────────────────────── + +export function resolveProjectName( + cwd: string, + env: Record = process.env, +): string { + const explicit = env["AGENTMEMORY_PROJECT_NAME"]?.trim(); + if (explicit) return explicit; + try { + const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + }).trim(); + if (top) return basename(top); + } catch { + // not a git repo, fall through + } + return basename(cwd) || cwd; +} + +// ─────────────────────────── pure event mapping ─────────────────────────── + +export function isAgentmemoryTool(name: string): boolean { + return name.startsWith("mcp__agentmemory__") || name.startsWith("agentmemory_"); +} + +export function eventTextContent(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((block) => { + if (typeof block === "string") return block; + if (block && typeof block === "object") { + const b = block as { type?: string; text?: unknown }; + if (b.type === "text" && typeof b.text === "string") return b.text; + } + return ""; + }) + .join("\n"); + } + return ""; +} + +export function userMessagePrompt(event: SessionEvent, maxChars: number): string | null { + if (event.type !== "user/message") return null; + const text = eventTextContent(event.data?.content); + if (!text.trim()) return null; + return text.slice(0, maxChars); +} + +export function toolCallObservation(event: SessionEvent, maxChars: number): Record | null { + if (event.type !== "tool/call") return null; + const data = event.data ?? {}; + const name = typeof data.name === "string" ? data.name : ""; + if (!name || isAgentmemoryTool(name)) return null; + let input: string; + try { + input = JSON.stringify(data.arguments ?? {}).slice(0, maxChars); + } catch { + input = String(data.arguments ?? "").slice(0, maxChars); + } + return { tool_name: name, call_id: typeof data.callId === "string" ? data.callId : null, tool_input: input }; +} + +export function compactionSummary(event: SessionEvent, maxChars: number): string | null { + if (event.type !== "compaction/summary") return null; + const data = event.data ?? {}; + const summary = + (typeof data.summary === "string" && data.summary ? data.summary : undefined) ?? + (typeof data.text === "string" && data.text ? data.text : undefined) ?? + (typeof data.content === "string" && data.content ? data.content : undefined); + if (!summary) return null; + return summary.slice(0, maxChars); +} + +// ─────────────────────────────── plugin ─────────────────────────────── + +export const name = "agentmemory"; + +export function apply(ctx: PluginContext, rawConfig: Partial = {}): void { + const cfg: AgentmemoryConfig = { ...DEFAULTS, ...rawConfig }; + const debug = process.env["AGENTMEMORY_DSH_DEBUG"] === "1"; + const rest = makeRestClient(cfg.url, cfg.secret, debug); + const logger = ctx.logger; + + // sessionId -> context returned by /session/start, for first-step injection. + const startContextCache = new Map(); + // sessionId -> set of sessions that already had their first-step injection. + const injectedSessions = new Set(); + // sessionId -> { cwd, project } captured at session/created; avoids re-spawning + // git per event and keeps approval observations on the right session. + const sessionInfos = new Map(); + // cwd -> projectName memo: git rev-parse is a blocking child process and must + // not run on every session/event (project identity is stable per cwd). + const projectNameCache = new Map(); + // In-flight REST calls still settling at teardown (e.g. /session/end on + // shutdown); kept referenced so nothing is dropped or unhandled. + const pendingCalls = new Set>(); + + function sessionCwd(session: SessionLike): string { + return session.header?.cwd || process.cwd(); + } + + function cachedProjectName(cwd: string): string { + let project = projectNameCache.get(cwd); + if (project === undefined) { + project = resolveProjectName(cwd); + projectNameCache.set(cwd, project); + } + return project; + } + + function trackSession(session: SessionLike): { cwd: string; project: string } | undefined { + if (!session || typeof session.id !== "string") return undefined; + const cwd = sessionCwd(session); + const info = { cwd, project: cachedProjectName(cwd) }; + sessionInfos.set(session.id, info); + return info; + } + + function fireObserve( + sid: string, + info: { cwd: string; project: string }, + hookType: string, + data: Record, + ): void { + const call = rest + .post("/observe", { + hookType, + sessionId: sid, + project: info.project, + cwd: info.cwd, + timestamp: new Date().toISOString(), + data, + }, OBSERVE_TIMEOUT_MS) + .catch(() => {}); + pendingCalls.add(call); + void call.then(() => pendingCalls.delete(call)); + } + + // ── session/created → register with the daemon (await: response feeds injection) ── + ctx.on("session/created", (session: SessionLike) => { + if (!session || typeof session.id !== "string") return; + const sid = session.id; + const info = trackSession(session); + if (!info) return; + const call = rest + .post<{ context?: unknown }>("/session/start", { + sessionId: sid, + project: info.project, + cwd: info.cwd, + agentId: cfg.agentId, + }, SESSION_START_TIMEOUT_MS) + .then((result) => { + const context = result?.context; + if (typeof context === "string" && context.length > 0) { + startContextCache.set(sid, context); + } + }) + .catch(() => {}); + pendingCalls.add(call); + void call.then(() => pendingCalls.delete(call)); + }); + + // ── session/event stream → telemetry observations (fire-and-forget) ── + ctx.on("session/event", (session: SessionLike, event: SessionEvent) => { + if (!session || !event || typeof event.type !== "string") return; + const sid = session.id; + if (!sid) return; + + let info = sessionInfos.get(sid); + if (!info) { + info = trackSession(session); + if (!info) return; + } + + const prompt = userMessagePrompt(event, PROMPT_MAX_CHARS); + if (prompt !== null) { + fireObserve(sid, info, "prompt_submit", { userPrompt: prompt }); + return; + } + + const tool = toolCallObservation(event, TOOL_INPUT_MAX_CHARS); + if (tool !== null && cfg.observeToolCalls) { + fireObserve(sid, info, "post_tool_use", tool); + return; + } + + if (cfg.compactionBridge) { + const summary = compactionSummary(event, COMPACTION_MAX_CHARS); + if (summary !== null) { + const call = rest + .post("/remember", { + content: `[dsh compaction] ${summary}`, + type: "fact", + concepts: ["compaction"], + project: info.project, + }, REMEMBER_TIMEOUT_MS) + .catch(() => {}); + pendingCalls.add(call); + void call.then(() => pendingCalls.delete(call)); + } + } + }); + + // ── agent/pre-step (step 1) → inject instructions + recalled context ── + ctx.on("agent/pre-step", async ({ agent, messages, step, signal }: PreStepPayload, next: PreStepNext) => { + const decision = await next(); + const session = agent?.session; + if (!session || typeof session.id !== "string") return decision; + const sid = session.id; + if (step !== 1 || injectedSessions.has(sid)) return decision; + if (decision.kind !== "enter" || !Array.isArray(decision.messages) || decision.messages.length === 0) { + return decision; + } + if (signal.aborted) return decision; + + const info = sessionInfos.get(sid) ?? trackSession(session); + const project = info?.project ?? cachedProjectName(sessionCwd(session)); + const parts: string[] = []; + if (cfg.injectInstructions) parts.push(INSTRUCTIONS); + if (cfg.injectContext) { + let context = startContextCache.get(sid); + if (!context) { + const result = await rest.post<{ context?: unknown }>("/context", { sessionId: sid, project }, CONTEXT_TIMEOUT_MS); + context = typeof result?.context === "string" ? result.context : ""; + } else { + startContextCache.delete(sid); + } + if (context) parts.push(context); + } + if (parts.length === 0) return decision; + + const text = parts.join("\n\n").slice(0, cfg.injectMaxChars); + const message = { + id: randomUUID(), + role: "user", + content: [{ type: "text", text }], + source: { kind: "agentmemory", form: "memory-context" }, + }; + injectedSessions.add(sid); + // ES2022-compatible equivalents of findLastIndex/toSpliced (Node 18 safe). + const lastClaimedIndex = + decision.messages.length - 1 - [...decision.messages].reverse().findIndex((m) => messages.includes(m)); + const nextMessages = decision.messages.slice(); + nextMessages.splice(lastClaimedIndex + 1, 0, message); + return { + ...decision, + messages: nextMessages, + }; + }); + + // ── approval/asked → permission observation (fire-and-forget) ── + // Allowlisted fields only: the raw request object may carry sensitive + // tool arguments or file paths; never forward it wholesale. + const APPROVAL_FIELDS = ["permission", "pattern", "title", "tool_call_id", "metadata"] as const; + ctx.on("approval/asked", (req: unknown) => { + const data = (req ?? {}) as Record; + const sid = typeof data.sessionId === "string" ? data.sessionId : undefined; + if (!sid) return; + const info = sessionInfos.get(sid); + if (!info) return; // unknown session: no cwd/project to attribute — skip + const payload: Record = { notification_type: "permission_prompt" }; + for (const key of APPROVAL_FIELDS) { + const value = data[key]; + if (value === undefined) continue; + // Truncate everything, not just strings: approval metadata can carry + // tool arguments or file paths and must not leave the process whole. + let text: string; + if (typeof value === "string") { + text = value; + } else { + try { + // JSON.stringify can return undefined (functions, symbols, toJSON + // returning undefined); fall back to String() before slicing. + text = JSON.stringify(value) ?? String(value); + } catch { + text = String(value); + } + } + payload[key] = text.slice(0, 2000); + } + fireObserve(sid, info, "notification", payload); + }); + + // ── session/disposed → summarize (tracked, longer timeout) ── + ctx.on("session/disposed", (session: SessionLike) => { + if (!session || typeof session.id !== "string") return; + const sid = session.id; + if (cfg.summarizeOnDispose) { + const call = rest + .post("/session/end", { sessionId: sid }, SESSION_END_TIMEOUT_MS) + .catch(() => {}); + pendingCalls.add(call); + void call.then(() => pendingCalls.delete(call)); + } + startContextCache.delete(sid); + injectedSessions.delete(sid); + sessionInfos.delete(sid); + }); + + // Dispose bookkeeping on plugin teardown. In-flight REST calls (including + // /session/end summarization) stay referenced in pendingCalls — never cleared + // here — and Node's event loop keeps the process alive until their sockets + // settle, so a normal host shutdown does not drop them. Only a forced + // process.exit() could cut them off; cordis disposes plugins before exiting. + ctx.effect(() => () => { + startContextCache.clear(); + injectedSessions.clear(); + sessionInfos.clear(); + projectNameCache.clear(); + }, "agentmemory.dsh.memory"); + + if (debug) { + logger.info( + `[agentmemory] dsh plugin active: url=${cfg.url} agentId=${cfg.agentId} inject=${cfg.injectContext} observe=${cfg.observeToolCalls} compactionBridge=${cfg.compactionBridge}`, + ); + } +} diff --git a/plugin/dsh/test/plugin.test.ts b/plugin/dsh/test/plugin.test.ts new file mode 100644 index 000000000..e86146afc --- /dev/null +++ b/plugin/dsh/test/plugin.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + apply, + makeRestClient, + resolveProjectName, + isAgentmemoryTool, + userMessagePrompt, + toolCallObservation, + compactionSummary, + type PluginContext, + type SessionEvent, +} from "../src/index"; + +// ─────────────────────────── helpers ─────────────────────────── + +function fakeFetch(impl: (url: string, init?: RequestInit) => Promise) { + vi.stubGlobal("fetch", impl); +} + +function jsonResponse(body: unknown, ok = true): Response { + return new Response(JSON.stringify(body), { status: ok ? 200 : 500, headers: { "Content-Type": "application/json" } }); +} + +function makeCtx(): { ctx: PluginContext; listeners: Map; logs: string[] } { + const listeners = new Map(); + const logs: string[] = []; + const ctx: PluginContext = { + on(event, listener) { + const arr = listeners.get(event) ?? []; + arr.push(listener); + listeners.set(event, arr); + }, + effect() {}, + logger: { + info: (...a) => logs.push("info " + a.join(" ")), + warn: (...a) => logs.push("warn " + a.join(" ")), + error: (...a) => logs.push("error " + a.join(" ")), + }, + }; + return { ctx, listeners, logs }; +} + +async function fire(listeners: Map, event: string, ...args: unknown[]) { + for (const fn of listeners.get(event) ?? []) { + await (fn as (...a: unknown[]) => unknown)(...args); + } +} + +function session(id: string, cwd = "/tmp/proj"): any { + return { id, header: { cwd } }; +} + +function event(type: string, data: unknown): SessionEvent { + return { type, data } as SessionEvent; +} + +// ─────────────────────────── tests ─────────────────────────── + +describe("makeRestClient", () => { + beforeEach(() => vi.restoreAllMocks()); + afterEach(() => vi.unstubAllGlobals()); + + it("posts JSON with auth header when secret set", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + fakeFetch(async (url, init) => { + calls.push({ url, init: init ?? {} }); + return jsonResponse({ ok: true }); + }); + const rest = makeRestClient("http://localhost:3111", "sekrit"); + const out = await rest.post<{ ok: boolean }>("/session/start", { sessionId: "s1" }); + expect(out).toEqual({ ok: true }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("http://localhost:3111/agentmemory/session/start"); + const headers = calls[0].init.headers as Record; + expect(headers["Authorization"]).toBe("Bearer sekrit"); + expect(JSON.parse(String(calls[0].init.body))).toEqual({ sessionId: "s1" }); + }); + + it("returns null on non-ok response", async () => { + fakeFetch(async () => jsonResponse({ error: "x" }, false)); + const rest = makeRestClient("http://x", ""); + expect(await rest.post("/x", {})).toBeNull(); + }); + + it("returns null on network failure without throwing", async () => { + fakeFetch(async () => { throw new Error("boom"); }); + const rest = makeRestClient("http://x", ""); + expect(await rest.post("/x", {})).toBeNull(); + expect(() => rest.fire("/x", {})).not.toThrow(); + }); + + it("fire swallows async rejection", async () => { + fakeFetch(async () => { throw new Error("boom"); }); + const rest = makeRestClient("http://x", ""); + rest.fire("/observe", {}); + await new Promise((r) => setTimeout(r, 20)); + expect(true).toBe(true); + }); +}); + +describe("resolveProjectName", () => { + it("uses explicit env override", () => { + expect(resolveProjectName("/tmp/a", { AGENTMEMORY_PROJECT_NAME: "override" })).toBe("override"); + }); + + it("falls back to cwd basename without git", () => { + expect(resolveProjectName("/tmp/some-dir", {})).toBe("some-dir"); + }); +}); + +describe("isAgentmemoryTool", () => { + it("recognizes dsh-namespaced and opencode-named tools", () => { + expect(isAgentmemoryTool("mcp__agentmemory__memory_recall")).toBe(true); + expect(isAgentmemoryTool("agentmemory_memory_save")).toBe(true); + expect(isAgentmemoryTool("read")).toBe(false); + }); +}); + +describe("event mapping", () => { + it("userMessagePrompt extracts string content", () => { + expect(userMessagePrompt(event("user/message", { content: "hello world" }), 100)).toBe("hello world"); + }); + + it("userMessagePrompt extracts text blocks", () => { + const e = event("user/message", { content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }); + expect(userMessagePrompt(e, 100)).toBe("a\nb"); + }); + + it("userMessagePrompt returns null for empty", () => { + expect(userMessagePrompt(event("user/message", { content: " " }), 100)).toBeNull(); + expect(userMessagePrompt(event("tool/call", {}), 100)).toBeNull(); + }); + + it("toolCallObservation extracts name and arguments and truncates", () => { + const e = event("tool/call", { callId: "c1", name: "read", arguments: { path: "/a/b.txt" } }); + const obs = toolCallObservation(e, 200); + expect(obs).not.toBeNull(); + expect(obs!.tool_name).toBe("read"); + expect(obs!.call_id).toBe("c1"); + expect(JSON.parse(obs!.tool_input as string)).toEqual({ path: "/a/b.txt" }); + }); + + it("toolCallObservation filters agentmemory tools", () => { + const e = event("tool/call", { callId: "c2", name: "mcp__agentmemory__memory_recall", arguments: {} }); + expect(toolCallObservation(e, 200)).toBeNull(); + }); + + it("compactionSummary extracts summary text", () => { + expect(compactionSummary(event("compaction/summary", { summary: "did stuff" }), 100)).toBe("did stuff"); + expect(compactionSummary(event("user/message", {}), 100)).toBeNull(); + }); +}); + +describe("plugin apply()", () => { + beforeEach(() => vi.restoreAllMocks()); + afterEach(() => vi.unstubAllGlobals()); + + it("registers session on session/created with project+cwd+agentId", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + fakeFetch(async (url, init) => { + calls.push({ url, init: init ?? {} }); + return jsonResponse({ context: "ctx" }); + }); + const { ctx, listeners } = makeCtx(); + apply(ctx, { url: "http://localhost:3111", secret: "", agentId: "dsh-test" }); + + await fire(listeners, "session/created", session("ses_1", "/tmp/proj")); + await new Promise((r) => setTimeout(r, 10)); + + expect(calls).toHaveLength(1); + const body = JSON.parse(String(calls[0].init.body)) as Record; + expect(body.sessionId).toBe("ses_1"); + expect(body.project).toBe("proj"); + expect(body.cwd).toBe("/tmp/proj"); + expect(body.agentId).toBe("dsh-test"); + }); + + it("injects instructions+context into first step batch", async () => { + const { ctx, listeners } = makeCtx(); + apply(ctx, { url: "http://localhost:3111" }); + const preStep = listeners.get("agent/pre-step"); + expect(preStep).toBeDefined(); + + const claimed = [{ id: "m1", role: "user", content: [{ type: "text", text: "hi" }] }]; + let nextCalled = 0; + const next = async () => { + nextCalled++; + return { kind: "enter", messages: [...claimed] }; + }; + const payload = { agent: { session: session("ses_2", "/tmp/proj") }, messages: claimed, step: 1, signal: new AbortController().signal }; + + const decision = await (preStep![0] as any)(payload, next); + expect(nextCalled).toBe(1); + expect(decision.messages).toHaveLength(2); + expect(decision.messages[1].role).toBe("user"); + expect(decision.messages[1].content[0].type).toBe("text"); + expect(String(decision.messages[1].content[0].text)).toContain("agentmemory"); + expect(String(decision.messages[1].content[0].text)).toContain("memory_recall"); + }); + + it("does not inject on step > 1 or already-injected session", async () => { + const { ctx, listeners } = makeCtx(); + apply(ctx, { url: "http://localhost:3111" }); + const preStep = listeners.get("agent/pre-step")![0] as any; + const claimed = [{ id: "m1" }]; + const next = async () => ({ kind: "enter", messages: [...claimed] }); + + const d1 = await preStep({ agent: { session: session("s", "/tmp/p") }, messages: claimed, step: 2, signal: new AbortController().signal }, next); + expect(d1.messages).toHaveLength(1); + + const d2 = await preStep({ agent: { session: session("s", "/tmp/p") }, messages: claimed, step: 1, signal: new AbortController().signal }, next); + expect(d2.messages).toHaveLength(2); + const d3 = await preStep({ agent: { session: session("s", "/tmp/p") }, messages: claimed, step: 1, signal: new AbortController().signal }, next); + expect(d3.messages).toHaveLength(1); + }); + + it("observes prompt_submit and post_tool_use via session/event", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + fakeFetch(async (url, init) => { + calls.push({ url, init: init ?? {} }); + return jsonResponse({}); + }); + const { ctx, listeners } = makeCtx(); + apply(ctx, { url: "http://localhost:3111" }); + const ses = session("ses_3", "/tmp/proj"); + + await fire(listeners, "session/event", ses, event("user/message", { content: "do the thing" })); + await fire(listeners, "session/event", ses, event("tool/call", { callId: "c9", name: "edit", arguments: { filePath: "/tmp/proj/a.ts" } })); + await new Promise((r) => setTimeout(r, 10)); + + const observes = calls.filter((c) => c.url.endsWith("/observe")).map((c) => JSON.parse(String(c.init.body))); + expect(observes).toHaveLength(2); + expect(observes[0].hookType).toBe("prompt_submit"); + expect(observes[0].data.userPrompt).toBe("do the thing"); + expect(observes[1].hookType).toBe("post_tool_use"); + expect(observes[1].data.tool_name).toBe("edit"); + }); + + it("compaction bridge remembers summaries", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + fakeFetch(async (url, init) => { + calls.push({ url, init: init ?? {} }); + return jsonResponse({}); + }); + const { ctx, listeners } = makeCtx(); + apply(ctx, { url: "http://localhost:3111" }); + + await fire(listeners, "session/event", session("ses_4", "/tmp/proj"), event("compaction/summary", { summary: "refactored auth module" })); + await new Promise((r) => setTimeout(r, 10)); + + const remembers = calls.filter((c) => c.url.endsWith("/remember")); + expect(remembers).toHaveLength(1); + const body = JSON.parse(String(remembers[0].init.body)) as Record; + expect(String(body.content)).toContain("refactored auth module"); + }); + + it("calls session/end on dispose", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + fakeFetch(async (url, init) => { + calls.push({ url, init: init ?? {} }); + return jsonResponse({}); + }); + const { ctx, listeners } = makeCtx(); + apply(ctx, { url: "http://localhost:3111" }); + + await fire(listeners, "session/disposed", session("ses_5")); + await new Promise((r) => setTimeout(r, 10)); + + const ends = calls.filter((c) => c.url.endsWith("/session/end")); + expect(ends).toHaveLength(1); + expect(JSON.parse(String(ends[0].init.body))).toEqual({ sessionId: "ses_5" }); + }); + + it("never throws when daemon is unreachable", async () => { + fakeFetch(async () => { throw new Error("ECONNREFUSED"); }); + const { ctx, listeners } = makeCtx(); + apply(ctx, { url: "http://localhost:1" }); + + await expect( + fire(listeners, "session/created", session("s6", "/tmp/p")), + ).resolves.toBeUndefined(); + await expect( + fire(listeners, "session/event", session("s6", "/tmp/p"), event("user/message", { content: "x" })), + ).resolves.toBeUndefined(); + await expect( + fire(listeners, "session/disposed", session("s6")), + ).resolves.toBeUndefined(); + + const preStep = listeners.get("agent/pre-step")![0] as any; + const claimed = [{ id: "m" }]; + const next = async () => ({ kind: "enter", messages: [...claimed] }); + await expect( + preStep({ agent: { session: session("s6", "/tmp/p") }, messages: claimed, step: 1, signal: new AbortController().signal }, next), + ).resolves.toBeTruthy(); + }); +}); diff --git a/plugin/dsh/tsconfig.json b/plugin/dsh/tsconfig.json new file mode 100644 index 000000000..fa9cb72f3 --- /dev/null +++ b/plugin/dsh/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "resolveJsonModule": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true + }, + "include": [ + "src/**/*", + "test/**/*", + "tsdown.config.ts" + ] +} diff --git a/plugin/dsh/tsdown.config.ts b/plugin/dsh/tsdown.config.ts new file mode 100644 index 000000000..ffb81d71f --- /dev/null +++ b/plugin/dsh/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "lib", + format: ["esm"], + target: "node20", + clean: true, + sourcemap: false, + dts: true, +}); diff --git a/scripts/dsh-install.cjs b/scripts/dsh-install.cjs new file mode 100644 index 000000000..af54285e7 --- /dev/null +++ b/scripts/dsh-install.cjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +// dsh-install.cjs — wire agentmemory into DeepSeek Harness +// L1: append the mcp-agentmemory MCP bridge to cordis.patch.yml (HMR hot-reload) +// L2: memory guideline in ~/.dsh/AGENTS.md + agentmemory-sync skill +// L3: declare @agentmemory/dsh file: dep in the profile package.json + patch entry + pnpm install +// Usage: node scripts/dsh-install.cjs [--dry-run] [--no-plugin] +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const { spawnSync } = require("node:child_process"); + +const args = process.argv.slice(2); +const DRY_RUN = args.includes("--dry-run"); +const WITH_PLUGIN = !args.includes("--no-plugin"); + +const repoDir = path.resolve(__dirname, ".."); +const pluginDir = path.join(repoDir, "plugin", "dsh"); +const dshHome = process.env.DSH_HOME || path.join(os.homedir(), ".dsh"); +const profile = process.env.DSH_PROFILE || "web"; +const patch = path.join(dshHome, "profiles", profile, "cordis.patch.yml"); +const agentsFile = path.join(dshHome, "AGENTS.md"); +const skillDir = path.join(dshHome, "skills", "agentmemory-sync"); +const profilePkg = path.join(dshHome, "profiles", profile, "package.json"); +const url = process.env.AGENTMEMORY_URL || "http://localhost:3111"; + +const L1 = [ + "", + "# ── agentmemory L1: MCP bridge (applied by dsh-install.cjs) ──", + "- insert:", + " - id: mcp-agentmemory", + " name: '@deepseek-ai/dsh-mcp-client'", + " config:", + " transport: stdio", + " serverName: agentmemory", + " command: npx", + " args: ['--cache', '" + path.join(os.homedir(), ".cache", "npmcache-dsh") + "', '-y', '@agentmemory/mcp']", + " env:", + // single-quote the URL in YAML and double any embedded single quotes + " AGENTMEMORY_URL: '" + String(url).replace(/'/g, "''") + "'", + " toolCallTimeoutMs: 60000", + " failOnStartupError: false", + "", +].join("\n"); + +const AGENTS_MD = [ + "", + fs.readFileSync(path.join(pluginDir, "install", "AGENTS.md"), "utf8").trimEnd(), + "", +].join("\n"); + +const SKILL_MD = fs.readFileSync(path.join(pluginDir, "install", "skills", "agentmemory-sync", "SKILL.md"), "utf8"); + +function step(msg) { console.log("\n==> " + msg); } + +if (DRY_RUN) { + step("DRY-RUN: the following would happen"); + console.log(" 1. append mcp-agentmemory MCP bridge entry -> " + patch); + console.log(" 2. write memory guideline -> " + agentsFile); + console.log(" 3. write agentmemory-sync skill -> " + path.join(skillDir, "SKILL.md")); + if (WITH_PLUGIN) { + console.log(" 4. declare @agentmemory/dsh -> " + profilePkg); + console.log(" 5. append plugin entry -> " + patch + " (restart dsh to load)"); + } + console.log(" Prerequisite: agentmemory daemon running, " + url + "/agentmemory/health returns 200"); + process.exit(0); +} + +step("0/4 prerequisite check: agentmemory daemon"); +{ + const res = spawnSync("curl", ["-sf", "-m", "3", url + "/agentmemory/health"], { encoding: "utf8" }); + if (res.error) { + // spawnSync does not throw on ENOENT: res.error distinguishes "curl + // missing" from "daemon unreachable". + console.log(" warning: cannot probe daemon (curl unavailable: " + res.error.message + ")"); + } else if (res.status !== 0) { + console.log(" warning: daemon not responding at " + url + " — the MCP shim will fall back to local mode. Start the daemon first (npx @agentmemory/agentmemory)"); + } else { + console.log(" ok: daemon online"); + } +} + +step("1/4 MCP bridge -> " + patch); +fs.mkdirSync(path.dirname(patch), { recursive: true }); +if (fs.existsSync(patch) && fs.readFileSync(patch, "utf8").includes("mcp-agentmemory")) { + console.log(" already present (skipped)"); +} else { + const cur = fs.existsSync(patch) ? fs.readFileSync(patch, "utf8") : ""; + const sep = cur.length === 0 || cur.endsWith("\n") ? "" : "\n"; + fs.writeFileSync(patch, cur + sep + L1, "utf8"); + console.log(" appended (HMR hot-reload, no restart needed)"); +} + +step("2/4 global guideline -> " + agentsFile); +fs.mkdirSync(dshHome, { recursive: true }); +if (fs.existsSync(agentsFile) && fs.readFileSync(agentsFile, "utf8").includes("agentmemory:start")) { + console.log(" already present (skipped)"); +} else { + const cur = fs.existsSync(agentsFile) ? fs.readFileSync(agentsFile, "utf8") : ""; + const sep = cur.length === 0 || cur.endsWith("\n") ? "" : "\n"; + fs.writeFileSync(agentsFile, cur + sep + "\n" + AGENTS_MD + "\n", "utf8"); + console.log(" written (dsh-agent-instructions injects it into every session)"); +} + +step("3/4 memory skill -> " + skillDir); +fs.mkdirSync(skillDir, { recursive: true }); +fs.writeFileSync(path.join(skillDir, "SKILL.md"), SKILL_MD, "utf8"); +console.log(" written (dsh-skill-filesystem scans /skills)"); + +if (WITH_PLUGIN) { + step("4/4 plugin @agentmemory/dsh -> " + profilePkg); + if (!fs.existsSync(profilePkg)) { + console.error(" error: " + profilePkg + " not found (profile '" + profile + "' missing? use DSH_PROFILE=)"); + process.exit(1); + } + let pkg; + try { + pkg = JSON.parse(fs.readFileSync(profilePkg, "utf8")); + } catch (err) { + console.error(" error: " + profilePkg + " is not valid JSON (" + err.message + ") — fix it first"); + process.exit(1); + } + const depKey = "@agentmemory/dsh"; + if (pkg.dependencies && pkg.dependencies[depKey]) { + console.log(" dependency already declared (skipped)"); + } else { + pkg.dependencies = pkg.dependencies || {}; + pkg.dependencies[depKey] = "file:" + pluginDir; + fs.writeFileSync(profilePkg, JSON.stringify(pkg, null, 2) + "\n", "utf8"); + console.log(" package.json now declares file:" + pluginDir); + } + const cur = fs.readFileSync(patch, "utf8"); + if (cur.includes("id: agentmemory") && cur.includes("@agentmemory/dsh")) { + console.log(" plugin entry already present (skipped)"); + } else { + const L3 = [ + "", + "# ── agentmemory L3: cordis plugin (applied by dsh-install.cjs) ──", + "- insert:", + " - id: agentmemory", + " name: '@agentmemory/dsh'", + " config:", + " url: '" + String(url).replace(/'/g, "''") + "'", + " agentId: dsh", + "", + ].join("\n"); + const sep = cur.endsWith("\n") ? "" : "\n"; + fs.writeFileSync(patch, cur + sep + L3, "utf8"); + console.log(" plugin entry appended"); + } + console.log(""); + console.log(" Next: cd " + path.join(dshHome, "profiles", profile) + " && pnpm install"); + console.log(" then restart dsh (plugin code needs a process restart; config changes hot-reload)"); +} else { + step("4/4 skipping plugin (--no-plugin)"); +} + +step("Done. Verify: open a new dsh session — the tool list should include mcp__agentmemory__*"); diff --git a/src/cli/connect/dsh.ts b/src/cli/connect/dsh.ts new file mode 100644 index 000000000..e37f11478 --- /dev/null +++ b/src/cli/connect/dsh.ts @@ -0,0 +1,193 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; +import { backupFile, logAlreadyWired, logBackup, logInstalled } from "./util.js"; + +const NL = "\n"; + +// DeepSeek Harness (dsh) adapter. +// +// DSH has no native hooks; it consumes agentmemory through its MCP client +// plugin (@deepseek-ai/dsh-mcp-client, stdio transport) and loads per-session +// instructions from $DSH_HOME/AGENTS.md. This adapter: +// 1. appends the mcp-agentmemory entry to the profile's cordis.patch.yml +// (HMR hot-reloads the config, no restart needed), +// 2. writes ~/.dsh/skills/agentmemory-sync/SKILL.md so the skill registry +// (scans /skills) exposes a memory-sync skill, +// 3. (runAdapter) writes the memory-usage guideline block into +// ~/.dsh/AGENTS.md via guidelines.ts. +// The L3 cordis plugin (@agentmemory/dsh) is installed separately: +// dsh plugin --profile web add @agentmemory/dsh + +function dshHome(): string { + // Aligns with guidelines.ts (which uses os.homedir() for the dsh guideline + // target); DSH_HOME is an explicit override for unusual setups. + return process.env.DSH_HOME || join(homedir(), ".dsh"); +} + +function profilesDir(): string { + return join(dshHome(), "profiles"); +} + +function defaultProfile(): string { + return process.env["AGENTMEMORY_DSH_PROFILE"]?.trim() || "web"; +} + +function profileDir(profile: string): string { + return join(profilesDir(), profile); +} + +function patchPath(profile: string): string { + return join(profileDir(profile), "cordis.patch.yml"); +} + +const MCP_ENTRY_MARKER = "# ── agentmemory (installed by 'agentmemory connect dsh') ──"; + +// Remove the previously installed agentmemory block (marker comment + its +// insert entry) so --force re-installs cleanly instead of appending a +// duplicate. Also removes a user-configured `- id: mcp-agentmemory` entry +// that lacks the marker, so --force never leaves two entries with the same +// server id. Content outside the removed spans (user's own entries) survives. +function stripInstalledBlock(content: string): string { + let out = content; + const idx = out.indexOf(MCP_ENTRY_MARKER); + if (idx !== -1) { + const lineStart = out.lastIndexOf("\n", idx) + 1; + const tail = out.slice(lineStart); + // The block's own "- insert:" line, then any following top-level entry. + const first = tail.search(/^- /m); + const rest = first === -1 ? "" : tail.slice(first + 1); + const second = rest.search(/^- /m); + const end = second === -1 ? out.length : lineStart + first + 1 + second; + out = out.slice(0, lineStart) + out.slice(end); + } + const entry = out.indexOf("- id: mcp-agentmemory"); + if (entry !== -1) { + const lineStart = out.lastIndexOf("\n", entry) + 1; + const insertPos = out.lastIndexOf("\n- insert:", lineStart); + const blockStart = insertPos === -1 ? lineStart : insertPos + 1; + const tail = out.slice(lineStart); + const nextTop = tail.search(/^- /m); + const end = nextTop === -1 ? out.length : lineStart + nextTop; + out = out.slice(0, blockStart) + out.slice(end); + } + return out; +} + +const MCP_ENTRY = [ + "# ── agentmemory (installed by 'agentmemory connect dsh') ──", + "- insert:", + " - id: mcp-agentmemory", + " name: '@deepseek-ai/dsh-mcp-client'", + " config:", + " transport: stdio", + " serverName: agentmemory", + " command: npx", + " args: ['-y', '@agentmemory/mcp']", + " env:", + " AGENTMEMORY_URL: http://localhost:3111", + " # AGENTMEMORY_SECRET: ''", + " toolCallTimeoutMs: 60000", + " failOnStartupError: false", + "", +].join(NL); + +const SKILL_MD = [ + "---", + "name: agentmemory-sync", + "description: Sync with the agentmemory long-term memory at task start/end. Use when starting a new task, recalling past work, finishing a task, needing cross-session context, or reviewing what was done.", + "---", + "# agentmemory memory sync", + "", + "1. At task start: call mcp__agentmemory__memory_recall with the task keywords + project path, format=compact, to bring relevant history into context.", + "2. During the task: when you learn something durable (decision/fix/preference/convention), call memory_save immediately (type=fact, concepts: 2-5 keywords).", + "3. At task end: call memory_save with a short outcome summary (type=insight), and confirm the session is registered via memory_sessions.", + "4. For large handoffs: call memory_smart_search with expandIds for graph-diffusion recall, or use memory_lesson_save/memory_lesson_recall for lessons.", + "", +].join(NL); + +export const adapter: ConnectAdapter = { + name: "dsh", + displayName: "DeepSeek Harness (dsh)", + category: "mcp", + docs: "https://github.com/deepseek-ai/deepseek-harness", + protocolNote: + "Using MCP via the profile's cordis.patch.yml (@deepseek-ai/dsh-mcp-client, stdio). For full auto-capture, also install the cordis plugin: dsh plugin --profile add @agentmemory/dsh", + + detect(): boolean { + return existsSync(dshHome()); + }, + + async install(opts: ConnectOptions): Promise { + const profile = defaultProfile(); + const patch = patchPath(profile); + const profileExists = existsSync(profileDir(profile)); + + if (!profileExists) { + p.log.warn( + "dsh profile '" + profile + "' not found under " + profilesDir() + ". Set AGENTMEMORY_DSH_PROFILE to a profile that exists.", + ); + return { kind: "stub", reason: "profile '" + profile + "' not found (set AGENTMEMORY_DSH_PROFILE)" }; + } + + const existing = existsSync(patch) ? readFileSync(patch, "utf8") : ""; + const alreadyHas = existing.includes("- id: mcp-agentmemory"); + const ensureSkill = (force: boolean): void => { + // Skill for the DSH skill registry (/skills). Never clobber a + // user-customized copy unless --force (and keep a backup when replacing). + const skillDir = join(dshHome(), "skills", "agentmemory-sync"); + const skillPath = join(skillDir, "SKILL.md"); + if (existsSync(skillPath) && !force) { + p.log.info(" skill exists (skipped; --force to overwrite)"); + return; + } + if (existsSync(skillPath)) { + const skillBackup = backupFile(skillPath, this.name, "md"); + logBackup(skillBackup); + } + mkdirSync(skillDir, { recursive: true }); + writeFileSync(skillPath, SKILL_MD, "utf8"); + }; + + if (alreadyHas && !opts.force) { + logAlreadyWired(this.displayName, patch); + ensureSkill(false); + return { kind: "already-wired", mutatedPath: patch }; + } + + if (opts.dryRun) { + p.log.info("[dry-run] Would " + (alreadyHas ? "replace" : "append") + " mcp-agentmemory entry in " + patch); + return { kind: "installed", mutatedPath: patch }; + } + + let backupPath: string | undefined; + if (existsSync(patch)) { + backupPath = backupFile(patch, this.name, "yml"); + logBackup(backupPath); + } else { + mkdirSync(dirname(patch), { recursive: true }); + } + + // Append, never rewrite: the patch layer carries the user's own + // commented entries and other MCP servers. --force replaces only the + // previously installed agentmemory block. + const base = alreadyHas ? stripInstalledBlock(existing) : existing; + const joiner = base.length === 0 || base.endsWith("\n") ? "" : "\n"; + const next = base + joiner + NL + MCP_ENTRY; + writeFileSync(patch, next, "utf8"); + + ensureSkill(opts.force); + + logInstalled(this.displayName, patch); + p.log.message( + " full auto-capture: dsh plugin --profile " + profile + " add @agentmemory/dsh", + ); + return { + kind: "installed", + mutatedPath: patch, + ...(backupPath !== undefined && { backupPath }), + }; + }, +}; diff --git a/src/cli/connect/guidelines.ts b/src/cli/connect/guidelines.ts index 26770b6a7..386071940 100644 --- a/src/cli/connect/guidelines.ts +++ b/src/cli/connect/guidelines.ts @@ -112,6 +112,19 @@ export function guidelineTargets( scope: "global", source: "https://docs.factory.ai/cli/configuration/agents-md", }, + // DeepSeek Harness loads $DSH_HOME/AGENTS.md into every session's first + // step via @deepseek-ai/dsh-agent-instructions (same mechanism as the + // project-level AGENTS.md chain). DSH_HOME override keeps this aligned + // with the dsh adapter, which resolves its profile paths through DSH_HOME. + dsh: { + globalPath: process.env.DSH_HOME + ? join(process.env.DSH_HOME, "AGENTS.md") + : join(home, ".dsh", "AGENTS.md"), + projectPath: "AGENTS.md", + format: "block", + scope: "global", + source: "https://github.com/deepseek-ai/deepseek-harness", + }, // Antigravity does NOT read AGENTS.md; global rules live in ~/.gemini/GEMINI.md. antigravity: { globalPath: join(home, ".gemini", "GEMINI.md"), diff --git a/src/cli/connect/index.ts b/src/cli/connect/index.ts index a0256c7ad..c2c5edecc 100644 --- a/src/cli/connect/index.ts +++ b/src/cli/connect/index.ts @@ -12,6 +12,7 @@ import { adapter as codex } from "./codex.js"; import { adapter as continueDev } from "./continue.js"; import { adapter as cursor } from "./cursor.js"; import { adapter as droid } from "./droid.js"; +import { adapter as dsh } from "./dsh.js"; import { adapter as geminiCli } from "./gemini-cli.js"; import { adapter as hermes } from "./hermes.js"; import { adapter as kiro } from "./kiro.js"; @@ -38,6 +39,7 @@ export const ADAPTERS: readonly ConnectAdapter[] = [ continueDev, zed, droid, + dsh, opencode, openclaw, hermes, diff --git a/test/cli-connect-dsh.test.ts b/test/cli-connect-dsh.test.ts new file mode 100644 index 000000000..3bb3da044 --- /dev/null +++ b/test/cli-connect-dsh.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { adapter } from "../src/cli/connect/dsh.js"; +import { writeGuideline, guidelineTargets } from "../src/cli/connect/guidelines.js"; + +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + isCancel: () => false, + multiselect: async () => [], + note: vi.fn(), + log: { + step: vi.fn(), + info: vi.fn(), + message: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +const OPTS = { dryRun: false, force: false, withHooks: false, guidelines: false }; + +describe("agentmemory connect — dsh adapter", () => { + let tmpHome: string; + + let prevDshHome: string | undefined; + let prevProfile: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "am-dsh-")); + prevDshHome = process.env.DSH_HOME; + prevProfile = process.env.AGENTMEMORY_DSH_PROFILE; + process.env.DSH_HOME = join(tmpHome, ".dsh"); + delete process.env.AGENTMEMORY_DSH_PROFILE; + }); + + afterEach(() => { + if (prevDshHome === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = prevDshHome; + if (prevProfile === undefined) delete process.env.AGENTMEMORY_DSH_PROFILE; + else process.env.AGENTMEMORY_DSH_PROFILE = prevProfile; + rmSync(tmpHome, { recursive: true, force: true }); + }); + + function makeProfile() { + mkdirSync(join(tmpHome, ".dsh", "profiles", "web"), { recursive: true }); + writeFileSync(join(tmpHome, ".dsh", "profiles", "web", "cordis.yml"), "[]\n"); + } + + it("detects dsh home", () => { + expect(adapter.detect()).toBe(false); + mkdirSync(join(tmpHome, ".dsh"), { recursive: true }); + expect(adapter.detect()).toBe(true); + }); + + it("appends mcp-agentmemory entry to cordis.patch.yml preserving user content", async () => { + makeProfile(); + const patch = join(tmpHome, ".dsh", "profiles", "web", "cordis.patch.yml"); + writeFileSync(patch, "# user comment\n- insert:\n - id: mcp-codegraph\n name: 'x'\n"); + const result = await adapter.install(OPTS); + expect(result.kind).toBe("installed"); + const content = readFileSync(patch, "utf8"); + expect(content).toContain("# user comment"); + expect(content).toContain("mcp-codegraph"); + expect(content).toContain("mcp-agentmemory"); + expect(content).toContain("@deepseek-ai/dsh-mcp-client"); + expect(content).toContain("serverName: agentmemory"); + expect(content).toContain("@agentmemory/mcp"); + }); + + it("reports already-wired on second run without force", async () => { + makeProfile(); + await adapter.install(OPTS); + const second = await adapter.install(OPTS); + expect(second.kind).toBe("already-wired"); + }); + + it("writes the memory-sync skill under ~/.dsh/skills", async () => { + makeProfile(); + await adapter.install(OPTS); + const skill = join(tmpHome, ".dsh", "skills", "agentmemory-sync", "SKILL.md"); + expect(existsSync(skill)).toBe(true); + expect(readFileSync(skill, "utf8")).toContain("name: agentmemory-sync"); + }); + + it("--force replaces the previous block instead of duplicating it", async () => { + makeProfile(); + await adapter.install(OPTS); + const patch = join(tmpHome, ".dsh", "profiles", "web", "cordis.patch.yml"); + const first = readFileSync(patch, "utf8"); + expect(first.match(/- id: mcp-agentmemory/g)).toHaveLength(1); + + await adapter.install({ ...OPTS, force: true }); + const second = readFileSync(patch, "utf8"); + // exactly one active entry after force-reinstall, and no stale duplicates + expect(second.match(/- id: mcp-agentmemory/g)).toHaveLength(1); + expect(second.match(/- id: mcp-agentmemory/g)).not.toBeNull(); + }); + + it("preserves user entries after --force replacement", async () => { + makeProfile(); + const patch = join(tmpHome, ".dsh", "profiles", "web", "cordis.patch.yml"); + writeFileSync(patch, "# user comment\n- insert:\n - id: mcp-codegraph\n name: 'x'\n"); + await adapter.install(OPTS); + await adapter.install({ ...OPTS, force: true }); + const after = readFileSync(patch, "utf8"); + expect(after).toContain("# user comment"); + expect(after).toContain("mcp-codegraph"); + expect(after.match(/- id: mcp-agentmemory/g)).toHaveLength(1); + }); + + it("dry-run does not mutate files", async () => { + makeProfile(); + const patch = join(tmpHome, ".dsh", "profiles", "web", "cordis.patch.yml"); + const result = await adapter.install({ ...OPTS, dryRun: true }); + expect(result.kind).toBe("installed"); + expect(existsSync(patch)).toBe(false); + }); + + it("returns stub when profile dir is missing", async () => { + mkdirSync(join(tmpHome, ".dsh"), { recursive: true }); + const result = await adapter.install(OPTS); + expect(result.kind).toBe("stub"); + }); + + it("honors AGENTMEMORY_DSH_PROFILE override", async () => { + mkdirSync(join(tmpHome, ".dsh", "profiles", "tui"), { recursive: true }); + process.env.AGENTMEMORY_DSH_PROFILE = "tui"; + const result = await adapter.install(OPTS); + expect(result.kind).toBe("installed"); + expect(existsSync(join(tmpHome, ".dsh", "profiles", "tui", "cordis.patch.yml"))).toBe(true); + }); +}); + +describe("writeGuideline for dsh", () => { + it("writes the memory-usage block into ~/.dsh/AGENTS.md", () => { + const home = mkdtempSync(join(tmpdir(), "am-dsh-guide-")); + const prev = process.env.DSH_HOME; + delete process.env.DSH_HOME; // the host may set it (e.g. running under dsh) + try { + expect(Object.keys(guidelineTargets(home))).toContain("dsh"); + const result = writeGuideline("dsh", { cwd: "/tmp", home }); + expect(result.kind).toBe("written"); + expect(result.path).toBe(join(home, ".dsh", "AGENTS.md")); + expect(existsSync(result.path)).toBe(true); + expect(readFileSync(result.path, "utf8")).toContain("agentmemory:start"); + } finally { + if (prev === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = prev; + rmSync(home, { recursive: true, force: true }); + } + }); + + it("honors DSH_HOME when resolving the global guideline path", () => { + const home = mkdtempSync(join(tmpdir(), "am-dsh-guide2-")); + const prev = process.env.DSH_HOME; + process.env.DSH_HOME = join(home, "custom-dsh"); + try { + const result = writeGuideline("dsh", { cwd: "/tmp", home }); + expect(result.kind).toBe("written"); + expect(result.path).toBe(join(home, "custom-dsh", "AGENTS.md")); + expect(existsSync(result.path)).toBe(true); + } finally { + if (prev === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = prev; + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/test/connect-guidelines.test.ts b/test/connect-guidelines.test.ts index 633aedc5e..694f9d31e 100644 --- a/test/connect-guidelines.test.ts +++ b/test/connect-guidelines.test.ts @@ -139,6 +139,7 @@ describe("guidelineTargets coverage", () => { "copilot-cli", "cursor", "droid", + "dsh", "gemini-cli", "kiro", "opencode",