From 648f12337706574a386b158b13b2a1c9434b115b Mon Sep 17 00:00:00 2001 From: hejiawow <16770133+hejiawow@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:58:55 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(dsh):=20DeepSeek=20Harness=20integrati?= =?UTF-8?q?on=20=E2=80=94=20connect=20adapter=20+=20cordis=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds dsh (DeepSeek Harness) as the 26th supported agent: - agentmemory connect dsh: new adapter (src/cli/connect/dsh.ts) wiring the MCP bridge into ~/.dsh/profiles//cordis.patch.yml (HMR hot-reload), the memory guideline into ~/.dsh/AGENTS.md, and the agentmemory-sync skill under ~/.dsh/skills/. --force replaces only the previously installed block, preserving user entries. - @agentmemory/dsh cordis plugin (plugin/dsh/, zero runtime deps): session/created -> /session/start registration with first-step context injection via agent/pre-step batch fold; user/message + tool/call + approval/asked observations; compaction/summary -> /remember bridge; session/disposed -> /session/end summarization. Same design contract as the official hooks: injecting handlers await + time out + fail silently, telemetry handlers fire-and-forget. - Tests: 30 new cases (adapter 10 + plugin 20) + guidelines coverage; full suite green (1620/1627; 6 pre-existing env failures in embedding-provider.test.ts unrelated to this change). - docs/dsh-integration.md: English design doc (event mapping, contract, verification). - scripts/dsh-install.cjs: idempotent one-shot installer (L1+L2+L3). Signed-off-by: hejiawow <16770133+hejiawow@users.noreply.github.com> --- README.md | 4 +- docs/dsh-integration.md | 52 ++ plugin/dsh/README.md | 99 ++++ plugin/dsh/install/AGENTS.md | 7 + plugin/dsh/install/cordis.patch.yml | 36 ++ .../install/skills/agentmemory-sync/SKILL.md | 10 + plugin/dsh/lib/index.d.ts | 45 ++ plugin/dsh/lib/index.js | 308 ++++++++++++ plugin/dsh/package.json | 47 ++ plugin/dsh/src/index.ts | 461 ++++++++++++++++++ plugin/dsh/test/plugin.test.ts | 296 +++++++++++ plugin/dsh/tsconfig.json | 20 + plugin/dsh/tsdown.config.ts | 11 + scripts/dsh-install.cjs | 159 ++++++ src/cli/connect/dsh.ts | 173 +++++++ src/cli/connect/guidelines.ts | 10 + src/cli/connect/index.ts | 2 + test/cli-connect-dsh.test.ts | 145 ++++++ test/connect-guidelines.test.ts | 1 + 19 files changed, 1885 insertions(+), 1 deletion(-) create mode 100644 docs/dsh-integration.md create mode 100644 plugin/dsh/README.md create mode 100644 plugin/dsh/install/AGENTS.md create mode 100644 plugin/dsh/install/cordis.patch.yml create mode 100644 plugin/dsh/install/skills/agentmemory-sync/SKILL.md create mode 100644 plugin/dsh/lib/index.d.ts create mode 100644 plugin/dsh/lib/index.js create mode 100644 plugin/dsh/package.json create mode 100644 plugin/dsh/src/index.ts create mode 100644 plugin/dsh/test/plugin.test.ts create mode 100644 plugin/dsh/tsconfig.json create mode 100644 plugin/dsh/tsdown.config.ts create mode 100644 scripts/dsh-install.cjs create mode 100644 src/cli/connect/dsh.ts create mode 100644 test/cli-connect-dsh.test.ts 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..80dddd956 --- /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`: full suite green (1620/1627; 6 pre-existing environment failures in `embedding-provider.test.ts` unrelated to this change). +- `@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 501:20 ~/.npm`, or add `--cache

` to the entry's `args` (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..6a60cff2a --- /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. + +## 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; any REST failure is logged once and never throws into cordis. + +## 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..b393cb505 --- /dev/null +++ b/plugin/dsh/install/cordis.patch.yml @@ -0,0 +1,36 @@ +# ── 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 + # --cache: work around a broken ~/.npm cache (root-owned files cause + # npx EPERM); drop it if your npm cache is healthy + args: ['--cache', '/tmp/npmcache-dsh', '-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..59e8c3214 --- /dev/null +++ b/plugin/dsh/lib/index.js @@ -0,0 +1,308 @@ +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) payload[key] = typeof value === "string" ? value.slice(0, 2e3) : value; + } + 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..7c7fed382 --- /dev/null +++ b/plugin/dsh/src/index.ts @@ -0,0 +1,461 @@ +// @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) payload[key] = typeof value === "string" ? value.slice(0, 2000) : value; + } + 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: clear caches and let in-flight + // REST calls settle (the promises are referenced, so nothing is dropped). + 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..f54887798 --- /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', '/tmp/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..ea442db2f --- /dev/null +++ b/src/cli/connect/dsh.ts @@ -0,0 +1,173 @@ +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. Content after the block (user's own entries) is preserved. +function stripInstalledBlock(content: string): string { + const idx = content.indexOf(MCP_ENTRY_MARKER); + if (idx === -1) return content; + const lineStart = content.lastIndexOf("\n", idx) + 1; + const tail = content.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 ? content.length : lineStart + first + 1 + second; + return content.slice(0, lineStart) + content.slice(end); +} + +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"); + if (alreadyHas && !opts.force) { + logAlreadyWired(this.displayName, patch); + 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"); + + // 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) && !opts.force) { + p.log.info(" skill exists (skipped; --force to overwrite)"); + } else { + if (existsSync(skillPath)) { + const skillBackup = backupFile(skillPath, this.name, "md"); + logBackup(skillBackup); + } + mkdirSync(skillDir, { recursive: true }); + writeFileSync(skillPath, SKILL_MD, "utf8"); + } + + 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..b6f6f79ea 100644 --- a/src/cli/connect/guidelines.ts +++ b/src/cli/connect/guidelines.ts @@ -112,6 +112,16 @@ 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: { + globalPath: 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..0c3cbd6c2 --- /dev/null +++ b/test/cli-connect-dsh.test.ts @@ -0,0 +1,145 @@ +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; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "am-dsh-")); + process.env.DSH_HOME = join(tmpHome, ".dsh"); + delete process.env.AGENTMEMORY_DSH_PROFILE; + }); + + afterEach(() => { + 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-")); + 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 { + 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", From 0a234ef6579696eaf90daba2dfecedff02d70d45 Mon Sep 17 00:00:00 2001 From: hejiawow <16770133+hejiawow@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:31:58 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(dsh):=20address=20CodeRabbit=20review?= =?UTF-8?q?=20=E2=80=94=20force=20dedup,=20DSH=5FHOME,=20security=20harden?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dsh.ts: --force now also strips a user-configured unmarked mcp-agentmemory entry (no duplicate server id); already-wired also ensures the skill exists. - guidelines.ts: dsh guideline path honors DSH_HOME (aligned with adapter). - plugin: approval payload fully truncated (metadata serialized + sliced); teardown documents that in-flight REST promises keep the event loop alive. - install: npx --cache moved from /tmp to a private per-user dir (~/.cache/npmcache-dsh) generated by the installer; patch template keeps the default entry without --cache. - docs: test totals breakdown (1620 passed/6 failed pre-existing/1 skipped), dynamic chown command (id -u:id -g), verify command auth header, logging claim aligned with the REST client. - tests: env vars restored after each case; new DSH_HOME guideline test. - Rebuilt plugin/dsh/lib/index.js. Signed-off-by: hejiawow <16770133+hejiawow@users.noreply.github.com> --- docs/dsh-integration.md | 4 +- plugin/dsh/README.md | 4 +- plugin/dsh/install/cordis.patch.yml | 8 ++-- plugin/dsh/lib/index.js | 10 ++++- plugin/dsh/src/index.ts | 22 +++++++-- scripts/dsh-install.cjs | 2 +- src/cli/connect/dsh.ts | 70 ++++++++++++++++++----------- src/cli/connect/guidelines.ts | 7 ++- test/cli-connect-dsh.test.ts | 29 ++++++++++++ 9 files changed, 117 insertions(+), 39 deletions(-) diff --git a/docs/dsh-integration.md b/docs/dsh-integration.md index 80dddd956..48dd259ad 100644 --- a/docs/dsh-integration.md +++ b/docs/dsh-integration.md @@ -38,14 +38,14 @@ Event names are taken from `@deepseek-ai/dsh-session` / `@deepseek-ai/dsh-agent` ## Verification (live, macOS) -- `npm test`: full suite green (1620/1627; 6 pre-existing environment failures in `embedding-provider.test.ts` unrelated to this change). +- `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 501:20 ~/.npm`, or add `--cache

` to the entry's `args` (both documented in `plugin/dsh/install/cordis.patch.yml`). +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 diff --git a/plugin/dsh/README.md b/plugin/dsh/README.md index 6a60cff2a..f84769e86 100644 --- a/plugin/dsh/README.md +++ b/plugin/dsh/README.md @@ -63,7 +63,7 @@ Restart dsh (or wait for HMR to load the new plugin). 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. +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 @@ -72,7 +72,7 @@ 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; any REST failure is logged once and never throws into cordis. +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 diff --git a/plugin/dsh/install/cordis.patch.yml b/plugin/dsh/install/cordis.patch.yml index b393cb505..d28689225 100644 --- a/plugin/dsh/install/cordis.patch.yml +++ b/plugin/dsh/install/cordis.patch.yml @@ -9,9 +9,11 @@ transport: stdio serverName: agentmemory command: npx - # --cache: work around a broken ~/.npm cache (root-owned files cause - # npx EPERM); drop it if your npm cache is healthy - args: ['--cache', '/tmp/npmcache-dsh', '-y', '@agentmemory/mcp'] + # 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: '' diff --git a/plugin/dsh/lib/index.js b/plugin/dsh/lib/index.js index 59e8c3214..df0f2ef25 100644 --- a/plugin/dsh/lib/index.js +++ b/plugin/dsh/lib/index.js @@ -280,7 +280,15 @@ function apply(ctx, rawConfig = {}) { const payload = { notification_type: "permission_prompt" }; for (const key of APPROVAL_FIELDS) { const value = data[key]; - if (value !== void 0) payload[key] = typeof value === "string" ? value.slice(0, 2e3) : value; + if (value === void 0) continue; + let text; + if (typeof value === "string") text = value; + else try { + text = JSON.stringify(value); + } catch { + text = String(value); + } + payload[key] = text.slice(0, 2e3); } fireObserve(sid, info, "notification", payload); }); diff --git a/plugin/dsh/src/index.ts b/plugin/dsh/src/index.ts index 7c7fed382..b6e5512b2 100644 --- a/plugin/dsh/src/index.ts +++ b/plugin/dsh/src/index.ts @@ -423,7 +423,20 @@ export function apply(ctx: PluginContext, rawConfig: Partial const payload: Record = { notification_type: "permission_prompt" }; for (const key of APPROVAL_FIELDS) { const value = data[key]; - if (value !== undefined) payload[key] = typeof value === "string" ? value.slice(0, 2000) : value; + 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 { + text = JSON.stringify(value); + } catch { + text = String(value); + } + } + payload[key] = text.slice(0, 2000); } fireObserve(sid, info, "notification", payload); }); @@ -444,8 +457,11 @@ export function apply(ctx: PluginContext, rawConfig: Partial sessionInfos.delete(sid); }); - // Dispose bookkeeping on plugin teardown: clear caches and let in-flight - // REST calls settle (the promises are referenced, so nothing is dropped). + // 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(); diff --git a/scripts/dsh-install.cjs b/scripts/dsh-install.cjs index f54887798..af54285e7 100644 --- a/scripts/dsh-install.cjs +++ b/scripts/dsh-install.cjs @@ -35,7 +35,7 @@ const L1 = [ " transport: stdio", " serverName: agentmemory", " command: npx", - " args: ['--cache', '/tmp/npmcache-dsh', '-y', '@agentmemory/mcp']", + " 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, "''") + "'", diff --git a/src/cli/connect/dsh.ts b/src/cli/connect/dsh.ts index ea442db2f..e37f11478 100644 --- a/src/cli/connect/dsh.ts +++ b/src/cli/connect/dsh.ts @@ -47,18 +47,33 @@ const MCP_ENTRY_MARKER = "# ── agentmemory (installed by 'agentmemory connec // Remove the previously installed agentmemory block (marker comment + its // insert entry) so --force re-installs cleanly instead of appending a -// duplicate. Content after the block (user's own entries) is preserved. +// 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 { - const idx = content.indexOf(MCP_ENTRY_MARKER); - if (idx === -1) return content; - const lineStart = content.lastIndexOf("\n", idx) + 1; - const tail = content.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 ? content.length : lineStart + first + 1 + second; - return content.slice(0, lineStart) + content.slice(end); + 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 = [ @@ -119,8 +134,26 @@ export const adapter: ConnectAdapter = { 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 }; } @@ -145,20 +178,7 @@ export const adapter: ConnectAdapter = { const next = base + joiner + NL + MCP_ENTRY; writeFileSync(patch, next, "utf8"); - // 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) && !opts.force) { - p.log.info(" skill exists (skipped; --force to overwrite)"); - } else { - if (existsSync(skillPath)) { - const skillBackup = backupFile(skillPath, this.name, "md"); - logBackup(skillBackup); - } - mkdirSync(skillDir, { recursive: true }); - writeFileSync(skillPath, SKILL_MD, "utf8"); - } + ensureSkill(opts.force); logInstalled(this.displayName, patch); p.log.message( diff --git a/src/cli/connect/guidelines.ts b/src/cli/connect/guidelines.ts index b6f6f79ea..386071940 100644 --- a/src/cli/connect/guidelines.ts +++ b/src/cli/connect/guidelines.ts @@ -114,9 +114,12 @@ export function guidelineTargets( }, // 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). + // 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: join(home, ".dsh", "AGENTS.md"), + globalPath: process.env.DSH_HOME + ? join(process.env.DSH_HOME, "AGENTS.md") + : join(home, ".dsh", "AGENTS.md"), projectPath: "AGENTS.md", format: "block", scope: "global", diff --git a/test/cli-connect-dsh.test.ts b/test/cli-connect-dsh.test.ts index 0c3cbd6c2..3bb3da044 100644 --- a/test/cli-connect-dsh.test.ts +++ b/test/cli-connect-dsh.test.ts @@ -28,13 +28,22 @@ 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 }); }); @@ -131,6 +140,8 @@ describe("agentmemory connect — dsh adapter", () => { 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 }); @@ -139,6 +150,24 @@ describe("writeGuideline for dsh", () => { 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 }); } }); From 57a0eddd797118f2f98b5651b8bdf68aed1793fc Mon Sep 17 00:00:00 2001 From: hejiawow <16770133+hejiawow@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:47:42 +0800 Subject: [PATCH 3/3] fix(dsh): guard JSON.stringify undefined in approval truncation (CodeRabbit) Signed-off-by: hejiawow <16770133+hejiawow@users.noreply.github.com> --- plugin/dsh/lib/index.js | 2 +- plugin/dsh/src/index.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/plugin/dsh/lib/index.js b/plugin/dsh/lib/index.js index df0f2ef25..1362099cc 100644 --- a/plugin/dsh/lib/index.js +++ b/plugin/dsh/lib/index.js @@ -284,7 +284,7 @@ function apply(ctx, rawConfig = {}) { let text; if (typeof value === "string") text = value; else try { - text = JSON.stringify(value); + text = JSON.stringify(value) ?? String(value); } catch { text = String(value); } diff --git a/plugin/dsh/src/index.ts b/plugin/dsh/src/index.ts index b6e5512b2..c3532cc8e 100644 --- a/plugin/dsh/src/index.ts +++ b/plugin/dsh/src/index.ts @@ -431,7 +431,9 @@ export function apply(ctx: PluginContext, rawConfig: Partial text = value; } else { try { - text = JSON.stringify(value); + // 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); }