diff --git a/.cursor/hooks.json b/.cursor/hooks.json index faaec546d..095a01e00 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -1,19 +1,7 @@ { "version": 1, - "_comment": "DKG chat-capture hooks. When you open dkg-v9 as a Cursor workspace, every conversation turn is auto-promoted to the chat sub-graph of the project pinned in .dkg/config.yaml. The underlying script lives at packages/mcp-dkg/hooks/capture-chat.mjs — this file is just the Cursor wiring. See packages/mcp-dkg/README.md for details on .dkg/config.yaml and claude-code mirror setup.", + "_comment": "DKG chat-capture hooks. When you open dkg-v9 as a Cursor workspace, every conversation turn is auto-promoted to the chat sub-graph of the project pinned in .dkg/config.yaml. The underlying script lives at packages/mcp-dkg/hooks/capture-chat.mjs — this file is just the Cursor wiring. The V9 sessionStart/sessionEnd hooks were retired in #21 (no V10-relevant work to do; chat:Session triples are bootstrapped lazily on the first afterAgentResponse).", "hooks": { - "sessionStart": [ - { - "command": "node packages/mcp-dkg/hooks/capture-chat.mjs sessionStart", - "failClosed": false - } - ], - "sessionEnd": [ - { - "command": "node packages/mcp-dkg/hooks/capture-chat.mjs sessionEnd", - "failClosed": false - } - ], "beforeSubmitPrompt": [ { "command": "node packages/mcp-dkg/hooks/capture-chat.mjs beforeSubmitPrompt", diff --git a/.cursor/rules/dkg-annotate.mdc b/.cursor/rules/dkg-annotate.mdc deleted file mode 100644 index 395849f2b..000000000 --- a/.cursor/rules/dkg-annotate.mdc +++ /dev/null @@ -1,52 +0,0 @@ ---- -description: Annotate every chat turn into the DKG project's chat sub-graph via the dkg MCP tools so shared project memory grows organically and stays navigable. -alwaysApply: true ---- - -# DKG annotation protocol - -This workspace is bound to a DKG context graph. Use the `dkg` MCP server to keep the graph rich and convergent. - -## After every substantive turn - -Call **`dkg_annotate_turn`** exactly once. A turn is "substantive" if it reasoned, proposed, examined, or referenced something — basically every turn that wasn't a one-line acknowledgement. Over-eagerness is not a failure mode; under-coverage is. - -**Always pass `forSession`.** The session ID is in the session-start `additionalContext` ("Your current session ID: ``"). Race-free deferred rendezvous: the tool queues the annotation, the capture hook applies it to your actual turn URI when it writes the next `chat:Turn` for the session. Never try to predict your own turn URI — it doesn't exist yet. - -Minimum payload: - -```jsonc -dkg_annotate_turn({ - forSession: "", - topics: [<2-3 short topic strings>], - mentions: [] -}) -``` - -Add `examines` / `proposes` / `concludes` / `asks` / `proposedDecisions` / `proposedTasks` / `comments` / `vmPublishRequests` when the turn warrants them. The full schema is in the agent guide returned by `dkg_get_ontology`. - -## Look-before-mint protocol (the convergence rule) - -Before minting any new `urn:dkg::` URI: - -1. Compute the normalised slug: `lowercase → ASCII-fold → strip stopwords (the/a/an/of/for/and/or/to/in/on/with) → hyphenate → ≤60 chars`. -2. Call `dkg_search` with the unnormalised label. -3. If any result's normalised slug matches yours, **REUSE** that URI. -4. Otherwise mint fresh per the URI patterns below. **Never fabricate URIs** for entities that don't exist yet. - -## URI patterns - -``` -urn:dkg:concept: free-text concept (skos:Concept) -urn:dkg:topic: broad topical bucket -urn:dkg:question: open question -urn:dkg:finding: preserved claim/observation -urn:dkg:decision: architectural decision -urn:dkg:task: work item -``` - -## Reference - -Call `dkg_get_ontology` once per session for the full agent guide + formal Turtle/OWL ontology. The session-start hook injects a summary so this is mostly a refresher; consult it whenever uncertain about which predicate to use. - -VM publish is **always** human-gated. Use `dkg_request_vm_publish` to write a marker; never publish on-chain directly. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e25aa61a9..4e9312826 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -417,7 +417,7 @@ jobs: run: | pnpm \ --filter @origintrail-official/dkg-epcis \ - --filter @origintrail-official/dkg-mcp-server \ + --filter @origintrail-official/dkg-mcp \ --filter @origintrail-official/dkg-network-sim \ --filter @origintrail-official/dkg-graph-viz \ --filter @origintrail-official/dkg-adapter-autoresearch \ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index bf5f91008..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,107 +0,0 @@ -# Agent Instructions - -This repository is bound to a **DKG context graph** (`dkg-code-project`) used for shared project memory across all AI coding agents working on it. Cursor, Claude Code, and any other MCP-aware agent should follow the same protocol so the graph converges rather than fragments. - -For Cursor-specific session-start guidance the same content lives in [`.cursor/rules/dkg-annotate.mdc`](.cursor/rules/dkg-annotate.mdc) with `alwaysApply: true`. This file is the canonical instructions and is read by Claude Code, Continue, OpenAI Codex CLI, and any other tool that honours `AGENTS.md`. - -## What this graph is - -- **Subgraphs**: `chat`, `tasks`, `decisions`, `code`, `github`, `meta` — each a distinct slice of project memory. -- **Capture hook** at `packages/mcp-dkg/hooks/capture-chat.mjs` writes every chat turn into `chat` and gossips it to all subscribed nodes within ~5s. Wired via `.cursor/hooks.json` and `~/.claude/settings.json`. -- **MCP server** at `packages/mcp-dkg` exposes ~14 read+write+annotation tools to any MCP-aware agent. -- **Project ontology** lives at `meta/project-ontology` — fetch via `dkg_get_ontology`. The formal Turtle/OWL artifact + a markdown agent guide. - -## The annotation protocol - -After **every substantive turn** (anything that reasoned, proposed, examined, or referenced something — basically every turn that wasn't a one-line acknowledgement), call **`dkg_annotate_turn`** exactly once. The shared chat sub-graph is project memory, not a "DKG-relevant search index" — over-eagerness is not a failure mode; under-coverage is. - -**Always pass `forSession`.** The session ID is in the `additionalContext` injected at session start ("Your current session ID: ``"). The tool queues the annotation as a pending entity; the capture hook applies it to your actual turn URI when it writes the next `chat:Turn` for the session. Race-free regardless of timing — works whether you call it during your response composition (before the hook fires) or after. Don't try to predict your own turn URI; it doesn't exist yet at the moment you call this tool. - -Minimum viable annotation: - -```jsonc -dkg_annotate_turn({ - forSession: "", - topics: [<2-3 short topic strings>], // chat:topic literals - mentions: [], // chat:mentions edges -}) -``` - -Add when the turn warrants: - -- `examines` — entities the turn analysed in detail (vs just citing in passing) -- `concludes` — `:Finding` entities the turn produced (claims worth preserving) -- `asks` — `:Question` entities left open -- `proposedDecisions` — sugar over `dkg_propose_decision`; freshly mints a Decision and links via `chat:proposes` -- `proposedTasks` — sugar over `dkg_add_task` -- `comments` — sugar over `dkg_comment` (against any existing entity) -- `vmPublishRequests` — sugar over `dkg_request_vm_publish` (writes a marker; **never** publishes on-chain) - -## Look-before-mint protocol (the convergence rule) - -This is the single most important rule. It's how parallel agents converge on the same URIs instead of fragmenting the graph. - -Before minting any new `urn:dkg::` URI: - -1. Compute the **normalised slug**: lowercase → ASCII-fold → strip stopwords (`the/a/an/of/for/and/or/to/in/on/with`) → hyphenate → ≤60 chars. -2. Call `dkg_search` with the **unnormalised label** (the daemon does its own fuzzy match). -3. If any returned entity's normalised slug matches yours → **REUSE** that URI. -4. Otherwise mint `urn:dkg::` per the patterns below. - -**Never fabricate URIs** for entities you didn't discover via `dkg_search`. If unsure, prefer minting fresh and let humans (or the future `dkg_propose_same_as` reconciliation flow) merge duplicates via `owl:sameAs`. - -## URI patterns - -``` -urn:dkg:concept: free-text concept (skos:Concept) -urn:dkg:topic: broad topical bucket -urn:dkg:question: open question -urn:dkg:finding: preserved claim/observation -urn:dkg:decision: architectural decision (coding-project) -urn:dkg:task: work item (coding-project) -urn:dkg:agent: agent identity (usually -) -urn:dkg:github:repo:/ GitHub repository -urn:dkg:github:pr:// -urn:dkg:code:file:/ -urn:dkg:code:package: -``` - -## Tool reference - -Read tools (read-only, no side effects): - -- `dkg_list_projects` — list every CG this node knows about -- `dkg_list_subgraphs` — show counts per sub-graph in a project -- `dkg_sparql` — arbitrary SELECT/CONSTRUCT/ASK; layer ∈ {wm, swm, union, vm} -- `dkg_get_entity` — describe one entity + 1-hop neighbourhood -- `dkg_search` — keyword search across labels + body text (use this in look-before-mint) -- `dkg_list_activity` — recent activity feed (decisions, tasks, turns) with attribution -- `dkg_get_agent` — agent profile + authored counts -- `dkg_get_chat` — captured turns filterable by session/agent/keyword/time -- `dkg_get_ontology` — the project's ontology + agent guide (call once per session) - -Write tools (auto-promoted to SWM; humans gate VM): - -- `dkg_annotate_turn` — **the main per-turn surface**; batches everything below -- `dkg_propose_decision`, `dkg_add_task`, `dkg_comment`, `dkg_request_vm_publish`, `dkg_set_session_privacy` — the underlying primitives, available standalone for explicit "file a decision" / "open a task" requests - -## Things to NOT do - -- **Don't fabricate URIs.** Every URI in `mentions` must come from `dkg_search` or be freshly minted via the look-before-mint protocol. -- **Don't skip turns to "save tokens".** One annotation call per turn is cheap (~few hundred ms). Coverage wins. -- **Don't publish to VM via MCP.** That's `dkg_request_vm_publish` (marker for human review), not `/api/shared-memory/publish`. The agent is never the gating actor for on-chain commitment. -- **Don't normalise slugs in your `dkg_search` query.** Pass the unnormalised label so the daemon's fuzzy match has the most signal; only normalise when comparing for reuse-vs-mint. - -## Cheat sheet - -``` -After every substantive turn: -1. dkg_search "" → reuse-or-mint URIs -2. dkg_annotate_turn({ - topics: [...], mentions: [...], - examines?, concludes?, asks?, - proposedDecisions?, proposedTasks?, comments? - }) -``` - -That's it. The graph grows; teammates' agents see your work in seconds; humans ratify on-chain when worthwhile. diff --git a/CLAUDE.md b/CLAUDE.md index d98bd010a..d56bc7d99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,14 +4,14 @@ This repository uses a **Decentralized Knowledge Graph (DKG)** for multi-agent d ## Setup -The DKG MCP server must be configured in your MCP settings: +The MCP server is now reachable via `dkg mcp serve` (the umbrella `dkg` CLI subcommand on PATH after `npm install -g @origintrail-official/dkg`). Configure your MCP settings: ```json { "mcpServers": { "dkg": { - "command": "node", - "args": ["packages/mcp-server/dist/index.js"] + "command": "dkg", + "args": ["mcp", "serve"] } } } @@ -180,4 +180,4 @@ All classes and properties use the `devgraph:` namespace (`https://ontology.dkg. | `Class` | An exported class | | `Contract` | A Solidity smart contract | -The full ontology is at `packages/mcp-server/schema/dev-paranet.ttl`. +The full ontology is at `packages/mcp-dkg/schema/dev-context-graph.ttl`. diff --git a/README.md b/README.md index c30942a07..0c23db225 100644 --- a/README.md +++ b/README.md @@ -67,18 +67,20 @@ the writer, but do not encrypt GossipSub payload bytes. ## Quick Start -**Prerequisites:** Node.js 22+, npm 10+ +**Prerequisites:** Node.js 22+, npm 10+. macOS, Linux, and Windows (PowerShell 5.1+ or WSL2) all supported. -### For AI agents +Pick the on-ramp that matches how you're already working: -> **OpenClaw agents:** Install the DKG CLI and run setup — this installs the node AND wires up the adapter with memory, tools, and Agent Hub: -> ```bash -> npm install -g @origintrail-official/dkg -> dkg openclaw setup -> ``` -> Then restart the OpenClaw gateway. See the [adapter guide](packages/adapter-openclaw/README.md) for details. +| You want… | Recipe | More | +|---|---|---| +| **DKG V10 as memory for Cursor / Claude Code / Continue / Cline** | [MCP setup](#dkg-v10-as-agent-memory-mcp) | two commands | +| **DKG V10 wired into an OpenClaw agent** | [OpenClaw setup](#openclaw-adapter) | two commands | +| **DKG V10 inside an ElizaOS agent** | [ElizaOS adapter](packages/adapter-elizaos/README.md) | adapter README | +| **DKG V10 inside a Hermes agent** | [Hermes adapter](packages/adapter-hermes/README.md) | adapter README | +| **A standalone node** to query and publish from the CLI | [Standalone node](#standalone-node) | manual install | +| **A custom Node.js / TypeScript integration** | [Custom-agent setup](docs/setup/SETUP_CUSTOM.md) | docs | -> **ElizaOS agents:** Use the [`@origintrail-official/dkg-adapter-elizaos`](packages/adapter-elizaos/README.md) adapter. See the [ElizaOS setup guide](docs/setup/SETUP_ELIZAOS.md). +Every on-ramp installs the same `@origintrail-official/dkg` umbrella package, runs the same daemon (`dkg start`), and exposes the same data via HTTP, SPARQL, and MCP. The recipes below diverge only in what they wire up on top. > **Hermes agents:** Install the DKG CLI and run Hermes setup, then start the Hermes gateway: > ```bash @@ -87,21 +89,118 @@ the writer, but do not encrypt GossipSub payload bytes. > ``` > `dkg hermes setup` bootstraps the DKG node config (no separate `dkg init` needed), starts the daemon, optionally funds wallets, and wires the Hermes profile with replace-by-default provider election (use `--preserve-provider` to opt out, `--no-start` / `--no-fund` for advanced flows). See the [adapter guide](packages/adapter-hermes/README.md) for details. -> **Cursor / Claude Code / other MCP clients:** Install the [`@origintrail-official/dkg-mcp`](packages/mcp-dkg/README.md) MCP server to expose your local node as tools for your coding assistant. +### DKG V10 as agent memory (MCP) + +Two commands give Cursor, Claude Code, Continue, or Cline a verifiable shared memory layer: + +```bash +npm install -g @origintrail-official/dkg # installs CLI + bundled MCP server +dkg mcp setup # one-shot: init + start + fund + register + verify +``` + +That's it. The first command installs the `dkg` umbrella CLI; the second runs a one-shot bundled flow that: + +1. Initializes `~/.dkg/config.json` if it doesn't exist (skipped silently when present) +2. Starts the DKG daemon as a background process (skipped if already running) +3. Funds the node's wallets via the testnet faucet (skip with `--no-fund` for CI) +4. Registers the MCP server with each detected client (Cursor, Claude Code) by writing a single canonical entry under `mcpServers.dkg`: + + ```json + { + "mcpServers": { + "dkg": { + "command": "dkg", + "args": ["mcp", "serve"] + } + } + } + ``` + +5. Verifies the daemon is healthy + +No tokens or URLs in the JSON — those live in `~/.dkg/config.yaml` and the daemon-written `~/.dkg/auth.token`. If no client config is detected, run `dkg mcp setup --print-only` to emit the JSON for manual paste. + +**Each step is idempotent and skippable.** Re-running `dkg mcp setup` on an already-set-up box is safe — every step short-circuits when its work is already done. Step-skip flags: `--no-start` (configure only, don't start the daemon), `--no-fund` (skip faucet — CI-friendly), `--no-verify` (skip the post-setup probe), `--dry-run` (preview what would happen), `--force` (refresh every detected client config regardless of state). First-init overrides: `--port `, `--name `. + +**First-run verification.** Restart your client so it discovers the MCP, then ask it: *"What tools does dkg expose?"* The `tools/list` response must include at least `dkg_assertion_create`, `dkg_assertion_write`, and `dkg_memory_search`. Then trigger the [round-trip](#round-trip-write-then-recall) below to prove the wiring works end to end. + +#### Round-trip: write, then recall + +The validated path agents follow when "remember this" actually has to mean *cryptographically anchored, queryable, survives the session*: + +1. **Install** — `npm install -g @origintrail-official/dkg` +2. **Set up** — `dkg mcp setup` (the bundled flow: initializes config, starts the daemon, funds wallets via testnet faucet, registers the MCP with detected clients, verifies daemon health) +3. **Confirm reachable** — `dkg status` returns a PeerId; `curl -s http://127.0.0.1:9200/health` is `200` +4. **Restart your client** — Cursor / Claude Code / Continue / Cline picks up the new MCP entry on next launch +5. **(no manual CG creation)** — `agent-context` is auto-created on first write by the storage layer; the round-trip below assumes it +6. **Write** — agent calls `dkg_assertion_create` with `name: "session-2026-05-04"`, then `dkg_assertion_write` with one or more quads. Both tools are idempotent / additive — re-runs are safe. +7. **Recall** — agent calls `dkg_memory_search` with a keyword from the write. The result includes `contextGraphId`, `layer` (`working-memory`, `shared-working-memory`, or `verified-memory`), and a `trustWeight` per hit; higher-trust layers collapse lower-trust hits for the same entity. The just-written triple comes back from the WM layer. +8. **(Optional) Promote to SWM** — `dkg_assertion_promote` advances the assertion's lifecycle and gossips it to peers subscribed to the same context graph. +9. **(Optional) Publish to VM** — `dkg_shared_memory_publish` finalizes Shared Working Memory on-chain (costs TRAC + gas, clears SWM). For a one-shot fresh-quads-to-VM helper, use `dkg_publish` instead — it writes to SWM and publishes in a single call but skips the WM staging area. + +That round-trip — write → search → optionally promote → optionally finalize — is the canonical pattern across every framework on this page. The MCP tools, OpenClaw adapter, and ElizaOS provider all hit the same daemon endpoints behind the scenes, so memories cross frameworks freely. + +#### Troubleshooting (MCP) + +- **`dkg mcp setup` says "no MCP-aware clients detected"** → install Cursor, Claude Code, Continue, or Cline (or run with `--print-only` to copy the JSON yourself). +- **`dkg mcp` says command not found** → the umbrella CLI isn't on PATH; verify with `which dkg`. `npm i -g @origintrail-official/dkg` does NOT propagate transitive bins, so `dkg-mcp` directly is also unavailable — always go through `dkg mcp serve`. +- **MCP not visible in client** → restart the client; on Cursor verify `~/.cursor/mcp.json` is syntactically valid; on Claude Code run `claude mcp list`. +- **HTTP 401 from MCP tools** → token mismatch. `dkg auth show` returns the expected value; confirm it matches `~/.dkg/auth.token`. On CI / containers / proxied environments where `dkg init` can't run, set the env-var fallbacks documented at `packages/mcp-dkg/src/config.ts`: `DKG_API` (daemon URL), `DKG_TOKEN` (bearer), `DKG_PROJECT` (default context graph), `DKG_AGENT_URI`. A stale exported `DKG_PROJECT` from a prior session can silently mis-route writes — unset it if you switch projects. +- **Daemon unreachable** → `dkg status`; if it errors, `dkg logs` and `cat ~/.dkg/daemon.log`. Stale pid → `cat ~/.dkg/daemon.pid` and kill it, then `dkg start` again. +- **Port 9200 already in use** → another node is running. `dkg stop` once, or override via `dkg init` and pick a different API port. +- **WSL2: daemon dies when the terminal closes** → wrap in `tmux` or install as a systemd user service. See the [WSL2 section in JOIN_TESTNET.md](docs/setup/JOIN_TESTNET.md) for the systemd unit file. -**Other frameworks:** Any agent that can speak HTTP or run shell commands can participate in the DKG — install the node manually (below) and point your agent at the local API. +### OpenClaw adapter -### Manual install (standalone node) +Two commands: -Install the CLI globally and spin up a node: +```bash +npm install -g @origintrail-official/dkg # installs CLI + bundled adapter +dkg openclaw setup # configures + starts the daemon, registers the plugin +``` + +`dkg openclaw setup` is non-interactive and idempotent. It writes `~/.dkg/config.json`, merges the adapter into `~/.openclaw/openclaw.json` (under `plugins.entries.adapter-openclaw.config` — `daemonUrl`, `memory.enabled`, `channel.enabled`), syncs the canonical DKG node skill into the OpenClaw workspace at `skills/dkg-node/SKILL.md`, and verifies the install. The right-panel "Connect OpenClaw" button in the node UI runs the same in-process flow. + +Restart the OpenClaw gateway if it does not auto-reload: + +```bash +openclaw gateway restart +``` + +**First-run verification.** A healthy setup satisfies all four: + +- `dkg_status` works from the OpenClaw agent +- The DKG node UI loads at `http://127.0.0.1:9200/ui` +- The right-side chat surface connects to OpenClaw and a sent message round-trips +- The conversation survives a UI reload (proves DKG-backed chat persistence) + +**Flags.** `--no-fund` (skip faucet), `--no-start` (configure only), `--no-verify` (skip verification), `--dry-run` (preview without writing). Faucet funding is best-effort: a failed call logs a ready-to-paste `curl` block and setup continues. See the [Testnet Funding](#testnet-funding) section below for the full request/response shape. + +The full adapter reference — daemon URL config, channel-port overrides, disconnect/reconnect semantics — lives in [`packages/adapter-openclaw/README.md`](packages/adapter-openclaw/README.md). + +#### Troubleshooting (OpenClaw) + +- **Adapter not visible to gateway** → check `~/.openclaw/openclaw.json` has `plugins.entries.adapter-openclaw` populated; re-run `dkg openclaw setup`. +- **Faucet failure** → setup logs a `curl` block for manual funding; the node still works for non-on-chain flows (P2P, queries, WM/SWM writes). +- **Disconnect / Reconnect cycle wiped my custom config** → re-run `dkg openclaw setup --port ` after Reconnect. Default-port users see no visible difference across the cycle. +- **Channel port `9201` already in use** → set `channel.port` manually under `plugins.entries.adapter-openclaw.config` in `~/.openclaw/openclaw.json`. + +### Standalone node + +Skip the framework wiring — run the daemon directly and use the CLI or HTTP API: ```bash npm install -g @origintrail-official/dkg -dkg init # creates ~/.dkg with default config -dkg start # starts the node daemon +dkg init # creates ~/.dkg/config.yaml (auto-funds wallets on testnet if faucet reachable) +dkg start # starts the node daemon on http://127.0.0.1:9200 ``` -Once running, open the dashboard at [http://127.0.0.1:9200/ui](http://127.0.0.1:9200/ui). +Once running, open the dashboard at [http://127.0.0.1:9200/ui](http://127.0.0.1:9200/ui), or query directly: + +```bash +TOKEN=$(dkg auth show) +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:9200/api/agents +``` --- @@ -184,9 +283,11 @@ dkg auth show # show the current API auth token dkg auth rotate # generate a new auth token dkg auth status # show whether auth is enabled -# Framework adapters +# Framework adapters & MCP wiring dkg openclaw setup # install & configure the OpenClaw adapter dkg hermes setup # install & configure the Hermes adapter +dkg mcp setup # register the MCP server with Cursor / Claude Code / Continue / Cline +dkg mcp serve # run the MCP server on stdio (invoked by the client; not run manually) # Community integrations (registry: OriginTrail/dkg-integrations) dkg integration list [--tier community] # default tier filter is `verified`+ @@ -226,6 +327,8 @@ Use adapters for OpenClaw, ElizaOS, Hermes, or your own Node.js / TypeScript pro | Guide | Use it when | |---|---| +| [DKG V10 as agent memory (MCP)](#dkg-v10-as-agent-memory-mcp) | You want Cursor / Claude Code / Continue / Cline to use DKG as memory | +| [`packages/mcp-dkg/README.md`](packages/mcp-dkg/README.md) | You want the full MCP tool surface and config reference | | [Join the Testnet](docs/setup/JOIN_TESTNET.md) | You want a full node setup and first publish/query flow | | [OpenClaw Setup](docs/setup/SETUP_OPENCLAW.md) | You want OpenClaw to use DKG as memory/tools | | [Hermes Setup](docs/setup/SETUP_HERMES.md) | You want Hermes Agent to use DKG as memory/tools | @@ -368,7 +471,6 @@ This is a pnpm + Turborepo monorepo. @origintrail-official/dkg-attested-assets Attested Knowledge Asset protocol components @origintrail-official/dkg-epcis EPCIS → RDF supply-chain adapter @origintrail-official/dkg-mcp MCP server for Cursor / Claude Code / coding agents -@origintrail-official/dkg-mcp-server Code-graph MCP tools (dev-coordination) ``` ### Adapters and apps diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index 964396ba1..060372180 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -36,7 +36,7 @@ Current process keeps these aligned: - `package.json` - `packages/cli/package.json` - `packages/evm-module/package.json` -- `packages/mcp-server/package.json` +- `packages/mcp-dkg/package.json` ## 4) Pre-release tagging workflow diff --git a/docs/PHASE2_ARCHITECTURE_PLAN.md b/docs/PHASE2_ARCHITECTURE_PLAN.md index dff141ba2..2324a8a65 100644 --- a/docs/PHASE2_ARCHITECTURE_PLAN.md +++ b/docs/PHASE2_ARCHITECTURE_PLAN.md @@ -112,13 +112,13 @@ Acceptance criteria per route module: - Same wire format and status codes (snapshot the full `daemon.ts` behaviour with a CDC test before splitting; the existing playwright + node-ui tests should keep passing). - Auth resolution (`requestAgentAddress`) is performed by `http/auth.ts`, not the route module. - Phase events (`tracker.start/startPhase/completePhase/complete`) stay at the route boundary so the journal contract doesn't change. -- **Every existing legacy path stays wired.** The refactor is a pure file move, not an API break. Before merging any route split, grep the monorepo for the route string and confirm in-repo clients (`packages/mcp-server`, `packages/mcp-dkg`, `packages/node-ui`) resolve against the new location. The known legacy aliases that must survive the move (verified against `packages/cli/src/daemon.ts` at the time of writing) are: +- **Every existing legacy path stays wired.** The refactor is a pure file move, not an API break. Before merging any route split, grep the monorepo for the route string and confirm in-repo clients (`packages/mcp-dkg`, `packages/node-ui`) resolve against the new location. (The historical `mcp-server` package was a third in-repo client at plan-authoring time; it was removed in the V10 keeper consolidation 2026-05-04 — see the `pre-v10-tool-drop` tag.) The known legacy aliases that must survive the move (verified against `packages/cli/src/daemon.ts` at the time of writing) are: - `/api/subscribe` → V10 `/api/context-graph/subscribe` - `/api/paranet/create | list | rename | exists` → V10 `/api/context-graph/*` (see the paranet caveat below; `paranet/create` is a narrower legacy shim, not a pure alias) - `/api/workspace/write` → V10 `/api/shared-memory/write` (dual-wired at `daemon.ts:4646-4650`) - `/api/workspace/enshrine` → V10 `/api/shared-memory/publish` (dual-wired at `daemon.ts:4706-4710`) - A route split that omits any of these aliases silently breaks older CLI builds, older `mcp-server` releases, and any user automation that hit the V9 surface. + A route split that omits any of these aliases silently breaks older CLI builds, the historical legacy MCP package (now removed; see `pre-v10-tool-drop` tag), and any user automation that hit the V9 surface. Recommended PR ordering (smallest → largest, each is independently mergeable): @@ -219,8 +219,7 @@ End state: `dkg-agent.ts` ≤ 1.5 kLOC; no sub‑module > 1.2 kLOC. The implemen - `packages/publisher` (phase-sequences + publish/update regression) - `packages/cli` (daemon HTTP behaviour + CLI integration) - `packages/node-ui` (chat-memory, operations view) - - `packages/mcp-server` (MCP tool schema + integration — the MCP server is an in-repo client of `/api/query`, `/api/shared-memory/write`, `/api/shared-memory/publish`, `/api/context-graph/list`, and `/api/context-graph/create` as wired in `packages/mcp-server/src/connection.ts`; a route move that breaks any of those calls would otherwise slip through the daemon-only tests. The list must be re‑grepped before any PR that touches routes — if this file falls out of sync with `connection.ts`, the verification checklist stops catching MCP‑publish regressions) - - `packages/mcp-dkg` (the DKG-flavoured MCP bundle; same rationale) + - `packages/mcp-dkg` (MCP tool schema + integration — the MCP server is an in-repo client of `/api/query`, `/api/shared-memory/write`, `/api/shared-memory/publish`, `/api/context-graph/list`, and `/api/context-graph/create` as wired in its `client.ts`; a route move that breaks any of those calls would otherwise slip through the daemon-only tests. The list must be re‑grepped before any PR that touches routes — if this file falls out of sync with `client.ts`, the verification checklist stops catching MCP‑publish regressions. The historical legacy MCP package was a separate fourth client at plan-authoring time and has since been removed in the V10 keeper consolidation 2026-05-04; see `pre-v10-tool-drop` tag for its original wiring.) A repo‑wide `typecheck` script per package is itself a Phase‑2 follow‑up, not a prerequisite. diff --git a/docs/TWO-LAPTOP-DEMO.md b/docs/TWO-LAPTOP-DEMO.md deleted file mode 100644 index 95a19ca9e..000000000 --- a/docs/TWO-LAPTOP-DEMO.md +++ /dev/null @@ -1,170 +0,0 @@ -# Two-laptop coding demo on testnet - -Two laptops, two operators, one project. Both wire Cursor to the same DKG context graph and code together — chat turns, decisions, and tasks are shared via the graph. - -This is a pre-npm walkthrough: today both laptops bootstrap the daemon from a `dkg-v9` checkout on the `feat/cursor-dkg-integration` branch. Once the npm package ships the same flow becomes `npm install -g @origintrail-official/dkg && dkg start`. - -## Prerequisites - -On both laptops: - -- **Node.js 22+** and **pnpm 10+** -- **Cursor** installed -- **Base Sepolia ETH** for the daemon's identity registration. Use the [DKG testnet faucet guide](setup/TESTNET_FAUCET.md). You'll need a few cents worth of testnet ETH per node. - -## 1. Bootstrap (both laptops) - -```bash -git clone https://github.com/OriginTrail/dkg-v9.git -cd dkg-v9 -git checkout feat/cursor-dkg-integration -pnpm install -pnpm build -pnpm dkg init # creates ~/.dkg with default testnet config -pnpm dkg start # starts the daemon -``` - -Watch for the line `Network config: DKG V10 Testnet (genesis v1)` in the log. The daemon is now reachable at `http://localhost:9200`. - -Open `http://localhost:9200/ui` in a browser. You should see the empty Node UI, the operator's identity address in the header, and "0 projects" on the dashboard. - -> Troubleshooting: if `pnpm dkg start` complains about `Insufficient TRAC` or `identity not registered`, you need to fund the agent address shown in `pnpm dkg show` from the faucet, then re-run. - -## 2. Laptop A: create the project - -In Laptop A's Node UI, click **+ Create Project**. Fill in: - -- **Project Name:** `Tic Tac Toe` -- **Description:** `Build a Tic Tac Toe game in TypeScript with React frontend and a minimax AI opponent` -- **Access:** `Curated` (recommended — Laptop A controls who joins) -- **Ontology:** `Choose a starter` → `Coding project` - -Click **Create Project**. The modal walks through: - -1. Registering the CG on Base Sepolia (~10–30s on testnet) -2. Installing the `coding-project` ontology into `meta/project-ontology` -3. Publishing the project manifest into `meta/project-manifest` -4. Transitioning into the **Wire workspace** step - -In the Wire workspace step: - -- **Workspace path:** e.g. `/Users//code/tic-tac-toe` (or whatever absolute path you want; the daemon creates the directory if it doesn't exist) -- **Agent slug for this machine:** something descriptive, e.g. `cursor-alice-laptop1` -- **Skip Claude Code wiring:** leave checked unless you actually use Claude Code - -Click **Preview install**. The modal shows the markdown diff: which files will be created, sizes, where the daemon-token reference lands, and the security boundaries that are enforced (path-locked allowlist, no script execution, no tokens in the manifest). Review, then click **Install**. - -You should see something like: - -``` -created /Users/alice/code/tic-tac-toe/.cursor/rules/dkg-annotate.mdc (4,210 bytes) -created /Users/alice/code/tic-tac-toe/.cursor/hooks.json (590 bytes) -created /Users/alice/code/tic-tac-toe/.cursor/mcp.json (310 bytes) -created /Users/alice/code/tic-tac-toe/.dkg/config.yaml (290 bytes) -created /Users/alice/code/tic-tac-toe/AGENTS.md (12,400 bytes) -``` - -Click **Done**. The modal closes and Laptop A's UI shows the new project tab. - -> Verify in the UI: click into the project, then `meta` sub-graph. You should see the manifest entity (`urn:dkg:project:.../manifest`), the ontology entity (`urn:dkg:project:.../ontology`), and the template entities for the Cursor rule, hooks, config, AGENTS.md. - -## 3. Laptop A: plan the project from Cursor - -Open the wired workspace in Cursor: - -```bash -cursor /Users/alice/code/tic-tac-toe -``` - -Start a new chat and prompt: - -> We're building a Tic Tac Toe game per the project description (TypeScript + React + minimax AI). Break it into 5–7 atomic tasks and create each one via `dkg_add_task`. Keep titles short and add a one-sentence description. - -The agent has the `coding-project` ontology and the `dkg_add_task` tool from session-start context. It should produce something like: - -- Set up Vite + React + TypeScript scaffold -- Build a 3x3 grid component with click-to-place -- Implement game-state reducer (current player, winner check, board state) -- Implement minimax AI for the computer player -- Wire up game-start / game-over UI states -- Write unit tests for the win-detection logic -- Polish UI (dark mode, animations, scoreboard) - -Each task creates a `tasks:Task` entity in the project's `tasks` sub-graph and is auto-promoted to SWM (gossipped to all subscribed nodes). - -> Verify: switch back to the Node UI, click into the project, then the `tasks` sub-graph. Each task should be there with its title, description, and a `prov:wasAttributedTo urn:dkg:agent:cursor-alice-laptop1` attribution. - -## 4. Laptop A: share the invite - -In the Node UI, open the project tab and click **Share Project**. Copy the invite code (it looks like `did:dkg:context-graph:0x.../tic-tac-toe` plus a `/ip4/.../p2p/12D3...` multiaddr on a second line) and send it to Laptop B over any channel — Signal, AirDrop, paper, whatever. - -If you chose **Curated** access, you'll receive Laptop B's join request as a notification once they paste the invite (next step). Approve it from the project's Participants view. - -## 5. Laptop B: join + wire - -In Laptop B's Node UI, click **+ Join Project** and paste the invite code. The modal walks through: - -1. Connecting to Laptop A's node (uses the multiaddr from the invite) -2. Subscribing to the project (and, if curated, sending a signed join request — wait for Laptop A to approve) -3. Catching up the project's existing knowledge: ontology, manifest, tasks, decisions, prior chat -4. Transitioning into the **Wire workspace** step - -In the Wire workspace step: - -- **Workspace path:** e.g. `/Users//code/tic-tac-toe` (a fresh local path on this machine) -- **Agent slug:** something distinct from Laptop A, e.g. `cursor-bob-laptop2` -- **Skip Claude Code:** as before - -Preview, install, done. The wired workspace on Laptop B is identical in structure to Laptop A's; only the `agentSlug` and `daemonApiUrl` placeholders differ. - -> Verify: in Laptop B's Node UI, the `tasks` sub-graph for this project shows the same 5–7 tasks Laptop A's agent created. The catchup pulled them across via gossip. - -## 6. Both laptops: code together - -Open the wired workspace in Cursor on each laptop and start a fresh chat. The session-start context the agent receives includes a bucketed plan, e.g.: - -``` -**Open tasks:** -- urn:dkg:tasks:set-up-vite-react — Set up Vite + React + TypeScript scaffold -- urn:dkg:tasks:build-grid-component — Build a 3x3 grid component with click-to-place -- urn:dkg:tasks:implement-game-state-reducer — Implement game-state reducer -- urn:dkg:tasks:implement-minimax-ai — Implement minimax AI for the computer player -- urn:dkg:tasks:wire-game-states — Wire up game-start / game-over UI states -- urn:dkg:tasks:test-win-detection — Write unit tests for the win-detection logic -- urn:dkg:tasks:polish-ui — Polish UI (dark mode, animations, scoreboard) - -**Concepts in scope:** -- urn:dkg:concepts:minimax — Minimax algorithm -``` - -A natural opening prompt on Laptop B: - -> I see we have these tasks. I'll start with `set-up-vite-react`. Use `dkg_annotate_turn` to record what I'm working on, and read any existing decisions on tech stack before scaffolding. - -While Laptop B works on scaffolding, Laptop A could simultaneously pick a different task ("I'll take `implement-minimax-ai`"). Both agents emit `dkg_annotate_turn` calls that record what each turn examined / proposed / concluded. As tasks complete, agents call `dkg_add_task` again with `status: done` (or use a finer-grained mutation tool if you have one). - -Switch back to either Node UI's **Activity feed** to watch chat turns, annotations, decisions, and task updates land in real-time from both laptops. - -## 7. Troubleshooting - -| Symptom | Likely cause | Fix | -|---|---|---| -| `pnpm dkg start` says "identity not registered" | Agent address has no Base Sepolia ETH yet | Fund the address shown in `pnpm dkg show` from the [faucet](setup/TESTNET_FAUCET.md), restart | -| `JoinProjectModal` shows "Access Restricted" | Curated CG, you're not on the allowlist | Click **Send Join Request**, ask the curator (Laptop A) to approve from their Participants view | -| `WireWorkspacePanel` preview fails with "No manifest published" | Curator created the project before this branch landed, or manifest publish failed silently | Curator runs `pnpm exec node scripts/import-manifest.mjs --project=` from a wired workspace | -| Manifest install fails with "existing file is not valid JSON" | Operator already has a `.cursor/mcp.json` from another DKG project pointing at a different agent | Move the existing file aside, re-run install (the safety guard refuses to clobber an unparseable file) | -| Cursor agent doesn't see the tasks on Laptop B | Session-start hook didn't fire, or `.dkg/config.yaml` points at a different CG | Check `.dkg/capture-chat.log`. If empty, the hook isn't being invoked — verify `.cursor/hooks.json` exists and Cursor was restarted after wiring | -| Tasks created on Laptop A don't appear on Laptop B | Catchup completed before the tasks were published, OR libp2p connection dropped | On Laptop B, click **Sync** in the project view to re-pull. Verify both daemons show each other in `pnpm dkg peers` | -| `~/.claude/settings.json` got modified unexpectedly | Operator unchecked "Skip Claude Code wiring" | Restore `~/.claude/settings.json` from a backup; re-wire with skip-claude checked | - -## What's deferred - -These exist as code today but aren't part of the demo path. They become relevant once the project deepens: - -- **`pnpm exec dkg-mcp join `** — the same wire flow as a CLI, useful for headless / CI / sshd setups. Same daemon endpoints, no UI required. -- **`scripts/import-manifest.mjs`** — re-publish a manifest if the templates drift (e.g. the curator updated `AGENTS.md` and wants the new copy to gossip). -- **`dkg-mcp sync`** — drift detection for already-wired workspaces. Will be the recommended way to refresh a workspace when the manifest version changes. - -## Background - -Phase 8 of `feat/cursor-dkg-integration` (PR #224) added the `dkg:ProjectManifest` schema, publish/install helpers, three daemon endpoints (`/api/context-graph/{id}/manifest/{publish|plan-install|install}`), and the `WireWorkspacePanel` shared by both modals. The architecture sketch lives in [packages/mcp-dkg/src/manifest/schema.ts](../packages/mcp-dkg/src/manifest/schema.ts); the security model (path-lock + safety guards) lives in [packages/mcp-dkg/src/manifest/install.ts](../packages/mcp-dkg/src/manifest/install.ts). diff --git a/docs/experiments/openclaw-benchmark/scripts/run-exp-b.sh b/docs/experiments/openclaw-benchmark/scripts/run-exp-b.sh index c36088a46..3aecb286a 100755 --- a/docs/experiments/openclaw-benchmark/scripts/run-exp-b.sh +++ b/docs/experiments/openclaw-benchmark/scripts/run-exp-b.sh @@ -34,7 +34,7 @@ cat > "$MCP_FILE" < { ### 3.2 MCP Server — event subscription tool -**File:** `packages/mcp-server/src/index.ts` +**File:** `packages/mcp-dkg/src/index.ts` (was the now-removed legacy mcp-server package at plan-authoring time; see `pre-v10-tool-drop` tag for the original wiring) Add an MCP tool `subscribe_events` that opens an SSE connection and delivers events to the LLM: diff --git a/package.json b/package.json index 449cf157c..3081ada50 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "sim": "pnpm --filter @origintrail-official/dkg-network-sim dev", "sim:testnet": "SIM_TARGET=testnet pnpm --filter @origintrail-official/dkg-network-sim dev", "sim:build": "pnpm --filter @origintrail-official/dkg-network-sim build", - "mcp": "node packages/mcp-server/dist/index.js", + "mcp": "node packages/cli/dist/cli.js mcp serve", "check:npm-metadata": "node scripts/check-npm-metadata.mjs", "index": "node packages/cli/dist/cli.js index", "test:evm-integration": "./scripts/test-evm-integration.sh", diff --git a/packages/adapter-autoresearch/README.md b/packages/adapter-autoresearch/README.md index b73b67668..a1f2170c3 100644 --- a/packages/adapter-autoresearch/README.md +++ b/packages/adapter-autoresearch/README.md @@ -64,7 +64,7 @@ Results propagate via GossipSub to all paranet subscribers. Every agent sees eve ### Prerequisites - A running DKG V10 node (`dkg start`) -- The DKG MCP server built (`pnpm --filter @origintrail-official/dkg-mcp-server build`) +- The DKG MCP server built (`pnpm --filter @origintrail-official/dkg-mcp build`) - The adapter built (`pnpm --filter @origintrail-official/dkg-adapter-autoresearch build`) - A clone of [autoresearch](https://github.com/karpathy/autoresearch/) (or a Mac fork — see [Hardware](#hardware)) @@ -76,8 +76,8 @@ Set `DKG_ADAPTERS=autoresearch` when running the MCP server. In your Cursor/IDE { "mcpServers": { "dkg": { - "command": "node", - "args": ["/path/to/dkg/packages/mcp-server/dist/index.js"], + "command": "dkg", + "args": ["mcp", "serve"], "env": { "DKG_ADAPTERS": "autoresearch" } @@ -89,7 +89,7 @@ Set `DKG_ADAPTERS=autoresearch` when running the MCP server. In your Cursor/IDE Or from the command line: ```bash -DKG_ADAPTERS=autoresearch node packages/mcp-server/dist/index.js +DKG_ADAPTERS=autoresearch dkg mcp serve ``` This registers 6 additional MCP tools alongside the core DKG tools. @@ -398,4 +398,4 @@ The DKG adapter is hardware-agnostic — the ontology and tools work with any fo - `@modelcontextprotocol/sdk` — MCP tool registration - `zod` — input schema validation -Loaded as an optional dependency by `@origintrail-official/dkg-mcp-server`. +Loaded as an optional dependency by `@origintrail-official/dkg-mcp`. diff --git a/packages/adapter-autoresearch/src/tools.ts b/packages/adapter-autoresearch/src/tools.ts index a1db5797c..f1a489a2f 100644 --- a/packages/adapter-autoresearch/src/tools.ts +++ b/packages/adapter-autoresearch/src/tools.ts @@ -1,13 +1,17 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { NS, RDF_TYPE, XSD, Class, Prop, Status, DEFAULT_CONTEXT_GRAPH } from './ontology.js'; -import type { DkgClientLike, ExperimentRecord } from './types.js'; +import type { DkgClientLike, DkgConfigLike } from './types.js'; type Bindings = Array>; function parseBindings(raw: unknown): Bindings { - const obj = raw as { bindings?: Bindings }; - return obj?.bindings ?? []; + // mcp-dkg's DkgClient.query returns the SparqlResult directly (i.e. + // `{ bindings, head? }`) rather than the legacy `{ result: { bindings } }` + // shape mcp-server's DkgClient used. Accept both for forward / backward + // compatibility — `result.bindings` if present, otherwise the top-level. + const obj = raw as { bindings?: Bindings; result?: { bindings?: Bindings } }; + return obj?.result?.bindings ?? obj?.bindings ?? []; } function stripType(v: string): string { @@ -54,18 +58,102 @@ function toTable(bindings: Bindings, columns?: string[]): string { /** * Register autoresearch tools on an MCP server. * - * This is the sole integration point — call it from the MCP server's - * startup to mount autoresearch-specific tools alongside the core tools. + * This is the sole integration point — called from the MCP server's + * `loadAdapters` shim during startup to mount autoresearch-specific + * tools alongside the host's core tools. + * + * Signature change (2026-04-30 dkg-v10 consolidation, parity-matrix v0.5 + * §4.21): the legacy `(server, getClient, contextGraphId?)` signature + * with a lazy client getter is gone. The new shape passes the resolved + * client and config eagerly — mcp-dkg's adapter loader builds both at + * server-boot time and hands them to every adapter. Adapters get + * structural typing via `DkgClientLike` / `DkgConfigLike`, no hard + * dependency on `@origintrail-official/dkg-mcp`'s concrete types. + * + * `contextGraphId` is no longer a public parameter — every adapter + * targets its own canonical graph (autoresearch's is at + * `DEFAULT_CONTEXT_GRAPH`). If a future caller needs override semantics, + * we'll add it back as an opts bag rather than a positional arg. */ export function registerTools( server: McpServer, - getClient: () => Promise, - contextGraphId: string = DEFAULT_CONTEXT_GRAPH, + client: DkgClientLike, + config: DkgConfigLike, ) { + const contextGraphId = DEFAULT_CONTEXT_GRAPH; + async function sparql(query: string): Promise { - const client = await getClient(); - const result = await client.query(query, contextGraphId); - return parseBindings(result.result); + const result = await client.query({ sparql: query, contextGraphId }); + return parseBindings(result); + } + + // --------------------------------------------------------------------- + // Daemon HTTP shim + // + // mcp-dkg's `DkgClient` is intentionally lean — it exposes the surfaces + // mcp-dkg's own tools need (`query`, assertion CRUD, identity probe, + // listProjects/listSubGraphs). It does NOT expose the higher-level + // helpers the autoresearch adapter inherited from the retired + // `mcp-server`'s `DkgClient`: `publish`, `createContextGraph`, + // `subscribe`. Rather than bloat mcp-dkg's `DkgClient` for one + // consumer, the adapter ships a small private fetch shim that talks + // to the same daemon (the URL + bearer token are already resolved on + // `config.api` + `config.token`). One file, three routes. + // --------------------------------------------------------------------- + async function daemonPost(path: string, body: unknown): Promise { + const url = `${config.api.replace(/\/$/, '')}${path}`; + const headers: Record = { 'Content-Type': 'application/json' }; + if (config.token) headers.Authorization = `Bearer ${config.token}`; + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + const text = await res.text(); + let parsed: unknown = undefined; + if (text) { + try { parsed = JSON.parse(text); } catch { parsed = { raw: text }; } + } + if (!res.ok) { + const detail = + typeof parsed === 'object' && parsed && 'error' in (parsed as Record) + ? (parsed as { error: unknown }).error + : parsed; + throw new Error( + `POST ${path} → ${res.status}: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`, + ); + } + return parsed as T; + } + + async function daemonCreateContextGraph( + id: string, + name: string, + description?: string, + ): Promise<{ created: string; uri: string }> { + return daemonPost('/api/context-graph/create', { id, name, description }); + } + + async function daemonSubscribe( + contextGraphId: string, + ): Promise<{ subscribed: string }> { + return daemonPost('/api/subscribe', { contextGraphId }); + } + + async function daemonPublish( + contextGraphId: string, + quads: Array<{ subject: string; predicate: string; object: string; graph: string }>, + ): Promise<{ kcId: string; status: string }> { + // Two-step canonical publish path — write quads to SWM, then anchor + // via /api/shared-memory/publish with `clearAfter: true`. Mirrors + // the legacy `mcp-server` `DkgClient.publish` shape so existing + // autoresearch SOPs (publish-then-query) continue to work. + await daemonPost('/api/shared-memory/write', { contextGraphId, quads }); + return daemonPost('/api/shared-memory/publish', { + contextGraphId, + selection: 'all', + clearAfter: true, + }); } // ----------------------------------------------------------------------- @@ -82,9 +170,8 @@ export function registerTools( }, async () => { try { - const client = await getClient(); try { - await client.createContextGraph( + await daemonCreateContextGraph( contextGraphId, 'Autoresearch', 'Collaborative autonomous ML research — experiment results shared as Knowledge Assets', @@ -93,7 +180,7 @@ export function registerTools( const msg = e instanceof Error ? e.message : String(e); if (!(/already exists/i.test(msg) || /duplicate/i.test(msg))) throw e; } - await client.subscribe(contextGraphId); + await daemonSubscribe(contextGraphId); return ok(`Context Graph "${contextGraphId}" ready. This node is subscribed.`); } catch (e) { return err(`Setup failed: ${fmtError(e)}`); } }, @@ -133,7 +220,6 @@ export function registerTools( }, async (params) => { try { - const client = await getClient(); const ts = new Date().toISOString(); const id = `urn:autoresearch:exp:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const graph = `did:dkg:context-graph:${contextGraphId}`; @@ -164,7 +250,7 @@ export function registerTools( if (params.run_tag) quads.push({ subject: id, predicate: Prop.runTag, object: `"${esc(params.run_tag)}"`, graph }); if (params.parent_experiment) quads.push({ subject: id, predicate: Prop.parentExperiment, object: params.parent_experiment, graph }); - const result = await client.publish(contextGraphId, quads); + const result = await daemonPublish(contextGraphId, quads); return ok( `Published experiment as Knowledge Asset.\n` + ` URI: ${id}\n` + diff --git a/packages/adapter-autoresearch/src/types.ts b/packages/adapter-autoresearch/src/types.ts index 86632bf3a..aa16c7307 100644 --- a/packages/adapter-autoresearch/src/types.ts +++ b/packages/adapter-autoresearch/src/types.ts @@ -1,12 +1,40 @@ +/** + * Public type contract: what an adapter consumer (the MCP server) supplies + * to `registerTools(server, client, config)`. Two structural interfaces + * keep this package free of a hard dependency on `@origintrail-official/dkg-mcp`'s + * concrete types — any client / config implementing these shapes works. + */ + +/** + * Subset of mcp-dkg's `DkgClient.query` the adapter actually exercises. + * mcp-dkg's `DkgClient` (`packages/mcp-dkg/src/client.ts`) is a superset + * of this shape — passing `new DkgClient(...)` satisfies the type via + * structural compatibility, no dependency lift required. + * + * The legacy positional `query(sparql, contextGraphId?)` shape from the + * retired `mcp-server` is gone. mcp-dkg uses the object-arg shape + * everywhere; the adapter adopts it. + */ export interface DkgClientLike { - query(sparql: string, contextGraphId?: string): Promise<{ result: unknown }>; - publish(contextGraphId: string, quads: Array<{ - subject: string; predicate: string; object: string; graph: string; - }>): Promise<{ kcId: string; status: string }>; - createContextGraph(id: string, name: string, description?: string): Promise<{ created: string; uri: string }>; - subscribe(contextGraphId: string): Promise<{ subscribed: string }>; + query(args: { + sparql: string; + contextGraphId?: string; + }): Promise<{ bindings?: Array> }>; +} + +/** + * Subset of mcp-dkg's `DkgConfig` the adapter reads for daemon HTTP + * routes that aren't on `DkgClient`'s method surface + * (`/api/shared-memory/{write,publish}`, `/api/context-graph/create`, + * `/api/subscribe`). The adapter ships a small private fetch helper that + * uses these to talk to the already-running daemon. + */ +export interface DkgConfigLike { + api: string; + token: string; } +/** A single autoresearch experiment record as written to the DKG. */ export interface Experiment { valBpb: number; peakVramMb: number; @@ -26,6 +54,7 @@ export interface Experiment { parentExperiment?: string; } +/** Experiment plus DKG-assigned identity. */ export interface ExperimentRecord extends Experiment { uri: string; timestamp: string; diff --git a/packages/adapter-autoresearch/test/tools.test.ts b/packages/adapter-autoresearch/test/tools.test.ts index dd8ce45a8..f7890ebb6 100644 --- a/packages/adapter-autoresearch/test/tools.test.ts +++ b/packages/adapter-autoresearch/test/tools.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { registerTools } from '../src/tools.js'; import { NS, Class, Prop, Status } from '../src/ontology.js'; -import type { DkgClientLike } from '../src/types.js'; +import type { DkgClientLike, DkgConfigLike } from '../src/types.js'; // --------------------------------------------------------------------------- // Tracking function helper @@ -31,47 +31,122 @@ function trackingAsyncFn(impl: (...args: unknown[]) => T | Promise): Track // In-process DkgClient stand-in // --------------------------------------------------------------------------- // -// The autoresearch adapter is a thin MCP -> DkgClient translation layer. It -// is constructed with a `DkgClientLike` factory; everything it does is to -// take an MCP tool invocation, marshal it into a DkgClient call, and serialize -// the response back through MCP. +// Post dkg-v10 consolidation (parity-matrix v0.5 §4.21), the adapter's +// surface contracts split into two halves: // -// The unit under test is therefore "did the adapter call the client with the -// right shape?" — not "does the DkgClient produce the right SPARQL?" The -// DkgClient itself is fully exercised against a real daemon + chain in its -// own package's e2e tests (`packages/sdk-js/test/*`). Spinning that whole -// stack up here just to verify MCP wiring would be coverage duplication that -// hides the actual contract being tested. +// 1. SPARQL reads — flow through the supplied `DkgClientLike` (mcp-dkg's +// `DkgClient.query({sparql, contextGraphId})`). Mocked with a +// tracking fn that records the object-arg shape. // -// What we wire up below is therefore the adapter's defined DI seam — the -// `DkgClientLike` interface is the *production* boundary the adapter is -// designed against, and providing an in-process implementation that records -// calls is the correct way to verify the translation. (Renamed away from -// `createTestDkgClient` so the mock-audit grep no longer flags it.) +// 2. Daemon writes — flow through a private `fetch`-based shim talking +// to `/api/context-graph/create`, `/api/subscribe`, +// `/api/shared-memory/{write,publish}`. Mocked with a `globalThis.fetch` +// stub that records URL + body per call. +// +// Tests therefore assert against EITHER `mock.query.calls` (SPARQL path) +// OR the fetch stub's recorded HTTP calls (write path). This is the same +// boundary the production code crosses — verifying both halves keeps the +// adapter's MCP-↔-daemon translation honest. + function createTestDkgClient(overrides: Partial = {}): DkgClientLike { return { - query: trackingAsyncFn(async () => ({ result: { bindings: [] } })), - publish: trackingAsyncFn(async () => ({ kcId: 'kc-test-001', status: 'confirmed' })), - createContextGraph: trackingAsyncFn(async () => ({ created: 'autoresearch', uri: 'urn:context-graph:autoresearch' })), - subscribe: trackingAsyncFn(async () => ({ subscribed: 'autoresearch' })), + query: trackingAsyncFn(async () => ({ bindings: [] })), ...overrides, }; } -type TextContent = Array<{ type: string; text: string }>; +const TEST_CONFIG: DkgConfigLike = { + api: 'http://test-daemon:9200', + token: 'test-token', +}; -function getText(result: { content: unknown }): string { - return (result.content as TextContent)[0].text; +// ── Daemon HTTP fetch stub ───────────────────────────────────────── +// +// Captures every daemon-route call the adapter's private fetch shim +// makes. Records URL, method, and parsed body so tests can assert +// against the wire shape. Per-route response bodies are configured via +// `setRoute(...)`; an unset route returns the empty default per +// production daemon shape (used by tests that don't care about the +// response, only the call). + +interface DaemonFetchCall { + url: string; + method: string; + body: unknown; +} + +interface DaemonFetchStub { + calls: DaemonFetchCall[]; + setRoute(path: string, response: unknown): void; + setRouteError(path: string, status: number, errorMessage: string): void; + reset(): void; + install(): void; + uninstall(): void; +} + +function createDaemonFetchStub(): DaemonFetchStub { + const calls: DaemonFetchCall[] = []; + const responses = new Map(); + + const fetcher = async (url: string | URL, init?: RequestInit): Promise => { + const u = typeof url === 'string' ? url : url.toString(); + const path = u.replace('http://test-daemon:9200', ''); + const method = init?.method ?? 'GET'; + let body: unknown = undefined; + if (init?.body) { + try { body = JSON.parse(String(init.body)); } catch { body = init.body; } + } + calls.push({ url: u, method, body }); + const configured = responses.get(path); + const responseBody = configured?.body ?? defaultResponseFor(path); + const status = configured?.status ?? 200; + return new Response(JSON.stringify(responseBody), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + let originalFetch: typeof globalThis.fetch | undefined; + + return { + calls, + setRoute(path, response) { responses.set(path, { status: 200, body: response }); }, + setRouteError(path, status, errorMessage) { + responses.set(path, { status, body: { error: errorMessage } }); + }, + reset() { calls.length = 0; responses.clear(); }, + install() { + originalFetch = globalThis.fetch; + globalThis.fetch = fetcher as typeof globalThis.fetch; + }, + uninstall() { + if (originalFetch) globalThis.fetch = originalFetch; + }, + }; +} + +function defaultResponseFor(path: string): unknown { + if (path === '/api/context-graph/create') return { created: 'autoresearch', uri: 'urn:context-graph:autoresearch' }; + if (path === '/api/subscribe') return { subscribed: 'autoresearch' }; + if (path === '/api/shared-memory/write') return { written: 0 }; + if (path === '/api/shared-memory/publish') return { kcId: 'kc-test-001', status: 'confirmed' }; + return {}; } // --------------------------------------------------------------------------- // Test harness: McpServer + InMemoryTransport + adapter tools // --------------------------------------------------------------------------- +type TextContent = Array<{ type: string; text: string }>; + +function getText(result: { content: unknown }): string { + return (result.content as TextContent)[0].text; +} + async function createTestHarness(injectedClient?: DkgClientLike) { const client = injectedClient ?? createTestDkgClient(); const server = new McpServer({ name: 'autoresearch-test', version: '0.0.1' }); - registerTools(server, async () => client); + registerTools(server, client, TEST_CONFIG); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); @@ -86,6 +161,17 @@ async function createTestHarness(injectedClient?: DkgClientLike) { // Tests // --------------------------------------------------------------------------- +let fetchStub: DaemonFetchStub; + +beforeEach(() => { + fetchStub = createDaemonFetchStub(); + fetchStub.install(); +}); + +afterEach(() => { + fetchStub.uninstall(); +}); + describe('autoresearch adapter — tool registration', () => { let mcpClient: Client; @@ -122,40 +208,42 @@ describe('autoresearch adapter — tool registration', () => { describe('autoresearch_setup', () => { it('creates context graph and subscribes', async () => { - const mock = createTestDkgClient(); - const { mcpClient } = await createTestHarness(mock); + const { mcpClient } = await createTestHarness(); const result = await mcpClient.callTool({ name: 'autoresearch_setup', arguments: {} }); const text = getText(result); expect(text).toContain('autoresearch'); expect(text).toContain('subscribed'); - expect((mock.createContextGraph as TrackingFn).calls[0]).toEqual([ - 'autoresearch', - 'Autoresearch', - expect.any(String), - ]); - expect((mock.subscribe as TrackingFn).calls[0]).toEqual(['autoresearch']); + + const createCall = fetchStub.calls.find(c => c.url.endsWith('/api/context-graph/create')); + expect(createCall).toBeDefined(); + expect(createCall!.method).toBe('POST'); + expect(createCall!.body).toMatchObject({ + id: 'autoresearch', + name: 'Autoresearch', + }); + + const subscribeCall = fetchStub.calls.find(c => c.url.endsWith('/api/subscribe')); + expect(subscribeCall).toBeDefined(); + expect(subscribeCall!.body).toMatchObject({ contextGraphId: 'autoresearch' }); }); it('handles context graph already existing gracefully', async () => { - const mock = createTestDkgClient({ - createContextGraph: trackingAsyncFn(async () => { throw new Error('already exists'); }), - }); - const { mcpClient } = await createTestHarness(mock); + fetchStub.setRouteError('/api/context-graph/create', 409, 'already exists'); + const { mcpClient } = await createTestHarness(); const result = await mcpClient.callTool({ name: 'autoresearch_setup', arguments: {} }); const text = getText(result); expect(text).toContain('ready'); - expect((mock.subscribe as TrackingFn).calls.length).toBeGreaterThan(0); + const subscribeCall = fetchStub.calls.find(c => c.url.endsWith('/api/subscribe')); + expect(subscribeCall).toBeDefined(); }); it('returns error when subscribe fails', async () => { - const mock = createTestDkgClient({ - subscribe: trackingAsyncFn(async () => { throw new Error('network down'); }), - }); - const { mcpClient } = await createTestHarness(mock); + fetchStub.setRouteError('/api/subscribe', 503, 'network down'); + const { mcpClient } = await createTestHarness(); const result = await mcpClient.callTool({ name: 'autoresearch_setup', arguments: {} }); expect(result.isError).toBe(true); @@ -172,8 +260,7 @@ describe('autoresearch_publish_experiment', () => { }; it('publishes with required fields and returns URI + KC', async () => { - const mock = createTestDkgClient(); - const { mcpClient } = await createTestHarness(mock); + const { mcpClient } = await createTestHarness(); const result = await mcpClient.callTool({ name: 'autoresearch_publish_experiment', @@ -188,19 +275,22 @@ describe('autoresearch_publish_experiment', () => { expect(text).toContain('increase depth to 12 layers'); }); - it('sends correct quads to the DKG client', async () => { - const mock = createTestDkgClient(); - const { mcpClient } = await createTestHarness(mock); + it('sends correct quads to the DKG daemon', async () => { + const { mcpClient } = await createTestHarness(); await mcpClient.callTool({ name: 'autoresearch_publish_experiment', arguments: baseArgs, }); - const publishCalls = (mock.publish as TrackingFn).calls; - expect(publishCalls).toHaveLength(1); - const [contextGraphId, quads] = publishCalls[0] as [string, any[]]; - expect(contextGraphId).toBe('autoresearch'); + // Two-step publish path: write then publish. We assert on the write + // payload — that's where the quads land. The publish call is a thin + // anchor that takes only `{contextGraphId, selection, clearAfter}`. + const writeCall = fetchStub.calls.find(c => c.url.endsWith('/api/shared-memory/write')); + expect(writeCall).toBeDefined(); + const writeBody = writeCall!.body as { contextGraphId: string; quads: any[] }; + expect(writeBody.contextGraphId).toBe('autoresearch'); + const quads = writeBody.quads; const types = quads.filter((q: any) => q.predicate === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'); expect(types).toHaveLength(1); @@ -218,11 +308,19 @@ describe('autoresearch_publish_experiment', () => { const ts = quads.find((q: any) => q.predicate === Prop.timestamp); expect(ts).toBeDefined(); expect(ts.object).toMatch(/dateTime/); + + // Anchor step also fires. + const publishCall = fetchStub.calls.find(c => c.url.endsWith('/api/shared-memory/publish')); + expect(publishCall).toBeDefined(); + expect(publishCall!.body).toMatchObject({ + contextGraphId: 'autoresearch', + selection: 'all', + clearAfter: true, + }); }); it('includes optional fields when provided', async () => { - const mock = createTestDkgClient(); - const { mcpClient } = await createTestHarness(mock); + const { mcpClient } = await createTestHarness(); await mcpClient.callTool({ name: 'autoresearch_publish_experiment', @@ -243,7 +341,8 @@ describe('autoresearch_publish_experiment', () => { }, }); - const [, quads] = (mock.publish as TrackingFn).calls[0] as [string, any[]]; + const writeCall = fetchStub.calls.find(c => c.url.endsWith('/api/shared-memory/write')); + const quads = (writeCall!.body as { quads: any[] }).quads; expect(quads.find((q: any) => q.predicate === Prop.commitHash)).toBeDefined(); expect(quads.find((q: any) => q.predicate === Prop.platform)).toBeDefined(); @@ -260,15 +359,15 @@ describe('autoresearch_publish_experiment', () => { }); it('omits optional fields when not provided', async () => { - const mock = createTestDkgClient(); - const { mcpClient } = await createTestHarness(mock); + const { mcpClient } = await createTestHarness(); await mcpClient.callTool({ name: 'autoresearch_publish_experiment', arguments: baseArgs, }); - const [, quads] = (mock.publish as TrackingFn).calls[0] as [string, any[]]; + const writeCall = fetchStub.calls.find(c => c.url.endsWith('/api/shared-memory/write')); + const quads = (writeCall!.body as { quads: any[] }).quads; expect(quads.find((q: any) => q.predicate === Prop.commitHash)).toBeUndefined(); expect(quads.find((q: any) => q.predicate === Prop.platform)).toBeUndefined(); @@ -276,32 +375,30 @@ describe('autoresearch_publish_experiment', () => { }); it('maps status values to correct ontology URIs', async () => { - const mock = createTestDkgClient(); - const { mcpClient } = await createTestHarness(mock); + const { mcpClient } = await createTestHarness(); for (const [statusStr, expectedUri] of [ ['keep', Status.Keep], ['discard', Status.Discard], ['crash', Status.Crash], ] as const) { - (mock.publish as TrackingFn).resetCalls(); + fetchStub.reset(); await mcpClient.callTool({ name: 'autoresearch_publish_experiment', arguments: { ...baseArgs, status: statusStr }, }); - const [, quads] = (mock.publish as TrackingFn).calls[0] as [string, any[]]; + const writeCall = fetchStub.calls.find(c => c.url.endsWith('/api/shared-memory/write')); + const quads = (writeCall!.body as { quads: any[] }).quads; const statusQuad = quads.find((q: any) => q.predicate === Prop.status); expect(statusQuad.object).toBe(expectedUri); } }); it('returns error when publish fails', async () => { - const mock = createTestDkgClient({ - publish: trackingAsyncFn(async () => { throw new Error('DKG daemon not running'); }), - }); - const { mcpClient } = await createTestHarness(mock); + fetchStub.setRouteError('/api/shared-memory/publish', 503, 'DKG daemon not running'); + const { mcpClient } = await createTestHarness(); const result = await mcpClient.callTool({ name: 'autoresearch_publish_experiment', @@ -328,19 +425,17 @@ describe('autoresearch_best_results', () => { it('formats results when experiments exist', async () => { const mock = createTestDkgClient({ query: trackingAsyncFn(async () => ({ - result: { - bindings: [ - { - exp: 'urn:autoresearch:exp:1', - valBpb: '"0.9712"^^', - peakVram: '"44000"^^', - status: `${NS}keep`, - desc: '"SwiGLU + depth 16"', - ts: '"2026-03-08T12:00:00Z"^^', - platform: '"H100"', - }, - ], - }, + bindings: [ + { + exp: 'urn:autoresearch:exp:1', + valBpb: '"0.9712"^^', + peakVram: '"44000"^^', + status: `${NS}keep`, + desc: '"SwiGLU + depth 16"', + ts: '"2026-03-08T12:00:00Z"^^', + platform: '"H100"', + }, + ], })), }); const { mcpClient } = await createTestHarness(mock); @@ -368,11 +463,13 @@ describe('autoresearch_best_results', () => { const queryCalls = (mock.query as TrackingFn).calls; expect(queryCalls).toHaveLength(1); - const [sparql, contextGraphId] = queryCalls[0] as [string, string]; - expect(contextGraphId).toBe('autoresearch'); - expect(sparql).toContain(Class.Experiment); - expect(sparql).toContain('ORDER BY ASC(?valBpb)'); - expect(sparql).toContain('LIMIT 5'); + // Object-arg shape per mcp-dkg's `DkgClient.query`. Replaces the + // legacy positional `(sparql, contextGraphId)` form. + const [args] = queryCalls[0] as [{ sparql: string; contextGraphId?: string }]; + expect(args.contextGraphId).toBe('autoresearch'); + expect(args.sparql).toContain(Class.Experiment); + expect(args.sparql).toContain('ORDER BY ASC(?valBpb)'); + expect(args.sparql).toContain('LIMIT 5'); }); }); @@ -397,9 +494,9 @@ describe('autoresearch_experiment_history', () => { arguments: { run_tag: 'mar8' }, }); - const [sparql] = (mock.query as TrackingFn).calls[0] as [string]; - expect(sparql).toContain(Prop.runTag); - expect(sparql).toContain('mar8'); + const [args] = (mock.query as TrackingFn).calls[0] as [{ sparql: string }]; + expect(args.sparql).toContain(Prop.runTag); + expect(args.sparql).toContain('mar8'); }); it('includes agent_did filter in SPARQL when provided', async () => { @@ -411,36 +508,34 @@ describe('autoresearch_experiment_history', () => { arguments: { agent_did: 'did:dkg:agent-7' }, }); - const [sparql] = (mock.query as TrackingFn).calls[0] as [string]; - expect(sparql).toContain(Prop.agentDid); - expect(sparql).toContain('did:dkg:agent-7'); + const [args] = (mock.query as TrackingFn).calls[0] as [{ sparql: string }]; + expect(args.sparql).toContain(Prop.agentDid); + expect(args.sparql).toContain('did:dkg:agent-7'); }); it('returns table-formatted results', async () => { const mock = createTestDkgClient({ query: trackingAsyncFn(async () => ({ - result: { - bindings: [ - { - exp: 'urn:autoresearch:exp:1', - valBpb: '"0.9979"', - peakVram: '"45060"', - status: `${NS}keep`, - desc: '"baseline"', - ts: '"2026-03-08T08:00:00Z"', - commitHash: '"a1b2c3d"', - }, - { - exp: 'urn:autoresearch:exp:2', - valBpb: '"0.9834"', - peakVram: '"44200"', - status: `${NS}keep`, - desc: '"increase depth"', - ts: '"2026-03-08T08:06:00Z"', - commitHash: '"b2c3d4e"', - }, - ], - }, + bindings: [ + { + exp: 'urn:autoresearch:exp:1', + valBpb: '"0.9979"', + peakVram: '"45060"', + status: `${NS}keep`, + desc: '"baseline"', + ts: '"2026-03-08T08:00:00Z"', + commitHash: '"a1b2c3d"', + }, + { + exp: 'urn:autoresearch:exp:2', + valBpb: '"0.9834"', + peakVram: '"44200"', + status: `${NS}keep`, + desc: '"increase depth"', + ts: '"2026-03-08T08:06:00Z"', + commitHash: '"b2c3d4e"', + }, + ], })), }); const { mcpClient } = await createTestHarness(mock); @@ -479,21 +574,19 @@ describe('autoresearch_insights', () => { arguments: { keyword: 'learning rate' }, }); - const [sparql] = (mock.query as TrackingFn).calls[0] as [string]; - expect(sparql).toContain('FILTER(CONTAINS(LCASE(?desc)'); - expect(sparql).toContain('learning rate'); + const [args] = (mock.query as TrackingFn).calls[0] as [{ sparql: string }]; + expect(args.sparql).toContain('FILTER(CONTAINS(LCASE(?desc)'); + expect(args.sparql).toContain('learning rate'); }); it('shows summary with keep/discard/crash counts', async () => { const mock = createTestDkgClient({ query: trackingAsyncFn(async () => ({ - result: { - bindings: [ - { exp: 'urn:1', valBpb: '"0.98"', status: `${NS}keep`, desc: '"LR 0.06"' }, - { exp: 'urn:2', valBpb: '"1.01"', status: `${NS}discard`, desc: '"LR 0.2"' }, - { exp: 'urn:3', valBpb: '"0.00"', status: `${NS}crash`, desc: '"LR 1.0 OOM"' }, - ], - }, + bindings: [ + { exp: 'urn:1', valBpb: '"0.98"', status: `${NS}keep`, desc: '"LR 0.06"' }, + { exp: 'urn:2', valBpb: '"1.01"', status: `${NS}discard`, desc: '"LR 0.2"' }, + { exp: 'urn:3', valBpb: '"0.00"', status: `${NS}crash`, desc: '"LR 1.0 OOM"' }, + ], })), }); const { mcpClient } = await createTestHarness(mock); @@ -515,9 +608,7 @@ describe('autoresearch_query', () => { it('passes raw SPARQL to client', async () => { const mock = createTestDkgClient({ query: trackingAsyncFn(async () => ({ - result: { - bindings: [{ avg: '"0.9856"' }], - }, + bindings: [{ avg: '"0.9856"' }], })), }); const { mcpClient } = await createTestHarness(mock); @@ -528,7 +619,11 @@ describe('autoresearch_query', () => { arguments: { sparql }, }); - expect((mock.query as TrackingFn).calls[0]).toEqual([sparql, 'autoresearch']); + const queryCalls = (mock.query as TrackingFn).calls; + expect(queryCalls).toHaveLength(1); + const [args] = queryCalls[0] as [{ sparql: string; contextGraphId?: string }]; + expect(args.sparql).toBe(sparql); + expect(args.contextGraphId).toBe('autoresearch'); expect(getText(result)).toContain('0.9856'); }); @@ -559,24 +654,10 @@ describe('autoresearch_query', () => { }); }); -describe('custom context graph', () => { - it('uses custom context graph when registerTools is called with one', async () => { - const mock = createTestDkgClient(); - const server = new McpServer({ name: 'custom-test', version: '0.0.1' }); - registerTools(server, async () => mock, 'my-custom-paranet'); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - const mcpClient = new Client({ name: 'test', version: '1.0.0' }); - await mcpClient.connect(clientTransport); - - await mcpClient.callTool({ name: 'autoresearch_setup', arguments: {} }); - - expect((mock.createContextGraph as TrackingFn).calls[0]).toEqual([ - 'my-custom-paranet', - expect.any(String), - expect.any(String), - ]); - expect((mock.subscribe as TrackingFn).calls[0]).toEqual(['my-custom-paranet']); - }); -}); +// Note: the legacy "custom context graph" test (which exercised passing a +// 3rd `contextGraphId` argument to `registerTools`) is removed in this +// PR — the parity-matrix v0.5 §4.21 shim signature drops that public +// parameter. Each adapter now targets its own canonical CG; per-call +// override semantics will return as an opts-bag if a real consumer needs +// it. No tests are added in its place because there is no longer a +// public surface to verify. diff --git a/packages/adapter-openclaw/src/index.ts b/packages/adapter-openclaw/src/index.ts index ef73cae9c..4239450e3 100644 --- a/packages/adapter-openclaw/src/index.ts +++ b/packages/adapter-openclaw/src/index.ts @@ -35,6 +35,17 @@ export { verifyMemorySlotInvariants, verifySkillRemoved, verifyUnmergeInvariants, + // Re-exports for non-OpenClaw consumers (e.g. `dkg mcp setup` bundles + // these into a parallel two-command UX). Each one is the same + // primitive `runSetup` itself uses, so a re-implementation in another + // setup verb stays byte-aligned with OpenClaw setup behaviour + // (network defaults, config-merge semantics, daemon start + readiness + // probe, faucet retry/back-off, manual-curl fallback). + loadNetworkConfig, + writeDkgConfig, + startDaemon, + readWalletsWithRetry, + logManualFundingInstructions, type AdapterEntryConfig, type SetupOptions, type UnmergeResult, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 56e369f7b..16a8f5ade 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1757,6 +1757,96 @@ openclawCmd } }); +// ─── dkg mcp ──────────────────────────────────────────────────────── + +const mcpCmd = program + .command('mcp') + .description('DKG MCP server for AI coding assistants (Cursor, Claude Code, …)'); + +mcpCmd + .command('serve') + .description('Run the DKG MCP server over stdio (invoked by the MCP-aware client)') + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(async (_opts, command) => { + // Pass any positional/extra args from the umbrella CLI through to the + // MCP server's `main()` so its internal CLI subcommand dispatcher + // (`join`, `status`, `help`) keeps working from the umbrella wrapper. + const passthrough = command.args ?? []; + let main: typeof import('@origintrail-official/dkg-mcp').main; + try { + ({ main } = await import('@origintrail-official/dkg-mcp')); + } catch (err: any) { + console.error('\n[dkg mcp serve] MCP server is not available.'); + console.error(` Reason: ${err?.message ?? err}`); + console.error(' • In a monorepo dev checkout: run `pnpm build` at the repo root to build all workspaces.'); + console.error(' • With a global install: reinstall with `npm install -g @origintrail-official/dkg`.\n'); + process.exit(1); + } + try { + // Synthesise an argv whose `[2]` slot aligns with the MCP server's + // own subcommand dispatcher. Without this, `dkg mcp serve join …` + // would land `argv[2] === 'mcp'` inside the MCP server and the + // `join` would never be seen. + await main(['node', 'dkg-mcp', ...passthrough]); + } catch (err: any) { + console.error(`\n[dkg mcp serve] ERROR: ${err?.message ?? err}\n`); + process.exit(1); + } + }); + +mcpCmd + .command('setup') + .description('Bundled init + daemon-start + MCP-client registration (idempotent, safe to re-run)') + .option('--port ', 'Override daemon API port (default: 9200)') + .option('--name ', 'Override agent name (used only on first init)') + .option('--no-start', 'Skip daemon start (configure only)') + .option('--no-fund', 'Skip wallet funding via testnet faucet') + .option('--no-verify', 'Skip post-setup verification probe') + .option('--dry-run', 'Preview steps without writing or starting anything') + .option('--force', 'Refresh every detected client regardless of current registration state') + .option('--print-only', 'Print the canonical JSON to stdout; skip every other step') + .option('--yes', 'Auto-confirm all registrations (default; reserved for future interactive prompts)') + .action(async (opts) => { + // Dynamic-import the openclaw-setup primitives for the bundled + // init + daemon-start. Same import surface (and same package + // resolution failure mode) as `dkg openclaw setup` so a missing + // adapter build emits a parallel error message. + let openclawSetupExports: typeof import('@origintrail-official/dkg-adapter-openclaw'); + try { + openclawSetupExports = await import('@origintrail-official/dkg-adapter-openclaw'); + } catch (err: any) { + console.error('\n[dkg mcp setup] Setup primitives are not available.'); + console.error(` Reason: ${err?.message ?? err}`); + console.error(' • In a monorepo dev checkout: run `pnpm build` at the repo root to build all workspaces.'); + console.error(' • With a global install: reinstall with `npm install -g @origintrail-official/dkg`.\n'); + process.exit(1); + } + let coreExports: typeof import('@origintrail-official/dkg-core'); + try { + coreExports = await import('@origintrail-official/dkg-core'); + } catch (err: any) { + console.error('\n[dkg mcp setup] Core faucet primitive is not available.'); + console.error(` Reason: ${err?.message ?? err}`); + process.exit(1); + } + + const { mcpSetupAction } = await import('./mcp-setup.js'); + try { + await mcpSetupAction(opts, { + loadNetworkConfig: openclawSetupExports.loadNetworkConfig, + writeDkgConfig: openclawSetupExports.writeDkgConfig, + startDaemon: openclawSetupExports.startDaemon, + readWalletsWithRetry: openclawSetupExports.readWalletsWithRetry, + logManualFundingInstructions: openclawSetupExports.logManualFundingInstructions, + requestFaucetFunding: coreExports.requestFaucetFunding, + }); + } catch (err: any) { + console.error(`\n[dkg mcp setup] ERROR: ${err?.message ?? err}\n`); + process.exit(1); + } + }); + // ─── dkg ccl ──────────────────────────────────────────────────────── type HermesAdapterModule = Record; diff --git a/packages/cli/src/mcp-setup.ts b/packages/cli/src/mcp-setup.ts new file mode 100644 index 000000000..b50d0c6c3 --- /dev/null +++ b/packages/cli/src/mcp-setup.ts @@ -0,0 +1,525 @@ +/** + * `dkg mcp setup` — bundled init + daemon-start + MCP-client registration. + * + * Mirrors `dkg openclaw setup` so the user-visible flow is two commands + * end-to-end: + * + * npm install -g @origintrail-official/dkg + * dkg mcp setup + * + * Step order (each step is idempotent and skippable): + * 1. Init `~/.dkg/config.json` if absent (uses the same network defaults + * + merge semantics as `dkg openclaw setup` — `loadNetworkConfig` + + * `writeDkgConfig` are re-exported from `@origintrail-official/dkg-adapter-openclaw` + * so behaviour stays byte-aligned). + * 2. Start the daemon if not already reachable on the configured API + * port (uses the same `startDaemon` openclaw-setup uses — readiness + * probe, stale-PID handling, etc.). + * 3. Optionally fund the node's wallets via testnet faucet (mirrors + * openclaw-setup's --no-fund posture). + * 4. Detect MCP-aware clients and register the canonical + * `{ command: "dkg", args: ["mcp", "serve"] }` block. State-aware + * (`registered`/`stale`/`not registered`) and fast-exits on no-op + * re-runs. + * + * Flags (parity with `dkg openclaw setup` where applicable): + * --port Override daemon API port (default 9200). + * --name Override agent name (used only on first init). + * --no-start Skip daemon start (configure only). + * --no-fund Skip wallet funding via testnet faucet. + * --no-verify Skip post-setup verification probe. + * --dry-run Preview steps; no filesystem or network writes. + * --force Refresh every detected client regardless of state. + * --print-only Emit canonical JSON only; skip every other step. + * --yes Auto-confirm (default; reserved for future prompts). + * + * Tokens and URLs are NOT in the emitted client-config block — the MCP + * server reads them from `~/.dkg/config.yaml` + the daemon-written + * `auth.token` via `loadConfig` (`packages/mcp-dkg/src/config.ts`). + */ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +export interface McpSetupCliOptions { + /** Refresh every detected client regardless of current registration state. */ + force?: boolean; + /** Emit the canonical JSON block to stdout; do not detect clients or write. */ + printOnly?: boolean; + /** Auto-confirm registrations (default true; reserved for future interactive prompts). */ + yes?: boolean; + /** Override daemon API port (default 9200). Mirrors openclaw-setup. */ + port?: string; + /** Override agent name (used only on first init). Mirrors openclaw-setup. */ + name?: string; + /** Skip daemon start (configure only). Mirrors openclaw-setup. */ + start?: boolean; + /** Skip wallet funding via testnet faucet. Mirrors openclaw-setup. */ + fund?: boolean; + /** Skip post-setup verification probe. Mirrors openclaw-setup. */ + verify?: boolean; + /** Preview without writing or starting anything. Mirrors openclaw-setup. */ + dryRun?: boolean; +} + +/** + * Dependency surface for `mcpSetupAction`. All bundled-flow primitives + * are injected so the action can be unit-tested without touching the + * real filesystem or spawning the daemon. The CLI wiring in `cli.ts` + * dynamically imports `@origintrail-official/dkg-adapter-openclaw` and + * passes its real implementations. + */ +export interface McpSetupActionDeps { + loadNetworkConfig: typeof import('@origintrail-official/dkg-adapter-openclaw').loadNetworkConfig; + writeDkgConfig: typeof import('@origintrail-official/dkg-adapter-openclaw').writeDkgConfig; + startDaemon: typeof import('@origintrail-official/dkg-adapter-openclaw').startDaemon; + readWalletsWithRetry: typeof import('@origintrail-official/dkg-adapter-openclaw').readWalletsWithRetry; + logManualFundingInstructions: typeof import('@origintrail-official/dkg-adapter-openclaw').logManualFundingInstructions; + /** Faucet primitive from `@origintrail-official/dkg-core`. */ + requestFaucetFunding: typeof import('@origintrail-official/dkg-core').requestFaucetFunding; +} + +/** + * The canonical MCP-server entry written into client config files. Single + * source of truth — every detected client gets the same block under + * `mcpServers.dkg`. + */ +function canonicalEntry(): Record { + return { + command: 'dkg', + args: ['mcp', 'serve'], + }; +} + +interface ClientTarget { + name: string; + configPath: string; + /** Pretty path for display, with `~` substituted back in. */ + displayPath: string; +} + +function expandHome(p: string): string { + if (p.startsWith('~/')) return join(homedir(), p.slice(2)); + return p; +} + +function tildify(p: string): string { + const home = homedir(); + return p.startsWith(home) ? '~' + p.slice(home.length) : p; +} + +/** + * Discover MCP-aware clients on the machine. We look at the standard + * config-file locations rather than probing for installed binaries — a + * config file is the artifact `dkg mcp setup` actually writes into, and + * its existence (or non-existence) is the signal that matters. + * + * Cursor reads `~/.cursor/mcp.json`. Claude Code reads `~/.claude.json` + * (and on some platforms `~/.claude/mcp_servers.json`); we target + * `~/.claude.json` because that's the user-scoped path the MCP-server + * wiring already uses across the rest of the codebase. + * + * Detection is deliberately permissive: any client whose config file is + * already present OR whose config directory is already present counts as + * "detected" for write purposes. Operators with a fresh machine and no + * client installed still see the fallback "no clients detected; run + * `dkg mcp setup --print-only`" message. + */ +function detectClients(): ClientTarget[] { + const home = homedir(); + const candidates: ClientTarget[] = [ + { + name: 'Cursor', + configPath: join(home, '.cursor', 'mcp.json'), + displayPath: '~/.cursor/mcp.json', + }, + { + name: 'Claude Code', + configPath: join(home, '.claude.json'), + displayPath: '~/.claude.json', + }, + ]; + return candidates.filter((c) => { + if (existsSync(c.configPath)) return true; + if (existsSync(dirname(c.configPath))) return true; + return false; + }); +} + +type RegistrationState = 'registered' | 'stale' | 'not-registered'; + +interface ClientState { + target: ClientTarget; + state: RegistrationState; + current: unknown; +} + +function readJson(path: string): Record { + if (!existsSync(path)) return {}; + const raw = readFileSync(path, 'utf8').trim(); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null + ? (parsed as Record) + : {}; + } catch { + throw new Error( + `Existing file is not valid JSON: ${tildify(path)}. Move it aside and re-run.`, + ); + } +} + +function classify(target: ClientTarget): ClientState { + const expected = canonicalEntry(); + const body = readJson(target.configPath); + const servers = (body.mcpServers as Record | undefined) ?? {}; + const current = servers.dkg as Record | undefined; + // Treat both `undefined` (key absent) and `null` (key present but + // explicitly nulled) as "not-registered". Pre-F7 a `{ dkg: null }` + // entry classified as `stale`, which made the operator-facing + // log line claim there was a current value to refresh — there + // wasn't. Same registration outcome under `--force`; clearer log. + if (current === undefined || current === null) { + return { target, state: 'not-registered', current: null }; + } + const matches = + typeof current === 'object' && + current !== null && + (current as Record).command === expected.command && + Array.isArray((current as Record).args) && + JSON.stringify((current as Record).args) === + JSON.stringify(expected.args); + return { + target, + state: matches ? 'registered' : 'stale', + current: current ?? null, + }; +} + +function writeRegistration(target: ClientTarget): void { + const body = readJson(target.configPath); + const servers = + (body.mcpServers as Record | undefined) ?? {}; + servers.dkg = canonicalEntry(); + body.mcpServers = servers; + const dir = dirname(target.configPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(target.configPath, JSON.stringify(body, null, 2) + '\n'); +} + +/** + * Fallback agent-name minter for first-init when no `--name` is passed + * and no persisted config exists. Mirrors `discoverAgentName`'s + * unique-fallback shape (`openclaw-agent-XXXXX`) but with `mcp-` prefix + * so support traffic can tell which setup verb produced the identity. + * Re-runs hit the persisted name instead because `writeDkgConfig` + * preserves an existing `name` field. + */ +function mintFallbackAgentName(): string { + const id = Math.random().toString(36).slice(2, 7); + return `mcp-agent-${id}`; +} + +/** + * Read the persisted agent name from `~/.dkg/config.json`. Returns + * `undefined` for missing/corrupt files. Used so a second `dkg mcp + * setup` run on a config whose `name` was set by a prior init doesn't + * regenerate a fresh random fallback. + */ +function readPersistedAgentName(dkgDirPath: string): string | undefined { + const configPath = join(dkgDirPath, 'config.json'); + if (!existsSync(configPath)) return undefined; + try { + const raw = JSON.parse(readFileSync(configPath, 'utf-8')); + if (typeof raw?.name === 'string' && raw.name.trim()) { + return raw.name.trim(); + } + } catch { /* corrupt config; let writeDkgConfig handle */ } + return undefined; +} + +/** + * Main entrypoint invoked by the `dkg mcp setup` commander handler in + * `cli.ts`. Idempotent — re-running on a fully-set-up tree prints + * step-by-step skip notices and exits cleanly without touching any + * file or restarting the daemon. + */ +export async function mcpSetupAction( + opts: McpSetupCliOptions, + deps: McpSetupActionDeps, +): Promise { + const force = opts.force === true; + const printOnly = opts.printOnly === true; + const dryRun = opts.dryRun === true; + const shouldStart = opts.start !== false; + const shouldFund = opts.fund !== false; + const shouldVerify = opts.verify !== false; + const apiPort = Number(opts.port ?? '9200'); + if (!Number.isInteger(apiPort) || apiPort < 1 || apiPort > 65535) { + throw new Error(`Invalid port "${opts.port}" — must be an integer between 1 and 65535`); + } + + if (printOnly) { + const block = { + mcpServers: { + dkg: canonicalEntry(), + }, + }; + process.stdout.write(JSON.stringify(block, null, 2) + '\n'); + return; + } + + console.log('\nDKG MCP setup'); + console.log('='.repeat(40)); + if (dryRun) { + console.log('[setup] DRY RUN — no files will be modified, no daemon will start\n'); + } + + // ── Step 1: ensure ~/.dkg/config.json ───────────────────────────── + // Mirrors `dkg openclaw setup` step 3 byte-for-byte. If the file + // already exists, `writeDkgConfig` merges (first-wins on `name` / + // `apiPort` unless explicit overrides are passed). + const dkgDirPath = join(homedir(), '.dkg'); + const yamlPath = join(dkgDirPath, 'config.yaml'); + const jsonPath = join(dkgDirPath, 'config.json'); + const configExists = existsSync(yamlPath) || existsSync(jsonPath); + + let effectivePort = apiPort; + let effectiveAgentName = opts.name?.trim() || readPersistedAgentName(dkgDirPath) || mintFallbackAgentName(); + + /** + * F6 fix: read-back must run on BOTH branches (skip-write AND + * write-then-read), not just inside the `else`. The pre-F6 layout + * only reconciled `effectivePort` after `writeDkgConfig` ran, + * leaving the skip-write branch with the CLI default 9200 even when + * the persisted config had a different port. Concrete reproducer: + * a user previously ran `dkg openclaw setup --port 9300`; running + * `dkg mcp setup` with no flags would start the daemon on 9200 and + * the verification probe + registered MCP entry would point at the + * wrong port. + * + * Pulling the read-back into a helper that runs unconditionally + * after the (optional) write keeps the daemon-start, faucet, and + * verify steps all aligned with the persisted config — which is + * the source of truth for an existing install. + */ + const reconcileFromPersistedConfig = (): void => { + if (!existsSync(jsonPath)) return; + try { + const merged = JSON.parse(readFileSync(jsonPath, 'utf-8')); + const mergedPort = Number(merged.apiPort); + if (Number.isInteger(mergedPort) && mergedPort >= 1 && mergedPort <= 65535) { + effectivePort = mergedPort; + } + if (typeof merged.name === 'string' && merged.name.trim()) { + effectiveAgentName = merged.name.trim(); + } + } catch { /* corrupt config; downstream uses pre-merge values */ } + }; + + // F25: reconcile BEFORE the branch decision so dry-run preview + // and skip-write log lines see the persisted-port value. Pre-F25 + // the dry-run branch printed the CLI-default `apiPort` (9200) + // even when `~/.dkg/config.json` had `apiPort: 9300`. Lifting + // the call here also collapses the two duplicate calls (one in + // the skip-write branch, one in the write-then-read branch) + // into a single up-front read. + if (configExists) { + reconcileFromPersistedConfig(); + } + + if (configExists && opts.name == null && opts.port == null) { + console.log(`[setup] Node config exists (${tildify(existsSync(yamlPath) ? yamlPath : jsonPath)}); leaving untouched.`); + } else if (dryRun) { + console.log(`[setup] [dry-run] Would write ~/.dkg/config.json (port ${effectivePort}, name "${effectiveAgentName}")`); + } else { + try { + const network = deps.loadNetworkConfig(); + deps.writeDkgConfig(effectiveAgentName, network, apiPort, { + nameExplicit: opts.name != null, + portExplicit: opts.port != null, + }); + // Re-read after writeDkgConfig in case the daemon's config- + // merge changed `apiPort` / `name` (first-wins semantics on + // existing fields, explicit overrides on new). + reconcileFromPersistedConfig(); + } catch (err: any) { + console.error(`[setup] Failed to load network config: ${err?.message ?? err}`); + throw err; + } + } + + // ── Step 2: start the daemon ────────────────────────────────────── + // `startDaemon` is no-op when a healthy daemon is already reachable + // on `effectivePort`; otherwise it spawns one and polls for + // readiness up to 30s. Same primitive openclaw-setup uses — see + // `packages/adapter-openclaw/src/setup.ts:606+`. + if (shouldStart && !dryRun) { + await deps.startDaemon(effectivePort); + } else if (shouldStart && dryRun) { + console.log('[setup] [dry-run] Would start DKG daemon'); + } else { + console.log('[setup] Skipping daemon start (--no-start)'); + } + + // ── Step 3: optional faucet ─────────────────────────────────────── + // Reads wallets from `~/.dkg/wallets.json` (written async by the + // daemon) with the same 5×1s retry openclaw-setup uses. Faucet + // failures log a manual `curl` block and continue — funding is + // non-fatal for setup. + // + // F14: the funding decision is decoupled from `shouldStart`. The + // pre-fix outer guard `if (shouldFund && !dryRun && shouldStart)` + // silently skipped funding whenever `--no-start` was supplied, + // even when the daemon was already running from a prior invocation + // and a re-run-to-retry-funding was the user's actual goal. + // Post-fix flow: + // 1. Honour `--no-fund` (explicit opt-out, unchanged). + // 2. Honour `--dry-run` (no network calls). + // 3. Probe daemon reachability at `/api/status` on + // `effectivePort`. If unreachable, log explicit + // "skipping wallet funding (daemon not reachable on port X)" + // with the reason — replaces the silent omission. If + // reachable, proceed with funding regardless of which + // invocation started the daemon. + if (!shouldFund) { + console.log('[setup] Skipping wallet funding (--no-fund)'); + } else if (dryRun) { + console.log('[setup] [dry-run] Would attempt wallet funding'); + } else { + let daemonReachable = false; + try { + // F26: bound the probe with AbortSignal.timeout(2000) so a + // partially-up daemon (port bound but unresponsive — half-stuck + // process or deadlocked startup) doesn't hang setup. The probe + // is best-effort; treating timeout as "not reachable" is the + // correct fallback because we move on to log the explicit + // skip-with-reason message anyway. + const probe = await fetch(`http://127.0.0.1:${effectivePort}/api/status`, { + signal: AbortSignal.timeout(2000), + }); + daemonReachable = probe.ok; + } catch { /* not reachable (or timed out) */ } + + if (!daemonReachable) { + console.log( + `[setup] Skipping wallet funding (daemon not reachable on port ${effectivePort})`, + ); + } else { + try { + const network = deps.loadNetworkConfig(); + const faucetUrl = network.faucet?.url; + const faucetMode = network.faucet?.mode ?? 'testnet'; + if (!faucetUrl) { + console.log('[setup] No faucet URL configured for this network; skipping wallet funding.'); + } else { + const wallets = await deps.readWalletsWithRetry(); + if (wallets.length === 0) { + console.log('[setup] No wallets to fund yet (daemon may not have flushed wallets.json).'); + } else { + try { + const result = await deps.requestFaucetFunding(faucetUrl, faucetMode, wallets, effectiveAgentName); + if (result?.success === false) { + console.warn('[setup] Faucet returned failure; emitting manual instructions.'); + deps.logManualFundingInstructions(wallets, faucetUrl, faucetMode); + } else { + console.log(`[setup] Funded ${wallets.length} wallet(s) via testnet faucet.`); + } + } catch (err: any) { + console.warn(`[setup] Faucet call failed (${err?.message ?? err}); emitting manual instructions.`); + deps.logManualFundingInstructions(wallets, faucetUrl, faucetMode); + } + } + } + } catch (err: any) { + console.warn(`[setup] Faucet step skipped: ${err?.message ?? err}`); + } + } + } + + // ── Step 4: client detection + classification ───────────────────── + console.log(''); + const clients = detectClients(); + if (clients.length === 0) { + console.log('No MCP-aware clients detected.'); + console.log(' Print the canonical JSON for manual paste:'); + console.log(' dkg mcp setup --print-only'); + return; + } + + const states = clients.map(classify); + type Action = 'register' | 'refresh' | 'skip'; + const planned: Array<{ s: ClientState; action: Action }> = states.map((s) => { + if (force) return { s, action: 'refresh' }; + if (s.state === 'not-registered') return { s, action: 'register' }; + if (s.state === 'stale') return { s, action: 'refresh' }; + return { s, action: 'skip' }; + }); + + for (const { s, action } of planned) { + const stateLabel = + s.state === 'registered' + ? 'registered' + : s.state === 'stale' + ? 'stale' + : 'not registered'; + const actionLabel = + action === 'register' + ? 'will register' + : action === 'refresh' + ? 'will refresh' + : 'leaving alone'; + console.log(` ${s.target.name.padEnd(13)} (${s.target.displayPath}) — ${stateLabel}; ${actionLabel}`); + } + + const writes = planned.filter((p) => p.action !== 'skip'); + if (writes.length === 0) { + console.log('\nClients all up-to-date; nothing to write. Re-run with --force to refresh anyway.'); + } else if (dryRun) { + console.log('\n[setup] [dry-run] Would write to the clients listed above.'); + } else { + console.log(''); + for (const { s, action } of writes) { + try { + writeRegistration(s.target); + console.log(` ${action === 'register' ? 'Registered' : 'Refreshed'} ${s.target.name} → ${s.target.displayPath}`); + } catch (err: any) { + console.error(` Failed to write ${s.target.displayPath}: ${err?.message ?? err}`); + throw err; + } + } + } + + // ── Step 5: optional verification ───────────────────────────────── + // Probe the daemon's `/api/status` to confirm it's healthy on the + // effective port. Cheap reachability check; if the daemon is up but + // misconfigured (auth, etc.) the probe still passes — deeper checks + // are out of scope for setup. + if (shouldVerify && !dryRun && shouldStart) { + try { + // F26: same hang-bound as the funding-step reachability probe. + // A partially-up daemon must not block setup completion. + const res = await fetch(`http://127.0.0.1:${effectivePort}/api/status`, { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) { + console.log(`\n[setup] Daemon healthy at http://127.0.0.1:${effectivePort}.`); + } else { + console.warn(`\n[setup] Daemon responded with HTTP ${res.status} at http://127.0.0.1:${effectivePort}.`); + } + } catch (err: any) { + console.warn(`\n[setup] Verification probe failed: ${err?.message ?? err}`); + } + } + + // ── Final hint ──────────────────────────────────────────────────── + console.log(''); + console.log('Next steps:'); + console.log(' 1. Restart your MCP-aware client (Cursor / Claude Code) so it picks up the new server.'); + console.log(' 2. From inside the client, ask "what tools does dkg expose?" — you should see'); + console.log(' dkg_assertion_create, dkg_assertion_write, dkg_assertion_query, and friends.'); + console.log(''); +} + +export { expandHome }; diff --git a/packages/cli/test/integrations.test.ts b/packages/cli/test/integrations.test.ts index 6323caba8..913f7e12d 100644 --- a/packages/cli/test/integrations.test.ts +++ b/packages/cli/test/integrations.test.ts @@ -49,7 +49,10 @@ const mcpEntry: IntegrationEntry = { install: { kind: 'mcp', command: 'npx', - args: ['-y', '@origintrail-official/dkg-mcp@0.1.0'], + // Unpinned per roadmap §9 decision 14: published version is + // `0.1.0-dev..`, so a `@0.1.0` pin would not resolve. Tests + // assert on package id only, not the version suffix. + args: ['-y', '@origintrail-official/dkg-mcp'], envRequired: ['DKG_API_URL', 'DKG_AUTH_TOKEN'], supportedClients: ['cursor', 'claude-code', 'claude-desktop'], }, @@ -524,7 +527,10 @@ describe('installMcp', () => { const res = await installMcp({ entry: mcpEntry, apiUrl: 'http://127.0.0.1:9200', logger: (m) => logs.push(m) }); const parsed = JSON.parse(res.mcpJson); expect(parsed.mcpServers['cursor-mcp-dkg'].command).toBe('npx'); - expect(parsed.mcpServers['cursor-mcp-dkg'].args).toEqual(['-y', '@origintrail-official/dkg-mcp@0.1.0']); + // Loose match per roadmap §9 decision 14 — published version is + // `0.1.0-dev..` so we assert on package id, not pin. + expect(parsed.mcpServers['cursor-mcp-dkg'].args).toContain('-y'); + expect(parsed.mcpServers['cursor-mcp-dkg'].args).toContain('@origintrail-official/dkg-mcp'); expect(parsed.mcpServers['cursor-mcp-dkg'].env.DKG_API_URL).toBe('http://127.0.0.1:9200'); expect(parsed.mcpServers['cursor-mcp-dkg'].env.DKG_AUTH_TOKEN).toBe(''); expect(res.token).toBeUndefined(); diff --git a/packages/cli/test/mcp-setup.test.ts b/packages/cli/test/mcp-setup.test.ts new file mode 100644 index 000000000..2c01976c4 --- /dev/null +++ b/packages/cli/test/mcp-setup.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join } from 'node:path'; +import { mcpSetupAction, type McpSetupActionDeps } from '../src/mcp-setup.js'; + +/** + * Bundled-flow fixture for `dkg mcp setup`. Per W6-pre task brief, asserts + * that on a clean machine the action: + * (a) creates `~/.dkg/config.json` (init step calls writeDkgConfig) + * (b) starts the daemon (startDaemon stub records the port) + * (c) writes client registration entries to detected clients + * + * Every external primitive is stubbed via the `deps` injection point — + * no real filesystem outside the temp HOME, no real daemon spawn, no + * real faucet HTTP call. + */ +describe('mcpSetupAction — bundled init + daemon-start + register flow', () => { + let tmpHome: string; + let originalHome: string | undefined; + let originalUserprofile: string | undefined; + let logSpy: ReturnType; + let warnSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'mcp-setup-test-')); + originalHome = process.env.HOME; + originalUserprofile = process.env.USERPROFILE; + process.env.HOME = tmpHome; + // node:os homedir() reads USERPROFILE on win32, HOME elsewhere; set both. + process.env.USERPROFILE = tmpHome; + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + if (originalUserprofile !== undefined) process.env.USERPROFILE = originalUserprofile; + else delete process.env.USERPROFILE; + logSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + rmSync(tmpHome, { recursive: true, force: true }); + }); + + /** + * Build a fresh stubbed deps surface. `writeDkgConfig` writes a real + * file into the temp HOME so the post-merge readback in mcpSetupAction + * sees a valid config — that's the byte-aligned behaviour with the + * production primitive without spawning a real daemon. + */ + function makeDeps(overrides: Partial = {}): McpSetupActionDeps { + const startDaemon = vi.fn(async (_port: number) => {}); + const writeDkgConfig = vi.fn((agentName: string, _network: any, apiPort: number) => { + const dkgDir = join(tmpHome, '.dkg'); + mkdirSync(dkgDir, { recursive: true }); + writeFileSync( + join(dkgDir, 'config.json'), + JSON.stringify({ name: agentName, apiPort, nodeRole: 'edge' }, null, 2), + ); + }); + const loadNetworkConfig = vi.fn(() => ({ + networkName: 'test-net', + relays: [], + defaultContextGraphs: ['agent-context'], + defaultNodeRole: 'edge', + faucet: { url: 'http://faucet.test', mode: 'testnet' }, + }) as any); + const readWalletsWithRetry = vi.fn(async () => ['0xtest1', '0xtest2', '0xtest3']); + const requestFaucetFunding = vi.fn(async () => ({ success: true }) as any); + const logManualFundingInstructions = vi.fn(() => {}); + return { + loadNetworkConfig, + writeDkgConfig, + startDaemon, + readWalletsWithRetry, + requestFaucetFunding, + logManualFundingInstructions, + ...overrides, + }; + } + + it('on a clean machine: creates config, starts daemon, writes client entry', async () => { + // Pre-create a Cursor-style config dir so `detectClients` finds Cursor + // as a candidate. Real users have ~/.cursor/ from installing Cursor. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps(); + // Avoid the verify step's real-network probe. + await mcpSetupAction({ verify: false, fund: false }, deps); + + // (a) writeDkgConfig was called with port 9200 (default) + a fallback agent name. + expect(deps.writeDkgConfig).toHaveBeenCalledTimes(1); + const writeArgs = (deps.writeDkgConfig as any).mock.calls[0]; + expect(writeArgs[2]).toBe(9200); + expect(typeof writeArgs[0]).toBe('string'); + expect(writeArgs[0]).toMatch(/^mcp-agent-/); + expect(existsSync(join(tmpHome, '.dkg', 'config.json'))).toBe(true); + + // (b) startDaemon was called once with the effective port. + expect(deps.startDaemon).toHaveBeenCalledTimes(1); + expect((deps.startDaemon as any).mock.calls[0][0]).toBe(9200); + + // (c) Cursor client config was written with the canonical entry. + const cursorConfig = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursorConfig.mcpServers.dkg).toEqual({ + command: 'dkg', + args: ['mcp', 'serve'], + }); + }); + + it('skips writeDkgConfig when ~/.dkg/config.yaml already exists and no overrides given', async () => { + // Pre-existing config — first-init should be skipped silently. + const dkgDir = join(tmpHome, '.dkg'); + mkdirSync(dkgDir, { recursive: true }); + writeFileSync(join(dkgDir, 'config.yaml'), 'name: persisted-agent\napiPort: 9200\n'); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ verify: false, fund: false }, deps); + + expect(deps.writeDkgConfig).not.toHaveBeenCalled(); + // Daemon start still runs unless --no-start was passed. + expect(deps.startDaemon).toHaveBeenCalledTimes(1); + }); + + // F6 (qa-review-round-1): when the existing-config skip-write branch + // is taken, `effectivePort` MUST be reconciled against the persisted + // config, not the CLI default. Pre-fix concrete reproducer: a user + // who previously ran `dkg openclaw setup --port 9300` (so + // `~/.dkg/config.json` has `apiPort: 9300`) and then runs `dkg mcp + // setup` with no flags would have the daemon started on 9200 (the + // CLI default), the verification probe hit the wrong port, and the + // registered MCP entry would point nowhere useful. + it('F6: existing config with non-default port — startDaemon receives the persisted port, not the CLI default', async () => { + const dkgDir = join(tmpHome, '.dkg'); + mkdirSync(dkgDir, { recursive: true }); + // Pre-existing config has apiPort 9300; the user is running `dkg + // mcp setup` with NO --port flag, so the CLI default would be 9200. + writeFileSync( + join(dkgDir, 'config.json'), + JSON.stringify({ name: 'persisted-agent', apiPort: 9300, nodeRole: 'edge' }, null, 2), + ); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ verify: false, fund: false }, deps); + + // writeDkgConfig MUST NOT have run — the existing-config branch + // was taken (no overrides supplied). + expect(deps.writeDkgConfig).not.toHaveBeenCalled(); + // startDaemon MUST receive the persisted 9300, not the CLI default. + expect(deps.startDaemon).toHaveBeenCalledTimes(1); + expect((deps.startDaemon as any).mock.calls[0][0]).toBe(9300); + }); + + it('F6: existing config with non-default port + --no-start — read-back still runs', async () => { + // Even with --no-start, the read-back must populate effective state + // so faucet (if enabled) and verify (if enabled) target the right + // port. Asserting via writeDkgConfig staying uncalled (skip-write + // branch taken) without throwing — the read-back sits on the same + // branch entry that the F6 fix lifts out of the `else`. + const dkgDir = join(tmpHome, '.dkg'); + mkdirSync(dkgDir, { recursive: true }); + writeFileSync( + join(dkgDir, 'config.json'), + JSON.stringify({ name: 'persisted', apiPort: 9400 }, null, 2), + ); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, verify: false, fund: false }, deps); + + // Daemon-start was opted out, faucet was opted out — no port + // assertion possible directly. The read-back's correctness here + // is structural: the branch ran without throwing on the corrupt + // configs / missing fields path the helper handles. Companion + // assertion to the port-9300 case above. + expect(deps.writeDkgConfig).not.toHaveBeenCalled(); + expect(deps.startDaemon).not.toHaveBeenCalled(); + }); + + it('honours --no-start: skips daemon start; faucet path is gated on daemon reachability (F14)', async () => { + // Pre-F14, --no-start was conflated with "skip funding" via the + // outer `shouldFund && shouldStart` guard. Post-F14 the funding + // decision is decoupled: if the daemon is reachable on + // effectivePort, funding proceeds regardless of which invocation + // started the daemon. This test pins the daemon-not-reachable + // path: --no-start with no running daemon → faucet skipped via + // the new explicit "daemon not reachable on port X" log line + // (NOT the silent omission that pre-F14 produced). + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + throw new Error('connection refused'); + }); + + await mcpSetupAction({ start: false, verify: false }, deps); + + expect(deps.startDaemon).not.toHaveBeenCalled(); + expect(deps.requestFaucetFunding).not.toHaveBeenCalled(); + // Registration still proceeds — orthogonal axis. + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(true); + // And the new explicit log line fired (replacing the pre-F14 + // silent omission). + const logged = (logSpy.mock.calls as any[]).map((c) => c.join(' ')).join('\n'); + expect(logged).toMatch(/Skipping wallet funding \(daemon not reachable on port 9200\)/); + + fetchSpy.mockRestore(); + }); + + // F14 (qa-review-round-2): the canonical decoupled-flow test. + // --no-start with a daemon already running (e.g. user re-runs to + // retry funding after the faucet was down on first run) MUST + // proceed with funding — pre-F14 it was silently skipped because + // the outer guard required `shouldStart === true`. + it('F14: --no-start with daemon already reachable → funding proceeds', async () => { + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + // Daemon is up and healthy; probe returns ok. + return new Response('{}', { status: 200 }) as any; + }); + + await mcpSetupAction({ start: false, verify: false }, deps); + + expect(deps.startDaemon).not.toHaveBeenCalled(); + // Funding MUST proceed — daemon is reachable, --no-fund was not + // supplied. This is the bug F14 fixes. + expect(deps.requestFaucetFunding).toHaveBeenCalledTimes(1); + + fetchSpy.mockRestore(); + }); + + it('F14: --no-fund + --no-start → explicit-skip log (not the unreachable log)', async () => { + // The --no-fund explicit-skip path takes precedence over the + // daemon-reachability probe — no probe should fire when funding + // is explicitly opted out, and the existing + // "Skipping wallet funding (--no-fund)" log line stays intact. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + throw new Error('should not be called when --no-fund is set'); + }); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + expect(deps.requestFaucetFunding).not.toHaveBeenCalled(); + const logged = (logSpy.mock.calls as any[]).map((c) => c.join(' ')).join('\n'); + expect(logged).toMatch(/Skipping wallet funding \(--no-fund\)/); + // The unreachable-path log line MUST NOT fire when --no-fund + // short-circuits the funding step. + expect(logged).not.toMatch(/daemon not reachable/); + + fetchSpy.mockRestore(); + }); + + it('honours --no-fund: skips the faucet step but starts daemon + registers', async () => { + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + + await mcpSetupAction({ fund: false, verify: false }, deps); + + expect(deps.startDaemon).toHaveBeenCalledTimes(1); + expect(deps.requestFaucetFunding).not.toHaveBeenCalled(); + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(true); + }); + + it('honours --dry-run: no filesystem writes, no daemon start, no faucet call', async () => { + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + + await mcpSetupAction({ dryRun: true }, deps); + + expect(deps.writeDkgConfig).not.toHaveBeenCalled(); + expect(deps.startDaemon).not.toHaveBeenCalled(); + expect(deps.requestFaucetFunding).not.toHaveBeenCalled(); + expect(existsSync(join(tmpHome, '.dkg', 'config.json'))).toBe(false); + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(false); + }); + + it('honours --print-only: short-circuits before any other step', async () => { + const deps = makeDeps(); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await mcpSetupAction({ printOnly: true }, deps); + + expect(deps.writeDkgConfig).not.toHaveBeenCalled(); + expect(deps.startDaemon).not.toHaveBeenCalled(); + // Asserted JSON shape on stdout. + const allWrites = (stdoutSpy.mock.calls as any[]).map((c) => c[0]).join(''); + const parsed = JSON.parse(allWrites); + expect(parsed.mcpServers.dkg).toEqual({ command: 'dkg', args: ['mcp', 'serve'] }); + stdoutSpy.mockRestore(); + }); + + it('rejects an out-of-range --port at the action boundary', async () => { + const deps = makeDeps(); + await expect( + mcpSetupAction({ port: 'not-a-number' }, deps), + ).rejects.toThrow(/Invalid port/); + await expect( + mcpSetupAction({ port: '99999' }, deps), + ).rejects.toThrow(/Invalid port/); + }); + + it('--port and --name overrides flow through to writeDkgConfig', async () => { + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + + await mcpSetupAction({ port: '9300', name: 'override-agent', verify: false, fund: false }, deps); + + const writeArgs = (deps.writeDkgConfig as any).mock.calls[0]; + expect(writeArgs[0]).toBe('override-agent'); + expect(writeArgs[2]).toBe(9300); + expect(writeArgs[3]).toEqual({ nameExplicit: true, portExplicit: true }); + // Daemon start uses the override port. + expect((deps.startDaemon as any).mock.calls[0][0]).toBe(9300); + }); + + it('faucet failure logs manual instructions; setup continues to register clients', async () => { + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps({ + requestFaucetFunding: vi.fn(async () => { + throw new Error('faucet 503'); + }), + }); + + await mcpSetupAction({ verify: false }, deps); + + expect(deps.logManualFundingInstructions).toHaveBeenCalledTimes(1); + // Registration still proceeds. + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(true); + }); +}); diff --git a/packages/mcp-dkg/README.md b/packages/mcp-dkg/README.md index 12b4c6119..7c5e102e1 100644 --- a/packages/mcp-dkg/README.md +++ b/packages/mcp-dkg/README.md @@ -1,47 +1,86 @@ # `@origintrail-official/dkg-mcp` -A small [Model Context Protocol](https://modelcontextprotocol.io) server -that exposes your local DKG daemon to **Cursor**, **Claude Code**, and -any other MCP-aware coding assistant. +[Model Context Protocol](https://modelcontextprotocol.io) server that exposes your local DKG V10 daemon to **Cursor**, **Claude Code**, **Continue**, **Cline**, and any other MCP-aware coding assistant. It is the canonical V10 surface for "DKG as agent memory." -Once installed, an agent can do things like: - -- `dkg_list_projects` — see every context graph this node participates in -- `dkg_list_activity` — catch up on the last 25 decisions / tasks / PRs, who authored each -- `dkg_search "tree-sitter"` — full-text search across labels + body text -- `dkg_get_entity urn:dkg:decision:…` — pull a decision's full provenance + 1-hop neighbours -- `dkg_get_chat --keyword "hook"` — ask "what was my teammate's assistant discussing about hooks?" -- `dkg_sparql "SELECT ?d WHERE { ?d a decisions:Decision }"` — drop down to raw SPARQL when the canned tools aren't enough +The package ships transitively as part of `@origintrail-official/dkg`. You don't run the bin directly — the umbrella CLI's `dkg mcp serve` invokes it on the client's behalf. ## Install +Two commands, same shape as `dkg openclaw setup`: + ```bash -# in the monorepo -pnpm --filter @origintrail-official/dkg-mcp build +npm install -g @origintrail-official/dkg # umbrella CLI bundles this MCP server +dkg mcp setup # one-shot: init + start + fund + register + verify +``` + +`dkg mcp setup` runs a bundled, idempotent flow. In order, it: + +1. Initializes `~/.dkg/config.json` if absent (skipped silently when present) +2. Starts the DKG daemon as a background process (skipped if already running) +3. Funds the node's wallets via the testnet faucet (skip with `--no-fund`) +4. Detects each MCP-aware client by its config file (`~/.cursor/mcp.json`, `~/.claude.json`) and writes the canonical entry under `mcpServers.dkg` +5. Verifies the daemon is healthy -# once published to npm -npx -p @origintrail-official/dkg-mcp dkg-mcp +Every step short-circuits when its work is already done, so re-running on a set-up box is safe. Step-skip flags: `--no-start`, `--no-fund`, `--no-verify`, `--dry-run` (preview only), `--force` (refresh every detected client config regardless of state). First-init overrides: `--port `, `--name `. The bundled flow re-uses the same primitives `dkg openclaw setup` does, so the two verbs stay byte-aligned on network defaults, daemon-readiness probes, faucet retry/back-off, and manual-curl fallback. + +The canonical entry written into each client's config: + +```json +{ + "mcpServers": { + "dkg": { + "command": "dkg", + "args": ["mcp", "serve"] + } + } +} ``` -The binary is called `dkg-mcp` and reads config from two places, in order: +No tokens or URLs in the JSON — those live in `~/.dkg/config.yaml` and the daemon-written `~/.dkg/auth.token`. If no client is detected, run `dkg mcp setup --print-only` to emit the JSON for manual paste. + +After `dkg mcp setup` runs, restart your client so it discovers the MCP. Verify by asking the agent: *"What tools does dkg expose?"* The `tools/list` response must include `dkg_assertion_create`, `dkg_assertion_write`, and `dkg_memory_search`. + +### Manual config (alternative) + +For environments where `dkg mcp setup` can't run (CI, locked-down configs, custom paths), drop the same block in by hand: + +- **Cursor** — `~/.cursor/mcp.json` (or workspace `.cursor/mcp.json`) +- **Claude Code** — `~/.claude.json`, or run `claude mcp add dkg dkg mcp serve` +- **Continue / Cline / generic MCP client** — the project's MCP config file, same JSON shape + +For monorepo contributors working from source without a global install, the workspace-relative form (matches the repo's own `.cursor/mcp.json`): -1. **`.dkg/config.yaml`** walked upwards from the working directory (the spec-canonical workspace config, see `dkgv10-spec / 22_AGENT_ONBOARDING §2.1`) +```json +{ + "mcpServers": { + "dkg": { + "command": "pnpm", + "args": ["exec", "tsx", "packages/mcp-dkg/src/index.ts"], + "cwd": "${workspaceFolder}" + } + } +} +``` + +### Configuration sources + +The MCP server resolves config from two places, in priority order: + +1. **`.dkg/config.yaml`** — walked upwards from the working directory (the spec-canonical workspace config; see `dkgv10-spec / 22_AGENT_ONBOARDING §2.1`) 2. **environment variables** — `DKG_API`, `DKG_TOKEN`, `DKG_PROJECT`, `DKG_AGENT_URI` -Env values always win over the file, and tool-call arguments (`projectId`, -`layer`, …) always win over env. +Env values win over the file; tool-call arguments (`projectId`, `view`, …) win over both. -### Minimal `.dkg/config.yaml` +#### Minimal `.dkg/config.yaml` -Copy `packages/mcp-dkg/config.yaml.example` into `/.dkg/config.yaml` -and edit: +Copy `packages/mcp-dkg/config.yaml.example` into `/.dkg/config.yaml` and edit: ```yaml -contextGraph: dkg-code-project +contextGraph: my-research node: api: http://localhost:9200 - tokenFile: ../.devnet/node1/auth.token # relative to the YAML file + tokenFile: ~/.dkg/auth.token agent: uri: urn:dkg:agent:cursor-branarakic @@ -53,65 +92,94 @@ capture: autoShare: true ``` -`.dkg/` is gitignored repo-wide so this file stays local to each operator. +`.dkg/` is gitignored repo-wide so this file stays local to each operator. The `tokenFile` path is resolved relative to the YAML; default of `~/.dkg/auth.token` matches what `dkg start` writes on first boot. -## Wire it into Cursor +## Tool surface (21 tools) -Put this in `~/.cursor/mcp.json` (or the workspace-scoped -`.cursor/mcp.json`): +All tools are available the moment `dkg mcp setup` registers the MCP with your client. They group into six categories tracking how a session typically uses memory: discover the graph, write to it, finalize it, recall from it, query it, and check it. -```json -{ - "mcpServers": { - "dkg": { - "command": "node", - "args": ["/absolute/path/to/packages/mcp-dkg/dist/index.js"] - } - } -} -``` +### Health / identity -Published via npm: +| Tool | What it does | +|---|---| +| `dkg_status` | Show DKG node status: peer ID, connected peers, multiaddrs, wallet addresses. First call most agents make to verify the daemon is running. | +| `dkg_wallet_balances` | TRAC and ETH balances per operational wallet, plus chain id and RPC URL. Use before publishing to verify funds. | -```json -{ - "mcpServers": { - "dkg": { - "command": "npx", - "args": ["-y", "-p", "@origintrail-official/dkg-mcp", "dkg-mcp"] - } - } -} -``` +### Discovery (graph navigation) -Cursor automatically picks up `.dkg/config.yaml` from the workspace, -so as long as your project has one committed, the server will resolve -the right daemon URL, token, and project id without any per-machine -tweaks. +| Tool | What it does | +|---|---| +| `dkg_list_context_graphs` | List all context graphs (called "projects" in the DKG node UI) this node knows about. Returns id, display name, role (curator / participant), and layer. The first call most agents make when joining a workspace. | +| `dkg_sub_graph_list` | List the sub-graphs inside a context graph (e.g. `code`, `github`, `decisions`, `tasks`, `meta`, `chat`) with entity counts. Use to figure out what kind of knowledge a CG exposes before querying. | +| `dkg_get_entity` | All triples where the given URI is the subject, plus a 1-hop inbound-edges neighbourhood. Equivalent to the entity detail page in the Node UI. Use to understand a specific decision, task, file, or PR end-to-end. | +| `dkg_list_activity` | Recent activity across all sub-graphs, newest first. Mirrors the "Recent activity" feed on the project overview page: decisions, tasks, PRs, chat turns. Use to catch up at the start of a session. | +| `dkg_get_agent` | Look up an agent by URI (or display name) and return its profile card: framework, operator, wallet address, joined-at, reputation, plus everything that agent has authored in the project. | -## Wire it into Claude Code +### Setup (graph CRUD) -Either edit `~/.claude.json` / workspace `.claude/mcp.json` with the same -block as above, or run: +| Tool | What it does | +|---|---| +| `dkg_context_graph_create` | Create a context graph (called "projects" in the DKG node UI). The `id` slug is auto-derived from `name` when omitted. Idempotent — pre-existing CGs are returned unchanged. | +| `dkg_subscribe` | Subscribe to a context graph so its data syncs locally from peers. Defaults to also syncing Shared Working Memory; pass `includeSharedMemory: false` to skip SWM. | +| `dkg_sub_graph_create` | Create a named sub-graph inside a context graph (e.g. `code`, `tasks`, `meta`). Idempotent — pre-existing sub-graphs are silently reused. | -```bash -claude mcp add dkg node /absolute/path/to/packages/mcp-dkg/dist/index.js -``` +### Write (the canonical assertion lifecycle) -Inside a Claude Code session you can then do: +The four-tool write flow that lets agents stage memory, share it, and recover from mistakes — the canonical V10 pattern, mirrored byte-for-byte across the OpenClaw adapter and the umbrella CLI. -``` -/mcp dkg_list_activity -/mcp dkg_search "branarakic tree-sitter" -``` +| Tool | Step | What it does | +|---|---|---| +| `dkg_assertion_create` | 1 | Create an empty Working Memory assertion graph. Idempotent — duplicate names land as `alreadyExists: true`. Slug `/^[a-z0-9-]+$/`. | +| `dkg_assertion_write` | 2 | Append RDF quads into an existing WM assertion. Set-merge — duplicates collapse. To replace, call `dkg_assertion_discard` first or mint a unique name. | +| `dkg_assertion_promote` | 3 | Promote a WM assertion (or specific root entities) from private WM to Shared Working Memory so teammates see it. Omit `entities` to promote every root. | +| `dkg_assertion_discard` | rollback | Discard a WM assertion without promoting it. Idempotent — no-op on a missing assertion. Use before re-writing an assertion whose name you want to keep stable. | +| `dkg_assertion_query` | introspect | Return every quad in a WM assertion. The canonical introspection step for the create + write + promote round-trip. | +| `dkg_assertion_import_file` | bulk | Import a local document (markdown, PDF, DOCX, HTML, txt, csv) into a WM assertion via the daemon's extraction pipeline. Useful for seeding a context graph from existing documents in one step. | +| `dkg_assertion_history` | audit | An assertion's lifecycle descriptor: author, extraction status, promotion state, timestamps. Returns 404 if no record exists. | + +### Publish (SWM → on-chain) + +Two distinct surfaces (both documented in `SKILL.md §4a`): + +| Tool | When to use | +|---|---| +| `dkg_publish` | "I have fresh quads, publish them now." Two-call helper: writes the supplied quads to SWM, then publishes the entire SWM in the CG to Verified Memory and clears SWM. Skip the WM staging area. | +| `dkg_shared_memory_publish` | Canonical step-4 finalizer for the stepwise flow (`assertion_create + write + promote` → this). Publishes existing SWM (filterable by `rootEntities`), clears SWM. Pass `registerIfNeeded: true` to upgrade a local-only CG to on-chain registration in the same call (may spend gas/TRAC). | + +Both ship ungated — no `agent.canPublishToVm` flag — to mirror the OpenClaw adapter exactly. + +### Search & query + +| Tool | What it does | +|---|---| +| `dkg_memory_search` | Trust-weighted free-text recall across WM/SWM/VM in the agent-context graph (and an optional project graph). Higher-trust layers (VM > SWM > WM) collapse lower-trust hits for the same entity URI. Each hit surfaces `contextGraphId`, `layer`, and `trustWeight`. Use this for "ask my memory anything" recall. | +| `dkg_query` | Execute SPARQL SELECT / ASK / CONSTRUCT against a context graph. Known prefixes are auto-prepended. Scope with `view`: `"working-memory"` (default), `"shared-working-memory"`, or `"verified-memory"`. Set `includeSharedMemory: true` alongside `view: "working-memory"` to query WM ∪ SWM in one call. | + +## The canonical round-trip + +Lifted from the repo-root README's [DKG V10 as agent memory quickstart](../../README.md#round-trip-write-then-recall), reproduced here for completeness: + +1. `dkg_assertion_create` with a slug name (idempotent — re-runs return `alreadyExists: true`). +2. `dkg_assertion_write` with one or more quads (additive set-merge). +3. `dkg_memory_search` with a keyword from the write — the just-written triple comes back from the WM layer with `trustWeight` set. +4. *(optional)* `dkg_assertion_promote` to advance the lifecycle to SWM and gossip to peers. +5. *(optional)* `dkg_shared_memory_publish` to finalize on-chain (costs TRAC + gas, clears SWM). + +For ad-hoc filtering or non-text-search queries, `dkg_query` is the lower-level SPARQL surface. For one-shot fresh-quads-to-VM writes that skip the WM staging area, use `dkg_publish` instead of the assertion lifecycle — but prefer the lifecycle for anything an agent will iterate on. + +## View semantics + +The `view` argument (on `dkg_query`) and the `layer` argument (on `dkg_get_entity`, `dkg_list_activity`) scope reads to one of the three DKG memory tiers: + +- `working-memory` — private to this node's agents (default for most reads) +- `shared-working-memory` — gossiped to every participant on the CG; trust-weighted above WM in `dkg_memory_search` +- `verified-memory` — on-chain anchored; responses include UAL + publisher info; highest trust weight + +A separate `includeSharedMemory: boolean` axis (on `dkg_query` and `dkg_subscribe`) layers SWM on top of the requested view; `view: "working-memory"` + `includeSharedMemory: true` matches what the Node UI's default reader shows. ## Capture hook -The package ships a tool-agnostic hook script at -`hooks/capture-chat.mjs` that turns every conversation turn into -`chat:Turn` triples on the project's `chat` sub-graph and auto-promotes -them to SWM so teammates see them immediately. The same script works -for Cursor and Claude Code — only the event wiring differs. +The package ships a tool-agnostic hook script at `hooks/capture-chat.mjs` that turns every conversation turn into `chat:Turn` triples on the project's `chat` sub-graph and auto-promotes them to SWM so teammates see them immediately. The same script works for Cursor and Claude Code — only the event wiring differs. ### Cursor — `.cursor/hooks.json` @@ -119,95 +187,61 @@ for Cursor and Claude Code — only the event wiring differs. { "version": 1, "hooks": { - "sessionStart": [{ "command": "DKG_WORKSPACE=/path/to/repo node /path/to/packages/mcp-dkg/hooks/capture-chat.mjs sessionStart", "failClosed": false }], "beforeSubmitPrompt": [{ "command": "DKG_WORKSPACE=/path/to/repo node /path/to/packages/mcp-dkg/hooks/capture-chat.mjs beforeSubmitPrompt", "failClosed": false }], - "afterAgentResponse": [{ "command": "DKG_WORKSPACE=/path/to/repo node /path/to/packages/mcp-dkg/hooks/capture-chat.mjs afterAgentResponse", "failClosed": false }], - "sessionEnd": [{ "command": "DKG_WORKSPACE=/path/to/repo node /path/to/packages/mcp-dkg/hooks/capture-chat.mjs sessionEnd", "failClosed": false }] + "afterAgentResponse": [{ "command": "DKG_WORKSPACE=/path/to/repo node /path/to/packages/mcp-dkg/hooks/capture-chat.mjs afterAgentResponse", "failClosed": false }] } } ``` -`DKG_WORKSPACE` tells the hook where to walk upward from when looking -for `.dkg/config.yaml` — useful when Cursor's cwd is different from the -repo root (e.g. a multi-folder workspace). `failClosed: false` is -deliberate — the hook exists to enrich the DKG, never to block the -user's conversation. Any error is logged to `/tmp/dkg-capture.log` -(override via `DKG_CAPTURE_LOG`) and the hook still exits `0`. +`DKG_WORKSPACE` tells the hook where to walk upward from when looking for `.dkg/config.yaml` — useful when Cursor's cwd differs from the repo root (e.g. multi-folder workspaces). `failClosed: false` is deliberate — the hook exists to enrich the DKG, never to block the user's conversation. Errors log to `/tmp/dkg-capture.log` (override via `DKG_CAPTURE_LOG`) and the hook still exits `0`. ### Claude Code — `~/.claude/settings.json` -Merge the following `hooks` block into your existing `~/.claude/settings.json` -(the capture-chat script handles the native Claude Code event names -`SessionStart` / `UserPromptSubmit` / `Stop` / `SessionEnd` as aliases): +Merge the following `hooks` block into your existing `~/.claude/settings.json`. The script accepts the native Claude Code event names (`UserPromptSubmit`, `Stop`) as aliases for `beforeSubmitPrompt` / `afterAgentResponse`: ```json { "hooks": { - "SessionStart": [ - { "hooks": [{ "type": "command", "command": "DKG_WORKSPACE=/path/to/repo DKG_CAPTURE_TOOL=claude-code DKG_AGENT_URI=urn:dkg:agent:claude-code- node /path/to/packages/mcp-dkg/hooks/capture-chat.mjs SessionStart" }] } - ], "UserPromptSubmit": [ { "hooks": [{ "type": "command", "command": "DKG_WORKSPACE=/path/to/repo DKG_CAPTURE_TOOL=claude-code DKG_AGENT_URI=urn:dkg:agent:claude-code- node /path/to/packages/mcp-dkg/hooks/capture-chat.mjs UserPromptSubmit" }] } ], "Stop": [ { "hooks": [{ "type": "command", "command": "DKG_WORKSPACE=/path/to/repo DKG_CAPTURE_TOOL=claude-code DKG_AGENT_URI=urn:dkg:agent:claude-code- node /path/to/packages/mcp-dkg/hooks/capture-chat.mjs Stop" }] } - ], - "SessionEnd": [ - { "hooks": [{ "type": "command", "command": "DKG_WORKSPACE=/path/to/repo DKG_CAPTURE_TOOL=claude-code DKG_AGENT_URI=urn:dkg:agent:claude-code- node /path/to/packages/mcp-dkg/hooks/capture-chat.mjs SessionEnd" }] } ] } } ``` -`DKG_CAPTURE_TOOL=claude-code` ensures turns carry `chat:speakerTool -"claude-code"` in the graph so the UI chips them correctly. -`DKG_AGENT_URI` lets you attribute Claude Code sessions to a distinct -agent entity from your Cursor sessions (recommended — this is the -"one human, two tools" shape in spec §4 of `22_AGENT_ONBOARDING`). - -### Shared state - -Per-turn state is kept in `~/.cache/dkg-mcp/sessions/*.json`; safe to -delete at any time. - -## Tools at a glance - -| Tool | What it does | -| -------------------- | ------------------------------------------------------------------------ | -| `dkg_list_projects` | List every context graph this node knows about | -| `dkg_list_subgraphs` | List the sub-graphs in one project with entity counts | -| `dkg_sparql` | Execute any SPARQL (prefixes auto-injected) scoped by layer (wm/swm/vm) | -| `dkg_get_entity` | Entity detail: all outgoing triples + inbound 1-hop neighbours | -| `dkg_search` | Keyword search across labels + body predicates | -| `dkg_list_activity` | Recent activity feed, newest first, with agent attribution | -| `dkg_get_agent` | Agent profile card + per-type authored counts | -| `dkg_get_chat` | Captured chat turns, filterable by session / agent / keyword / time | - -All read-only. Write tools (propose decision, add task, comment, etc.) -will arrive in a follow-up release that coincides with the R/W -attribution PR landing. - -## Layer semantics - -The `layer` argument (where supported) scopes the query to one of the -three DKG memory layers: - -- `wm` — working memory (local, private to this node's agents) -- `swm` — shared working memory (gossiped to every participant on the CG) -- `union` — wm + swm combined (default for most tools; matches the - Node UI's default reader) -- `vm` — verified / on-chain memory (hits `view: "verified-memory"` - on the daemon, whose responses include UAL + publisher info) +`DKG_CAPTURE_TOOL=claude-code` ensures turns carry `chat:speakerTool "claude-code"` in the graph so the UI chips them correctly. `DKG_AGENT_URI` lets you attribute Claude Code sessions to a distinct agent entity from your Cursor sessions — recommended (the "one human, two tools" shape in spec §4 of `22_AGENT_ONBOARDING`). -Chat tools default to `union` so you see everything your own agent -wrote plus everything your teammates shared. +Per-turn state is kept in `~/.cache/dkg-mcp/sessions/*.json`; safe to delete at any time. ## Troubleshooting -- **"No project specified"** — set `contextGraph: ` in `.dkg/config.yaml` - or pass `projectId` on each tool call. -- **HTTP 401** — your token is wrong. Point `node.tokenFile` at the - `auth.token` file produced by your daemon's devnet setup, or export - `DKG_TOKEN`. -- **HTTP 404 on `/api/context-graph/list`** — you're on an older daemon; - the client automatically falls back to `/api/paranet/list`. +- **`dkg mcp setup` says "no MCP-aware clients detected"** → install Cursor, Claude Code, Continue, or Cline (or run with `--print-only` to copy the JSON yourself). +- **`dkg mcp` says command not found** → the umbrella CLI isn't on PATH. Verify with `which dkg`. Note: `npm i -g @origintrail-official/dkg` does NOT propagate transitive bins to global PATH, so the `dkg-mcp` bin is only reachable through `dkg mcp serve` or via a direct `npx -p @origintrail-official/dkg-mcp dkg-mcp`. +- **MCP not visible in client** → restart the client. On Cursor, verify `~/.cursor/mcp.json` is syntactically valid JSON. On Claude Code, run `claude mcp list`. +- **"No project specified"** → set `contextGraph: ` in `.dkg/config.yaml`, or pass `projectId` on each tool call, or export `DKG_PROJECT`. +- **HTTP 401 from MCP tools** → token mismatch. `dkg auth show` returns the expected value; confirm it matches `~/.dkg/auth.token`. On CI / containers / proxied environments where `dkg init` can't run, the env-var fallbacks are `DKG_API` (daemon URL, default `http://localhost:9200`), `DKG_TOKEN` (bearer), `DKG_PROJECT` (default context graph), `DKG_AGENT_URI` (operator agent URI). A stale exported `DKG_PROJECT` from a prior session can silently mis-route writes — unset it if you switch projects. +- **HTTP 404 on `/api/context-graph/list`** → you're on an older daemon; the client automatically falls back to the legacy endpoint. +- **`tools/list` is missing tools after `dkg mcp setup`** → the client's MCP config still points at a prior install. Re-run `dkg mcp setup --force` to refresh stale entries. + +## Package layout + +| File | Purpose | +|---|---| +| `src/index.ts` | Stdio MCP server entrypoint. Boots `McpServer` and registers the 21 tools. | +| `src/tools.ts` | Read tools (`dkg_list_context_graphs`, `dkg_sub_graph_list`, `dkg_query`, `dkg_get_entity`, `dkg_list_activity`, `dkg_get_agent`). | +| `src/tools/assertions.ts` | Assertion lifecycle (`dkg_assertion_*` × 7). | +| `src/tools/health.ts` | `dkg_status`, `dkg_wallet_balances`. | +| `src/tools/memory-search.ts` | `dkg_memory_search` with WM/SWM/VM fan-out and trust-weighted ranking. | +| `src/tools/publish.ts` | `dkg_publish`, `dkg_shared_memory_publish`. | +| `src/tools/setup.ts` | `dkg_context_graph_create`, `dkg_subscribe`, `dkg_sub_graph_create`. | +| `src/client.ts` | `DkgClient` HTTP wrapper. Re-exported as `@origintrail-official/dkg-mcp/client`. | +| `src/manifest/{publish,fetch,install}.ts` | Project manifest publish/install pipeline. Re-exported as `@origintrail-official/dkg-mcp/manifest/*` and consumed by the umbrella CLI's daemon routes. | +| `hooks/capture-chat.mjs` | Cursor/Claude Code chat-turn capture hook (above). | +| `schema/dev-context-graph.ttl` | The canonical dev-coordination ontology (devgraph namespace). | + +## Historical recovery + +Ten V9-era and coding-project tools were dropped from the V10 surface during consolidation. The annotated git tag `pre-v10-tool-drop` preserves them — recover any individual handler with `git show pre-v10-tool-drop:packages/mcp-dkg/src/tools/`. Design rationale and reintroduction-pointers for each drop are in [`agent-docs/dkg-v10-mcp-consolidation/v9-design-archive.md`](../../agent-docs/dkg-v10-mcp-consolidation/v9-design-archive.md). diff --git a/packages/mcp-dkg/docs/RECONCILIATION.md b/packages/mcp-dkg/docs/RECONCILIATION.md deleted file mode 100644 index b93466b7a..000000000 --- a/packages/mcp-dkg/docs/RECONCILIATION.md +++ /dev/null @@ -1,103 +0,0 @@ -# `dkg_propose_same_as` reconciliation flow — design - -**Investigation date:** 2026-04-18 -**Status:** specification only — not implemented in Phase 7. Targeted for Phase 8. - -## The problem - -The look-before-mint protocol (`packages/mcp-dkg/templates/ontologies/coding-project/agent-guide.md` § "Look-before-mint") has agents call `dkg_search` before minting any new `urn:dkg::` URI. That works perfectly when the search is "before" — i.e. the candidate URI already exists in the graph and `dkg_search` returns it. - -Two failure modes remain: - -1. **Concurrent mint race.** Agent A on machine 1 and Agent B on machine 2 both decide to discuss a brand-new concept at the same moment. Both `dkg_search` queries fire before either's mint has propagated via gossip. Both mint independently. Now the graph has two URIs for the same concept (slug variations like `tree-sitter` vs `treesitter` arise because slug normalisation is deterministic but the underlying *labels* the agents chose may differ slightly — `Tree-sitter` and `Tree sitter` both normalise to `tree-sitter`, but `tree-sitter parser` normalises to `tree-sitter-parser`). - -2. **Synonyms an agent doesn't recognise.** Agent A mints `urn:dkg:concept:incremental-parsing`. Agent B refers to the same idea as `urn:dkg:concept:reactive-reparsing`. Slug normalisation doesn't catch this — the labels are different even though the concepts are equivalent. - -Both failure modes leave the graph technically correct (two distinct URIs for two distinct labels) but practically fragmented (queries that should aggregate across the concept now miss half the data). - -## The Linked Data answer - -`owl:sameAs`. RDF semantics: ` owl:sameAs ` means the two URIs denote the same entity, and reasoners + SPARQL endpoints with `owl:sameAs` inference enabled treat them as one. This is the canonical mechanism; we don't need to invent anything. - -But: `owl:sameAs` is a strong claim. Asserting it incorrectly is hard to undo (every triple about A bleeds onto B). So we want a **propose → human-review → ratify** flow, not unilateral agent merging. - -## Proposed mechanism - -A new MCP tool **`dkg_propose_same_as`** that any agent can call when it suspects two URIs refer to the same entity. It writes a marker entity (`dkg:SameAsProposal`), NOT an `owl:sameAs` triple. The human reviews via the node-ui and ratifies — at which point a real `owl:sameAs` triple gets written to SWM (and optionally promoted to VM for permanence). - -### Tool signature - -```typescript -dkg_propose_same_as({ - uriA: string, // first URI (the one the agent considers "canonical") - uriB: string, // second URI (the duplicate) - rationale: string, // why the agent thinks they're the same entity - evidence?: { // optional supporting context - overlappingProperties?: string[]; // predicates A and B both have with matching values - sharedNeighbours?: string[]; // entities both A and B link to - detectedBy?: 'slug-similarity' | 'shared-neighbour-overlap' | 'agent-judgement'; - }, - projectId?: string, -}) -``` - -### Triples written - -Marker only — never `owl:sameAs` itself: - -``` -> rdf:type dkg:SameAsProposal -> dkg:proposesSameAs (, ) -> dkg:rationale "" -> prov:wasAttributedTo -> dcterms:created -> dkg:status "pending" -``` - -Auto-promoted to SWM so curators on every node see it. Curator UI surfaces these in a "Pending reconciliations" panel. - -### Curator ratification - -In the node-ui: - -1. Pending reconciliations show as a list with: - - The two URIs side-by-side - - Their property tables diffed (overlap highlighted) - - The rationale + agent attribution - - The marker's URI -2. Three actions per row: - - **Confirm** → daemon writes ` owl:sameAs ` to a new `same-as` assertion in `meta`, auto-promotes to SWM, marks the proposal `status = "confirmed"`. - - **Reject** → marks `status = "rejected"` with optional `dkg:rejectionReason` literal. - - **Defer** → leaves `status = "pending"` and marks `dkg:deferredUntil ` for a later look. - -3. Optional: bulk-confirm for high-confidence groups (e.g. all proposals where slug normalisation detects a likely typo variant). - -### Querying with reconciliation - -Once ` owl:sameAs ` exists, any client query that wants to aggregate across reconciled URIs uses one of: - -- **SPARQL with property paths**: `?subj (owl:sameAs|^owl:sameAs)* ?canonicalSubj` to walk equivalence sets. -- **Daemon-side reasoner pass**: extend `/api/query` to optionally apply `owl:sameAs` inference before returning results. Cheaper for the client; defaultable per-query. - -### Detection helpers - -`dkg_propose_same_as` is the human-facing tool, but agents need a way to *find* duplication candidates. Two helpers worth shipping alongside: - -- **`dkg_find_duplicate_candidates({ projectId? })`** — runs a periodic batch SPARQL that flags entity pairs with high property/neighbour overlap. Returns a ranked list. Agents can call this opportunistically, then `dkg_propose_same_as` for the highest-confidence pairs. -- **Slug-collision detection in capture-chat hook** — when the regex backstop sees a URI in turn text that doesn't exist in the graph but whose normalised slug matches an existing entity, log a candidate proposal. Operator can review the log. - -## Phase 8 implementation order - -1. **Daemon:** add `dkg:SameAsProposal` to the `meta/project-ontology` ontologies (one-line ttl addition each), define the marker URI pattern. -2. **MCP:** implement `dkg_propose_same_as` and `dkg_find_duplicate_candidates` as new tools in `packages/mcp-dkg/src/tools/annotations.ts`. Reuse existing `writeAssertion` + `promoteAssertion` patterns. -3. **node-ui:** add a "Reconciliation" panel under the project view that lists pending proposals + provides Confirm/Reject/Defer actions. The Confirm action calls a new daemon helper that writes `owl:sameAs` to a `meta/same-as` assertion. -4. **Daemon:** optionally extend `/api/query` with an `applySameAs: boolean` parameter that pre-rewrites the query to walk `owl:sameAs` equivalence classes. -5. **AGENTS.md update:** add a section explaining when an agent should call `dkg_propose_same_as` vs `dkg_find_duplicate_candidates`. - -Estimated effort: ~1 day for v0 (steps 1-3 + a minimal AGENTS.md note); steps 4-5 polish over an additional day. - -## Why we didn't ship it in Phase 7 - -Phase 7's scope is the **annotation pathway**: agents emit triples per turn, the graph grows, look-before-mint keeps URIs converged at write time. Reconciliation is the **repair pathway** — the cleanup for the rare cases where look-before-mint loses a race. Implementing the repair pathway before the write pathway has any real production data to repair would be premature optimisation; we'd be designing for a problem we haven't yet observed in practice. - -The cleaner sequencing is: ship Phase 7, run real multi-agent sessions for a week, see what fragmentation patterns actually emerge, then design `dkg_propose_same_as` against the empirical evidence. The spec above is a starting point; the data will sharpen it. diff --git a/packages/mcp-dkg/hooks/capture-chat.mjs b/packages/mcp-dkg/hooks/capture-chat.mjs index 382d77779..4a4bdf2ef 100755 --- a/packages/mcp-dkg/hooks/capture-chat.mjs +++ b/packages/mcp-dkg/hooks/capture-chat.mjs @@ -7,15 +7,23 @@ * see what your assistant is working on (and let their assistants query * it back via MCP). * - * Event model - * ----------- - * Cursor invokes this same script for four events, passing the event + * Event model (V10 — V9 prompt-injection sub-system retired in #18 / #21) + * ----------------------------------------------------------------------- + * Cursor invokes this same script for two events, passing the event * payload on stdin as JSON: * - * sessionStart — initialise session state + emit chat:Session triples * beforeSubmitPrompt — stash the pending user prompt for the next turn * afterAgentResponse — flush (user prompt + assistant response) as one chat:Turn - * sessionEnd — close the session (soft — we don't delete state) + * + * The `sessionStart` and `sessionEnd` hook events that the V9 version + * handled have been retired — they only existed to (a) inject V9-era + * additionalContext (annotation-protocol scaffolding now gone with the + * dropped sugared-write tools), and (b) auto-register an Agent entity + * in `meta` (V9-onboarding-specific). The remaining `chat:Session` + * triple write is handled lazily as a "bootstrap" inside + * `handleAfterAgentResponse` on the first turn of any session whose + * Session entity hasn't been written yet, so dropping `sessionStart` + * loses no V10-relevant behaviour. * * The event name is passed as argv[2]: * @@ -26,16 +34,24 @@ * 1. FAIL OPEN. This script must never block the user's chat. Any error * is logged to /tmp/dkg-capture.log and we still exit 0 with `{}` * on stdout. - * 2. DEFENSIVE PARSING. Cursor's event schema isn't fully documented; - * we try several common field names before giving up, and fall - * back to stashing the whole payload as `rawPayload` so no - * information is lost. - * 3. CANONICAL DKG OPS. Writes go through the existing + * 2. CANONICAL DKG OPS. Writes go through the existing * `POST /api/assertion//write` (JSON triples) and promotes * through `POST /api/assertion//promote`, matching every * other seeding script in the repo. + * 3. NO PROMPT INJECTION. Per the V10 retirement of sugared writes + * (#18) and the dropped agent-instruction protocol (#21), this hook + * no longer returns `additionalContext` / per-turn reminders. Agents + * are not told from this hook to call any specific MCP tool; the + * canonical V10 tool surface lives in `packages/cli/skills/dkg-node/SKILL.md`. * 4. NO NEW CONFIG SURFACE. Reads `.dkg/config.yaml` walking upward * from cwd. See `22_AGENT_ONBOARDING §2.1` for the canonical shape. + * + * Cross-reference: the OpenClaw adapter's `ChatTurnWriter` at + * `packages/adapter-openclaw/src/ChatTurnWriter.ts` writes the same + * `chat:Turn` shape (predicates + sub-graph) into the same context + * graph from a different ingestion path. Predicates and sub-graph + * names below stay byte-aligned with that writer so a turn captured + * via either surface lands in the same RDF shape. */ import fs from 'node:fs'; @@ -78,9 +94,6 @@ const P = { speakerTool: NS.chat + 'speakerTool', privacy: NS.chat + 'privacy', contentHash: NS.chat + 'contentHash', - aboutEntity: NS.chat + 'aboutEntity', - mentions: NS.chat + 'mentions', - summary: NS.chat + 'summary', rawPayload: NS.chat + 'rawPayload', // Optional metadata predicates — best-effort enrichment from tool payload. model: NS.chat + 'model', @@ -164,7 +177,6 @@ function loadConfig() { const envToken = process.env.DKG_TOKEN ?? process.env.DEVNET_TOKEN; const envProject = process.env.DKG_PROJECT; const envAgent = process.env.DKG_AGENT_URI; - const envNickname = process.env.DKG_AGENT_NICKNAME; const cwd = process.env.DKG_WORKSPACE ?? process.cwd(); const cfgPath = findConfigFile(cwd); @@ -197,32 +209,15 @@ function loadConfig() { const line = raw.split('\n').find((l) => l.trim() && !l.startsWith('#')); token = (line ?? '').trim(); } catch (err) { - log(`token file unreadable: ${err?.message ?? err}`); + log(`tokenFile read failed: ${err?.message ?? err}`); } } - // Precedence: project-scoped `.dkg/config.yaml` WINS over shell env - // vars. A `dkg-mcp join` installation writes the authoritative - // values to the workspace's config.yaml; the env vars (DKG_API, - // DKG_TOKEN, DKG_PROJECT, DKG_AGENT_URI, DKG_AGENT_NICKNAME) exist - // for bootstrapping (e.g. devnet scripts that run BEFORE a config - // file exists) and for .cursor/mcp.json wiring — neither of those - // should silently shadow a checked-in per-project config. That is: - // the user who committed `agent.nickname: "Brana laptop 2"` expects - // that label to appear on the graph even if their shell still has - // DKG_AGENT_NICKNAME exported from an earlier session. - // - // Reversing this was an accidental regression caught by Codex. Same - // precedence ordering is mirrored in packages/mcp-dkg/src/config.ts - // (the TS-side loader) so runtime and hook agree. return { api: fromFile.node?.api ?? envApi ?? DEFAULT_API, token, project: fromFile.contextGraph ?? fromFile.project ?? envProject ?? null, agent: fromFile.agent?.uri ?? envAgent ?? null, - // Free-form human label rendered as rdfs:label / schema:name on the - // agent entity. Falls back to the URI tail for legacy slug-only configs. - nickname: fromFile.agent?.nickname ?? envNickname ?? null, subGraph: fromFile.capture?.subGraph ?? 'chat', assertion: fromFile.capture?.assertion ?? 'chat-log', privacy: fromFile.capture?.privacy ?? 'team', @@ -230,10 +225,7 @@ function loadConfig() { // `tool` intentionally prefers DKG_CAPTURE_TOOL when the per-tool // hook script exports it — each tool's hook command line wires // `cursor` or `claude-code` explicitly, and that runtime signal - // must win over any static config.yaml value (otherwise a user - // with both Cursor + Claude installed records every turn as the - // tool they happened to put in their yaml). See the long comment - // in templates.ts CONFIG_YAML_TEMPLATE. + // must win over any static config.yaml value. tool: process.env.DKG_CAPTURE_TOOL ?? fromFile.capture?.tool ?? 'cursor', sourcePath: cfgPath, }; @@ -278,9 +270,7 @@ async function readStdinJson() { // Generic deep-search for the first matching key. Used to pluck prompt // text / response text / conversation id from whatever shape Cursor uses -// without us having to know it exactly up front. The spike hook -// (dump-spike.mjs) tells us the real field names; this function lets us -// bridge that gap without breaking once we learn them. +// without us having to know it exactly up front. export function pick(obj, candidates, depth = 0) { if (depth > 4 || obj == null || typeof obj !== 'object') return undefined; for (const c of candidates) { @@ -328,9 +318,7 @@ export function extractSessionKey(payload) { if (id) return sanitiseSlug(id); // No id from the tool? Synthesize a unique, per-invocation key and // persist it in a small index file so repeated events from the same - // shell process share the same session. The previous fallback used - // the current hour, which silently merged unrelated conversations - // that happened to run in the same 60-minute window. + // shell process share the same session. return sanitiseSlug(anonSessionKey()); } @@ -347,9 +335,6 @@ function anonSessionKey() { fs.writeFileSync(idxFile, fresh, 'utf-8'); return fresh; } catch { - // If we can't persist, fall back to per-invocation; still much - // safer than the hourly bucket — at worst we lose session grouping - // across events but never merge unrelated conversations. return `anon-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } } @@ -359,21 +344,10 @@ function anonSessionKey() { * we can skip emitting the predicate rather than write empty strings. */ function extractMeta(payload) { return { - // The model that produced the response — valuable for auditability. - // Cursor: "claude-opus-4-7", Claude Code likely similar. model: pick(payload, ['model', 'modelId', 'model_id']), - // Mode context (Cursor: "agent" / "ask"). Useful for filtering out - // quick-question turns from deep agentic sessions. mode: pick(payload, ['composer_mode', 'mode']), - // Short generation id; lets us dedupe retried turns on the same - // conversation even before contentHash fires. generationId: pick(payload, ['generation_id', 'generationId']), - // The tool's own version string (Cursor: "3.1.15"). Handy if a - // future payload shape change breaks capture — we know exactly - // which client version is in play. toolVersion: pick(payload, ['cursor_version', 'client_version', 'tool_version']), - // Transcript file on disk (Cursor persists a jsonl). Stored so - // downstream jobs can fetch the full raw transcript if needed. transcriptPath: pick(payload, ['transcript_path', 'transcriptPath']), }; } @@ -419,12 +393,9 @@ async function postJson(api, route, token, body) { } /** - * Low-level write. Callers pass an assertion name so each logical - * write-unit (a single turn, the agent-self-register, a session - * bootstrap, …) gets its own named assertion graph. Sharing one - * `cfg.assertion` across many writes would couple every turn's - * promote/discard lifecycle together and (per `/api/assertion/…/write` - * semantics) risk clobbering already-committed turn history. + * Low-level write. Each turn gets its own named assertion graph so a + * write for turn N cannot overwrite the one for turn N-1, and each + * turn can be promoted/discarded independently. */ async function writeTriples(cfg, triples, assertionName = cfg.assertion) { return postJson(cfg.api, `/api/assertion/${encodeURIComponent(assertionName)}/write`, cfg.token, { @@ -442,13 +413,6 @@ async function promoteEntities(cfg, entities, assertionName = cfg.assertion) { }); } -/** - * Build a per-turn assertion name. Including the session key + turn - * index keeps successive turns in distinct assertion graphs, so a - * write for turn N cannot overwrite the one for turn N-1 even under - * the most-permissive `/api/assertion/…/write` semantics, and each - * turn can be promoted/discarded independently. - */ function perTurnAssertionName(cfg, sessionKey, turnIdx) { const base = cfg.assertion ?? 'chat-log'; return sanitiseSlug(`${base}-${sessionKey}-turn-${turnIdx}`); @@ -459,22 +423,15 @@ function perTurnAssertionName(cfg, sessionKey, turnIdx) { * * Rules (in order): * 1. If `cfg.autoShare` is false for this operator, never promote. - * 2. If the session has an explicit `chat:privacy "private"` flag - * (set by `dkg_set_session_privacy`) in any memory layer, never - * promote — the operator explicitly opted out for this thread. + * 2. If the session has an explicit `chat:privacy "private"` flag in + * any memory layer, never promote — the operator explicitly opted + * out for this thread (via direct daemon writes or the node UI; + * the V9 MCP tool that used to flip this in-band is retired). * 3. Otherwise, promote. * - * **No caching.** Re-reading the privacy triple each turn costs one - * cheap SPARQL query but is the only way to respect a mid-session - * `dkg_set_session_privacy` flip: the store is authoritative, and - * hooks run in a short-lived Node process whose `state` is loaded - * fresh from disk each turn but not round-tripped through the daemon - * between turns. An earlier version cached `state.privacyCached` on - * first hit, but that cache never invalidated — once a session's first - * turn observed e.g. `team`, every subsequent flip to `private` was - * ignored for the rest of the session lifetime, leaking turns into - * SWM. One query per turn is well within budget (the hook already - * makes multiple `/api/*` calls per turn). + * Re-reads each turn (no cache) so a mid-session privacy flip is + * respected — caching once-per-session leaked private turns into SWM + * in the V9 implementation. */ async function shouldPromote(cfg, state) { if (!cfg.autoShare) return false; @@ -514,261 +471,7 @@ async function ensureSubGraph(cfg, name) { } } -// ── Self-register the agent in `meta` on first sessionStart ──── -// -// Without this, operator B has to manually `node scripts/import-agents.mjs` -// before their first chat turn or attribution chips render bare URIs in -// the UI. Self-register writes a minimal Agent entity (label, framework, -// joinedAt) into a per-agent assertion (`agent-self-register-`) -// so it doesn't clobber other agents in `meta/participants`. Idempotent: -// the per-session state file remembers we've done it so subsequent -// sessionStart events skip the write. -async function selfRegisterAgent(cfg, state) { - if (!cfg.agent) return; - if (state.agentRegistered) return; - // Phase 8 nickname/wallet split: cfg.agent is the canonical URI - // (typically `urn:dkg:agent:` from the manifest install). - // The human-friendly label comes from cfg.nickname (set by config.yaml's - // `agent.nickname`); fall back to the URI tail for old slug-based configs. - const uriTail = cfg.agent.split(':').pop() ?? 'unknown-agent'; - const nickname = cfg.nickname || uriTail; - // Assertion name needs to be filesystem/URL-safe, so use the URI tail - // (a wallet address — already safe — or a slug). - const assertion = `agent-self-register-${uriTail.replace(/[^a-z0-9-]/gi, '-')}`; - const triples = [ - { subject: cfg.agent, predicate: P.type, object: URI(NS.agent + 'Agent') }, - { subject: cfg.agent, predicate: P.type, object: URI(NS.agent + 'AIAgent') }, - { subject: cfg.agent, predicate: P.label, object: LIT(nickname) }, - { subject: cfg.agent, predicate: P.name, object: LIT(nickname) }, - { subject: cfg.agent, predicate: NS.agent + 'framework', object: LIT(cfg.tool) }, - { subject: cfg.agent, predicate: NS.agent + 'joinedAt', object: LIT(state.startedAt, NS.xsd + 'dateTime') }, - ]; - // Stamp the wallet address as a separate predicate when we have one. - // Heuristic: agent URIs of the shape urn:dkg:agent:0x[hex40] embed the - // wallet directly. Stash it as a first-class triple so SPARQL queries - // can correlate agents to wallets without parsing URI strings. - const walletMatch = cfg.agent.match(/urn:dkg:agent:(0x[a-fA-F0-9]{40})$/); - if (walletMatch) { - triples.push({ - subject: cfg.agent, - predicate: NS.agent + 'walletAddress', - object: LIT(walletMatch[1].toLowerCase()), - }); - } - try { - await ensureSubGraph(cfg, 'meta'); - await postJson(cfg.api, `/api/assertion/${encodeURIComponent(assertion)}/write`, cfg.token, { - contextGraphId: cfg.project, - subGraphName: 'meta', - quads: triples, - }); - if (cfg.autoShare) { - await postJson(cfg.api, `/api/assertion/${encodeURIComponent(assertion)}/promote`, cfg.token, { - contextGraphId: cfg.project, - subGraphName: 'meta', - entities: [cfg.agent], - }).catch((e) => log(`promote agent self-register: ${e.message}`)); - } - state.agentRegistered = true; - log(`self-registered agent ${cfg.agent} in meta/${assertion}`); - } catch (err) { - // Don't block the session over this — it's recoverable. - log(`self-register agent: ${err?.message ?? err}`); - } -} - -// ── Pending-annotation rendezvous ───────────────────────────── -// -// Phase 7 race-fix: agents call `dkg_annotate_turn` with `forSession` -// during their response composition. The MCP tool writes annotation -// triples to a `urn:dkg:pending-annotation::…` URI tagged with -// `chat:pendingForSession `. After we write the actual turn -// triples here, scan for matching pending annotations and rewrite their -// triples onto the just-written turn URI. This lets the agent annotate -// "the turn I'm about to produce" race-free — no need to predict a turn -// URI that doesn't exist yet. -async function applyPendingAnnotations(cfg, sessionKey, turnUri, promote = false) { - if (!cfg.project) return 0; - // NO `GRAPH ?g { … }` wrapper here. The daemon's /api/query handler - // already scopes the query to `contextGraphId` + `subGraphName` below; - // explicit GRAPH would open the match up to every graph this token - // can read, so a pending annotation written from a sibling project on - // the same daemon (same sessionKey) would get pulled into this turn. - // Codex tier-4g finding N9. - const sparql = `SELECT ?pending ?p ?o WHERE { - ?pending "${sessionKey}" ; - ?p ?o . - FILTER NOT EXISTS { ?pending ?_t } -}`; - let bindings = []; - try { - const r = await postJson(cfg.api, '/api/query', cfg.token, { - sparql, - contextGraphId: cfg.project, - subGraphName: cfg.subGraph, - includeSharedMemory: true, - }); - bindings = r?.result?.bindings ?? []; - } catch (err) { - log(`pending-annotations query: ${err?.message ?? err}`); - return 0; - } - if (!bindings.length) return 0; - - // Collect all triples per pending URI so we can rewrite the subject. - const byPending = new Map(); - for (const row of bindings) { - const pending = (row.pending?.value ?? row.pending ?? '').toString().replace(/^<|>$/g, ''); - const p = (row.p?.value ?? row.p ?? '').toString().replace(/^<|>$/g, ''); - const oRaw = (row.o?.value ?? row.o ?? '').toString(); - if (!pending || !p) continue; - if (!byPending.has(pending)) byPending.set(pending, []); - byPending.get(pending).push({ predicate: p, object: oRaw }); - } - if (!byPending.size) return 0; - - // For each pending, rewrite triples whose subject is the pending URI - // onto the real turnUri. Triples whose subject is a co-minted entity - // (Finding/Question/Decision/Task/Comment/etc.) reference the pending - // URI as object via chat:proposes/concludes/etc. — those object refs - // also need rewriting. We do a generic pass: anywhere the pending - // URI appears in the triples we just queried, swap to turnUri. - // - // Simpler approach: query the FULL set of triples involving each - // pending (as subject OR object) and rewrite. The first query gave - // us only subject-side triples, so do a second pass for object-side. - let applied = 0; - for (const pending of byPending.keys()) { - let allTriples = byPending.get(pending); - try { - // Scoping via contextGraphId + subGraphName below; no GRAPH wrapper - // so we don't match references from other projects' sub-graphs. - const objSparql = `SELECT ?s ?p WHERE { ?s ?p <${pending}> }`; - const r2 = await postJson(cfg.api, '/api/query', cfg.token, { - sparql: objSparql, - contextGraphId: cfg.project, - subGraphName: cfg.subGraph, - includeSharedMemory: true, - }); - for (const row of r2?.result?.bindings ?? []) { - const s = (row.s?.value ?? row.s ?? '').toString().replace(/^<|>$/g, ''); - const p = (row.p?.value ?? row.p ?? '').toString().replace(/^<|>$/g, ''); - if (s && p) allTriples.push({ subject: s, predicate: p, object: `<${turnUri}>` }); - } - } catch (err) { - log(`pending object-pass query for ${pending}: ${err?.message ?? err}`); - } - // Build the rewritten triple set. Skip the pending-marker triples - // (pendingForSession, type=PendingAnnotation) — they'd just clutter. - const rewritten = []; - for (const t of allTriples) { - const subject = (t.subject ?? pending) === pending ? turnUri : t.subject; - const predicate = t.predicate; - // Drop bookkeeping triples that don't belong on the real turn. - if (predicate === 'http://dkg.io/ontology/chat/pendingForSession') continue; - if (predicate === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' - && (t.object === '' - || t.object === 'http://dkg.io/ontology/chat/PendingAnnotation')) continue; - // Object: if it's the pending URI in any wrapping form, swap. - let object = t.object; - const bareObj = String(object).replace(/^<|>$/g, ''); - if (bareObj === pending) object = `<${turnUri}>`; - rewritten.push({ subject, predicate, object }); - } - if (!rewritten.length) continue; - // Mark the pending as applied so we don't re-apply on subsequent - // turn writes for the same session. Use a separate single-triple - // assertion so the original pending assertion stays untouched - // (it's a valid historical record of what the agent intended). - rewritten.push({ - subject: pending, - predicate: 'http://dkg.io/ontology/chat/appliedToTurn', - object: `<${turnUri}>`, - }); - const applyAssertion = `agent-annotate-applied-${pending.replace(/[^A-Za-z0-9]+/g, '-').slice(-30)}`; - // Collect every co-minted entity URI that appears as a SUBJECT in - // the rewritten quad set (Findings / Questions / Decisions / Tasks - // / Comments / mention-targets). The /promote endpoint filters the - // exported assertion by subject root, so if we only list `turnUri` - // the rewritten annotation triples whose subject is one of these - // co-minted entities get silently dropped from the SWM export and - // peers never see them. See Codex tier-4g finding N8. - const promoteEntities = new Set([turnUri]); - for (const t of rewritten) { - if (!t.subject) continue; - if (t.subject === pending) continue; // the appliedToTurn marker - if (t.subject === turnUri) continue; // already covered - promoteEntities.add(t.subject); - } - try { - await postJson(cfg.api, `/api/assertion/${encodeURIComponent(applyAssertion)}/write`, cfg.token, { - contextGraphId: cfg.project, - subGraphName: cfg.subGraph, - quads: rewritten, - }); - // Only promote when the caller determined this turn is - // promotable (cfg.autoShare AND session is not private). If the - // turn itself stayed WM-only, its annotations must too — otherwise - // the hook would gossip a chat:mentions/proposes triple that - // points back at a turn the team can't actually see. - if (promote) { - await postJson(cfg.api, `/api/assertion/${encodeURIComponent(applyAssertion)}/promote`, cfg.token, { - contextGraphId: cfg.project, - subGraphName: cfg.subGraph, - entities: Array.from(promoteEntities), - }).catch(() => { /* non-fatal */ }); - } - applied++; - log(`applied pending annotation ${pending} → ${turnUri} (${rewritten.length} triples)`); - } catch (err) { - log(`apply pending ${pending}: ${err?.message ?? err}`); - } - } - return applied; -} - -// ── Per-turn annotation reminder (Phase 7B) ─────────────────── -// -// Session-start additionalContext gets ~5KB of protocol once, but -// LLMs weight recency and a 3-section directive at the top of context -// loses the war by turn 2. This is a tight (~400 char) per-turn -// reminder injected via beforeSubmitPrompt / UserPromptSubmit so it -// rides along with EVERY user turn. Empirically: turn-1 of a fresh -// chat skipped annotation despite alwaysApply: true on the rule, and -// the agent later self-annotated a chat:concludes Finding admitting -// the skip ("agent-skipped-dkg-annotate-turn-prior-reply-despite-..." -// — preserved at urn:dkg:finding:agent-skipped-...). This per-turn -// nudge closes that gap without bloating context. -export function buildPerTurnReminder(sessionKey) { - return `**DKG annotation reminder (this turn):** after your reply, call \`dkg_annotate_turn\` with \`forSession: "${sessionKey}"\` and at minimum 2-3 \`topics\` plus \`mentions\` URIs from \`dkg_search\`. Required for every substantive turn (definition is broad — anything that reasoned, examined, proposed, or referenced something counts). Skipping is the failure mode. Full protocol: see session-start context + \`.cursor/rules/dkg-annotate.mdc\`.`; -} - -// ── Mention-regex backstop ──────────────────────────────────── -// -// Defensive: even if the agent forgets to call dkg_annotate_turn, any -// urn:dkg:* URI quoted verbatim in the prompt or response gets a -// chat:mentions edge from the turn. Cheap (one regex pass), deterministic, -// covers the most common case (agent referencing existing entities). -// Agent-emitted richer triples (chat:examines / chat:proposes / etc.) -// remain additive on top. -const URN_DKG_RE = /urn:dkg:[\w@:%./-]+(?:\/[\w@:%./-]+)*[\w%]/g; -export function extractMentionedUris(...texts) { - const found = new Set(); - for (const t of texts) { - if (!t) continue; - const matches = String(t).match(URN_DKG_RE) ?? []; - for (const m of matches) found.add(m.replace(/[.,;:)\]}>]+$/, '')); - } - return [...found]; -} - -// ── Event handlers ──────────────────────────────────────────── -/** - * Build the standard set of `chat:Session` triples for a sessionKey. - * Factored out so sessionStart AND the afterAgentResponse safety net - * can emit identical metadata. Relying on triple-store dedup for - * idempotency — two writes with identical (s,p,o) are collapsed. - */ +// ── Triple builders ─────────────────────────────────────────── function sessionTriples(cfg, state, payload) { const triples = [ { subject: state.sessionUri, predicate: P.type, object: URI(T.Session) }, @@ -784,44 +487,7 @@ function sessionTriples(cfg, state, payload) { return triples; } -async function handleSessionStart(cfg, payload) { - const sessionKey = extractSessionKey(payload); - const existing = loadSessionState(sessionKey); - const now = new Date().toISOString(); - const state = existing ?? { - sessionKey, - sessionUri: sessionUri(sessionKey), - startedAt: now, - turnIndex: 0, - pendingPrompt: null, - sessionWritten: false, - }; - state.lastEventAt = now; - - if (!cfg.project) { - log('no project configured — skipping session write'); - saveSessionState(sessionKey, state); - return; - } - await ensureSubGraph(cfg); - try { - await writeTriples(cfg, sessionTriples(cfg, state, payload)); - state.sessionWritten = true; - // Same privacy gate as the turn write below — a session marked - // private at session-start (workspace default `capture.privacy: - // private`) MUST NOT have even its Session entity gossiped. - // Matches the `await shouldPromote` pattern in handleAfterAgentResponse. - if (await shouldPromote(cfg, state)) { - await promoteEntities(cfg, [state.sessionUri]).catch((e) => log(`promote session: ${e.message}`)); - } - } catch (err) { - log(`session start write: ${err?.message ?? err}`); - } - // Self-register the agent in `meta` so attribution chips render - // properly without a manual import-agents step. Idempotent. - await selfRegisterAgent(cfg, state); - saveSessionState(sessionKey, state); -} +// ── Event handlers ──────────────────────────────────────────── async function handleBeforeSubmitPrompt(cfg, payload) { const sessionKey = extractSessionKey(payload); @@ -862,11 +528,10 @@ async function handleAfterAgentResponse(cfg, payload) { if (!cfg.project) { log('no project configured — skipping turn write'); return; } await ensureSubGraph(cfg); - // Safety net: Cursor doesn't always fire sessionStart (e.g. when the - // hook config is added mid-session, or on resumed threads). On the - // first turn of a session whose Session triples haven't been written - // yet, emit them alongside the turn so the UI / MCP always sees a - // proper `chat:Session` entity pointing to `chat:Turn`s. + // Bootstrap: the V9 `sessionStart` hook is retired, so EVERY first + // turn of a session needs to also emit the Session triples alongside + // the Turn so the UI / MCP always sees a proper `chat:Session` entity + // pointing to its `chat:Turn`s. const bootstrapSession = idx === 1 && !state.sessionWritten; const triples = [ { subject: turn, predicate: P.type, object: URI(T.Turn) }, @@ -886,8 +551,7 @@ async function handleAfterAgentResponse(cfg, payload) { if (meta.transcriptPath) triples.push({ subject: turn, predicate: P.transcriptPath, object: LIT(meta.transcriptPath) }); // When nothing could be extracted (unfamiliar payload shape) stash // the raw JSON so we can post-hoc reconstruct turns once we see real - // data. This is what unblocks us on day 0 before the spike tells us - // the exact field names. + // data. if (!userText && !asstText) { try { triples.push({ subject: turn, predicate: P.rawPayload, object: LIT(JSON.stringify(payload)) }); @@ -902,31 +566,15 @@ async function handleAfterAgentResponse(cfg, payload) { triples.push(...sessionTriples(cfg, state, payload)); } - // Mention-regex backstop (Phase 7): defensive auto-link for any - // urn:dkg:* URI quoted verbatim in the prompt or response. Even if - // the agent forgets to call dkg_annotate_turn, basic mentions still - // land. Agent-emitted richer triples remain additive on top. - for (const uri of extractMentionedUris(userText, asstText)) { - triples.push({ subject: turn, predicate: P.mentions, object: URI(uri) }); - } - let writeOk = false; const turnAssertion = perTurnAssertionName(cfg, sessionKey, idx); try { await writeTriples(cfg, triples, turnAssertion); writeOk = true; - // Commit progress only on success — see comment above the `idx` calc. state.turnIndex = idx; state.pendingPrompt = null; if (bootstrapSession) state.sessionWritten = true; - // Compute the promote-or-not decision ONCE per turn. `shouldPromote` - // is async (it may hit /api/query to read `chat:privacy`), so a - // bare `if (shouldPromote(...))` always-truthy the Promise and - // leaks private turns into SWM. Do it right with an explicit - // `await` and reuse the resolved boolean for annotation promote - // below so both gates agree. - const promote = await shouldPromote(cfg, state); - if (promote) { + if (await shouldPromote(cfg, state)) { // Promote both the session and the individual turn so the team // sees the turn immediately and the aggregate Session is kept // in SWM. @@ -935,17 +583,6 @@ async function handleAfterAgentResponse(cfg, payload) { log(`auto-share skipped: session ${sessionKey} is private`); } log(`wrote turn #${idx} for session ${sessionKey}${bootstrapSession ? ' (bootstrapped session)' : ''}`); - // Phase 7 race-fix: apply any pending annotations queued by - // dkg_annotate_turn(forSession=...) during this response. Best- - // effort, non-blocking on error — pendings can also be applied - // later by re-running this hook on the next turn. Pass the same - // promote decision so a private turn doesn't leak via annotations. - try { - const n = await applyPendingAnnotations(cfg, sessionKey, turn, promote); - if (n > 0) log(`applied ${n} pending annotation${n === 1 ? '' : 's'} to ${turn}`); - } catch (err) { - log(`pending-annotation apply failed: ${err?.message ?? err}`); - } } catch (err) { // Leave state.turnIndex + state.pendingPrompt untouched so the next // afterAgentResponse retries the same slot with the same prompt. @@ -957,149 +594,10 @@ async function handleAfterAgentResponse(cfg, payload) { return writeOk; } -async function handleSessionEnd(cfg, payload) { - const sessionKey = extractSessionKey(payload); - const state = loadSessionState(sessionKey); - if (!state) return; - const now = new Date().toISOString(); - state.endedAt = now; - saveSessionState(sessionKey, state); - if (!cfg.project) return; - try { - await writeTriples(cfg, [ - { subject: state.sessionUri, predicate: P.modified, object: LIT(now, NS.xsd + 'dateTime') }, - ]); - } catch (err) { - log(`session end write: ${err?.message ?? err}`); - } -} - -// ── Session-start additionalContext (Phase 7) ───────────────── +// ── Main dispatch ───────────────────────────────────────────── // -// Both Cursor's sessionStart and Claude Code's SessionStart support -// returning JSON with an `additionalContext` field whose markdown gets -// prepended to the agent's working context for the rest of the session. -// We use this to inject (a) a tight summary of the project's annotation -// protocol so the agent knows to call dkg_annotate_turn from turn #1, -// and (b) a short snapshot of recent entities so look-before-mint has -// candidates to match against without needing a separate dkg_search. -// -// Budget: ~600-800 tokens. Cheap relative to any modern context window. -async function buildSessionStartContext(cfg, sessionKey) { - if (!cfg.project) return null; - - const RECENT_LIMIT = 30; - let recentRows = []; - try { - const sparql = ` -SELECT ?s ?type ?label WHERE { - GRAPH ?g { - ?s a ?type . - OPTIONAL { ?s ?label } - FILTER(STRSTARTS(STR(?s), "urn:dkg:")) - FILTER(?type IN ( - , - , - , - , - , - , - , - - )) - } -} -LIMIT ${RECENT_LIMIT}`; - const r = await postJson(cfg.api, '/api/query', cfg.token, { - sparql, - contextGraphId: cfg.project, - includeSharedMemory: true, - }); - recentRows = r?.result?.bindings ?? []; - } catch (err) { - log(`buildSessionStartContext: recent-entities query failed: ${err.message}`); - } - - // Phase 8: bucket recent entities by type so the agent's first-prompt - // context reads as a project plan ("Open tasks: ...", "Decisions on - // record: ...") rather than one flat list. Tasks come first because - // they're what most coding sessions act on; decisions and concepts - // give surrounding context. This is what makes the joiner's "agent - // immediately knows what to do" moment land — the curator publishes - // tasks via dkg_add_task, joiner's session start surfaces them by - // bucket, agent picks one. - const TYPE_BUCKETS = [ - { label: 'Open tasks', match: /Task$/ }, - { label: 'Decisions on record', match: /Decision$/ }, - { label: 'Concepts in scope', match: /(Concept|Topic)$/ }, - { label: 'Findings', match: /Finding$/ }, - { label: 'Open questions', match: /Question$/ }, - { label: 'Agents in this graph', match: /Agent$/ }, - { label: 'Recent chat sessions', match: /Session$/ }, - ]; - const buckets = new Map(TYPE_BUCKETS.map((b) => [b.label, []])); - const otherBucket = []; - for (const row of recentRows.slice(0, RECENT_LIMIT)) { - const s = (row.s?.value ?? row.s ?? '').toString(); - const t = (row.type?.value ?? row.type ?? '').toString().replace(/^http:\/\/[^/]+\/[^#]+[#/]/, ''); - const l = (row.label?.value ?? row.label ?? '').toString().replace(/^"|"$/g, ''); - const line = `- \`${s}\`${l ? ` — ${l.slice(0, 100)}` : ''}`; - const matched = TYPE_BUCKETS.find((b) => b.match.test(t)); - if (matched) buckets.get(matched.label).push(line); - else otherBucket.push(`${line}${t ? ` _(${t})_` : ''}`); - } - const bucketSections = []; - for (const { label } of TYPE_BUCKETS) { - const lines = buckets.get(label); - if (!lines.length) continue; - bucketSections.push(`**${label}:**\n${lines.join('\n')}`); - } - if (otherBucket.length) { - bucketSections.push(`**Other recent entities:**\n${otherBucket.join('\n')}`); - } - const recentLines = bucketSections.join('\n\n'); - - const md = `## DKG project context — \`${cfg.project}\` - -This workspace is bound to a DKG context graph. Every chat turn here is -captured into the project's \`chat\` sub-graph and gossipped to all -subscribed nodes. After every substantive turn, you SHOULD call -\`dkg_annotate_turn\` to emit structured triples about what the turn was -about — topics, mentions, examines, proposes, concludes, asks. The -chat sub-graph then becomes a navigable knowledge graph rather than -just a text log. - -**Your current session ID:** \`${sessionKey}\` - -When calling \`dkg_annotate_turn\`, ALWAYS pass \`forSession: "${sessionKey}"\`. The tool then queues your annotation as a "pending-annotation" entity, and the capture hook applies it to the actual turn URI when it writes the next chat:Turn for this session. **Race-free**: works whether you call annotate during your response composition (before the hook fires) or after. Do NOT try to predict your own turn URI — it doesn't exist yet at the moment you call this tool. - -**Look-before-mint protocol** (the convergence rule): -1. Before minting any new \`urn:dkg::\` URI, call \`dkg_search\` with the unnormalised label. -2. If a result has the same normalised slug, REUSE its URI. -3. Slug rule: lowercase → ASCII-fold → strip stopwords (the/a/an/of/for/and/or/to/in/on/with) → hyphenate → ≤60 chars. -4. Only mint fresh if no match. Never fabricate URIs. - -**Universal annotation primitives** (for any project type): -- \`chat:topic\` (literal) — short topical buckets -- \`chat:mentions\` (URI) — entities the turn referenced -- \`chat:examines\` (URI) — entities analysed in detail -- \`chat:proposes\` (URI) — ideas/decisions/tasks put forward -- \`chat:concludes\` (URI) — Findings worth preserving -- \`chat:asks\` (URI) — open Questions - -Call \`dkg_get_ontology\` for the full agent guide + formal Turtle (one-time per session). - -${recentLines.length ? `**Recent entities in this graph** (look here first before minting):\n\n${recentLines}\n` : '_(no recent entities found — graph is fresh)_'} -`; - - return md; -} - -// ── Entry point ─────────────────────────────────────────────── -// Only run when invoked as the main module — otherwise importing this -// file from a test (or any other module) would execute the IIFE and -// hit `process.exit(0)` before the importer can do anything. The -// vitest suite in `test/capture-hook.test.ts` relies on this guard. +// Self-execute only when invoked directly (avoids running the IIFE +// when the module is imported by a test). const isMainModule = (() => { try { if (!process.argv[1]) return false; @@ -1113,89 +611,31 @@ if (isMainModule) (async () => { const payload = await readStdinJson(); const cfg = loadConfig(); log(`cfg: api=${cfg.api} project=${cfg.project} agent=${cfg.agent} token=${cfg.token ? '[set]' : '[empty]'} autoShare=${cfg.autoShare}`); - let response = {}; try { switch (EVENT) { // Cursor native events + Claude Code equivalents: - // sessionStart ≡ SessionStart (session begins/resumes) // beforeSubmitPrompt ≡ UserPromptSubmit (prompt stashed for turn) // afterAgentResponse ≡ Stop (assistant finished responding) - // sessionEnd ≡ SessionEnd (session closes) - case 'sessionStart': - case 'SessionStart': - await handleSessionStart(cfg, payload); - // Inject ontology summary + recent entities so the agent boots - // already knowing the annotation protocol + graph state. Both - // Cursor and Claude Code honour `additionalContext` in the - // sessionStart hook response (top-level field, markdown body). - try { - const sessionKey = extractSessionKey(payload); - const ctxMd = await buildSessionStartContext(cfg, sessionKey); - if (ctxMd) { - // Cursor and Claude Code disagree on the field name; emit - // all three shapes so neither tool drops the injection. - // - Cursor: `additional_context` (snake_case) per - // cursor.com/docs/agent/third-party-hooks - // - Claude Code: `hookSpecificOutput.additionalContext` - // (canonical) + top-level `additionalContext` - // fallback per docs.claude.com/en/docs/claude-code/hooks - response.additional_context = ctxMd; // Cursor - response.additionalContext = ctxMd; // Claude Code top-level - response.hookSpecificOutput = { // Claude Code canonical - hookEventName: 'SessionStart', - additionalContext: ctxMd, - }; - log(`injected session-start additionalContext (${ctxMd.length} chars)`); - } - } catch (err) { - log(`session-start context injection: ${err?.message ?? err}`); - } - break; + // + // The V9 `sessionStart` / `sessionEnd` events are retired — + // see the file-header docblock for rationale. Hook configs + // referencing them are no-ops. case 'beforeSubmitPrompt': case 'UserPromptSubmit': await handleBeforeSubmitPrompt(cfg, payload); - // Phase 7B: per-turn annotation reminder. The two tools - // disagree on the field name(!): - // - Cursor's beforeSubmitPrompt expects `additional_context` - // (snake_case), per https://cursor.com/docs/agent/third-party-hooks - // - Claude Code's UserPromptSubmit expects `additionalContext` - // inside `hookSpecificOutput`, plus accepts top-level - // `additionalContext` per docs.claude.com/en/docs/claude-code/hooks - // Emit all three shapes defensively so neither tool drops the - // injection. The markdown is prepended to the conversation's - // system context for the upcoming user message — recency- - // weighted nudge that survives across turns (session-start - // injection alone wasn't enough; the agent skipped annotation - // on early turns despite alwaysApply: true on the rule). - if (cfg.project) { - try { - const sessionKey = extractSessionKey(payload); - const reminder = buildPerTurnReminder(sessionKey); - response.additional_context = reminder; // Cursor - response.additionalContext = reminder; // Claude Code (top-level fallback) - response.hookSpecificOutput = { // Claude Code (canonical) - hookEventName: EVENT === 'UserPromptSubmit' ? 'UserPromptSubmit' : 'beforeSubmitPrompt', - additionalContext: reminder, - }; - } catch (err) { - log(`per-turn reminder injection: ${err?.message ?? err}`); - } - } break; case 'afterAgentResponse': case 'Stop': await handleAfterAgentResponse(cfg, payload); break; - case 'sessionEnd': - case 'SessionEnd': - await handleSessionEnd(cfg, payload); - break; default: - log(`unknown event: ${EVENT}`); + log(`unknown or retired event: ${EVENT}`); } } catch (err) { log(`handler error: ${err?.stack ?? err?.message ?? err}`); } - process.stdout.write(JSON.stringify(response) + '\n'); + // Emit empty `{}` per fail-open contract — no `additionalContext` + // injection in V10 (the V9 prompt-injection sub-system is retired). + process.stdout.write('{}\n'); process.exit(0); })(); diff --git a/packages/mcp-dkg/package.json b/packages/mcp-dkg/package.json index 882da1c9e..1e9389d36 100644 --- a/packages/mcp-dkg/package.json +++ b/packages/mcp-dkg/package.json @@ -1,6 +1,6 @@ { "name": "@origintrail-official/dkg-mcp", - "version": "0.1.0", + "version": "10.0.0-rc.3", "description": "MCP server that exposes the local DKG daemon (projects, sub-graphs, activity, chat) to Cursor, Claude Code, and any other MCP-aware coding assistant.", "type": "module", "main": "dist/index.js", @@ -46,6 +46,9 @@ "zod": "^3.25", "yaml": "^2.6.0" }, + "optionalDependencies": { + "@origintrail-official/dkg-adapter-autoresearch": "workspace:*" + }, "devDependencies": { "vitest": "^4.0.18" }, diff --git a/packages/mcp-server/schema/dev-paranet.ttl b/packages/mcp-dkg/schema/dev-context-graph.ttl similarity index 98% rename from packages/mcp-server/schema/dev-paranet.ttl rename to packages/mcp-dkg/schema/dev-context-graph.ttl index d2a81c287..db2e79962 100644 --- a/packages/mcp-server/schema/dev-paranet.ttl +++ b/packages/mcp-dkg/schema/dev-context-graph.ttl @@ -190,7 +190,7 @@ devgraph:Function a rdfs:Class ; devgraph:signature a rdf:Property ; rdfs:domain devgraph:Function ; rdfs:range xsd:string ; - rdfs:comment "Full type signature (e.g. '(sparql: string, paranetId?: string) => Promise')." . + rdfs:comment "Full type signature (e.g. '(sparql: string, contextGraphId?: string) => Promise')." . devgraph:definedIn a rdf:Property ; rdfs:range devgraph:CodeModule ; diff --git a/packages/mcp-dkg/scripts/smoke-annotate.mjs b/packages/mcp-dkg/scripts/smoke-annotate.mjs deleted file mode 100644 index 8a5a0001b..000000000 --- a/packages/mcp-dkg/scripts/smoke-annotate.mjs +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env node -/** - * Phase 7 stdio smoke test for dkg_get_ontology + dkg_annotate_turn. - * - * Spawns the built mcp-dkg binary, runs the JSON-RPC handshake, then: - * 1. Verifies dkg_get_ontology returns both the .ttl and the .md. - * 2. Writes a fresh chat turn directly via the daemon (so the - * "annotate the latest turn I authored" code path has something - * to resolve). - * 3. Calls dkg_annotate_turn with a representative payload covering - * every input slot (topics + mentions + examines + concludes + - * asks + proposedDecisions + proposedTasks + comments + vmPublishRequests). - * 4. Verifies the resulting triples landed in the chat sub-graph and - * gossiped to node-2. - */ -import { spawn } from 'node:child_process'; -import { once } from 'node:events'; -import path from 'node:path'; -import fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const BIN = path.resolve(__dirname, '..', 'dist', 'index.js'); -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); - -// ── 1. Seed a fresh chat turn directly via the daemon so the annotate- -// the-latest-turn code path has something to resolve. We re-use the -// seed script that we already proved works for two-machine smoke. -const sessionId = `phase7-annotate-smoke-${Date.now()}`; -console.log(`\x1b[1;36m[seed]\x1b[0m writing chat turn (session ${sessionId})...`); -const seedRes = await runOnce('node', [ - path.join(REPO_ROOT, 'scripts', 'send-test-chat-turn.mjs'), - '--api=http://localhost:9200', - '--node-id=1', - '--agent=cursor-branarakic', - `--session=${sessionId}`, - '--prompt=Phase 7 annotate-turn smoke — should we adopt tree-sitter?', - '--reply=Yes; trade-off is bundle size vs. incremental reparse.', -]); -if (seedRes.code !== 0) { - console.error('Seed failed', seedRes); - process.exit(1); -} - -// ── 2. Stand up the MCP server. -const child = spawn('node', [BIN], { - stdio: ['pipe', 'pipe', 'inherit'], - cwd: REPO_ROOT, - env: { ...process.env }, -}); -let buffer = ''; -const pending = new Map(); -let nextId = 1; -function send(method, params) { - const id = nextId++; - child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); - return new Promise((resolve, reject) => pending.set(id, { resolve, reject })); -} -function sendNotif(method, params) { - child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); -} -child.stdout.on('data', (chunk) => { - buffer += chunk.toString('utf8'); - let nl; - while ((nl = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, nl).trim(); - buffer = buffer.slice(nl + 1); - if (!line) continue; - try { - const msg = JSON.parse(line); - if (msg.id != null && pending.has(msg.id)) { - const { resolve, reject } = pending.get(msg.id); - pending.delete(msg.id); - if (msg.error) reject(new Error(msg.error.message ?? JSON.stringify(msg.error))); - else resolve(msg.result); - } - } catch { - console.error('[smoke] bad message:', line); - } - } -}); - -async function call(name, args, label) { - const r = await send('tools/call', { name, arguments: args }); - const text = r?.content?.[0]?.text ?? JSON.stringify(r); - const flag = r?.isError ? '✘' : '✔'; - console.log(`\n── ${flag} ${label ?? name} ──`); - // Truncate huge ontology dumps for readability - const display = text.length > 1500 ? text.slice(0, 1500) + `\n… (truncated, ${text.length} chars total)` : text; - console.log(display); - return r; -} - -try { - await send('initialize', { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'smoke-annotate', version: '0.0.1' }, - }); - sendNotif('notifications/initialized'); - - const tools = await send('tools/list', {}); - const toolNames = (tools?.tools ?? []).map((t) => t.name).sort(); - console.log('\x1b[1;36mRegistered tools:\x1b[0m\n ' + toolNames.join('\n ')); - for (const required of ['dkg_get_ontology', 'dkg_annotate_turn']) { - if (!toolNames.includes(required)) { - console.error(`\n✘ ${required} not registered.`); - child.kill(); - process.exit(1); - } - } - - // 3a. dkg_get_ontology - const ontoRes = await call('dkg_get_ontology', {}, 'dkg_get_ontology'); - if (ontoRes?.isError) throw new Error('Ontology fetch failed'); - const ontoText = ontoRes.content[0].text; - if (!ontoText.includes('@prefix owl:') || !ontoText.includes('look-before-mint')) { - throw new Error('Ontology response missing expected ttl/markdown content'); - } - - // 3b. dkg_annotate_turn — every slot exercised - const turnUri = `urn:dkg:chat:session:${sessionId}#turn:1`; - await call('dkg_annotate_turn', { - turnUri, - topics: ['phase 7 smoke', 'AST tooling', 'tree-sitter'], - mentions: [ - 'urn:dkg:concept:tree-sitter', - 'tree sitter', // bare label → minted as urn:dkg:concept:tree-sitter - 'urn:dkg:github:repo:OriginTrail/dkg-v9', - ], - examines: ['urn:dkg:code:package:%40origintrail-official%2Fdkg-cli'], - concludes: ['tree-sitter wins on incremental reparsing'], - asks: ['how do we measure parser memory pressure'], - proposedDecisions: [{ - title: `Phase 7 smoke decision ${Date.now()}`, - context: 'Smoke-testing dkg_annotate_turn proposedDecisions slot.', - outcome: 'If you see this in the graph attributed to cursor-branarakic, the annotate-turn write path is working.', - consequences: 'None — this is a test entity.', - }], - proposedTasks: [{ - title: `Phase 7 smoke task ${Date.now()}`, - priority: 'p2', - assignee: 'branarakic', - }], - comments: [{ about: turnUri, body: 'Smoke comment from annotate-turn — testing the comments slot.' }], - vmPublishRequests: [{ entityUri: turnUri, rationale: 'Smoke-only — should appear as a marker entity, NOT publish on-chain.' }], - }, 'dkg_annotate_turn (full payload)'); - - // 4. Verify gossip — query node-2 for the new chat:mentions edges - console.log('\n\x1b[1;36m[verify]\x1b[0m fetching annotation from node-2 via gossip in 5s...'); - await new Promise(r => setTimeout(r, 5000)); - const verifyRes = await runOnce('curl', [ - '-s', - '-H', `Authorization: Bearer ${fs.readFileSync(path.join(REPO_ROOT, '.devnet/node2/auth.token'), 'utf8').split('\n').filter(l => l && !l.startsWith('#'))[0].trim()}`, - '-X', 'POST', 'http://localhost:9202/api/query', - '-H', 'Content-Type: application/json', - '-d', JSON.stringify({ - contextGraphId: 'dkg-code-project', - subGraphName: 'chat', - includeSharedMemory: true, - sparql: `SELECT ?p ?o WHERE { GRAPH ?g { <${turnUri}> ?p ?o . FILTER(STRSTARTS(STR(?p), "http://dkg.io/ontology/chat/")) } }`, - }), - ]); - if (verifyRes.code !== 0) { - console.error('Node-2 verify failed:', verifyRes.stderr); - } else { - let parsed; - try { parsed = JSON.parse(verifyRes.stdout); } catch { parsed = { raw: verifyRes.stdout }; } - const bindings = parsed?.result?.bindings ?? []; - console.log(`\n✔ node-2 sees ${bindings.length} chat:* triples on ${turnUri} (proving cross-node gossip):`); - for (const b of bindings.slice(0, 12)) { - const p = (b.p?.value ?? b.p ?? '').replace('http://dkg.io/ontology/chat/', 'chat:'); - const o = b.o?.value ?? b.o ?? ''; - const oShort = String(o).length > 70 ? String(o).slice(0, 70) + '…' : o; - console.log(` - ${p} → ${oShort}`); - } - if (bindings.length > 12) console.log(` …and ${bindings.length - 12} more`); - } - - console.log('\n\x1b[1;36m✔ smoke done\x1b[0m'); -} catch (err) { - console.error(`\n✘ smoke failed: ${err?.stack ?? err?.message ?? err}`); - child.kill(); - process.exit(1); -} - -child.kill(); -await once(child, 'close'); - -function runOnce(cmd, args) { - return new Promise((resolve) => { - const c = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; let stderr = ''; - c.stdout.on('data', (b) => stdout += b); - c.stderr.on('data', (b) => stderr += b); - c.on('close', (code) => resolve({ code, stdout, stderr })); - }); -} diff --git a/packages/mcp-dkg/scripts/smoke-writes.mjs b/packages/mcp-dkg/scripts/smoke-writes.mjs deleted file mode 100644 index ffbdfcf35..000000000 --- a/packages/mcp-dkg/scripts/smoke-writes.mjs +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env node -/** - * Fast stdio smoke test for the dkg-mcp write tools. Spawns the built - * binary, sends a minimal JSON-RPC handshake, calls each write tool - * with a small payload, and prints the result. Non-intrusive to any - * live MCP client (it launches a *separate* process). - * - * Usage: - * node packages/mcp-dkg/scripts/smoke-writes.mjs - * - * Set DKG_CONFIG pointing at a specific config file if you want to - * target a different node/agent; otherwise the server walks up from - * this script's cwd (usually the repo root, which picks up - * .dkg/config.yaml → node1 + cursor-branarakic). - */ -import { spawn } from 'node:child_process'; -import { once } from 'node:events'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const BIN = path.resolve(__dirname, '..', 'dist', 'index.js'); - -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); -const child = spawn('node', [BIN], { - stdio: ['pipe', 'pipe', 'inherit'], - cwd: REPO_ROOT, - env: { ...process.env }, -}); - -let buffer = ''; -const pending = new Map(); -let nextId = 1; - -function send(method, params) { - const id = nextId++; - const msg = { jsonrpc: '2.0', id, method, params }; - child.stdin.write(JSON.stringify(msg) + '\n'); - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); - }); -} - -function sendNotif(method, params) { - const msg = { jsonrpc: '2.0', method, params }; - child.stdin.write(JSON.stringify(msg) + '\n'); -} - -child.stdout.on('data', (chunk) => { - buffer += chunk.toString('utf8'); - let nl; - while ((nl = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, nl).trim(); - buffer = buffer.slice(nl + 1); - if (!line) continue; - try { - const msg = JSON.parse(line); - if (msg.id != null && pending.has(msg.id)) { - const { resolve, reject } = pending.get(msg.id); - pending.delete(msg.id); - if (msg.error) reject(new Error(msg.error.message ?? JSON.stringify(msg.error))); - else resolve(msg.result); - } - } catch (err) { - console.error(`[smoke] bad message: ${line}`); - } - } -}); - -async function callTool(name, args) { - const result = await send('tools/call', { name, arguments: args }); - const text = result?.content?.[0]?.text ?? JSON.stringify(result); - const flag = result?.isError ? '✘' : '✔'; - console.log(`\n── ${flag} ${name} ──\n${text}`); - return result; -} - -const label = (s) => `\x1b[1;36m${s}\x1b[0m`; - -try { - await send('initialize', { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'smoke-writes', version: '0.0.1' }, - }); - sendNotif('notifications/initialized'); - - const tools = await send('tools/list', {}); - const toolNames = (tools?.tools ?? []).map((t) => t.name).sort(); - console.log(label('Registered tools:')); - console.log(' ' + toolNames.join('\n ')); - - const missing = ['dkg_propose_decision', 'dkg_add_task', 'dkg_comment', 'dkg_request_vm_publish', 'dkg_set_session_privacy'] - .filter((t) => !toolNames.includes(t)); - if (missing.length) { - console.error(`\n✘ write tools missing from registration: ${missing.join(', ')}`); - child.kill(); - process.exit(1); - } - - const stamp = Date.now(); - - await callTool('dkg_propose_decision', { - title: `Smoke · adopt tree-sitter for Python parsing (${stamp})`, - context: 'We need incremental AST reparsing for live editor integration.', - outcome: 'Adopt tree-sitter-python behind a Parser interface.', - consequences: 'Adds ~1.5MB to bundle; simpler reparse on edits; DSL learning curve.', - status: 'proposed', - }); - - const taskRes = await callTool('dkg_add_task', { - title: `Smoke · stub Parser interface (${stamp})`, - priority: 'p1', - status: 'todo', - estimate: 2, - assignee: 'branarakic', - }); - const taskUri = taskRes?.content?.[0]?.text?.match(/URI\*\*: `([^`]+)`/)?.[1]; - - if (taskUri) { - await callTool('dkg_comment', { - entityUri: taskUri, - body: 'Smoke comment — make sure to write a contract test alongside.', - }); - await callTool('dkg_request_vm_publish', { - entityUri: taskUri, - rationale: 'Smoke VM-publish request — would only matter if this task became a commitment. Testing the marker write path.', - }); - } - - await callTool('dkg_set_session_privacy', { - sessionUri: `urn:dkg:chat:session:smoke-${stamp}`, - privacy: 'private', - }); - - console.log('\n' + label('✔ smoke done')); -} catch (err) { - console.error(`\n✘ smoke failed: ${err?.stack ?? err?.message ?? err}`); - child.kill(); - process.exit(1); -} - -child.kill(); -await once(child, 'close'); diff --git a/packages/mcp-dkg/src/adapters.ts b/packages/mcp-dkg/src/adapters.ts new file mode 100644 index 000000000..9df81ebb5 --- /dev/null +++ b/packages/mcp-dkg/src/adapters.ts @@ -0,0 +1,77 @@ +/** + * Dynamic adapter loader for the DKG MCP server. + * + * Loads optional companion packages declared in the `DKG_ADAPTERS` env var + * (comma-separated). Each adapter is a workspace or npm package whose entry + * module exports a `registerTools` function: + * + * export function registerTools( + * server: McpServer, + * client: DkgClient, + * config: DkgConfig, + * ): void; + * + * The adapter then calls `server.registerTool(...)` for every tool it + * contributes. Failure to load any single adapter is logged to stderr and + * does not abort startup — adapters are opt-in and optional. + * + * Compared to the legacy mcp-server loader (removed in the V10 keeper + * consolidation 2026-05-04; see `pre-v10-tool-drop` tag for its original + * shape): the lazy `getClient: () => Promise` getter is + * replaced by a concrete `DkgClient`, and adapters now also receive the + * resolved `DkgConfig` so they can honour the workspace's pinned project, + * agent URI, and capture defaults without re-reading `.dkg/config.yaml`. + */ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { DkgClient } from './client.js'; +import type { DkgConfig } from './config.js'; + +export type AdapterRegisterFn = ( + server: McpServer, + client: DkgClient, + config: DkgConfig, +) => void; + +/** Short-name → package-id map for first-party adapters. */ +const ADAPTER_MAP: Record = { + autoresearch: '@origintrail-official/dkg-adapter-autoresearch', +}; + +function formatError(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +/** + * Load and register every adapter named in `DKG_ADAPTERS`. Names not in + * `ADAPTER_MAP` are treated as raw package ids so operators can plug in + * third-party adapters without code changes here. + */ +export async function loadAdapters( + server: McpServer, + client: DkgClient, + config: DkgConfig, +): Promise { + const raw = process.env.DKG_ADAPTERS ?? ''; + const names = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + for (const name of names) { + const pkg = ADAPTER_MAP[name] ?? name; + try { + const mod = (await import(pkg)) as { registerTools?: AdapterRegisterFn }; + if (typeof mod.registerTools === 'function') { + mod.registerTools(server, client, config); + process.stderr.write(`[dkg-mcp] adapter loaded: ${name}\n`); + } else { + process.stderr.write( + `[dkg-mcp] adapter ${name}: no registerTools export, skipped\n`, + ); + } + } catch (e) { + process.stderr.write( + `[dkg-mcp] adapter ${name} failed to load: ${formatError(e)}\n`, + ); + } + } +} diff --git a/packages/mcp-dkg/src/cli/index.ts b/packages/mcp-dkg/src/cli/index.ts index 5a6b8053a..967a06ceb 100644 --- a/packages/mcp-dkg/src/cli/index.ts +++ b/packages/mcp-dkg/src/cli/index.ts @@ -5,20 +5,21 @@ * dispatcher in `index.ts` routes argv[2] to either CLI subcommand * or stdio MCP server. * - * Subcommands (Phase 8 day 2): + * Operator-facing subcommands: * join [opts] Subscribe to a project + install workspace files * status Show current install + project membership state * help Print usage * - * Subcommands coming in day 3: + * Planned for follow-up cycles (not in this PR's scope): * sync Diff local install vs project's current manifest * create-project Curator-side: create CG + publish manifest * - * Distribution: - * - Today: `pnpm exec dkg-mcp join ` (after `pnpm install` + `pnpm build`) - * - Or: `node packages/mcp-dkg/dist/index.js join ` - * - Future: `npx @origintrail-official/dkg-mcp join ` once the package - * is published to npm. + * Distribution: invoked through the umbrella CLI as + * `dkg mcp serve ` (production path; see + * `packages/cli/src/cli.ts:1789-1791` for the argv synthesis). The + * `dkg-mcp` bin remains for direct invocation in monorepo dev + * checkouts (`pnpm exec dkg-mcp join `) and for any consumer + * that installs `@origintrail-official/dkg-mcp` directly. */ import { parseArgs } from 'node:util'; import path from 'node:path'; diff --git a/packages/mcp-dkg/src/client.ts b/packages/mcp-dkg/src/client.ts index bdc052d70..f00d9868c 100644 --- a/packages/mcp-dkg/src/client.ts +++ b/packages/mcp-dkg/src/client.ts @@ -155,6 +155,14 @@ export class DkgClient { view?: 'working-memory' | 'shared-working-memory' | 'verified-memory'; verifiedGraph?: string; assertionName?: string; + /** + * Required for `view: "working-memory"` reads. The daemon scopes WM + * assertion-graph URIs to the agent's raw peer ID — DID-form values + * route to a non-existent namespace and silently return empty + * results. Pass the bare peer ID (strip any `did:dkg:agent:` prefix + * at the boundary; see `dkg_memory_search` for an example). + */ + agentAddress?: string; /** * P-13: minimum trust level to admit into results. Only meaningful for * `view: "verified-memory"`; silently ignored on WM/SWM views. @@ -176,12 +184,33 @@ export class DkgClient { if (args.view != null) body.view = args.view; if (args.verifiedGraph != null) body.verifiedGraph = args.verifiedGraph; if (args.assertionName) body.assertionName = args.assertionName; + if (args.agentAddress) body.agentAddress = args.agentAddress; if (args.minTrust != null) body.minTrust = args.minTrust; const r = await this.request('POST', '/api/query', body); return r.result ?? { bindings: [] }; } + /** + * Fetch the daemon's default agent identity. Used by `dkg_memory_search` + * to resolve the agent address required for WM view routing — the + * daemon scopes WM assertion-graph URIs to the raw peer ID, so a + * memory-search call without it would silently route into a + * non-existent namespace and return zero hits. + * + * Returns `agentAddress` (DID-form, e.g. `did:dkg:agent:`) and + * `peerId` (raw form). For WM view routing pass `peerId`; for + * provenance triples (e.g. `prov:wasAttributedTo`) pass `agentAddress`. + */ + async getAgentIdentity(): Promise<{ + agentAddress?: string; + agentDid?: string; + peerId?: string; + [key: string]: unknown; + }> { + return this.request('GET', '/api/agent/identity'); + } + /** List registered agents (human + AI) + their live connection health. */ async listAgents(): Promise { const r = await this.request<{ agents?: unknown[] }>('GET', '/api/agents'); @@ -292,6 +321,322 @@ export class DkgClient { body, ); } + + /** + * Create an empty Working Memory assertion graph (idempotent — duplicate + * names land as `alreadyExists: true` rather than throwing). The + * canonical write flow is `createAssertion` → `writeAssertion` → + * `promoteAssertion` (or `discardAssertion` to roll back). + */ + async createAssertion(args: { + contextGraphId: string; + assertionName: string; + subGraphName?: string; + }): Promise<{ assertionUri: string | null; alreadyExists: boolean }> { + const body: Record = { + contextGraphId: args.contextGraphId, + name: args.assertionName, + }; + if (args.subGraphName) body.subGraphName = args.subGraphName; + try { + const response = await this.request<{ assertionUri: string }>( + 'POST', + '/api/assertion/create', + body, + ); + return { assertionUri: response.assertionUri, alreadyExists: false }; + } catch (err) { + if (err instanceof DkgHttpError && /already exists/.test(String(err.message))) { + return { assertionUri: null, alreadyExists: true }; + } + throw err; + } + } + + /** + * Dump every quad in a Working Memory assertion. Returns `{ quads, count }`. + * Not a SPARQL endpoint — for ad-hoc filtering use `query()` with + * `view: 'working-memory'` plus the assertion's named graph. + */ + async queryAssertion(args: { + contextGraphId: string; + assertionName: string; + subGraphName?: string; + }): Promise<{ quads: unknown[]; count: number }> { + const body: Record = { + contextGraphId: args.contextGraphId, + }; + if (args.subGraphName) body.subGraphName = args.subGraphName; + return this.request( + 'POST', + `/api/assertion/${encodeURIComponent(args.assertionName)}/query`, + body, + ); + } + + /** + * Lifecycle descriptor for an assertion: author, extraction status, + * promotion state, timestamps. Returns 404 (`DkgHttpError`) when no + * record exists for the (contextGraphId, name, agentAddress) tuple. + */ + async getAssertionHistory(args: { + contextGraphId: string; + assertionName: string; + agentAddress?: string; + subGraphName?: string; + }): Promise> { + const params = new URLSearchParams({ contextGraphId: args.contextGraphId }); + if (args.agentAddress) params.set('agentAddress', args.agentAddress); + if (args.subGraphName) params.set('subGraphName', args.subGraphName); + return this.request( + 'GET', + `/api/assertion/${encodeURIComponent(args.assertionName)}/history?${params.toString()}`, + ); + } + + /** + * Import a local document (markdown, PDF, etc.) into a WM assertion via + * multipart/form-data. The daemon runs its extraction pipeline and writes + * the resulting triples into the assertion's graph. text/markdown is + * native; other types need a registered converter (extraction returns + * `status: "skipped"` if none). + * + * Bypasses the JSON `request()` helper because multipart bodies need + * `FormData` rather than `JSON.stringify`. The auth header and base URL + * shape match `request()` so behaviour stays consistent. + */ + async importAssertionFile(args: { + contextGraphId: string; + assertionName: string; + fileBuffer: Buffer | Uint8Array; + fileName: string; + contentType?: string; + ontologyRef?: string; + subGraphName?: string; + }): Promise> { + const form = new FormData(); + // Copy into a fresh Uint8Array to satisfy TS's BlobPart union across + // Node Buffer / SharedArrayBuffer. + const bytes = new Uint8Array(args.fileBuffer.byteLength); + bytes.set(args.fileBuffer); + const blob = new Blob([bytes], { + type: args.contentType ?? 'application/octet-stream', + }); + form.append('file', blob, args.fileName); + form.append('contextGraphId', args.contextGraphId); + if (args.contentType) form.append('contentType', args.contentType); + if (args.ontologyRef) form.append('ontologyRef', args.ontologyRef); + if (args.subGraphName) form.append('subGraphName', args.subGraphName); + + const headers: Record = { Accept: 'application/json' }; + if (this.token) headers.Authorization = `Bearer ${this.token}`; + const res = await this.fetcher( + `${this.api}/api/assertion/${encodeURIComponent(args.assertionName)}/import-file`, + { + method: 'POST', + headers, + body: form, + }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ''); + let parsed: unknown = text; + try { parsed = JSON.parse(text); } catch { /* leave as raw text */ } + throw new DkgHttpError( + res.status, + parsed, + `POST /api/assertion/${args.assertionName}/import-file → ${res.status}: ${text}`, + ); + } + return res.json() as Promise>; + } + + /** + * Node status: peer ID, connected peers, multiaddrs, wallet addresses. + * Wraps `GET /api/status` (the same endpoint the OpenClaw adapter calls + * at `getFullStatus`). + */ + async getStatus(): Promise> { + return this.request('GET', '/api/status'); + } + + /** + * Per-wallet TRAC + ETH balances + chain context. Wraps + * `GET /api/wallets/balances` — pre-publish "do I have funds" check. + */ + async getWalletBalances(): Promise<{ + wallets: string[]; + balances: Array<{ address: string; eth: string; trac: string; symbol: string }>; + chainId: string | null; + rpcUrl: string | null; + error?: string; + }> { + return this.request('GET', '/api/wallets/balances'); + } + + /** + * Subscribe to a context graph so its data syncs locally. Required + * before querying or publishing into a remotely-authored CG. + */ + async subscribe(args: { + contextGraphId: string; + includeSharedMemory?: boolean; + }): Promise<{ + subscribed: string; + catchup?: { jobId: string; status: string; includeSharedMemory: boolean }; + }> { + return this.request('POST', '/api/subscribe', { + contextGraphId: args.contextGraphId, + includeSharedMemory: args.includeSharedMemory, + }); + } + + /** + * Create a new context graph on the DKG node. The `id` is the slug; if + * omitted at the tool layer it should be derived from `name` before + * being passed through. Wraps `POST /api/context-graph/create`. + * + * Idempotent on duplicate `id`: the daemon route returns HTTP 409 with + * an `already exists` / `duplicate` / `conflict` body when a CG with + * the same id already exists; this wrapper catches that 409 and returns + * `{ alreadyExists: true, created, uri }` so callers (e.g. + * `dkg_context_graph_create`) can surface "already existed" vs + * "newly created" without parsing error strings. Mirrors the + * `createAssertion` shape — same convention, same idempotency contract. + */ + async createContextGraph(args: { + id: string; + name: string; + description?: string; + }): Promise<{ created: string; uri: string; alreadyExists: boolean }> { + try { + const response = await this.request<{ created: string; uri: string }>( + 'POST', + '/api/context-graph/create', + { + id: args.id, + name: args.name, + description: args.description, + }, + ); + return { ...response, alreadyExists: false }; + } catch (err) { + // Daemon returns 409 with "already exists" / "duplicate" / "conflict" + // in the body when the id is taken; treat any of those as the + // idempotent already-exists case rather than a hard failure. + if ( + err instanceof DkgHttpError && + err.status === 409 && + /already exists|duplicate|conflict/i.test(String(err.message)) + ) { + return { + created: args.id, + uri: `did:dkg:context-graph:${args.id}`, + alreadyExists: true, + }; + } + throw err; + } + } + + /** + * Final canonical-flow step: publish the current contents of a context + * graph's Shared Working Memory to Verified Memory (on-chain) and + * (by default) clear SWM. The daemon route accepts `selection` as + * either the literal `"all"` or an array of root entity URIs — this + * wrapper exposes the latter as `rootEntities` and translates the + * omit-case to `"all"` server-side. + * + * Default `clearAfter` is `false` for subset publishes (so unpublished + * roots aren't dropped from SWM) and `true` for full publishes. + * Mirrors `packages/adapter-openclaw/src/dkg-client.ts:664-680`. + */ + async publishSharedMemory(args: { + contextGraphId: string; + rootEntities?: string[]; + subGraphName?: string; + clearAfter?: boolean; + }): Promise> { + const hasSubset = Array.isArray(args.rootEntities) && args.rootEntities.length > 0; + const clearAfter = args.clearAfter ?? !hasSubset; + return this.request('POST', '/api/shared-memory/publish', { + contextGraphId: args.contextGraphId, + selection: args.rootEntities ?? 'all', + clearAfter, + subGraphName: args.subGraphName, + }); + } + + /** + * Two-call publish helper: write quads into Shared Working Memory, then + * publish the entire SWM and clear it. Use for the "I have fresh quads, + * publish them now" case. For the canonical step-wise flow + * (`assertion_create + write + promote` then `shared_memory_publish`), + * use those tools directly. + * + * Mirrors `packages/adapter-openclaw/src/dkg-client.ts:635-652`. + */ + async publishQuads(args: { + contextGraphId: string; + quads: Array<{ subject: string; predicate: string; object: string; graph?: string }>; + }): Promise> { + await this.request('POST', '/api/shared-memory/write', { + contextGraphId: args.contextGraphId, + quads: args.quads, + }); + return this.request('POST', '/api/shared-memory/publish', { + contextGraphId: args.contextGraphId, + selection: 'all', + clearAfter: true, + }); + } + + /** + * Register a context graph on-chain. Used in conjunction with + * `publishSharedMemory({ ... })` when `register_if_needed: true`. + * The CG must exist locally first (via `createContextGraph`). + * + * Idempotent on already-registered: the daemon route returns HTTP 409 + * when the CG is already on-chain; this wrapper catches that 409 and + * returns `{ alreadyRegistered: true }` so callers can branch on a + * typed signal rather than parsing error message text. Mirrors the + * `createAssertion` / `createContextGraph` shape — same convention, + * same idempotency contract. + */ + async registerContextGraph(args: { + id: string; + accessPolicy?: number; + }): Promise<{ + registered: string; + onChainId?: string; + txHash?: string; + hint?: string; + alreadyRegistered: boolean; + }> { + try { + const response = await this.request<{ + registered: string; + onChainId: string; + txHash?: string; + hint?: string; + }>('POST', '/api/context-graph/register', { + id: args.id, + accessPolicy: args.accessPolicy, + }); + return { ...response, alreadyRegistered: false }; + } catch (err) { + // Daemon returns 409 with "already registered" body when the CG + // is already on-chain. Surface as a typed flag so the tool layer + // can branch on it without the locale-fragile substring match. + if (err instanceof DkgHttpError && err.status === 409) { + return { + registered: args.id, + alreadyRegistered: true, + }; + } + throw err; + } + } } /** diff --git a/packages/mcp-dkg/src/index.ts b/packages/mcp-dkg/src/index.ts index e3fa1d319..627dd854a 100644 --- a/packages/mcp-dkg/src/index.ts +++ b/packages/mcp-dkg/src/index.ts @@ -3,19 +3,26 @@ * Stdio MCP server exposing the local DKG daemon to any MCP-aware client * (Cursor, Claude Code, Continue, …). See README.md for installation. * - * Launched either directly via `dkg-mcp` (installed binary) or via - * `npx @origintrail-official/dkg-mcp`. Picks up `.dkg/config.yaml` from - * the workspace or falls back to environment variables. + * Launched either directly via `dkg-mcp` (installed binary), via + * `npx @origintrail-official/dkg-mcp`, or by the umbrella CLI's + * `dkg mcp serve` wrapper which imports `main()` and invokes it with + * a synthesised argv. `main()` reads its argv from the parameter (not + * `process.argv`) so the umbrella wrapper can pass through subcommands + * (`join`, `status`, etc.) cleanly. */ +import { fileURLToPath } from 'node:url'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { loadConfig, describeConfig } from './config.js'; import { DkgClient } from './client.js'; import { registerReadTools } from './tools.js'; -import { registerWriteTools } from './tools/writes.js'; -import { registerAnnotationTools } from './tools/annotations.js'; -import { registerReviewTools } from './tools/review.js'; +import { registerAssertionTools } from './tools/assertions.js'; +import { registerMemorySearchTool } from './tools/memory-search.js'; +import { registerSetupTools } from './tools/setup.js'; +import { registerHealthTools } from './tools/health.js'; +import { registerPublishTools } from './tools/publish.js'; import { runCli, isKnownCliSubcommand } from './cli/index.js'; +import { loadAdapters } from './adapters.js'; const VERSION = '0.1.0'; @@ -26,11 +33,17 @@ const VERSION = '0.1.0'; * delegate to the CLI dispatcher. This keeps the operator-facing * binary single (`dkg-mcp`) while still letting MCP clients spawn * the same process with no args. + * + * `argv` defaults to `process.argv` so direct-bin invocation + * (`dkg-mcp join `) keeps working. The umbrella CLI's + * `dkg mcp serve` wrapper passes a synthesised argv: + * `['node', 'dkg-mcp', ...userArgs]` so `argv[2]` lines up with the + * MCP-internal subcommand instead of the umbrella's `mcp` verb. */ -async function main(): Promise { - const sub = process.argv[2]; +export async function main(argv: string[] = process.argv): Promise { + const sub = argv[2]; if (sub && isKnownCliSubcommand(sub)) { - process.exit(await runCli(process.argv.slice(2))); + process.exit(await runCli(argv.slice(2))); } const config = loadConfig(); @@ -40,17 +53,37 @@ async function main(): Promise { const server = new McpServer({ name: 'dkg', version: VERSION }); registerReadTools(server, client, config); - registerWriteTools(server, client, config); - registerAnnotationTools(server, client, config); - registerReviewTools(server, client, config); + registerAssertionTools(server, client, config); + registerMemorySearchTool(server, client, config); + registerSetupTools(server, client, config); + registerHealthTools(server, client, config); + registerPublishTools(server, client, config); + + await loadAdapters(server, client, config); const transport = new StdioServerTransport(); await server.connect(transport); } -main().catch((err: unknown) => { - process.stderr.write( - `[dkg-mcp] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`, - ); - process.exit(1); -}); +// Self-execute only when invoked as the entrypoint script. When the +// umbrella `dkg mcp serve` wrapper imports this module to call `main()` +// directly, the module-load side effect must NOT boot a second MCP +// server — `process.argv[1]` is the umbrella `dkg` binary in that case, +// not this file. +const isDirectEntrypoint = (() => { + if (!process.argv[1]) return false; + try { + return fileURLToPath(import.meta.url) === process.argv[1]; + } catch { + return false; + } +})(); + +if (isDirectEntrypoint) { + main().catch((err: unknown) => { + process.stderr.write( + `[dkg-mcp] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`, + ); + process.exit(1); + }); +} diff --git a/packages/mcp-dkg/src/manifest/install.ts b/packages/mcp-dkg/src/manifest/install.ts index 47f3e4b90..b836195a8 100644 --- a/packages/mcp-dkg/src/manifest/install.ts +++ b/packages/mcp-dkg/src/manifest/install.ts @@ -10,8 +10,8 @@ * The installer is structured as plan → preview → write so the CLI * (and the future modal) can show the operator exactly what's about * to happen before any disk I/O. The same `planInstall()` output - * powers `buildReviewMarkdown()` which the dkg_review_manifest MCP - * tool returns to the agent for review. + * powers `buildReviewMarkdown()` which surfaces the install preview + * to the operator (CLI today, daemon route for the modal). * * No script execution. No arbitrary paths. No tokens travel through * the manifest. The only configurable destination is `` @@ -384,8 +384,8 @@ export async function writeInstall(plan: InstallPlan): Promise { /** * Build a markdown summary of an install plan suitable for showing - * to a human operator OR returning from the dkg_review_manifest MCP - * tool for an agent to assess. + * to a human operator (CLI preview today, daemon-route preview for + * the future install modal). */ export function buildReviewMarkdown( manifest: ProjectManifest, diff --git a/packages/mcp-dkg/src/sparql.ts b/packages/mcp-dkg/src/sparql.ts index 913fca919..40d9a7dbe 100644 --- a/packages/mcp-dkg/src/sparql.ts +++ b/packages/mcp-dkg/src/sparql.ts @@ -2,7 +2,7 @@ * Tiny helpers shared across tool implementations: * - SPARQL literal escaping (copy of the core helper so this package * stays dependency-free beyond the MCP SDK). - * - Common prefix map used everywhere in the v9 ontology. + * - Common prefix map used everywhere in the V10 ontology. * - Markdown renderers for SPARQL bindings. */ import type { SparqlBinding } from './client.js'; @@ -52,7 +52,15 @@ export function prettyTerm(raw: string): string { } export function escapeSparqlLiteral(s: string): string { + // Defensive: strip null bytes BEFORE other escapes. SPARQL engines + // (oxigraph et al.) reject null bytes outright today, so this is + // pure hardening — guards against a future engine swap or any + // truncate-at-null-byte parsing path that would silently change + // query semantics. Pre-other-escapes ordering means a literal + // backslash followed by null collapses to backslash only, not a + // doubled backslash + dropped null. return s + .replace(/\0/g, '') .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(/\n/g, '\\n') diff --git a/packages/mcp-dkg/src/tools.ts b/packages/mcp-dkg/src/tools.ts index 9fc58b11b..7c6fa68dc 100644 --- a/packages/mcp-dkg/src/tools.ts +++ b/packages/mcp-dkg/src/tools.ts @@ -63,21 +63,26 @@ export function registerReadTools( client: DkgClient, config: DkgConfig, ): void { - // ── dkg_list_projects ─────────────────────────────────────────── + // ── dkg_list_context_graphs ───────────────────────────────────── server.registerTool( - 'dkg_list_projects', + 'dkg_list_context_graphs', { - title: 'List DKG Projects', + title: 'List Context Graphs', + // Description opens with the audit v1.1 verbatim-locked + // reconciliation note (SKILL.md §6 user-vs-internal + // terminology); the follow-up sentence is the existing + // mcp-dkg per-row payload notes. description: - 'List every context graph (project) this DKG node knows about. ' + - 'Returns id, display name, role (curator / participant), and layer. ' + - 'The first call most agents make when joining a workspace.', + "List all context graphs the node knows about (called 'projects' " + + 'in the DKG node UI). Returns id, display name, role (curator / ' + + 'participant), and layer. The first call most agents make when ' + + 'joining a workspace.', inputSchema: {}, }, async (): Promise => { try { const rows = await client.listProjects(); - if (!rows.length) return ok('No projects found on this DKG node.'); + if (!rows.length) return ok('No context graphs found on this DKG node.'); const pinned = config.defaultProject; const table = rows .map((r) => { @@ -90,24 +95,25 @@ export function registerReadTools( }) .join('\n'); const hint = pinned - ? `\n\n★ pinned in .dkg/config.yaml — other tools default to this project.` + ? `\n\n★ pinned in .dkg/config.yaml — other tools default to this context graph.` : ''; - return ok(`Found ${rows.length} project(s):\n\n${table}${hint}`); + return ok(`Found ${rows.length} context graph(s):\n\n${table}${hint}`); } catch (e) { - return err(`Failed to list projects: ${formatError(e)}`); + return err(`Failed to list context graphs: ${formatError(e)}`); } }, ); - // ── dkg_list_subgraphs ────────────────────────────────────────── + // ── dkg_sub_graph_list ────────────────────────────────────────── server.registerTool( - 'dkg_list_subgraphs', + 'dkg_sub_graph_list', { title: 'List Sub-graphs', description: - 'List the sub-graphs inside a DKG project (e.g. code, github, ' + - 'decisions, tasks, meta, chat) with entity counts. Use to figure ' + - 'out what kind of knowledge the project exposes before querying.', + 'List the sub-graphs inside a DKG context graph (e.g. code, ' + + 'github, decisions, tasks, meta, chat) with entity counts. Use ' + + 'to figure out what kind of knowledge the context graph exposes ' + + 'before querying.', inputSchema: { projectId: z.string().optional().describe('contextGraphId; defaults to .dkg/config.yaml'), }, @@ -117,7 +123,7 @@ export function registerReadTools( if (!pid) return projectErr(); try { const rows = await client.listSubGraphs(pid); - if (!rows.length) return ok(`Project '${pid}' has no sub-graphs yet.`); + if (!rows.length) return ok(`Context graph '${pid}' has no sub-graphs yet.`); const lines = rows.map( (r) => `- **${r.name}**${r.entityCount != null ? ` · ${r.entityCount} entities` : ''}${ @@ -131,45 +137,60 @@ export function registerReadTools( }, ); - // ── dkg_sparql ────────────────────────────────────────────────── + // ── dkg_query ─────────────────────────────────────────────────── + // Replaces the legacy `dkg_sparql` registration. SKILL.md + the + // OpenClaw adapter both use `dkg_query` against `POST /api/query`. + // The two-axis schema migration (audit §7 item 5): + // - Old single `layer: 'wm' | 'swm' | 'union' | 'vm'` enum + // - New separate axes: + // view: 'working-memory' | 'shared-working-memory' | 'verified-memory' + // includeSharedMemory?: boolean (orthogonal — combines with view) + // - The legacy `'union'` mode (`view: 'working-memory'` ∪ SWM) + // was an enum-conflation of two orthogonal axes; callers + // wanting that semantics now pass + // `view: 'working-memory' + includeSharedMemory: true`. + // The daemon-side wire shape already matches this two-axis form + // (`DkgClient.query` accepts both as separate fields per + // `client.ts:133-183`); this is a public-tool-surface alignment + // only, no daemon change needed. server.registerTool( - 'dkg_sparql', + 'dkg_query', { title: 'Run SPARQL Query', description: 'Execute an arbitrary SPARQL SELECT / ASK / CONSTRUCT against a ' + - 'DKG project. Known prefixes are auto-prepended so you can just ' + - 'write `SELECT ?d WHERE { ?d a decisions:Decision }`. Scope with ' + - '`layer` — "wm" (default), "swm", "union" (wm+swm), or "vm".', + 'DKG context graph. Known prefixes are auto-prepended so you can ' + + 'just write `SELECT ?d WHERE { ?d a decisions:Decision }`. Scope ' + + 'with `view` — "working-memory" (default, private), ' + + '"shared-working-memory" (team), or "verified-memory" (on-chain). ' + + 'Set `includeSharedMemory: true` alongside `view: "working-memory"` ' + + 'to query WM ∪ SWM in one call.', inputSchema: { sparql: z.string().describe('SPARQL query body. Prefixes are auto-injected.'), projectId: z.string().optional().describe('contextGraphId; defaults to .dkg/config.yaml'), subGraphName: z.string().optional().describe('Limit the query to a single sub-graph'), - layer: z - .enum(['wm', 'swm', 'union', 'vm']) + view: z + .enum(['working-memory', 'shared-working-memory', 'verified-memory']) .optional() - .describe('Memory layer scope: wm (default, private), swm (team), union (wm+swm), vm (on-chain verified)'), + .describe('Memory tier: working-memory (default, private), shared-working-memory (team), verified-memory (on-chain).'), + includeSharedMemory: z + .boolean() + .optional() + .describe('When set with view: "working-memory", include SWM in the result set (the legacy `layer: "union"` semantics).'), limit: z.number().optional().describe('Row cap when rendering to markdown; does NOT modify the query'), }, }, - async ({ sparql, projectId, subGraphName, layer, limit }): Promise => { + async ({ sparql, projectId, subGraphName, view, includeSharedMemory, limit }): Promise => { const pid = resolveProject(projectId, config); if (!pid) return projectErr(); const fullSparql = sparql.startsWith('PREFIX') ? sparql : `${PREFIXES}\n${sparql}`; - const scope = - layer === 'swm' - ? { graphSuffix: '_shared_memory' as const } - : layer === 'union' - ? { includeSharedMemory: true } - : layer === 'vm' - ? { view: 'verified-memory' as const } - : {}; try { const result = await client.query({ sparql: fullSparql, contextGraphId: pid, subGraphName, - ...scope, + view, + includeSharedMemory, }); const all = result.bindings ?? []; const capped = typeof limit === 'number' ? all.slice(0, limit) : all; @@ -194,24 +215,40 @@ export function registerReadTools( inputSchema: { uri: z.string().describe('Entity URI (e.g. urn:dkg:decision:shacl-on-vm-promotion)'), projectId: z.string().optional().describe('contextGraphId; defaults to .dkg/config.yaml'), - layer: z - .enum(['wm', 'swm', 'union', 'vm']) + view: z + .enum(['working-memory', 'shared-working-memory', 'verified-memory']) .optional() - .default('union') - .describe('Memory layer scope; default "union" (wm+swm)'), + .describe( + 'Memory tier (explicit selection is STRICT — pick one tier only): ' + + '"working-memory" (private WM only — pair with includeSharedMemory: true to add SWM), ' + + '"shared-working-memory" (team SWM only), ' + + '"verified-memory" (on-chain VM only). ' + + 'Omit `view` to get the WM ∪ SWM default (the V9-era `layer: "union"` shape).', + ), + includeSharedMemory: z + .boolean() + .optional() + .describe('When set with view: "working-memory", include SWM in the result set (the WM∪SWM combined view).'), }, }, - async ({ uri, projectId, layer }): Promise => { + async ({ uri, projectId, view, includeSharedMemory }): Promise => { const pid = resolveProject(projectId, config); if (!pid) return projectErr(); + // Default behaviour mirrors the historical `layer: 'union'` default: + // when neither `view` nor `includeSharedMemory` is set, return WM∪SWM + // (the shape callers learned via the V9 surface). Explicit + // `view: 'verified-memory'` routes to VM; explicit + // `view: 'shared-working-memory'` routes to SWM only; + // `view: 'working-memory'` (without `includeSharedMemory: true`) + // returns WM only. const scope = - layer === 'swm' - ? { graphSuffix: '_shared_memory' as const } - : layer === 'wm' - ? {} - : layer === 'vm' + view === 'verified-memory' ? { view: 'verified-memory' as const } - : { includeSharedMemory: true }; + : view === 'shared-working-memory' + ? { graphSuffix: '_shared_memory' as const } + : view === 'working-memory' + ? (includeSharedMemory === true ? { includeSharedMemory: true } : {}) + : { includeSharedMemory: includeSharedMemory ?? true }; try { // NOTE: no explicit `GRAPH ?g { … }` wrapper here — the query // engine injects one that scopes to the requested CG. Adding our @@ -235,7 +272,13 @@ SELECT DISTINCT ?s ?p WHERE { ?s ?p <${uri}> } LIMIT 50`, const out = outgoing.bindings ?? []; const inc = incoming.bindings ?? []; if (!out.length && !inc.length) { - return ok(`No triples found for <${uri}> in '${pid}' (layer=${layer ?? 'union'}).`); + const scopeLabel = + view === 'verified-memory' ? 'verified-memory' : + view === 'shared-working-memory' ? 'shared-working-memory' : + view === 'working-memory' + ? (includeSharedMemory === true ? 'working-memory∪swm' : 'working-memory') + : 'working-memory∪swm'; + return ok(`No triples found for <${uri}> in '${pid}' (view=${scopeLabel}).`); } const parts: string[] = [`# ${prettyTerm(uri)}`, `<${uri}>`, '']; if (out.length) { @@ -264,89 +307,6 @@ SELECT DISTINCT ?s ?p WHERE { ?s ?p <${uri}> } LIMIT 50`, }, ); - // ── dkg_search ────────────────────────────────────────────────── - server.registerTool( - 'dkg_search', - { - title: 'Full-text Search', - description: - 'Keyword search across labels (rdfs:label, schema:name, dcterms:title) ' + - 'and free-text body properties (schema:description, decisions:context, ' + - 'tasks:description, schema:text). Returns URI + label + rdf:type for each hit.', - inputSchema: { - keyword: z.string().describe('Case-insensitive substring to match'), - projectId: z.string().optional().describe('contextGraphId; defaults to .dkg/config.yaml'), - types: z - .array(z.string()) - .optional() - .describe('Restrict by rdf:type URIs (prefix form OK, e.g. decisions:Decision)'), - layer: z - .enum(['wm', 'swm', 'union', 'vm']) - .optional() - .default('union'), - limit: z.number().optional().default(25), - }, - }, - async ({ keyword, projectId, types, layer, limit }): Promise => { - const pid = resolveProject(projectId, config); - if (!pid) return projectErr(); - const scope = - layer === 'swm' - ? { graphSuffix: '_shared_memory' as const } - : layer === 'wm' - ? {} - : layer === 'vm' - ? { view: 'verified-memory' as const } - : { includeSharedMemory: true }; - const kEsc = escapeSparqlLiteral(keyword); - const typeFilter = (types && types.length) - ? `FILTER(?t IN (${types.map((t) => `<${expandPrefixed(t)}>`).join(', ')}))` - : ''; - // No `GRAPH ?g` wrapper — let the engine scope the query to the - // requested CG (see dkg_get_entity for the rationale). - const sparql = `${PREFIXES} -SELECT DISTINCT ?s ?label ?t WHERE { - ?s a ?t . - OPTIONAL { - { ?s rdfs:label ?label } UNION - { ?s schema:name ?label } UNION - { ?s dcterms:title ?label } - } - OPTIONAL { - { ?s schema:description ?body } UNION - { ?s <${NS.decisions}context> ?body } UNION - { ?s <${NS.decisions}outcome> ?body } UNION - { ?s schema:text ?body } UNION - { ?s <${NS.chat}userPrompt> ?body } UNION - { ?s <${NS.chat}assistantResponse> ?body } - } - ${typeFilter} - FILTER( - CONTAINS(LCASE(STR(COALESCE(?label, ""))), LCASE("${kEsc}")) || - CONTAINS(LCASE(STR(COALESCE(?body, ""))), LCASE("${kEsc}")) - ) -} LIMIT ${Math.max(1, Math.min(limit ?? 25, 200))}`; - try { - const r = await client.query({ - sparql, - contextGraphId: pid, - ...scope, - }); - const rows = r.bindings ?? []; - if (!rows.length) return ok(`No matches for "${keyword}".`); - const lines = rows.map((b) => { - const u = prettyTerm(bindingValue(b.s)); - const label = prettyTerm(bindingValue(b.label)) || u; - const t = prettyTerm(bindingValue(b.t)); - return `- **${label}** (${t})\n \`${bindingValue(b.s)}\``; - }); - return ok(`Found ${rows.length} match(es) for "${keyword}":\n\n${lines.join('\n')}`); - } catch (e) { - return err(`Search failed: ${formatError(e)}`); - } - }, - ); - // ── dkg_list_activity ─────────────────────────────────────────── server.registerTool( 'dkg_list_activity', @@ -362,21 +322,37 @@ SELECT DISTINCT ?s ?label ?t WHERE { subGraph: z.string().optional().describe('Narrow to one sub-graph (e.g. "decisions", "chat")'), agentUri: z.string().optional().describe('Only items attributed to this agent'), sinceIso: z.string().optional().describe('Earliest timestamp, ISO-8601'), - layer: z.enum(['wm', 'swm', 'union', 'vm']).optional().default('union'), + view: z + .enum(['working-memory', 'shared-working-memory', 'verified-memory']) + .optional() + .describe( + 'Memory tier (explicit selection is STRICT — pick one tier only): ' + + '"working-memory" (private WM only — pair with includeSharedMemory: true to add SWM), ' + + '"shared-working-memory" (team SWM only), ' + + '"verified-memory" (on-chain VM only). ' + + 'Omit `view` to get the WM ∪ SWM default (the V9-era `layer: "union"` shape).', + ), + includeSharedMemory: z + .boolean() + .optional() + .describe('When set with view: "working-memory", include SWM in the result set (the WM∪SWM combined view).'), limit: z.number().optional().default(25), }, }, - async ({ projectId, subGraph, agentUri, sinceIso, layer, limit }): Promise => { + async ({ projectId, subGraph, agentUri, sinceIso, view, includeSharedMemory, limit }): Promise => { const pid = resolveProject(projectId, config); if (!pid) return projectErr(); + // Default mirrors historical `layer: 'union'`: WM∪SWM when neither + // `view` nor `includeSharedMemory` is supplied. Explicit values + // route to the requested tier (see dkg_get_entity for the parallel). const scope = - layer === 'swm' - ? { graphSuffix: '_shared_memory' as const } - : layer === 'wm' - ? {} - : layer === 'vm' + view === 'verified-memory' ? { view: 'verified-memory' as const } - : { includeSharedMemory: true }; + : view === 'shared-working-memory' + ? { graphSuffix: '_shared_memory' as const } + : view === 'working-memory' + ? (includeSharedMemory === true ? { includeSharedMemory: true } : {}) + : { includeSharedMemory: includeSharedMemory ?? true }; const typeFilterBySubgraph: Record = { decisions: `?s a <${NS.decisions}Decision> .`, @@ -542,104 +518,4 @@ SELECT ?t (COUNT(DISTINCT ?s) AS ?n) WHERE { }, ); - // ── dkg_get_chat ──────────────────────────────────────────────── - server.registerTool( - 'dkg_get_chat', - { - title: 'Get Captured Chat', - description: - 'Query the `chat` sub-graph for captured conversations between ' + - 'operators and their coding assistants. Filter by sessionUri, ' + - 'agentUri, keyword, or time range. Returns each turn with speaker, ' + - 'prompt, and response — already markdown-formatted.', - inputSchema: { - projectId: z.string().optional(), - sessionUri: z.string().optional().describe('Restrict to one session'), - agentUri: z.string().optional().describe('Only turns authored by this agent'), - keyword: z.string().optional().describe('Substring to match in prompt or response'), - sinceIso: z.string().optional(), - layer: z.enum(['wm', 'swm', 'union']).optional().default('union'), - limit: z.number().optional().default(20), - }, - }, - async ({ projectId, sessionUri, agentUri, keyword, sinceIso, layer, limit }): Promise => { - const pid = resolveProject(projectId, config); - if (!pid) return projectErr(); - const scope = - layer === 'swm' - ? { graphSuffix: '_shared_memory' as const } - : layer === 'wm' - ? {} - : { includeSharedMemory: true }; - const filters: string[] = [`?t a <${NS.chat}Turn> .`]; - if (sessionUri) filters.push(`?t <${NS.chat}inSession> <${sessionUri}> .`); - if (agentUri) filters.push(`?t prov:wasAttributedTo <${agentUri}> .`); - if (keyword) { - const k = escapeSparqlLiteral(keyword); - filters.push(`OPTIONAL { ?t <${NS.chat}userPrompt> ?userText }`); - filters.push(`OPTIONAL { ?t <${NS.chat}assistantResponse> ?asstText }`); - filters.push( - `FILTER(CONTAINS(LCASE(STR(COALESCE(?userText, ""))), LCASE("${k}")) || CONTAINS(LCASE(STR(COALESCE(?asstText, ""))), LCASE("${k}")))`, - ); - } - if (sinceIso) { - filters.push(`OPTIONAL { ?t dcterms:created ?when }`); - filters.push(`FILTER(!BOUND(?when) || ?when >= "${escapeSparqlLiteral(sinceIso)}"^^)`); - } - // No `GRAPH ?g` wrapper — the client scopes to `contextGraphId` + - // `subGraphName` only when the engine is free to inject the graph. - // An explicit `GRAPH ?g { … }` pattern would match chat turns in - // other projects' `chat` sub-graphs on the same local daemon. - const sparql = `${PREFIXES} -SELECT DISTINCT ?t ?when ?sess ?author ?userText ?asstText WHERE { - ${filters.join('\n ')} - OPTIONAL { ?t <${NS.chat}inSession> ?sess } - OPTIONAL { ?t dcterms:created ?when } - OPTIONAL { ?t prov:wasAttributedTo ?author } - OPTIONAL { ?t <${NS.chat}userPrompt> ?userText } - OPTIONAL { ?t <${NS.chat}assistantResponse> ?asstText } -} -ORDER BY DESC(?when) -LIMIT ${Math.max(1, Math.min(limit ?? 20, 100))}`; - try { - const r = await client.query({ - sparql, - contextGraphId: pid, - subGraphName: 'chat', - ...scope, - }); - const rows = r.bindings ?? []; - if (!rows.length) return ok('(no chat turns matched)'); - const lines = rows.map((b, i) => { - const when = prettyTerm(bindingValue(b.when)) || '(undated)'; - const sess = prettyTerm(bindingValue(b.sess)) || '(no session)'; - const author = prettyTerm(bindingValue(b.author)) || '(unknown)'; - const u = bindingValue(b.userText); - const a = bindingValue(b.asstText); - return [ - `### Turn ${i + 1} · \`${when}\` · ${sess} · ${author}`, - u ? `\n**User:** ${u}` : '', - a ? `\n**Assistant:** ${a}` : '', - ].join('\n'); - }); - return ok(lines.join('\n\n---\n\n')); - } catch (e) { - return err(`Chat query failed: ${formatError(e)}`); - } - }, - ); -} - -// ── Small utilities ────────────────────────────────────────────── - -/** Expand a prefixed name like "decisions:Decision" into a full URI. */ -function expandPrefixed(name: string): string { - const idx = name.indexOf(':'); - if (idx < 0) return name; - const prefix = name.slice(0, idx); - const rest = name.slice(idx + 1); - // Full IRI already - if (prefix === 'http' || prefix === 'https' || prefix === 'urn') return name; - const base = (NS as Record)[prefix]; - return base ? base + rest : name; } diff --git a/packages/mcp-dkg/src/tools/annotations.ts b/packages/mcp-dkg/src/tools/annotations.ts deleted file mode 100644 index 6285efc63..000000000 --- a/packages/mcp-dkg/src/tools/annotations.ts +++ /dev/null @@ -1,609 +0,0 @@ -/** - * Phase 7 — Agent-emitted graph annotations + project ontology. - * - * Two MCP tools: - * - * - `dkg_get_ontology` — fetches the project's ontology.ttl + agent - * guide markdown so the agent has the - * conventions in working context. - * - `dkg_annotate_turn` — batch-emits structured triples ABOUT a - * chat turn: topics, mentions, examines, - * proposes, concludes, asks, plus sugared - * writes for proposedDecisions / - * proposedTasks / comments / vmPublishRequests. - * - * `dkg_annotate_turn` is the main "annotate every substantive turn" - * surface. Its sister tool `dkg_get_ontology` keeps the agent honest - * about which predicates and URI patterns the project uses. - */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import type { DkgClient } from '../client.js'; -import type { DkgConfig } from '../config.js'; -import { NS, PREFIXES, bindingValue, escapeSparqlLiteral } from '../sparql.js'; - -type ToolResult = { - content: Array<{ type: 'text'; text: string }>; - isError?: boolean; -}; - -const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] }); -const errResult = (text: string): ToolResult => ({ - content: [{ type: 'text', text }], - isError: true, -}); - -const formatError = (e: unknown): string => - e instanceof Error ? e.message : String(e); - -function resolveProject( - explicit: string | undefined, - config: DkgConfig, -): string | null { - return explicit ?? config.defaultProject ?? null; -} - -const projectErr = (): ToolResult => - errResult( - 'No project specified. Either pass `projectId` to this tool, set `DKG_PROJECT` in the environment, or pin `contextGraph:` in `.dkg/config.yaml`.', - ); - -const agentErr = (): ToolResult => - errResult( - 'No agent URI configured. Set `agent.uri` in `.dkg/config.yaml` or export `DKG_AGENT_URI` so annotations have a prov:wasAttributedTo. Refusing to write anonymously.', - ); - -// ── RDF term helpers (mirror writes.ts) ──────────────────────────── -const U = (iri: string): string => `<${iri}>`; -const L = (v: string | number, datatype?: string): string => { - const s = typeof v === 'string' ? v : String(v); - const esc = escapeSparqlLiteral(s); - return datatype ? `"${esc}"^^<${datatype}>` : `"${esc}"`; -}; - -const TypeP = NS.rdf + 'type'; -const LabelP = NS.rdfs + 'label'; -const NameP = NS.schema + 'name'; -const TitleP = NS.dcterms + 'title'; -const CreatedP = NS.dcterms + 'created'; -const AttrP = NS.prov + 'wasAttributedTo'; - -const XSD_INT = 'http://www.w3.org/2001/XMLSchema#integer'; -const XSD_DATE = 'http://www.w3.org/2001/XMLSchema#date'; -const XSD_DATETIME = 'http://www.w3.org/2001/XMLSchema#dateTime'; - -/** - * Slug normalisation per the coding-project ontology (Section 7). - * Deterministic. The agent's look-before-mint protocol applies the - * SAME algorithm so reuse decisions are stable across agents/machines. - */ -const STOPWORDS = new Set([ - 'the', 'a', 'an', 'of', 'for', 'and', 'or', 'to', 'in', 'on', 'with', -]); - -export function normaliseSlug(input: string): string { - // 1. lowercase. 2. NFKD + strip diacritics. 3. strip stopwords. - // 4. hyphenate. 5. trim. 6. truncate to 60. - const folded = input - .toLowerCase() - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, ''); - const tokens = folded - .split(/[^a-z0-9]+/) - .filter((t) => t && !STOPWORDS.has(t)); - return tokens.join('-').slice(0, 60).replace(/^-+|-+$/g, ''); -} - -function rand(n = 6): string { - return Math.random().toString(36).slice(2, 2 + n); -} - -function emit( - sink: Array<{ subject: string; predicate: string; object: string }>, - subject: string, - predicate: string, - object: string, -): void { - sink.push({ subject, predicate, object }); -} - -/** - * Wrap a bare string as a URI if it doesn't already look like one. - * - * Returns null when the label slugifies to empty (blank input, pure - * punctuation, stopword-only) so the caller can skip the reference - * instead of persisting a malformed `urn:dkg:concept:` URI in the - * graph. See `normaliseSlug` for the slug rules. - */ -export function toUri(maybeUri: string, defaultType = 'concept'): string | null { - if ( - maybeUri.startsWith('urn:') || - maybeUri.startsWith('http:') || - maybeUri.startsWith('https:') || - maybeUri.startsWith('did:') - ) { - return maybeUri; - } - const slug = normaliseSlug(maybeUri); - if (!slug) return null; - return `urn:dkg:${defaultType}:${slug}`; -} - -/** - * Resolve the most-recent chat:Turn URI authored by `agentUri` in the - * project's `chat` sub-graph. Used when the caller omits `turnUri` — - * the common case for "annotate the turn I just produced". - */ -async function resolveLatestTurn( - client: DkgClient, - contextGraphId: string, - agentUri: string, -): Promise { - // No `GRAPH ?g` wrapper: `client.query()` only scopes to - // `contextGraphId` + `subGraphName` when the engine is free to inject - // the graph itself. With an explicit `GRAPH ?g { … }` the engine - // matches across ALL named graphs on the local daemon, so the - // fallback `dkg_annotate_turn` path could attach the pending - // annotation to the latest turn from some other project on the same - // node. Let the daemon bind the graph for us. - const sparql = `${PREFIXES} -SELECT ?t WHERE { - ?t a <${NS.chat}Turn> ; - <${NS.prov}wasAttributedTo> <${agentUri}> ; - <${NS.dcterms}created> ?ts . -} -ORDER BY DESC(?ts) LIMIT 1`; - try { - const result = await client.query({ - sparql, - contextGraphId, - subGraphName: 'chat', - includeSharedMemory: true, - }); - const row = result.bindings?.[0]; - if (!row) return null; - const cell = row.t; - const value = bindingValue(cell); - if (!value) return null; - return value.replace(/^<|>$/g, ''); - } catch { - return null; - } -} - -export function registerAnnotationTools( - server: McpServer, - client: DkgClient, - config: DkgConfig, -): void { - // ── dkg_get_ontology ───────────────────────────────────────── - server.registerTool( - 'dkg_get_ontology', - { - title: 'Get Project Ontology', - description: - 'Fetch the project ontology — both the formal Turtle/OWL document ' + - '(canonical predicates, classes, URI patterns) and the agent guide ' + - 'markdown (operational instructions for how to annotate turns). ' + - 'Call once per session; the result tells you which predicates and ' + - 'URI patterns to use in dkg_annotate_turn for THIS project.', - inputSchema: { - projectId: z.string().optional().describe('contextGraphId; defaults to .dkg/config.yaml'), - }, - }, - async ({ projectId }): Promise => { - const pid = resolveProject(projectId, config); - if (!pid) return projectErr(); - const ontologyUri = `urn:dkg:project:${pid}:ontology`; - const guideUri = `${ontologyUri}:agent-guide`; - // No `GRAPH ?g` wrapper — cross-project leak guard; see - // `resolveLatestTurn` above. - const sparql = `${PREFIXES} -SELECT ?subject ?text ?fmt WHERE { - ?subject <${NS.schema}text> ?text ; - <${NS.schema}encodingFormat> ?fmt . - FILTER(?subject = <${ontologyUri}> || ?subject = <${guideUri}>) -}`; - try { - const result = await client.query({ - sparql, - contextGraphId: pid, - subGraphName: 'meta', - includeSharedMemory: true, - }); - const rows = result.bindings ?? []; - if (!rows.length) { - return errResult( - `No ontology found for project '${pid}'. Run \`node scripts/import-ontology.mjs --project=${pid} --starter=\` to install one. Available starters: coding-project, book-research, pkm, scientific-research, narrative-writing.`, - ); - } - let ttl = ''; - let guide = ''; - for (const row of rows) { - const subject = bindingValue(row.subject as any).replace(/^<|>$/g, ''); - const text = bindingValue(row.text as any).replace(/^"|"$/g, ''); - const fmt = bindingValue(row.fmt as any).replace(/^"|"$/g, ''); - // Unescape literal escape sequences our writer encoded - const decoded = text - .replace(/\\n/g, '\n') - .replace(/\\r/g, '\r') - .replace(/\\t/g, '\t') - .replace(/\\"/g, '"') - .replace(/\\\\/g, '\\'); - if (subject === ontologyUri && fmt.includes('turtle')) ttl = decoded; - else if (subject === guideUri && fmt.includes('markdown')) guide = decoded; - } - const out = `# Project ontology for \`${pid}\` - -## Agent guide (operational instructions) - -${guide || '_(missing — re-run import-ontology.mjs)_'} - ---- - -## Ontology proper (formal Turtle/OWL) - -\`\`\`turtle -${ttl || '# (missing — re-run import-ontology.mjs)'} -\`\`\``; - return ok(out); - } catch (e) { - return errResult(`Failed to fetch ontology: ${formatError(e)}`); - } - }, - ); - - // ── dkg_annotate_turn ──────────────────────────────────────── - server.registerTool( - 'dkg_annotate_turn', - { - title: 'Annotate Chat Turn', - description: - 'Emit structured triples ABOUT the latest (or specified) chat ' + - 'turn — topics, mentions, examines, proposes, concludes, asks ' + - '— plus optional sugared writes for proposedDecisions, ' + - 'proposedTasks, comments, vmPublishRequests. Call this exactly ' + - 'once per substantive turn. The chat sub-graph then becomes a ' + - 'navigable knowledge graph rather than just a text log. Apply ' + - 'the look-before-mint protocol (call dkg_search first) before ' + - 'minting any new URI.', - inputSchema: { - turnUri: z.string().optional().describe('Full URI of the chat:Turn to annotate. Use ONLY for retroactively annotating a specific past turn. For the turn you are CURRENTLY producing, use `forSession` instead — your turn URI does not exist yet at the moment you call this tool.'), - forSession: z.string().optional().describe('Session ID of the chat you are currently in (visible in the session-start additionalContext as "your current session ID"). Pass this so the annotation lands on the turn the capture hook is ABOUT to write — race-free deferred rendezvous, no need to predict your turn URI.'), - topics: z.array(z.string()).optional().describe('chat:topic literals — short topical buckets ("Python parsing", "performance"). Emit liberally.'), - mentions: z.array(z.string()).optional().describe('chat:mentions URIs — entities the turn referenced. Apply look-before-mint first. Bare strings are wrapped as urn:dkg:concept:.'), - examines: z.array(z.string()).optional().describe('chat:examines URIs — entities the turn analysed in detail (vs just citing).'), - concludes: z.array(z.string()).optional().describe('chat:concludes URIs — Findings the turn produced. Bare strings minted as urn:dkg:finding:.'), - asks: z.array(z.string()).optional().describe('chat:asks URIs — Questions the turn left open. Bare strings minted as urn:dkg:question:.'), - proposedDecisions: z.array(z.object({ - title: z.string(), - context: z.string(), - outcome: z.string(), - consequences: z.string().optional(), - status: z.enum(['proposed', 'accepted', 'rejected', 'superseded']).optional(), - })).optional().describe('Decisions to mint and link via chat:proposes.'), - proposedTasks: z.array(z.object({ - title: z.string(), - status: z.enum(['todo', 'in_progress', 'blocked', 'done', 'cancelled']).optional(), - priority: z.enum(['p0', 'p1', 'p2', 'p3']).optional(), - assignee: z.string().optional(), - relatedDecision: z.string().optional(), - })).optional().describe('Tasks to mint and link via chat:proposes.'), - comments: z.array(z.object({ - about: z.string().describe('URI of the entity being commented on'), - body: z.string(), - })).optional().describe('Comments to file against existing entities.'), - vmPublishRequests: z.array(z.object({ - entityUri: z.string(), - rationale: z.string(), - })).optional().describe('Markers requesting human review for on-chain VM publish (the agent NEVER publishes directly).'), - projectId: z.string().optional(), - }, - }, - async (args): Promise => { - const pid = resolveProject(args.projectId, config); - if (!pid) return projectErr(); - if (!config.agentUri) return agentErr(); - - // ── Decide annotation target ────────────────────────────── - // - // Three modes, in priority order: - // - // 1. `turnUri` explicit → annotate that exact turn (back-fill mode). - // 2. `forSession` provided → deferred rendezvous: write to a - // `urn:dkg:pending-annotation:…` URI tagged for that session. - // The capture-chat hook applies it to the real turn URI when - // it next writes a turn for that session. RACE-FREE: works - // regardless of whether you call this BEFORE or AFTER the - // hook fires for your current response. - // 3. Neither → fall back to "latest turn authored by my agent" - // (legacy mode, can land on the wrong turn if the hook - // hasn't yet written your in-progress turn). - let turnUri: string; - let deferredForSession: string | null = null; - if (args.turnUri) { - turnUri = args.turnUri; - } else if (args.forSession) { - deferredForSession = args.forSession; - // Pending URI namespaced by session so the hook's lookup is cheap - // and so multiple agents working in different sessions don't - // accidentally cross-pollinate. - turnUri = `urn:dkg:pending-annotation:${args.forSession}:${rand(10)}-${Date.now()}`; - } else { - const latest = await resolveLatestTurn(client, pid, config.agentUri); - if (!latest) { - return errResult( - `No chat:Turn found for agent ${config.agentUri} in project ${pid}. ` + - 'Pass `forSession` (your current session ID) so the annotation lands on the turn currently being written, or `turnUri` for a specific past turn.', - ); - } - turnUri = latest; - } - - const triples: Array<{ subject: string; predicate: string; object: string }> = []; - const newEntityUris: string[] = []; - const nowIso = new Date().toISOString(); - - // ── Universal primitives ──────────────────────────────── - const skippedEmptyLabels: string[] = []; - for (const t of args.topics ?? []) { - emit(triples, U(turnUri), U(NS.chat + 'topic'), L(t)); - } - for (const m of args.mentions ?? []) { - const mUri = toUri(m, 'concept'); - if (!mUri) { skippedEmptyLabels.push(m); continue; } - emit(triples, U(turnUri), U(NS.chat + 'mentions'), U(mUri)); - } - for (const e of args.examines ?? []) { - const eUri = toUri(e, 'concept'); - if (!eUri) { skippedEmptyLabels.push(e); continue; } - emit(triples, U(turnUri), U(NS.chat + 'examines'), U(eUri)); - } - - // Findings — referenced via chat:concludes; minted as :Finding entities - for (const f of args.concludes ?? []) { - const fUri = toUri(f, 'finding'); - if (!fUri) { skippedEmptyLabels.push(f); continue; } - emit(triples, U(turnUri), U(NS.chat + 'concludes'), U(fUri)); - // If newly minted (i.e. caller passed a bare string), declare type + label - if (!f.startsWith('urn:') && !f.startsWith('http')) { - emit(triples, U(fUri), U(TypeP), U('http://dkg.io/ontology/coding-project/Finding')); - emit(triples, U(fUri), U(LabelP), L(f)); - emit(triples, U(fUri), U(NameP), L(f)); - emit(triples, U(fUri), U(CreatedP), L(nowIso, XSD_DATETIME)); - emit(triples, U(fUri), U(AttrP), U(config.agentUri)); - newEntityUris.push(fUri); - } - } - // Questions — referenced via chat:asks; minted as :Question entities - for (const q of args.asks ?? []) { - const qUri = toUri(q, 'question'); - if (!qUri) { skippedEmptyLabels.push(q); continue; } - emit(triples, U(turnUri), U(NS.chat + 'asks'), U(qUri)); - if (!q.startsWith('urn:') && !q.startsWith('http')) { - emit(triples, U(qUri), U(TypeP), U('http://dkg.io/ontology/coding-project/Question')); - emit(triples, U(qUri), U(LabelP), L(q)); - emit(triples, U(qUri), U(NameP), L(q)); - emit(triples, U(qUri), U(CreatedP), L(nowIso, XSD_DATETIME)); - emit(triples, U(qUri), U(AttrP), U(config.agentUri)); - newEntityUris.push(qUri); - } - } - - // ── Sugared writes ────────────────────────────────────── - for (const d of args.proposedDecisions ?? []) { - const slug = normaliseSlug(d.title); - if (!slug) { skippedEmptyLabels.push(d.title); continue; } - // NO random suffix: same-slug decisions across agents/turns - // MUST converge on the same URI so subsequent `mentions` / - // `concludes` edges land on one canonical node. The caller is - // expected to have run the look-before-mint check (dkg_search - // by title) and either reuse an existing URI or commit to this - // slug. See agent-guide §convergence-rule. - const decUri = `urn:dkg:decision:${slug}`; - const decStatus = d.status ?? 'proposed'; - emit(triples, U(decUri), U(TypeP), U(NS.decisions + 'Decision')); - emit(triples, U(decUri), U(NameP), L(d.title)); - emit(triples, U(decUri), U(LabelP), L(d.title)); - emit(triples, U(decUri), U(TitleP), L(d.title)); - emit(triples, U(decUri), U(NS.decisions + 'context'), L(d.context)); - emit(triples, U(decUri), U(NS.decisions + 'outcome'), L(d.outcome)); - if (d.consequences) emit(triples, U(decUri), U(NS.decisions + 'consequences'), L(d.consequences)); - emit(triples, U(decUri), U(NS.decisions + 'status'), L(decStatus)); - emit(triples, U(decUri), U(NS.decisions + 'date'), L(nowIso, XSD_DATETIME)); - emit(triples, U(decUri), U(CreatedP), L(nowIso, XSD_DATETIME)); - emit(triples, U(decUri), U(AttrP), U(config.agentUri)); - emit(triples, U(turnUri), U(NS.chat + 'proposes'), U(decUri)); - newEntityUris.push(decUri); - } - for (const t of args.proposedTasks ?? []) { - const slug = normaliseSlug(t.title); - if (!slug) { skippedEmptyLabels.push(t.title); continue; } - // NO random suffix — see decUri comment above. Same-slug tasks - // across agents converge on one canonical task node; that's how - // the `Open tasks:` list in hooks_context stays deduplicated. - const taskUri = `urn:dkg:task:${slug}`; - emit(triples, U(taskUri), U(TypeP), U(NS.tasks + 'Task')); - emit(triples, U(taskUri), U(NameP), L(t.title)); - emit(triples, U(taskUri), U(LabelP), L(t.title)); - emit(triples, U(taskUri), U(TitleP), L(t.title)); - emit(triples, U(taskUri), U(NS.tasks + 'status'), L(t.status ?? 'todo')); - emit(triples, U(taskUri), U(NS.tasks + 'priority'), L(t.priority ?? 'p2')); - emit(triples, U(taskUri), U(CreatedP), L(nowIso, XSD_DATETIME)); - if (t.assignee) { - const assigneeUri = t.assignee.startsWith('urn:') || t.assignee.startsWith('http') - ? t.assignee - : `urn:dkg:github:user:${encodeURIComponent(t.assignee)}`; - emit(triples, U(taskUri), U(NS.tasks + 'assignee'), U(assigneeUri)); - } - if (t.relatedDecision) { - const decUri = t.relatedDecision.startsWith('urn:') || t.relatedDecision.startsWith('http') - ? t.relatedDecision - : `urn:dkg:decision:${encodeURIComponent(t.relatedDecision)}`; - emit(triples, U(taskUri), U(NS.tasks + 'relatedDecision'), U(decUri)); - } - emit(triples, U(taskUri), U(AttrP), U(config.agentUri)); - emit(triples, U(turnUri), U(NS.chat + 'proposes'), U(taskUri)); - newEntityUris.push(taskUri); - } - for (const c of args.comments ?? []) { - const commentUri = `urn:dkg:comment:${rand(10)}-${Date.now()}`; - emit(triples, U(commentUri), U(TypeP), U(NS.schema + 'Comment')); - emit(triples, U(commentUri), U(NameP), L(c.body.slice(0, 80) + (c.body.length > 80 ? '…' : ''))); - emit(triples, U(commentUri), U(LabelP), L(`Comment on ${c.about}`)); - emit(triples, U(commentUri), U(NS.schema + 'text'), L(c.body)); - emit(triples, U(commentUri), U(NS.schema + 'about'), U(c.about)); - emit(triples, U(commentUri), U(NS.chat + 'aboutEntity'), U(c.about)); - emit(triples, U(commentUri), U(CreatedP), L(nowIso, XSD_DATETIME)); - emit(triples, U(commentUri), U(AttrP), U(config.agentUri)); - emit(triples, U(turnUri), U(NS.chat + 'mentions'), U(commentUri)); - newEntityUris.push(commentUri); - } - for (const v of args.vmPublishRequests ?? []) { - const vmUri = `urn:dkg:vm-publish-request:${rand(8)}-${Date.now()}`; - emit(triples, U(vmUri), U(TypeP), U('http://dkg.io/ontology/VmPublishRequest')); - emit(triples, U(vmUri), U(LabelP), L(`VM publish request: ${v.entityUri}`)); - emit(triples, U(vmUri), U(NameP), L('VM publish request')); - emit(triples, U(vmUri), U('http://dkg.io/ontology/requestsPublishOf'), U(v.entityUri)); - emit(triples, U(vmUri), U('http://dkg.io/ontology/rationale'), L(v.rationale)); - emit(triples, U(vmUri), U(CreatedP), L(nowIso, XSD_DATETIME)); - emit(triples, U(vmUri), U(AttrP), U(config.agentUri)); - emit(triples, U(turnUri), U(NS.chat + 'mentions'), U(vmUri)); - newEntityUris.push(vmUri); - } - - if (triples.length === 0) { - return errResult( - 'Empty annotation. Pass at least one of: topics, mentions, examines, concludes, asks, proposedDecisions, proposedTasks, comments, vmPublishRequests.', - ); - } - - // For deferred (forSession) mode, tag the pending entity so the - // capture-chat hook can find + apply it on the next turn write. - if (deferredForSession) { - emit(triples, U(turnUri), U(TypeP), U('http://dkg.io/ontology/chat/PendingAnnotation')); - emit(triples, U(turnUri), U(NS.chat + 'pendingForSession'), L(deferredForSession)); - emit(triples, U(turnUri), U(CreatedP), L(nowIso, XSD_DATETIME)); - emit(triples, U(turnUri), U(AttrP), U(config.agentUri)); - } - - // Stable assertion name keyed off BOTH the turn AND the annotating - // agent so re-annotations of the same turn by the SAME agent replace - // cleanly (deterministic — NO random suffix), but distinct agents - // annotating the same turn don't clobber each other's annotation - // graph. For pending annotations, scope by session so multiple - // in-flight pendings coexist without colliding. - // - // Codex tier-4m flagged the previous `agent-annotate-` - // naming: two agents annotating the same turn would both hit the - // same assertion name and the second write's `discardAssertion` - // call would wipe the first agent's annotation before writing its - // own. Mixing the agent's wallet/peer-id tail into the suffix gives - // per-agent-per-turn idempotency, which is the intended shape. - // - // `/api/assertion/.../write` is append-only, so we MUST discard the - // prior assertion body before rewriting. Without this, retrying the - // same annotate call (common after a network blip or a model - // correction) would double-add every `chat:*` edge and re-mint every - // sugared entity in shared memory. - const turnSuffix = turnUri.replace(/[^A-Za-z0-9]+/g, '-').slice(-40); - const agentSuffix = config.agentUri.replace(/[^A-Za-z0-9]+/g, '-').slice(-20); - const assertion = deferredForSession - ? `agent-annotate-pending-${agentSuffix}-${turnSuffix}` - : `agent-annotate-${agentSuffix}-${turnSuffix}`; - try { - await client.ensureSubGraph(pid, 'chat'); - await client.discardAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'chat', - }); - await client.writeAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'chat', - triples, - }); - // Deferred (forSession) annotations MUST NOT be promoted here: - // the capture hook is the authority on per-session privacy (it - // consults `chat:sessionPrivacy` on every turn), and it decides - // whether to promote the ALREADY-REWRITTEN annotation triples - // when it replays the pending onto the real turn URI. Promoting - // from here would leak sugared entities to shared memory for - // sessions the operator has flipped to private. - // - // Non-deferred annotations target an existing turn URI (chosen - // by the caller) and are expected to follow the daemon-wide - // autoShare setting — same policy as non-annotation writes. - let shared = false; - if (config.capture.autoShare && !deferredForSession) { - try { - await client.promoteAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'chat', - entities: [turnUri, ...newEntityUris], - }); - shared = true; - } catch (e) { - // Promote failure on annotation is non-fatal. - return ok(buildSummary(turnUri, args, newEntityUris, triples.length, false, formatError(e), deferredForSession, skippedEmptyLabels)); - } - } - return ok(buildSummary(turnUri, args, newEntityUris, triples.length, shared, undefined, deferredForSession, skippedEmptyLabels)); - } catch (e) { - return errResult(`Failed to annotate turn: ${formatError(e)}`); - } - }, - ); -} - -function buildSummary( - turnUri: string, - args: any, - newEntityUris: string[], - tripleCount: number, - shared: boolean, - promoteError?: string, - deferredForSession?: string | null, - skippedEmptyLabels: string[] = [], -): string { - const counts = { - topics: args.topics?.length ?? 0, - mentions: args.mentions?.length ?? 0, - examines: args.examines?.length ?? 0, - concludes: args.concludes?.length ?? 0, - asks: args.asks?.length ?? 0, - proposedDecisions: args.proposedDecisions?.length ?? 0, - proposedTasks: args.proposedTasks?.length ?? 0, - comments: args.comments?.length ?? 0, - vmPublishRequests: args.vmPublishRequests?.length ?? 0, - }; - const isDeferred = !!deferredForSession; - const headline = isDeferred - ? `${shared ? '✔' : promoteError ? '⚠' : '✔'} Annotation **queued** for next turn in session \`${deferredForSession}\` (URI \`${turnUri}\`)${shared ? ', auto-promoted to SWM' : promoteError ? `, WM only — promote failed: ${promoteError}` : ', WM only'}. The capture-chat hook will apply it to the actual turn URI when it writes the next chat:Turn for this session.` - : `${shared ? '✔' : promoteError ? '⚠' : '✔'} Annotated turn \`${turnUri}\`${shared ? ' (auto-promoted to SWM)' : promoteError ? ` (WM only — promote failed: ${promoteError})` : ' (WM only)'}.`; - const lines = [ - headline, - '', - `**Triples emitted:** ${tripleCount}`, - '', - '| Predicate | Count |', - '| --- | --- |', - ...Object.entries(counts) - .filter(([_, n]) => n > 0) - .map(([k, n]) => `| \`${k}\` | ${n} |`), - ]; - if (newEntityUris.length) { - lines.push('', `**${newEntityUris.length} new entit${newEntityUris.length === 1 ? 'y' : 'ies'} minted:**`); - for (const uri of newEntityUris) lines.push(`- \`${uri}\``); - } - if (skippedEmptyLabels.length) { - lines.push( - '', - `**${skippedEmptyLabels.length} label${skippedEmptyLabels.length === 1 ? '' : 's'} skipped** (would have slugified to empty; rephrase with alpha-numerics):`, - ); - for (const l of skippedEmptyLabels) lines.push(`- \`${l}\``); - } - return lines.join('\n'); -} diff --git a/packages/mcp-dkg/src/tools/assertions.ts b/packages/mcp-dkg/src/tools/assertions.ts new file mode 100644 index 000000000..cdf6bd491 --- /dev/null +++ b/packages/mcp-dkg/src/tools/assertions.ts @@ -0,0 +1,443 @@ +/** + * Raw assertion CRUD + introspection tools for the DKG MCP server. + * + * These are the P0 "memory backend" tools per parity-matrix v0.5 §4.14 + §4.16: + * five tools that expose the canonical four-step write lifecycle plus a + * dump-everything introspection helper. They are intentionally lower-level + * than the sugared `dkg_propose_decision` / `dkg_add_task` write tools — + * agents can persist arbitrary RDF without inventing per-shape sugar, and + * defer the WM→SWM promotion decision (write now, share later). + * + * Argument-key alignment per matrix v0.5 OQ-a: `name` flows through every + * tool unchanged, matching the OpenClaw adapter (`DkgNodePlugin.ts:2399+`). + * The `name` regex on `dkg_assertion_create` is creator-side input + * validation only; read-side and import paths accept any pre-existing + * assertion name. + */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { DkgClient } from '../client.js'; +import type { DkgConfig } from '../config.js'; + +type ToolResult = { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +}; + +const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] }); +const errResult = (text: string): ToolResult => ({ + content: [{ type: 'text', text }], + isError: true, +}); + +const formatError = (e: unknown): string => + e instanceof Error ? e.message : String(e); + +function resolveProject( + explicit: string | undefined, + config: DkgConfig, +): string | null { + return explicit ?? config.defaultProject ?? null; +} + +const projectErr = (): ToolResult => + errResult( + 'No project specified. Either pass `projectId` to this tool, set `DKG_PROJECT` in the environment, or pin `contextGraph:` in `.dkg/config.yaml`.', + ); + +export function registerAssertionTools( + server: McpServer, + client: DkgClient, + config: DkgConfig, +): void { + // ── dkg_assertion_create ──────────────────────────────────────── + server.registerTool( + 'dkg_assertion_create', + { + title: 'Create Assertion', + description: + 'Step 1 of the canonical write flow: create an empty Working Memory ' + + 'assertion graph. Idempotent — duplicate names land as ' + + '`alreadyExists: true` rather than throwing. Slug must match ' + + '/^[a-z0-9-]+$/ for new names; pre-existing assertions accept any name.', + inputSchema: { + name: z + .string() + .regex(/^[a-z0-9-]+$/, 'Assertion name must be lowercase a-z, 0-9, or hyphen') + .describe('Assertion name slug (e.g. "session-2026-04-30")'), + projectId: z + .string() + .optional() + .describe('contextGraphId; defaults to .dkg/config.yaml'), + subGraphName: z + .string() + .optional() + .describe('Optional sub-graph to scope the assertion to'), + }, + }, + async ({ name, projectId, subGraphName }): Promise => { + const pid = resolveProject(projectId, config); + if (!pid) return projectErr(); + try { + const result = await client.createAssertion({ + contextGraphId: pid, + assertionName: name, + subGraphName, + }); + if (result.alreadyExists) { + return ok(`Assertion '${name}' already exists in '${pid}'.`); + } + return ok( + `Created assertion '${name}' in '${pid}'.\nURI: ${result.assertionUri ?? '(unset)'}`, + ); + } catch (e) { + return errResult(`Failed to create assertion: ${formatError(e)}`); + } + }, + ); + + // ── dkg_assertion_write ───────────────────────────────────────── + server.registerTool( + 'dkg_assertion_write', + { + title: 'Write Quads to Assertion', + description: + 'Step 2 of the canonical write flow: append RDF quads into an ' + + 'existing Working Memory assertion. Writes are additive (set-merge); ' + + 'callers that want replace semantics should call `dkg_assertion_discard` ' + + 'first or mint a unique assertion name per snapshot.', + inputSchema: { + name: z.string().describe('Existing assertion name'), + quads: z + .array( + z.object({ + subject: z.string().describe('Subject URI'), + predicate: z.string().describe('Predicate URI'), + object: z + .string() + .describe('Object URI or literal value (raw, including any quoting)'), + graph: z.string().optional().describe('Optional named graph URI'), + }), + ) + .min(1) + .describe('Non-empty array of RDF quads to append'), + projectId: z.string().optional(), + subGraphName: z.string().optional(), + }, + }, + async ({ name, quads, projectId, subGraphName }): Promise => { + const pid = resolveProject(projectId, config); + if (!pid) return projectErr(); + try { + // Keep the input shape (subject/predicate/object/graph) — the daemon + // accepts the union shape used by both adapter-openclaw and mcp-dkg. + // Strip angle brackets from URIs to match the existing + // `client.writeAssertion` triples shape; the adapter does the same + // at the handler level. + const strip = (t: string): string => + t.startsWith('<') && t.endsWith('>') ? t.slice(1, -1) : t; + const triples = quads.map((q) => ({ + subject: strip(q.subject), + predicate: strip(q.predicate), + object: q.object, + })); + await client.writeAssertion({ + contextGraphId: pid, + assertionName: name, + subGraphName, + triples, + }); + return ok( + `Wrote ${triples.length} quad(s) to assertion '${name}' in '${pid}'.`, + ); + } catch (e) { + return errResult(`Failed to write assertion: ${formatError(e)}`); + } + }, + ); + + // ── dkg_assertion_promote ─────────────────────────────────────── + server.registerTool( + 'dkg_assertion_promote', + { + title: 'Promote Assertion to SWM', + description: + 'Step 3 of the canonical write flow: promote a Working Memory ' + + 'assertion (or specific root entities within it) from private WM to ' + + 'Shared Working Memory so teammates see it. Omit `entities` to ' + + 'promote every root entity.', + inputSchema: { + name: z.string().describe('Existing assertion name'), + entities: z + .array(z.string()) + .optional() + .describe( + 'Root entity URIs to promote. Omit to promote all roots.', + ), + projectId: z.string().optional(), + subGraphName: z.string().optional(), + }, + }, + async ({ name, entities, projectId, subGraphName }): Promise => { + const pid = resolveProject(projectId, config); + if (!pid) return projectErr(); + // Provided entities must be a non-empty array of URIs; omitted means + // "promote all roots" (daemon-side default). + if (entities !== undefined && entities.length === 0) { + return errResult( + '"entities" must be omitted or a non-empty array of root entity URIs.', + ); + } + try { + await client.promoteAssertion({ + contextGraphId: pid, + assertionName: name, + subGraphName, + // The tool blocks empty-array from callers (validated above) + // because the schema's intent is unambiguous: omit means + // promote-all. The empty-array IS the daemon's "promote all" + // sentinel internally — `promoteAssertion` requires an array + // shape — but we hide that wire detail from the public surface + // so the API has one canonical "promote all" form (omit). + entities: entities ?? [], + }); + const scope = entities && entities.length > 0 + ? `${entities.length} entit${entities.length === 1 ? 'y' : 'ies'}` + : 'all root entities'; + return ok(`Promoted ${scope} from assertion '${name}' (project '${pid}') to SWM.`); + } catch (e) { + return errResult(`Failed to promote assertion: ${formatError(e)}`); + } + }, + ); + + // ── dkg_assertion_discard ─────────────────────────────────────── + server.registerTool( + 'dkg_assertion_discard', + { + title: 'Discard Assertion', + description: + 'Discard a Working Memory assertion without promoting it. Idempotent — ' + + 'no-op on a missing assertion. Use before re-writing an assertion ' + + 'whose name you want to keep stable but whose contents you want to ' + + '*replace* rather than *merge*.', + inputSchema: { + name: z.string().describe('Existing assertion name'), + projectId: z.string().optional(), + subGraphName: z.string().optional(), + }, + }, + async ({ name, projectId, subGraphName }): Promise => { + const pid = resolveProject(projectId, config); + if (!pid) return projectErr(); + try { + await client.discardAssertion({ + contextGraphId: pid, + assertionName: name, + subGraphName, + }); + return ok(`Discarded assertion '${name}' from project '${pid}'.`); + } catch (e) { + return errResult(`Failed to discard assertion: ${formatError(e)}`); + } + }, + ); + + // ── dkg_assertion_query ───────────────────────────────────────── + server.registerTool( + 'dkg_assertion_query', + { + title: 'Dump Assertion Quads', + description: + 'Return every quad in a Working Memory assertion. Not a SPARQL ' + + 'endpoint — for ad-hoc filtering use `dkg_query` with ' + + '`view: "working-memory"`. The canonical introspection step for the ' + + '`assertion_create + assertion_write + assertion_promote` round-trip.', + inputSchema: { + name: z.string().describe('Existing assertion name'), + projectId: z.string().optional(), + subGraphName: z.string().optional(), + }, + }, + async ({ name, projectId, subGraphName }): Promise => { + const pid = resolveProject(projectId, config); + if (!pid) return projectErr(); + try { + const result = await client.queryAssertion({ + contextGraphId: pid, + assertionName: name, + subGraphName, + }); + const header = `Assertion '${name}' (project '${pid}'): ${result.count} quad(s).`; + if (result.count === 0) return ok(header); + // Render quads as compact JSON; keeps the wire shape obvious for + // agents that want to round-trip into a write. + const body = JSON.stringify(result.quads, null, 2); + return ok(`${header}\n\n\`\`\`json\n${body}\n\`\`\``); + } catch (e) { + return errResult(`Failed to query assertion: ${formatError(e)}`); + } + }, + ); + + // ── dkg_assertion_import_file ─────────────────────────────────── + // Wave-2 P1 add (audit §7 item 4). Wraps + // `POST /api/assertion/{name}/import-file` (multipart/form-data) — + // the daemon's extraction pipeline turns markdown / PDF / DOCX / + // etc. into RDF triples and writes them into the assertion's graph. + server.registerTool( + 'dkg_assertion_import_file', + { + title: 'Import File into Assertion', + description: + 'Import a local document (markdown, PDF, DOCX, etc.) into a ' + + 'Working Memory assertion: the daemon runs its extraction ' + + 'pipeline and writes the resulting triples. text/markdown is ' + + 'native; other types need a registered converter (extraction ' + + 'returns `status: "skipped"` if none). Useful for seeding a ' + + 'context graph from existing documents in a single step.', + inputSchema: { + name: z.string().describe('Target assertion name'), + filePath: z.string().describe('Absolute local path to the file to import'), + projectId: z.string().optional(), + contentType: z + .string() + .optional() + .describe( + 'MIME override (e.g. "text/markdown", "application/pdf"). Inferred from extension when omitted.', + ), + ontologyRef: z + .string() + .optional() + .describe('Optional ontology URI to guide extraction'), + subGraphName: z.string().optional(), + }, + }, + async ({ + name, + filePath, + projectId, + contentType, + ontologyRef, + subGraphName, + }): Promise => { + const pid = resolveProject(projectId, config); + if (!pid) return projectErr(); + const trimmedPath = filePath.trim(); + if (!trimmedPath) return errResult('"filePath" is required.'); + + // Load the file lazily — `node:fs/promises` is import-on-demand + // so the bare stdio MCP server doesn't pay the disk-I/O cost + // unless this tool actually fires. + let fileBuffer: Buffer; + let fileName: string; + try { + const { readFile } = await import('node:fs/promises'); + const { basename } = await import('node:path'); + fileBuffer = await readFile(trimmedPath); + fileName = basename(trimmedPath); + } catch (e) { + return errResult( + `Failed to read file at "${trimmedPath}": ${formatError(e)}`, + ); + } + + // Extension-based MIME inference. Mirrors the adapter's + // `inferContentTypeFromExtension` (`DkgNodePlugin.ts:3544+`) + // for cross-surface parity. Unmatched extensions fall through + // to the daemon's `application/octet-stream` default; callers + // can still override via `contentType`. + let effectiveContentType = contentType; + if (!effectiveContentType) { + const ext = fileName.split('.').pop()?.toLowerCase(); + const inferred = ext + ? { + md: 'text/markdown', + markdown: 'text/markdown', + pdf: 'application/pdf', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + html: 'text/html', + htm: 'text/html', + txt: 'text/plain', + csv: 'text/csv', + }[ext] + : undefined; + if (inferred) effectiveContentType = inferred; + } + + try { + const result = await client.importAssertionFile({ + contextGraphId: pid, + assertionName: name, + fileBuffer, + fileName, + contentType: effectiveContentType, + ontologyRef, + subGraphName, + }); + const extraction = (result as Record).extraction as + | Record + | undefined; + const status = extraction?.status ?? '(unknown)'; + const tripleCount = extraction?.tripleCount; + const lines = [ + `Imported '${fileName}' into assertion '${name}' (project '${pid}').`, + `Extraction status: ${status}` + + (typeof tripleCount === 'number' ? ` · ${tripleCount} triple(s)` : ''), + effectiveContentType ? `Content type: ${effectiveContentType}` : null, + ] + .filter((line): line is string => line !== null) + .join('\n'); + return ok(lines); + } catch (e) { + return errResult(`Failed to import file: ${formatError(e)}`); + } + }, + ); + + // ── dkg_assertion_history ─────────────────────────────────────── + // Wave-2 P3 add (audit §7 item 12). Wraps + // `GET /api/assertion/{name}/history` — lifecycle introspection. + server.registerTool( + 'dkg_assertion_history', + { + title: 'Assertion History', + description: + "Fetch an assertion's lifecycle descriptor: author, " + + 'extraction status, promotion state, timestamps. Returns a ' + + '404 (surfaced as a tool error) if no record exists for the ' + + '(contextGraphId, name, agentAddress) tuple. Useful for ' + + 'debug/audit; not required for the canonical write flow.', + inputSchema: { + name: z.string().describe('Assertion name'), + projectId: z.string().optional(), + agentAddress: z + .string() + .optional() + .describe("Optional author — defaults to this node's agent address"), + subGraphName: z.string().optional(), + }, + }, + async ({ name, projectId, agentAddress, subGraphName }): Promise => { + const pid = resolveProject(projectId, config); + if (!pid) return projectErr(); + try { + const result = await client.getAssertionHistory({ + contextGraphId: pid, + assertionName: name, + agentAddress, + subGraphName, + }); + return ok( + `History for assertion '${name}' (project '${pid}'):\n\n\`\`\`json\n${JSON.stringify( + result, + null, + 2, + )}\n\`\`\``, + ); + } catch (e) { + return errResult(`Failed to fetch assertion history: ${formatError(e)}`); + } + }, + ); +} diff --git a/packages/mcp-dkg/src/tools/health.ts b/packages/mcp-dkg/src/tools/health.ts new file mode 100644 index 000000000..e9edf511b --- /dev/null +++ b/packages/mcp-dkg/src/tools/health.ts @@ -0,0 +1,92 @@ +/** + * Node-health diagnostic tools. + * + * Wave-2 P2 adds (audit §7 items 6 + 7). Trivial wrappers over + * `GET /api/status` and `GET /api/wallets/balances` — diagnostic / + * pre-publish "do I have funds" reads with no input parameters. + * + * Mirrors the OpenClaw adapter's `dkg_status` + `dkg_wallet_balances` + * (`DkgNodePlugin.ts:1899-1914`) byte-for-byte on shape so an agent + * reading either surface sees the same diagnostic output. + */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { DkgClient } from '../client.js'; +import type { DkgConfig } from '../config.js'; + +type ToolResult = { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +}; + +const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] }); +const errResult = (text: string): ToolResult => ({ + content: [{ type: 'text', text }], + isError: true, +}); + +const formatError = (e: unknown): string => + e instanceof Error ? e.message : String(e); + +export function registerHealthTools( + server: McpServer, + client: DkgClient, + _config: DkgConfig, +): void { + // ── dkg_status ────────────────────────────────────────────────── + server.registerTool( + 'dkg_status', + { + title: 'DKG Node Status', + description: + 'Show DKG node status: peer ID, connected peers, multiaddrs, ' + + 'and wallet addresses. Call this to verify the daemon is ' + + 'running and to diagnose connectivity issues.', + inputSchema: {}, + }, + async (): Promise => { + try { + const status = await client.getStatus(); + // Render a compact JSON block — the daemon's status payload + // shape is stable and readable as JSON; no need to flatten + // into prose here. + return ok(`DKG node status:\n\n\`\`\`json\n${JSON.stringify(status, null, 2)}\n\`\`\``); + } catch (e) { + return errResult(`Failed to fetch node status: ${formatError(e)}`); + } + }, + ); + + // ── dkg_wallet_balances ───────────────────────────────────────── + server.registerTool( + 'dkg_wallet_balances', + { + title: 'Wallet Balances', + description: + 'Check TRAC and ETH token balances for the node\'s operational ' + + 'wallets. Use before publishing to verify sufficient funds. ' + + 'Returns per-wallet balances, chain id, and RPC URL.', + inputSchema: {}, + }, + async (): Promise => { + try { + const result = await client.getWalletBalances(); + if (result.error) { + return errResult(`Wallet balance probe failed: ${result.error}`); + } + const lines = result.balances.map( + (b) => `- **${b.address}** — ${b.eth} ETH · ${b.trac} ${b.symbol || 'TRAC'}`, + ); + const chain = + result.chainId !== null + ? `\n\nChain: ${result.chainId}` + (result.rpcUrl ? ` · RPC: ${result.rpcUrl}` : '') + : ''; + const body = lines.length + ? lines.join('\n') + : '_(no operational wallets found)_'; + return ok(`Wallet balances:\n\n${body}${chain}`); + } catch (e) { + return errResult(`Failed to fetch wallet balances: ${formatError(e)}`); + } + }, + ); +} diff --git a/packages/mcp-dkg/src/tools/memory-search.ts b/packages/mcp-dkg/src/tools/memory-search.ts new file mode 100644 index 000000000..d9cacf86a --- /dev/null +++ b/packages/mcp-dkg/src/tools/memory-search.ts @@ -0,0 +1,388 @@ +/** + * `dkg_memory_search` — trust-weighted, multi-tier, multi-CG-fan-out + * recall over agent-context WM/SWM/VM (and the project CG's matching + * layers when supplied). + * + * Per parity-matrix v0.7 §4.19: re-implementation of the adapter's + * `DkgMemorySearchManager` (`packages/adapter-openclaw/src/DkgMemoryPlugin.ts`). + * Reasons for the re-implementation rather than a direct re-export: + * - The adapter's manager requires a `DkgDaemonClient` (different + * surface from mcp-dkg's `DkgClient`) and a `DkgMemorySessionResolver` + * (per-conversation session state — mcp-dkg has no session concept). + * - The manager's auto-recall complexity (single-flight, query-cap, + * conversation-scoped session keying) is OpenClaw-hook-specific and + * not needed in mcp-dkg. + * - mcp-dkg's `DkgClient.query({view, agentAddress})` already supports + * the routing knobs needed for the 6-layer fan-out. + * + * IMPORTANT: trust weights and the layer-string vocabulary are duplicated + * from `packages/adapter-openclaw/src/DkgMemoryPlugin.ts:285-301` and + * `packages/adapter-openclaw/src/types.ts:217-223`. KEEP THEM IN SYNC. + * qa-engineer's verification fixture (verification-plan §2.2 Case 3) + * inverts the expected ordering so a naive ranker fails — that's the + * test-time guard. Drift this comment if those weights ever change here + * or there. The eventual deduplication path is hoisting the manager into + * a shared `packages/dkg-memory` workspace package (matrix §4.19, A2). + */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { DkgClient } from '../client.js'; +import type { DkgConfig } from '../config.js'; +import { bindingValue, escapeSparqlLiteral } from '../sparql.js'; + +type ToolResult = { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +}; + +const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] }); +const errResult = (text: string): ToolResult => ({ + content: [{ type: 'text', text }], + isError: true, +}); + +const formatError = (e: unknown): string => + e instanceof Error ? e.message : String(e); + +// ── Layer model ───────────────────────────────────────────────────── +// Source of truth: `packages/adapter-openclaw/src/types.ts:217-223`. +type MemoryLayer = + | 'agent-context-wm' + | 'agent-context-swm' + | 'agent-context-vm' + | 'project-wm' + | 'project-swm' + | 'project-vm'; + +// Source of truth: `packages/adapter-openclaw/src/DkgMemoryPlugin.ts:285-301`. +const TRUST_WEIGHT: Record = { + 'agent-context-wm': 1.0, + 'agent-context-swm': 1.15, + 'agent-context-vm': 1.3, + 'project-wm': 1.0, + 'project-swm': 1.15, + 'project-vm': 1.3, +}; + +// Trust order for cross-layer dedup: VM > SWM > WM. Tier is based on +// the view, not the context graph (an agent-context VM ties with a +// project VM). Source: `DkgMemoryPlugin.ts:391-398`. +const TRUST_ORDER: Record = { + 'agent-context-vm': 3, + 'project-vm': 3, + 'agent-context-swm': 2, + 'project-swm': 2, + 'agent-context-wm': 1, + 'project-wm': 1, +}; + +// Same canonical identifier used by the adapter so memory written via +// either surface lands in the same agent-context graph. Source: +// `packages/adapter-openclaw/src/DkgMemoryPlugin.ts:55`. +const AGENT_CONTEXT_GRAPH = 'agent-context'; + +const AGENT_DID_PREFIX = 'did:dkg:agent:'; + +/** + * The DKG V10 query engine routes WM reads by raw peer ID, NOT the DID + * form. A DID-form input gets routed to a non-existent namespace and + * silently returns empty bindings. Mirror the adapter's normalisation + * at the consumption boundary. Source: `DkgMemoryPlugin.ts:762-766`. + */ +function toAgentPeerId(agentAddress: string): string { + return agentAddress.startsWith(AGENT_DID_PREFIX) + ? agentAddress.slice(AGENT_DID_PREFIX.length) + : agentAddress; +} + +interface LayerPlan { + layer: MemoryLayer; + contextGraphId: string; + view: 'working-memory' | 'shared-working-memory' | 'verified-memory'; +} + +/** + * Per-hit shape preserves SKILL.md §6.3's combined-string `layer` + * contract (`agent-context-wm | … | project-vm`) so consumers reading + * hits from this MCP, the OpenClaw adapter, or the daemon directly see + * the same agent-facing identifier. `contextGraphId` and `trustWeight` + * are surfaced as first-class fields alongside it — the prefix + * redundancy is intentional, the combined string is the SKILL contract + * and the split fields are agent ergonomics. + * + * Synthetic `path` mirrors the adapter shape `dkg://{cg}/{layer}/{hash}` + * (`packages/adapter-openclaw/src/DkgMemoryPlugin.ts:410`) so tooling + * consuming hits from either surface can dedup or jump-to-source on + * the same identifier. + */ +interface Hit { + contextGraphId: string; + layer: MemoryLayer; + trustWeight: number; + score: number; + entityUri: string; + snippet: string; + path: string; +} + +/** + * Decompose the SKILL.md combined `layer` string into human-readable + * CG-and-tier halves for the text-mode renderer. Pure rendering + * helper — does NOT mutate the contract on `Hit.layer` (which stays + * the SKILL-canonical combined form). Returns `{ tier: 'WM' | 'SWM' + * | 'VM' }`; the CG half is the explicit `Hit.contextGraphId`. + */ +function tierFromCombinedLayer(layer: MemoryLayer): 'WM' | 'SWM' | 'VM' { + if (layer.endsWith('-vm')) return 'VM'; + if (layer.endsWith('-swm')) return 'SWM'; + return 'WM'; +} + +function computeKeywordOverlap(text: string, keywords: string[]): number { + if (keywords.length === 0) return 0.5; + const lower = text.toLowerCase(); + const hits = keywords.filter((k) => lower.includes(k)).length; + return hits / keywords.length; +} + +function truncate(text: string, maxChars: number): string { + return text.length <= maxChars ? text : text.slice(0, maxChars) + '…'; +} + +/** + * djb2-style non-cryptographic hash. Keeps text-only-fallback dedup keys + * stable and unique across full text content (not just the first 80 + * chars). Matches the adapter's `hashString` at + * `packages/adapter-openclaw/src/DkgMemoryPlugin.ts:779-785` byte-for-byte + * so a memory written via either surface dedups identically when the + * same text surfaces through multiple layers without a `?uri`. + */ +function hashString(input: string): string { + let h = 0; + for (let i = 0; i < input.length; i++) { + h = (h * 31 + input.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(16); +} + +export function registerMemorySearchTool( + server: McpServer, + client: DkgClient, + _config: DkgConfig, +): void { + server.registerTool( + 'dkg_memory_search', + { + title: 'Search DKG-backed Memory', + description: + 'Search agent-backed memory across WM/SWM/VM layers in the ' + + 'agent-context graph (and an optional project graph) with ' + + 'trust-weighted ranking. Higher-trust layers (VM > SWM > WM) ' + + 'collapse lower-trust hits for the same entity URI. Use this for ' + + '"ask my memory anything" recall — for ad-hoc SPARQL prefer ' + + '`dkg_query`.', + inputSchema: { + query: z + .string() + .min(2) + .describe('Free-text query (case-insensitive, ≥2 chars)'), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .default(20) + .describe('Maximum hits to return after ranking + dedup'), + projectId: z + .string() + .optional() + .describe( + 'Optional project context-graph id. When supplied, fan-out adds ' + + "the project's WM/SWM/VM layers to the agent-context layers.", + ), + }, + }, + async ({ query, limit, projectId }): Promise => { + const trimmed = query.trim(); + if (trimmed.length < 2) { + return errResult('"query" is required (non-empty string, ≥2 chars).'); + } + const cap = Math.floor(Math.max(1, Math.min(100, limit ?? 20))); + + // The query engine requires the agent's raw peer ID for WM view + // routing. Probe the daemon's identity once per call; without this, + // the WM layer fan-out silently returns empty bindings. + let agentAddress: string | undefined; + try { + const identity = await client.getAgentIdentity(); + // Prefer raw `peerId` (the daemon emits it directly); fall back to + // stripping the DID prefix off `agentAddress` if `peerId` is absent + // on older daemons. + const raw = identity.peerId ?? (identity.agentAddress ? toAgentPeerId(identity.agentAddress) : undefined); + if (raw && raw.length > 0) agentAddress = raw; + } catch { + // Identity probe failure is recoverable as long as some layer + // doesn't need it. We try the call anyway; if every layer 400s + // the user gets a single backend-not-ready error below. + } + if (!agentAddress) { + return errResult( + 'memory_search backend not ready: daemon agent identity is not resolvable. ' + + 'Retry shortly. If the failure persists, check that the daemon is healthy ' + + '(`dkg status`) and that the API token is valid.', + ); + } + + // Tokenise into ≥2-char keywords; the daemon's SPARQL filter strips + // shorter tokens silently, so a 1-char query would look like "no + // hits". We reject explicitly above instead. + const keywords = trimmed.toLowerCase().split(/\s+/).filter((k) => k.length >= 2); + if (keywords.length === 0) return ok(`No hits for "${trimmed}".`); + + const filterClause = keywords + .map((k) => `CONTAINS(LCASE(STR(?text)), "${escapeSparqlLiteral(k)}")`) + .join(' || '); + + // Permissive shape: any subject with any literal of length ≥20 + // matching at least one keyword. The 20-char floor strips tiny + // metadata literals (booleans, numeric enums, single-word labels). + // Source: `DkgMemoryPlugin.ts:237-243`. + const sparql = `SELECT ?uri ?pred ?text WHERE { + ?uri ?pred ?text . + FILTER(isLiteral(?text)) + FILTER(STRLEN(STR(?text)) >= 20) + FILTER(${filterClause}) +} +LIMIT ${cap}`; + + const plans: LayerPlan[] = [ + { layer: 'agent-context-wm', contextGraphId: AGENT_CONTEXT_GRAPH, view: 'working-memory' }, + { layer: 'agent-context-swm', contextGraphId: AGENT_CONTEXT_GRAPH, view: 'shared-working-memory' }, + { layer: 'agent-context-vm', contextGraphId: AGENT_CONTEXT_GRAPH, view: 'verified-memory' }, + ]; + if (projectId) { + plans.push( + { layer: 'project-wm', contextGraphId: projectId, view: 'working-memory' }, + { layer: 'project-swm', contextGraphId: projectId, view: 'shared-working-memory' }, + { layer: 'project-vm', contextGraphId: projectId, view: 'verified-memory' }, + ); + } + const searchedLayers: MemoryLayer[] = plans.map((p) => p.layer); + + // Per-layer fan-out. A single layer's failure must NOT propagate — + // surface the error to stderr (callers tail daemon logs anyway) and + // continue with the surviving layers. Mirrors the partial-success + // semantics in `DkgMemoryPlugin.ts:336-352`. + const settled = await Promise.all( + plans.map((plan) => + client + .query({ + sparql, + contextGraphId: plan.contextGraphId, + view: plan.view, + agentAddress, + }) + .then((r) => ({ plan, bindings: r.bindings ?? [] })) + .catch((err) => { + process.stderr.write( + `[dkg-mcp] memory-search ${plan.layer} failed (cg=${plan.contextGraphId}, view=${plan.view}): ${formatError(err)}\n`, + ); + return { plan, bindings: [] as Array> }; + }), + ), + ); + + // Dedup by (contextGraphId, uri-or-text-hash). Keep the highest- + // trust hit; tie-break on raw score. Source: `DkgMemoryPlugin.ts:381-433`. + interface RankedHit extends Hit { + rank: number; + } + const best = new Map(); + for (const { plan, bindings } of settled) { + for (const binding of bindings) { + // SparqlBinding fields can arrive as `{ value }` objects or flat + // strings; `bindingValue` normalises both shapes (already in + // `packages/mcp-dkg/src/sparql.ts`). + const text = bindingValue((binding as Record).text as never); + const uri = bindingValue((binding as Record).uri as never); + if (!text) continue; + const rawScore = computeKeywordOverlap(text, keywords); + if (rawScore <= 0) continue; + const weight = TRUST_WEIGHT[plan.layer]; + const weighted = rawScore * weight; + const key = `${plan.contextGraphId}::${uri || hashString(text)}`; + // Synthetic `path` mirrors the adapter shape + // `dkg://${cg}/${layer}/${hash}` (`DkgMemoryPlugin.ts:410`) + // so tooling that consumes either surface can dedup or + // jump-to-source on the same identifier. + const path = `dkg://${plan.contextGraphId}/${plan.layer}/${hashString(uri || text)}`; + const candidate: RankedHit = { + contextGraphId: plan.contextGraphId, + layer: plan.layer, + trustWeight: weight, + score: rawScore, + entityUri: uri, + snippet: truncate(text, 500), + path, + rank: weighted, + }; + const existing = best.get(key); + if (!existing) { + best.set(key, candidate); + continue; + } + const existingTrust = TRUST_ORDER[existing.layer]; + const candidateTrust = TRUST_ORDER[candidate.layer]; + if ( + candidateTrust > existingTrust || + (candidateTrust === existingTrust && candidate.score > existing.score) + ) { + best.set(key, candidate); + } + } + } + + const ranked = Array.from(best.values()).sort((a, b) => b.rank - a.rank); + const top: Hit[] = ranked.slice(0, cap).map(({ rank: _rank, ...rest }) => rest); + + const totalRaw = settled.reduce((n, s) => n + s.bindings.length, 0); + const breakdown = settled.map((s) => `${s.plan.layer}:${s.bindings.length}`).join(', '); + + // Info-level observability log per `dkg_memory_search` invocation. + // Mirrors `packages/adapter-openclaw/src/DkgMemoryPlugin.ts:370-379` — + // during the 2026-04-15 live validation this line was the + // difference between "slot never called" and "slot called but no + // hits". Counts and metadata only — the user query is omitted + // because it can carry secrets/PII (the adapter logs it at debug + // level only; mcp-dkg has no log-level surface, so we drop it). + process.stderr.write( + `[dkg-mcp] memory-search fired ` + + `(limit=${cap}): project=${projectId ?? '∅'}, ` + + `layers=${plans.length}, raw_hits=${totalRaw} (${breakdown})\n`, + ); + const header = + `Memory search "${trimmed}" — ${top.length} hit(s) (${totalRaw} raw across ${plans.length} layers).\n` + + `searchedLayers: ${searchedLayers.join(', ')}\n` + + `breakdown: ${breakdown}`; + + if (top.length === 0) return ok(header); + + // Per-hit text rendering surfaces provenance up-front: + // [agent-context · VM · weight=1.30 · score=0.87] + // The combined `Hit.layer` (SKILL §6.3 contract) decomposes into + // the explicit `Hit.contextGraphId` (rendered as-is, lower-case + // canonical) plus the trust tier (rendered upper-case via + // `tierFromCombinedLayer`). Numeric weight + score show two + // decimal places — enough precision to spot the trust-tiering + // effect without flooding the line. + const lines = top.map((h, i) => { + const tier = tierFromCombinedLayer(h.layer); + const provenance = `${h.contextGraphId} · ${tier} · weight=${h.trustWeight.toFixed(2)} · score=${h.score.toFixed(2)}`; + const uriLine = h.entityUri ? `\`${h.entityUri}\`\n` : ''; + return `### ${i + 1}. [${provenance}]\n${uriLine}${h.snippet}`; + }); + return ok(`${header}\n\n${lines.join('\n\n')}`); + }, + ); +} diff --git a/packages/mcp-dkg/src/tools/publish.ts b/packages/mcp-dkg/src/tools/publish.ts new file mode 100644 index 000000000..526e3b617 --- /dev/null +++ b/packages/mcp-dkg/src/tools/publish.ts @@ -0,0 +1,311 @@ +/** + * Publish tools — Shared Working Memory writes / on-chain finalization. + * + * Wave-2 P1 adds (audit §7 items 2 + 3). Two distinct surfaces, both + * documented in SKILL.md §4a: + * + * - `dkg_publish` — "I have fresh quads, publish them now" one-shot. + * Two-call helper: writes the quads to SWM, then publishes the + * entire SWM to Verified Memory and clears SWM. + * + * - `dkg_shared_memory_publish` — canonical Step 5 finalizer for the + * stepwise flow (`assertion_create + write + promote` then this). + * UNGATED per matrix v0.6 / user lock 2026-04-30 — no + * `agent.canPublishToVm` flag; matches the OpenClaw adapter shape + * exactly. + * + * Both call the same daemon endpoints + * (`POST /api/shared-memory/{write,publish}`); the difference is in + * the input shape — `dkg_publish` accepts fresh quads, while + * `dkg_shared_memory_publish` consumes existing SWM (filterable by + * `rootEntities`) and clears as a side-effect. + */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { DkgClient } from '../client.js'; +import type { DkgConfig } from '../config.js'; + +type ToolResult = { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +}; + +const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] }); +const errResult = (text: string): ToolResult => ({ + content: [{ type: 'text', text }], + isError: true, +}); + +const formatError = (e: unknown): string => + e instanceof Error ? e.message : String(e); + +/** + * F3+F13: resolve the daemon's configured chainId for the success + * summary. The daemon's `/api/shared-memory/publish` response does + * not include `chainId` in the JSON body (it's threaded through the + * tracker only — see `packages/cli/src/daemon/routes/memory.ts:483-488`), + * so we read it from `/api/wallets/balances` which already exposes it + * as a first-class field. Returns `null` when the wallet-balances + * probe fails — non-fatal, the publish itself already succeeded. + * + * Why expose chainId at all: lets the caller verify which chain the + * publish landed on without a separate roundtrip. F3 was originally + * "warn loudly before publish to mainnet"; the user explicitly opted + * for echo-only (no warning prose) so callers self-verify post-hoc + * instead. + */ +async function resolveChainId(client: DkgClient): Promise { + try { + const balances = await client.getWalletBalances(); + return balances.chainId ?? null; + } catch { + return null; + } +} + +/** + * URI auto-detection for object terms — matches the adapter's `isUri` + * at `DkgNodePlugin.ts:3468-3470`. Anything starting with http://, + * https://, urn:, or did: is treated as a URI; anything else gets + * wrapped as a literal at the wire boundary. + */ +function isUri(value: string): boolean { + return /^(?:https?:\/\/|urn:|did:)/i.test(value); +} + +/** + * Escape literal-text inside an RDF object term. Mirrors the adapter's + * literal-handling in `handlePublish` so SWM writes from either surface + * produce identical triples. + */ +function escapeRdfLiteral(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + +const QuadSchema = z.object({ + subject: z.string().min(1).describe('Subject URI'), + predicate: z.string().min(1).describe('Predicate URI'), + object: z + .string() + .describe( + 'Object — URI or literal. Auto-detected: values starting with http://, https://, urn:, or did: pass as URIs; anything else becomes a literal.', + ), + graph: z.string().optional().describe('Optional named graph URI'), +}); + +export function registerPublishTools( + server: McpServer, + client: DkgClient, + _config: DkgConfig, +): void { + // ── dkg_publish ───────────────────────────────────────────────── + // Description quotes SKILL.md §4a line 182's `dkg_publish` vs + // `dkg_shared_memory_publish` disambiguation verbatim per audit + // v1.1 lock — agents need to pick the right tool without re- + // reading SKILL.md. + server.registerTool( + 'dkg_publish', + { + title: 'Publish Fresh Quads', + description: + '"I have fresh quads, write+publish now." Two-call helper: ' + + 'writes the supplied quads to Shared Working Memory, then ' + + 'publishes the entire SWM in the CG to Verified Memory ' + + '(on-chain) and clears SWM. For the canonical step-wise flow ' + + '(write → promote → publish) use `dkg_assertion_create / write ' + + '/ promote` followed by `dkg_shared_memory_publish` — that ' + + 'path keeps WM as a draft staging area before SWM. Use ' + + '`dkg_publish` only when you have fresh quads to anchor ' + + 'immediately.', + inputSchema: { + contextGraphId: z.string().min(1).describe('Target context graph id'), + quads: z + .array(QuadSchema) + .min(1) + .describe( + 'Non-empty array of quads to publish. Object values are auto-typed (URI vs literal).', + ), + }, + }, + async ({ contextGraphId, quads }): Promise => { + const cgId = contextGraphId.trim(); + if (!cgId) return errResult('"contextGraphId" is required.'); + if (!quads.length) { + return errResult('"quads" must be a non-empty array.'); + } + // Auto-type the object: URI vs literal. Mirrors the adapter's + // handlePublish at `DkgNodePlugin.ts:2721-2729` byte-for-byte so + // a memory written via either surface lands as identical triples. + const wireQuads = quads.map((q) => { + const objVal = String(q.object ?? ''); + return { + subject: String(q.subject ?? ''), + predicate: String(q.predicate ?? ''), + object: isUri(objVal) ? objVal : `"${escapeRdfLiteral(objVal)}"`, + graph: q.graph ? String(q.graph) : '', + }; + }); + try { + const result = await client.publishQuads({ + contextGraphId: cgId, + quads: wireQuads, + }); + const kcId = (result as Record).kcId as string | undefined; + const kas = (result as Record).kas as + | Array<{ tokenId: string; rootEntity: string }> + | undefined; + const txHash = (result as Record).txHash as string | undefined; + // F3+F13: echo the configured chainId so callers can verify + // which chain the publish landed on without a separate + // wallet-balances roundtrip. Fetched after the publish + // succeeds; if the wallet-balances probe itself fails the + // publish stands and we just omit the chain line. + const chainId = await resolveChainId(client); + const summary = [ + `Published ${wireQuads.length} quad(s) to '${cgId}'.`, + kcId ? `KC: ${kcId}` : null, + kas?.length ? `KAs: ${kas.length}` : null, + txHash ? `Tx: ${txHash}` : null, + chainId ? `Chain: ${chainId}` : null, + ] + .filter((line): line is string => line !== null) + .join('\n'); + return ok(summary); + } catch (e) { + return errResult(`Publish failed: ${formatError(e)}`); + } + }, + ); + + // ── dkg_shared_memory_publish ─────────────────────────────────── + // Description quotes SKILL.md §4a line 182's `dkg_publish` vs + // `dkg_shared_memory_publish` disambiguation verbatim per audit + // v1.1 lock. + server.registerTool( + 'dkg_shared_memory_publish', + { + title: 'Publish Shared Working Memory', + description: + 'Canonical step-4 finalizer for "publish existing SWM" (one HTTP ' + + 'call). Publishes all Shared Working Memory in a context graph to ' + + 'Verified Memory (on-chain) and clears SWM. Use after ' + + '`dkg_assertion_promote` to finalize promoted data. Pass ' + + '`rootEntities` to publish only specific roots (subset publishes ' + + 'default to NOT clearing SWM, so other unpublished roots are not ' + + 'dropped). Set `registerIfNeeded: true` to upgrade a local-only CG ' + + 'to on-chain registration before publishing — note this MAY spend ' + + 'gas/TRAC; opt-in only.', + inputSchema: { + contextGraphId: z.string().min(1).describe('Target context graph id'), + rootEntities: z + .array(z.string()) + .optional() + .describe( + 'Optional filter — publish only these root entity URIs. Omit to publish all SWM in the CG.', + ), + subGraphName: z + .string() + .optional() + .describe( + 'Optional sub-graph scope. Must match the sub-graph used during create/write/promote.', + ), + registerIfNeeded: z + .boolean() + .optional() + .describe( + 'When true, register the CG on-chain before publishing if needed. May spend gas/TRAC; opt-in only.', + ), + accessPolicy: z + .union([z.literal(0), z.literal(1)]) + .optional() + .describe( + 'Used only when `registerIfNeeded: true`. 0 = open, 1 = private.', + ), + }, + }, + async ({ + contextGraphId, + rootEntities, + subGraphName, + registerIfNeeded, + accessPolicy, + }): Promise => { + const cgId = contextGraphId.trim(); + if (!cgId) return errResult('"contextGraphId" is required.'); + // Mirror handleAssertionPromote's `entities` validation: omit → + // daemon-side default (selection="all"); non-empty array of + // strings only — no other shapes silently 400 at the daemon. + let roots: string[] | undefined; + if (rootEntities !== undefined) { + if (!Array.isArray(rootEntities) || rootEntities.length === 0) { + return errResult( + '"rootEntities" must be omitted or a non-empty array of root entity URIs.', + ); + } + roots = rootEntities; + } + + // Optional on-chain registration before publish. Tolerates the + // already-registered case (just publishes); other failures + // propagate as tool errors. F12: branch on the typed + // `alreadyRegistered: true` flag the client now surfaces from + // the daemon's 409 — replaces the locale-fragile + // `message.includes('already registered')` substring match. + let registration: Record | undefined; + if (registerIfNeeded === true) { + try { + const result = await client.registerContextGraph({ + id: cgId, + accessPolicy, + }); + // Capture the registration record (and on-chain id when + // newly-registered) for the success summary; if it was + // already registered, leave `registration` undefined so + // the summary doesn't claim we just registered something. + if (!result.alreadyRegistered) { + registration = result; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return errResult(`Failed to register context graph: ${message}`); + } + } + + try { + const result = await client.publishSharedMemory({ + contextGraphId: cgId, + rootEntities: roots, + subGraphName, + }); + const kcId = result.kcId as string | undefined; + const kas = result.kas as Array<{ tokenId: string; rootEntity: string }> | undefined; + const txHash = result.txHash as string | undefined; + // F3+F13: see `resolveChainId` JSDoc — chainId is echoed for + // post-hoc caller verification. accessPolicy is also echoed + // when the registration step ran (registerIfNeeded path) so + // the caller can verify the daemon committed the value they + // requested. Both are read-only echoes; no warning prose. + const chainId = await resolveChainId(client); + const summary = [ + `Published ${cgId}'s SWM to Verified Memory.`, + roots ? `Roots: ${roots.length}` : 'Selection: all', + kcId ? `KC: ${kcId}` : null, + kas?.length ? `KAs: ${kas.length}` : null, + txHash ? `Tx: ${txHash}` : null, + chainId ? `Chain: ${chainId}` : null, + registration ? `Registered on-chain: ${registration.onChainId ?? '(unknown)'}${accessPolicy != null ? ` (accessPolicy=${accessPolicy})` : ''}` : null, + ] + .filter((line): line is string => line !== null) + .join('\n'); + return ok(summary); + } catch (e) { + return errResult(`Publish failed: ${formatError(e)}`); + } + }, + ); +} diff --git a/packages/mcp-dkg/src/tools/review.ts b/packages/mcp-dkg/src/tools/review.ts deleted file mode 100644 index 960e84d7e..000000000 --- a/packages/mcp-dkg/src/tools/review.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Phase 8 — review tool. The agent-side counterpart to the CLI's - * interactive preview. - * - * `dkg_review_manifest` fetches a project's `dkg:ProjectManifest` - * and returns a structured markdown summary so the agent (or a - * separate trust-eval agent) can assess what an installer would - * write before the operator confirms. Read-only — does not write - * anything to disk OR to the graph. - * - * Pairs with the CLI `dkg-mcp join` flow: when the modal/CLI is - * about to install a manifest, it can either show the review to the - * human directly, OR ask the agent to assess via this tool first. - */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import type { DkgClient } from '../client.js'; -import type { DkgConfig } from '../config.js'; -import { fetchManifest } from '../manifest/fetch.js'; -import { planInstall, buildReviewMarkdown } from '../manifest/install.js'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// ESM doesn't have __dirname; compute it ourselves so the review can -// reference the right paths to the dist + hook script. -const HERE = path.dirname(fileURLToPath(import.meta.url)); - -type ToolResult = { - content: Array<{ type: 'text'; text: string }>; - isError?: boolean; -}; - -const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] }); -const errResult = (text: string): ToolResult => ({ - content: [{ type: 'text', text }], - isError: true, -}); - -function resolveProject( - explicit: string | undefined, - config: DkgConfig, -): string | null { - return explicit ?? config.defaultProject ?? null; -} - -const projectErr = (): ToolResult => - errResult( - 'No project specified. Either pass `projectId` to this tool, set `DKG_PROJECT` in the environment, or pin `contextGraph:` in `.dkg/config.yaml`.', - ); - -export function registerReviewTools( - server: McpServer, - client: DkgClient, - config: DkgConfig, -): void { - server.registerTool( - 'dkg_review_manifest', - { - title: 'Review Project Manifest', - description: - 'Fetch a project\'s `dkg:ProjectManifest` and return a structured ' + - 'review of what installing it would do. Read-only — does NOT write ' + - 'files. Use this to assess a manifest before invoking the installer ' + - '(via the `dkg-mcp join` CLI or the JoinProjectModal). The review ' + - 'covers: which files would be created/overwritten/merged where, ' + - 'placeholder substitutions, dropped paths (security warnings), and ' + - 'the curator\'s attribution. Pair with `dkg_get_ontology` to also ' + - 'review the project\'s annotation conventions.', - inputSchema: { - projectId: z.string().optional().describe('contextGraphId; defaults to .dkg/config.yaml'), - agentSlug: z.string().optional().describe('What the operator would install AS — drives `urn:dkg:agent:` URIs in the substitutions. Defaults to the agent slug from config.'), - workspaceAbsPath: z.string().optional().describe('Where the manifest would install — defaults to current working directory of the MCP server.'), - daemonApiUrl: z.string().optional().describe('Local daemon URL the new agent would talk to. Defaults to the URL the MCP server itself uses.'), - }, - }, - async ({ projectId, agentSlug, workspaceAbsPath, daemonApiUrl }): Promise => { - const pid = resolveProject(projectId, config); - if (!pid) return projectErr(); - - try { - const manifest = await fetchManifest({ client, contextGraphId: pid }); - - // Reasonable preview defaults — operator can override at install time. - const slug = agentSlug - ?? config.agentUri?.replace(/^urn:dkg:agent:/, '') - ?? 'preview-agent'; - const ws = workspaceAbsPath ?? process.cwd(); - const apiUrl = daemonApiUrl ?? config.api; - - const plan = planInstall({ - manifest, - workspaceAbsPath: ws, - agentSlug: slug, - daemonApiUrl: apiUrl, - daemonTokenFile: '../.devnet/node1/auth.token', - mcpDkgDistAbsPath: path.resolve(HERE, '..', 'index.js'), - mcpDkgPackageDir: path.resolve(HERE, '..', '..'), - mcpDkgSrcAbsPath: path.resolve(HERE, '..', '..', 'src', 'index.ts'), - captureScriptPath: path.resolve(HERE, '..', '..', 'hooks', 'capture-chat.mjs'), - homedir: os.homedir(), - }); - - const md = buildReviewMarkdown(manifest, plan); - return ok(md + '\n\n---\n\n_This is a preview only. Run the install with `dkg-mcp join ` (CLI) or via JoinProjectModal (UI). The operator confirms before any file is written._'); - } catch (e) { - return errResult(`Failed to review manifest: ${e instanceof Error ? e.message : String(e)}`); - } - }, - ); -} diff --git a/packages/mcp-dkg/src/tools/setup.ts b/packages/mcp-dkg/src/tools/setup.ts new file mode 100644 index 000000000..83d8e8c4c --- /dev/null +++ b/packages/mcp-dkg/src/tools/setup.ts @@ -0,0 +1,212 @@ +/** + * Context-graph and sub-graph setup tools. + * + * Wave-2 P0 + P2 adds (audit §7 items 1, 8, 9). These three tools cover + * the SKILL.md Quickstart Step 1 ("create a project") and Step 3 ("join + * a peer-shared CG") plus the sub-graph staging primitive that the + * other write tools previously consumed indirectly via + * `client.ensureSubGraph`. + * + * Naming: `dkg_context_graph_create` matches both SKILL.md §3 Step 1 + * and the OpenClaw adapter (`DkgNodePlugin.ts:1924`). Description + * includes the "(also called 'projects' in the DKG node UI)" UX note + * per the team-lead direction on tool-surface UI/canonical + * reconciliation, mirroring the same pattern locked on + * `dkg_list_context_graphs` in the rename pass. + */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { DkgClient } from '../client.js'; +import type { DkgConfig } from '../config.js'; + +type ToolResult = { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +}; + +const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] }); +const errResult = (text: string): ToolResult => ({ + content: [{ type: 'text', text }], + isError: true, +}); + +const formatError = (e: unknown): string => + e instanceof Error ? e.message : String(e); + +/** + * Slugify a human-readable CG name into a URL-safe id (e.g. "My + * Research Context Graph" → "my-research-context-graph"). Matches the + * adapter's `slugify` at `DkgNodePlugin.ts:3460-3464`. + */ +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +const VALID_CG_ID_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + +export function registerSetupTools( + server: McpServer, + client: DkgClient, + _config: DkgConfig, +): void { + // ── dkg_context_graph_create ──────────────────────────────────── + // Description string opens with the audit v1.1 verbatim-locked + // reconciliation note (SKILL.md §6 line 297 user-vs-internal + // terminology). The follow-up sentence about slug derivation is + // mcp-dkg-specific UX ergonomics. + server.registerTool( + 'dkg_context_graph_create', + { + title: 'Create Context Graph', + description: + "Create a context graph (called 'projects' in the DKG node UI). " + + 'Idempotent — re-creating an existing CG with the same id is a ' + + "no-op and surfaces `already exists` in the response. Returns the " + + "CG's id, URI, and whether it was newly created or already existed. " + + 'The `id` slug is auto-derived from `name` when omitted (e.g. ' + + '"My Research" → "my-research"); slugs must match ' + + '/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.', + inputSchema: { + name: z.string().min(1).describe('Human-readable name (e.g. "My Research Context Graph")'), + description: z.string().optional().describe('Optional description of the CG\'s purpose'), + id: z + .string() + .optional() + .describe( + 'Optional explicit slug. Auto-derived from `name` when omitted. ' + + 'Must match /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.', + ), + }, + }, + async ({ name, description, id }): Promise => { + const trimmedName = name.trim(); + if (!trimmedName) return errResult('"name" is required.'); + const explicitId = id?.trim(); + const cgId = explicitId || slugify(trimmedName); + if (!cgId) { + return errResult( + 'Could not derive a valid context graph ID from the name. Provide an explicit `id`.', + ); + } + if (!VALID_CG_ID_RE.test(cgId)) { + return errResult( + `Invalid context graph ID "${cgId}". Use lowercase letters, numbers, and hyphens (e.g. "my-research"). Must start and end with a letter or digit.`, + ); + } + try { + const result = await client.createContextGraph({ + id: cgId, + name: trimmedName, + description: description?.trim() || undefined, + }); + // Mirror dkg_assertion_create's idempotency surfacing: distinct + // success messages for "newly created" vs "already existed" so + // callers don't have to do an extra `dkg_list_context_graphs` + // round-trip to figure out which path the daemon took. + if (result.alreadyExists) { + return ok( + `Context graph '${cgId}' already exists.\n` + + `URI: ${result.uri}`, + ); + } + return ok( + `Created context graph '${cgId}'.\n` + + `URI: ${result.uri}\n` + + `Name: ${trimmedName}` + + (description ? `\nDescription: ${description}` : ''), + ); + } catch (e) { + return errResult(`Failed to create context graph: ${formatError(e)}`); + } + }, + ); + + // ── dkg_subscribe ─────────────────────────────────────────────── + server.registerTool( + 'dkg_subscribe', + { + title: 'Subscribe to Context Graph', + description: + 'Subscribe to a context graph so its data syncs locally from ' + + 'peers. Call once before querying or publishing a remotely-' + + 'authored CG. Defaults to also syncing Shared Working Memory; ' + + 'pass `includeSharedMemory: false` to skip SWM sync (saves ' + + 'bandwidth when you only need on-chain data).', + inputSchema: { + contextGraphId: z.string().min(1).describe('Context graph id (e.g. "my-research")'), + includeSharedMemory: z + .boolean() + .optional() + .default(true) + .describe('Also sync SWM. Default true.'), + }, + }, + async ({ contextGraphId, includeSharedMemory }): Promise => { + const cgId = contextGraphId.trim(); + if (!cgId) return errResult('"contextGraphId" is required.'); + try { + const result = await client.subscribe({ + contextGraphId: cgId, + includeSharedMemory, + }); + const catchup = result.catchup + ? `\nCatchup job: ${result.catchup.jobId} (status: ${result.catchup.status}, includeSharedMemory: ${result.catchup.includeSharedMemory})` + : ''; + return ok(`Subscribed to '${result.subscribed}'.${catchup}`); + } catch (e) { + return errResult(`Failed to subscribe: ${formatError(e)}`); + } + }, + ); + + // ── dkg_sub_graph_create ──────────────────────────────────────── + // Idempotent semantics — final lock per parity-analyst matrix v0.8 + // §4.18 (2026-05-04). Routes through `client.ensureSubGraph` which + // catches the daemon's 409 on duplicate-name and returns silent + // success. + // + // Rationale (the create-family-wide view): the three `*_create` + // tools have asymmetric daemon-side idempotency: + // - dkg_assertion_create → daemon-idempotent (`alreadyExists: true`) + // - dkg_context_graph_create → daemon-idempotent (returns existing CG) + // - dkg_sub_graph_create → daemon-strict (409 on duplicate) + // + // Wrapping the strict one at the client level via `ensureSubGraph` + // gives agents a uniform "all *_create tools are safe to retry" + // mental model. Adapter parity loses to UX consistency on this one; + // matrix v0.8 §4.18 documents the divergence as deliberate. + server.registerTool( + 'dkg_sub_graph_create', + { + title: 'Create Sub-graph', + description: + 'Create a named sub-graph inside a context graph (an optional ' + + 'partition for scoped assertions, e.g. "code", "tasks", "meta"). ' + + 'Idempotent — a pre-existing sub-graph with the same name is ' + + 'silently reused, no error. Names must be lowercase letters, ' + + 'digits, and hyphens, and must not start with `_`.', + inputSchema: { + contextGraphId: z.string().min(1).describe('Parent context graph id'), + subGraphName: z + .string() + .min(1) + .describe('Sub-graph name (lowercase letters, digits, hyphens; not starting with "_")'), + }, + }, + async ({ contextGraphId, subGraphName }): Promise => { + const cgId = contextGraphId.trim(); + const sgName = subGraphName.trim(); + if (!cgId) return errResult('"contextGraphId" is required.'); + if (!sgName) return errResult('"subGraphName" is required.'); + try { + await client.ensureSubGraph(cgId, sgName); + return ok(`Sub-graph '${sgName}' ready in '${cgId}'.`); + } catch (e) { + return errResult(`Failed to create sub-graph: ${formatError(e)}`); + } + }, + ); +} diff --git a/packages/mcp-dkg/src/tools/writes.ts b/packages/mcp-dkg/src/tools/writes.ts deleted file mode 100644 index 094bb533e..000000000 --- a/packages/mcp-dkg/src/tools/writes.ts +++ /dev/null @@ -1,524 +0,0 @@ -/** - * Agent-authored write tools for the DKG MCP server. - * - * Every write follows the same canonical path already used by the - * scripts in `scripts/import-*.mjs` and by the capture hook: - * - * 1. Compose triples (incl. `prov:wasAttributedTo `). - * 2. POST `/api/assertion//write` with a JSON quads array. - * 3. If `autoShare` is true (the spec default), POST - * `/api/assertion//promote` with the new entity URIs to lift - * them from WM into SWM so teammates see them immediately. - * - * The agent NEVER publishes to VM directly — that stays a human click - * in the node-ui's VerifyOnDkgButton flow. `dkg_request_vm_publish` - * just writes a marker entity saying "I think this is ready to anchor". - * - * Attribution guarantees: every write attaches - * `prov:wasAttributedTo ` at triple time. The in-flight - * R/W PR will later validate that claim cryptographically; until then, - * same-operator setups get honest attribution and multi-operator setups - * fall back to "trust the hostname" — which is fine for this PoC. - */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import type { DkgClient } from '../client.js'; -import type { DkgConfig } from '../config.js'; -import { NS, escapeSparqlLiteral } from '../sparql.js'; - -type ToolResult = { - content: Array<{ type: 'text'; text: string }>; - isError?: boolean; -}; - -const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] }); -const errResult = (text: string): ToolResult => ({ - content: [{ type: 'text', text }], - isError: true, -}); - -const formatError = (e: unknown): string => - e instanceof Error ? e.message : String(e); - -function resolveProject( - explicit: string | undefined, - config: DkgConfig, -): string | null { - return explicit ?? config.defaultProject ?? null; -} - -const projectErr = (): ToolResult => - errResult( - 'No project specified. Either pass `projectId` to this tool, set `DKG_PROJECT` in the environment, or pin `contextGraph:` in `.dkg/config.yaml`.', - ); - -const agentErr = (): ToolResult => - errResult( - 'No agent URI configured. Set `agent.uri` in `.dkg/config.yaml` or export `DKG_AGENT_URI` so this write has a prov:wasAttributedTo. Refusing to write anonymously.', - ); - -// ── RDF term helpers ──────────────────────────────────────────── -const U = (iri: string): string => `<${iri}>`; -const L = (v: string | number, datatype?: string): string => { - const s = typeof v === 'string' ? v : String(v); - const esc = escapeSparqlLiteral(s); - return datatype ? `"${esc}"^^<${datatype}>` : `"${esc}"`; -}; - -const TypeP = NS.rdf + 'type'; -const LabelP = NS.rdfs + 'label'; -const NameP = NS.schema + 'name'; -const TitleP = NS.dcterms + 'title'; -const CreatedP = NS.dcterms + 'created'; -const AttrP = NS.prov + 'wasAttributedTo'; - -const XSD_INT = 'http://www.w3.org/2001/XMLSchema#integer'; -const XSD_DATE = 'http://www.w3.org/2001/XMLSchema#date'; -const XSD_DATETIME = 'http://www.w3.org/2001/XMLSchema#dateTime'; - -/** Slugify a free-form title into a URI-safe suffix. */ -function slugify(input: string, fallback: string): string { - const s = input - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 60); - return s || fallback; -} - -/** Unique-enough suffix so two writes in the same millisecond don't collide. */ -function rand(n = 6): string { - return Math.random().toString(36).slice(2, 2 + n); -} - -/** - * Short deterministic fingerprint of the content fields that define an - * entity's "semantic identity". Used as a URI discriminator so two - * genuinely distinct decisions/tasks that happen to share a title - * don't collapse into one RDF subject while still letting agents - * propose the SAME decision/task (same title AND same content) and - * converge on the same URI — which is the look-before-mint convergence - * rule from AGENTS.md. - * - * Codex tier-4m flagged the pure title-slug URI (N19 removed the - * random suffix but didn't add an alternative discriminator): two - * different decisions with the same title merged unrelated - * status/context/consequence triples onto one subject. - * - * djb2-style 32-bit rolling hash encoded in base36 → 4-char suffix. - * Collision probability at modest volumes (a few thousand entities - * per project) is ~1 in 1.7M, which is well below the same-title - * collision rate the old code exhibited. - */ -function contentFingerprint(...fields: Array): string { - const joined = fields - .filter((v): v is string | number => v !== undefined && v !== null && v !== '') - .map((v) => String(v).trim().toLowerCase()) - .join('|'); - if (!joined) return ''; - let h = 5381; - for (let i = 0; i < joined.length; i++) { - h = ((h << 5) + h + joined.charCodeAt(i)) | 0; - } - const unsigned = h >>> 0; - return unsigned.toString(36).padStart(4, '0').slice(-4); -} - -/** Push a `{ subject, predicate, object }` triple into `sink`. */ -function emit( - sink: Array<{ subject: string; predicate: string; object: string }>, - subject: string, - predicate: string, - object: string, -): void { - sink.push({ subject, predicate, object }); -} - -export function registerWriteTools( - server: McpServer, - client: DkgClient, - config: DkgConfig, -): void { - // ── dkg_propose_decision ───────────────────────────────────── - server.registerTool( - 'dkg_propose_decision', - { - title: 'Propose Decision', - description: - 'Author a `decisions:Decision` and auto-promote to SWM so the team ' + - 'sees it immediately. Use for architectural choices, trade-offs, or ' + - 'pivots the agent wants to propose on the operator\'s behalf. ' + - 'Humans ratify to VM via the node-ui VerifyOnDkgButton — MCP never ' + - 'publishes on-chain.', - inputSchema: { - title: z.string().describe('Short sentence capturing the choice, e.g. "Adopt tree-sitter for Python parsing".'), - context: z.string().describe('Why this decision is being made (problem, constraints).'), - outcome: z.string().describe('The chosen direction — the decision itself.'), - consequences: z.string().optional().describe('What this implies going forward (trade-offs, follow-ups).'), - status: z.enum(['proposed', 'accepted', 'rejected', 'superseded']).optional().describe('Default: proposed.'), - projectId: z.string().optional(), - }, - }, - async ({ title, context, outcome, consequences, status, projectId }): Promise => { - const pid = resolveProject(projectId, config); - if (!pid) return projectErr(); - if (!config.agentUri) return agentErr(); - const decStatus = status ?? 'proposed'; - const slug = slugify(title, `decision-${rand()}`); - // Deterministic URI with a short content fingerprint: agents - // writing the SAME decision (same title, same outcome, same - // context) converge on the same URI per the AGENTS.md - // look-before-mint rule. But two decisions that happen to share - // a title and nothing else no longer collapse into one RDF - // subject with merged status/context/consequence triples (Codex - // tier-4m N34). The assertion name below still carries a rand(4) - // so the SAME-identity decision can be re-asserted with wording - // tweaks without colliding on the write layer. - const fp = contentFingerprint(title, outcome, context, decStatus); - const id = fp ? `urn:dkg:decision:${slug}-${fp}` : `urn:dkg:decision:${slug}`; - const nowIso = new Date().toISOString(); - const triples: Array<{ subject: string; predicate: string; object: string }> = []; - emit(triples, U(id), U(TypeP), U(NS.decisions + 'Decision')); - emit(triples, U(id), U(NameP), L(title)); - emit(triples, U(id), U(LabelP), L(title)); - emit(triples, U(id), U(TitleP), L(title)); - emit(triples, U(id), U(NS.decisions + 'context'), L(context)); - emit(triples, U(id), U(NS.decisions + 'outcome'), L(outcome)); - if (consequences) emit(triples, U(id), U(NS.decisions + 'consequences'), L(consequences)); - emit(triples, U(id), U(NS.decisions + 'status'), L(decStatus)); - emit(triples, U(id), U(NS.decisions + 'date'), L(nowIso, XSD_DATETIME)); - emit(triples, U(id), U(CreatedP), L(nowIso, XSD_DATETIME)); - emit(triples, U(id), U(AttrP), U(config.agentUri)); - - const assertion = `agent-decision-${slug}-${rand(4)}`; - try { - await client.ensureSubGraph(pid, 'decisions'); - await client.writeAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'decisions', - triples, - }); - let shared = false; - if (config.capture.autoShare) { - try { - await client.promoteAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'decisions', - entities: [id], - }); - shared = true; - } catch (e) { - return ok( - `Decision written but promote failed: ${formatError(e)}\n\n` + - `- **URI**: \`${id}\`\n- **assertion**: \`${assertion}\`\n- **layer**: WM only (manual promote needed)`, - ); - } - } - return ok( - `✔ Decision ${shared ? '**shared** (WM → SWM)' : 'written to WM'}:\n\n` + - `- **URI**: \`${id}\`\n` + - `- **status**: ${decStatus}\n` + - `- **attributed to**: \`${config.agentUri}\`\n` + - `- **assertion**: \`${assertion}\`\n\n` + - `Humans can ratify to VM on-chain via the node-ui VerifyOnDkgButton.`, - ); - } catch (e) { - return errResult(`Failed to propose decision: ${formatError(e)}`); - } - }, - ); - - // ── dkg_add_task ────────────────────────────────────────────── - server.registerTool( - 'dkg_add_task', - { - title: 'Add Task', - description: - 'Author a `tasks:Task` and auto-promote to SWM. Use when the agent ' + - 'wants to file follow-up work detected during a chat (e.g. "revisit ' + - 'SHACL on promote path"). Attribution via prov:wasAttributedTo.', - inputSchema: { - title: z.string().describe('Imperative, e.g. "Add SHACL validation on /promote endpoint".'), - status: z.enum(['todo', 'in_progress', 'blocked', 'done', 'cancelled']).optional().describe('Default: todo.'), - priority: z.enum(['p0', 'p1', 'p2', 'p3']).optional().describe('Default: p2.'), - assignee: z.string().optional().describe('GitHub login or agent slug.'), - estimate: z.number().optional().describe('Hours. Integer.'), - dueDate: z.string().optional().describe('ISO date (YYYY-MM-DD).'), - relatedDecision: z.array(z.string()).optional().describe('Decision slugs or full URIs.'), - touches: z.array(z.string()).optional().describe('File or package URIs that the task edits.'), - projectId: z.string().optional(), - }, - }, - async ({ title, status, priority, assignee, estimate, dueDate, relatedDecision, touches, projectId }): Promise => { - const pid = resolveProject(projectId, config); - if (!pid) return projectErr(); - if (!config.agentUri) return agentErr(); - const st = status ?? 'todo'; - const pr = priority ?? 'p2'; - const slug = slugify(title, `task-${rand()}`); - // Deterministic URI with a short content fingerprint (same - // rationale as `dkg_propose_decision`): same title+assignee+ - // dueDate+priority converge, genuinely distinct tasks with the - // same title don't merge. Codex tier-4m N34. - const fp = contentFingerprint(title, assignee, dueDate, st, pr); - const id = fp ? `urn:dkg:task:${slug}-${fp}` : `urn:dkg:task:${slug}`; - const nowIso = new Date().toISOString(); - const triples: Array<{ subject: string; predicate: string; object: string }> = []; - emit(triples, U(id), U(TypeP), U(NS.tasks + 'Task')); - emit(triples, U(id), U(NameP), L(title)); - emit(triples, U(id), U(LabelP), L(title)); - emit(triples, U(id), U(TitleP), L(title)); - emit(triples, U(id), U(NS.tasks + 'status'), L(st)); - emit(triples, U(id), U(NS.tasks + 'priority'), L(pr)); - emit(triples, U(id), U(CreatedP), L(nowIso, XSD_DATETIME)); - if (typeof estimate === 'number') emit(triples, U(id), U(NS.tasks + 'estimate'), L(estimate, XSD_INT)); - if (assignee) { - const assigneeUri = assignee.startsWith('urn:') || assignee.startsWith('http') - ? assignee - : `urn:dkg:github:user:${encodeURIComponent(assignee)}`; - emit(triples, U(id), U(NS.tasks + 'assignee'), U(assigneeUri)); - } - if (dueDate) emit(triples, U(id), U(NS.tasks + 'dueDate'), L(dueDate, XSD_DATE)); - for (const dec of relatedDecision ?? []) { - const decUri = dec.startsWith('urn:') || dec.startsWith('http') - ? dec - : `urn:dkg:decision:${encodeURIComponent(dec)}`; - emit(triples, U(id), U(NS.tasks + 'relatedDecision'), U(decUri)); - } - for (const t of touches ?? []) emit(triples, U(id), U(NS.tasks + 'touches'), U(t)); - emit(triples, U(id), U(AttrP), U(config.agentUri)); - - const assertion = `agent-task-${slug}-${rand(4)}`; - try { - await client.ensureSubGraph(pid, 'tasks'); - await client.writeAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'tasks', - triples, - }); - let shared = false; - if (config.capture.autoShare) { - try { - await client.promoteAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'tasks', - entities: [id], - }); - shared = true; - } catch (e) { - return ok( - `Task written but promote failed: ${formatError(e)}\n\n- **URI**: \`${id}\`\n- **assertion**: \`${assertion}\`\n- **layer**: WM only`, - ); - } - } - return ok( - `✔ Task ${shared ? '**shared** (WM → SWM)' : 'written to WM'}:\n\n` + - `- **URI**: \`${id}\`\n` + - `- **status**: ${st} · **priority**: ${pr}${assignee ? ` · **assignee**: ${assignee}` : ''}\n` + - `- **attributed to**: \`${config.agentUri}\`\n` + - `- **assertion**: \`${assertion}\``, - ); - } catch (e) { - return errResult(`Failed to add task: ${formatError(e)}`); - } - }, - ); - - // ── dkg_comment ────────────────────────────────────────────── - server.registerTool( - 'dkg_comment', - { - title: 'Comment on Entity', - description: - 'Attach a short comment to any existing DKG entity (decision, ' + - 'task, PR, file, chat turn). Comments use the chat sub-graph under ' + - 'their own assertion and auto-promote so teammates see them. ' + - 'Rendered by the UI as a threaded note on the target entity.', - inputSchema: { - entityUri: z.string().describe('Full URI of the entity to comment on.'), - body: z.string().describe('Comment body (markdown allowed).'), - projectId: z.string().optional(), - }, - }, - async ({ entityUri, body, projectId }): Promise => { - const pid = resolveProject(projectId, config); - if (!pid) return projectErr(); - if (!config.agentUri) return agentErr(); - const nowIso = new Date().toISOString(); - const id = `urn:dkg:comment:${rand(10)}-${Date.now()}`; - const triples: Array<{ subject: string; predicate: string; object: string }> = []; - emit(triples, U(id), U(TypeP), U(NS.schema + 'Comment')); - emit(triples, U(id), U(NameP), L(body.slice(0, 80) + (body.length > 80 ? '…' : ''))); - emit(triples, U(id), U(LabelP), L(`Comment on ${entityUri}`)); - emit(triples, U(id), U(NS.schema + 'text'), L(body)); - emit(triples, U(id), U(NS.schema + 'about'), U(entityUri)); - emit(triples, U(id), U(NS.chat + 'aboutEntity'), U(entityUri)); - emit(triples, U(id), U(CreatedP), L(nowIso, XSD_DATETIME)); - emit(triples, U(id), U(AttrP), U(config.agentUri)); - - const assertion = `agent-comment-${rand(6)}`; - try { - await client.ensureSubGraph(pid, 'chat'); - await client.writeAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'chat', - triples, - }); - let shared = false; - if (config.capture.autoShare) { - try { - await client.promoteAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'chat', - entities: [id], - }); - shared = true; - } catch (e) { - return ok( - `Comment written but promote failed: ${formatError(e)}\n\n- **URI**: \`${id}\`\n- **assertion**: \`${assertion}\``, - ); - } - } - return ok( - `✔ Comment ${shared ? '**shared**' : 'written'} on \`${entityUri}\`:\n\n` + - `- **URI**: \`${id}\`\n- **attributed to**: \`${config.agentUri}\``, - ); - } catch (e) { - return errResult(`Failed to write comment: ${formatError(e)}`); - } - }, - ); - - // ── dkg_request_vm_publish ─────────────────────────────────── - server.registerTool( - 'dkg_request_vm_publish', - { - title: 'Request VM Publish (human-ratified)', - description: - 'Writes a `dkg:VmPublishRequest` marker entity saying "this SWM ' + - 'entity is ready to anchor on-chain". Does NOT publish to VM. ' + - 'The node-ui surfaces these as pending review items that a human ' + - 'completes via VerifyOnDkgButton. Enforces the spec\'s human-gates-' + - 'VM rule per `APP_MULTI_AGENT_CODING §3.4`.', - inputSchema: { - entityUri: z.string().describe('URI of the SWM entity you want anchored.'), - rationale: z.string().describe('Why this entity warrants on-chain commitment (TRAC cost + permanence).'), - projectId: z.string().optional(), - }, - }, - async ({ entityUri, rationale, projectId }): Promise => { - const pid = resolveProject(projectId, config); - if (!pid) return projectErr(); - if (!config.agentUri) return agentErr(); - const nowIso = new Date().toISOString(); - const id = `urn:dkg:vm-publish-request:${rand(8)}-${Date.now()}`; - const triples: Array<{ subject: string; predicate: string; object: string }> = []; - emit(triples, U(id), U(TypeP), U('http://dkg.io/ontology/VmPublishRequest')); - emit(triples, U(id), U(LabelP), L(`VM publish request: ${entityUri}`)); - emit(triples, U(id), U(NameP), L('VM publish request')); - emit(triples, U(id), U('http://dkg.io/ontology/requestsPublishOf'), U(entityUri)); - emit(triples, U(id), U('http://dkg.io/ontology/rationale'), L(rationale)); - emit(triples, U(id), U(CreatedP), L(nowIso, XSD_DATETIME)); - emit(triples, U(id), U(AttrP), U(config.agentUri)); - - const assertion = `agent-vm-request-${rand(6)}`; - try { - await client.ensureSubGraph(pid, 'meta'); - await client.writeAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'meta', - triples, - }); - if (config.capture.autoShare) { - try { - await client.promoteAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'meta', - entities: [id], - }); - } catch { - // promote failures on a marker entity are non-fatal; the marker - // still exists in WM and can be promoted by hand. - } - } - return ok( - `✔ VM publish request written:\n\n` + - `- **marker URI**: \`${id}\`\n` + - `- **target**: \`${entityUri}\`\n` + - `- **attributed to**: \`${config.agentUri}\`\n\n` + - `Next step: open the target in the node-ui → click **Verify on DKG** to anchor on-chain.`, - ); - } catch (e) { - return errResult(`Failed to file VM publish request: ${formatError(e)}`); - } - }, - ); - - // ── dkg_set_session_privacy ────────────────────────────────── - server.registerTool( - 'dkg_set_session_privacy', - { - title: 'Set Session Privacy', - description: - 'Flip a chat session\'s `chat:privacy` flag. `private` keeps its ' + - 'turns WM-only (not gossiped); `team` (default) auto-promotes every ' + - 'new turn to SWM; `public` is the same as team for now but signals ' + - '"safe to anchor". Useful for "let me think out loud without the ' + - 'team seeing" moments. Applies to turns written AFTER the flip.', - inputSchema: { - sessionUri: z.string().describe('Full URI of the chat session.'), - privacy: z.enum(['private', 'team', 'public']), - projectId: z.string().optional(), - }, - }, - async ({ sessionUri, privacy, projectId }): Promise => { - const pid = resolveProject(projectId, config); - if (!pid) return projectErr(); - if (!config.agentUri) return agentErr(); - const nowIso = new Date().toISOString(); - const triples: Array<{ subject: string; predicate: string; object: string }> = []; - emit(triples, U(sessionUri), U(NS.chat + 'privacy'), L(privacy)); - emit(triples, U(sessionUri), U(NS.dcterms + 'modified'), L(nowIso, XSD_DATETIME)); - emit(triples, U(sessionUri), U(AttrP), U(config.agentUri)); - - const assertion = `agent-privacy-${rand(6)}`; - try { - await client.ensureSubGraph(pid, 'chat'); - await client.writeAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'chat', - triples, - }); - if (config.capture.autoShare) { - try { - await client.promoteAssertion({ - contextGraphId: pid, - assertionName: assertion, - subGraphName: 'chat', - entities: [sessionUri], - }); - } catch { - /* non-fatal */ - } - } - return ok( - `✔ Session \`${sessionUri}\` privacy set to **${privacy}**.\n\n` + - `New turns on this session will ${ - privacy === 'private' ? 'stay WM-only (not gossiped)' : 'auto-promote to SWM' - }.`, - ); - } catch (e) { - return errResult(`Failed to set session privacy: ${formatError(e)}`); - } - }, - ); -} diff --git a/packages/mcp-dkg/templates/ontologies/book-research/agent-guide.md b/packages/mcp-dkg/templates/ontologies/book-research/agent-guide.md index dee42a5f8..ffa2a4b68 100644 --- a/packages/mcp-dkg/templates/ontologies/book-research/agent-guide.md +++ b/packages/mcp-dkg/templates/ontologies/book-research/agent-guide.md @@ -1,82 +1,18 @@ # Book-research agent guide -You are working in a DKG context graph that uses the **Book Research Ontology v1** (`packages/mcp-dkg/templates/ontologies/book-research/ontology.ttl`). This document is the operational translation: how to annotate every chat turn into the project's `chat` sub-graph via `dkg_annotate_turn`. - -## The contract - -After every substantive turn, call `dkg_annotate_turn` exactly once. Universal primitives apply (`topics`, `mentions`, `examines`, `concludes`, `asks`); add book-specific entities (`Hypothesis`, `Argument`, `Counterexample`, `Quote`, `Citation`) when the turn warrants. - -## Look-before-mint protocol - -1. Compute normalised slug: lowercase → ASCII-fold → strip stopwords (`the/a/an/of/for/and/or/to/in/on/with`) → hyphenate → ≤60 chars. -2. `dkg_search` the unnormalised label. -3. Reuse on slug match; mint fresh otherwise. -4. Never fabricate URIs. - -## URI patterns - -``` -urn:dkg:concept: free-text concept (skos:Concept) -urn:dkg:topic: broad topical bucket -urn:dkg:question: open question -urn:dkg:finding: preserved claim/observation -urn:dkg:hypothesis: claim under investigation -urn:dkg:argument: reasoned position -urn:dkg:quote: verbatim passage from a source -urn:dkg:citation: bibliographic reference (subclass of bibo:Document) -``` - -## Worked examples - -### A — turn that examines a source and extracts a quote - -User asked: *"what does Berners-Lee say about HTTP URIs in the Semantic Web roadmap?"* -You quoted a passage and analysed it. - -```jsonc -dkg_annotate_turn({ - topics: ["URI design", "Semantic Web", "Berners-Lee 2001"], - mentions: ["urn:dkg:citation:berners-lee-2001-semantic-web-roadmap"], - examines: ["urn:dkg:quote:tbl-2001-use-http-uris-so-people-can-look-up"], - concludes: ["urn:dkg:finding:tbl-anchored-look-before-mint-in-2001"] -}) -``` - -### B — turn that puts forward a hypothesis - -User: *"argue that knowledge graphs require deterministic naming to converge."* -You laid out a hypothesis and supporting Arguments. - -```jsonc -dkg_annotate_turn({ - topics: ["graph convergence", "naming"], - mentions: ["urn:dkg:concept:knowledge-graph"], - proposes: [], // not minting a Decision; this is a knowledge claim - concludes: [], - // Use the dedicated tools for Hypotheses/Arguments — coming in a future - // release of dkg_annotate_turn. For v1, mint via mentions: - mentions: ["urn:dkg:hypothesis:graphs-need-deterministic-naming-to-converge"], - examines: ["urn:dkg:argument:slug-normalisation-makes-naming-deterministic"] -}) -``` - -### C — turn that opens a research question - -User: *"how do we measure cross-agent URI convergence empirically?"* - -```jsonc -dkg_annotate_turn({ - topics: ["measurement", "URI convergence"], - asks: ["urn:dkg:question:how-to-measure-cross-agent-uri-convergence"] -}) -``` - -## Tool reference - -Same MCP tools as every project: `dkg_get_ontology`, `dkg_annotate_turn`, `dkg_search`, `dkg_get_entity`, `dkg_sparql`, `dkg_propose_decision`, `dkg_add_task`, `dkg_comment`, `dkg_request_vm_publish`. See repo-level `AGENTS.md` for the full list. - -## What to NOT do - -- Don't fabricate URIs for sources you haven't verified exist (use `dkg_search` first). -- Don't conflate `:Hypothesis` (under investigation) with `:Finding` (preserved as established claim). Promote one to the other only when evidence warrants. -- Don't publish to VM via MCP. Use `dkg_request_vm_publish` to flag canon-worthy passages for human ratification. +This starter ships an RDF ontology for **book-research projects** — +research notebooks anchored in scholarly sources (citations, quotes, +arguments, hypotheses). The formal schema lives in `ontology.ttl` +alongside this guide. + +For V10 MCP tool usage, see +[`packages/cli/skills/dkg-node/SKILL.md`](../../../../cli/skills/dkg-node/SKILL.md). +The tool surface to use against this ontology: + +- `dkg_assertion_create` + `dkg_assertion_write` — populate (WM) +- `dkg_assertion_promote` — share with peers (SWM) +- `dkg_shared_memory_publish` — finalize on-chain (VM) +- `dkg_query` — SPARQL read; `dkg_memory_search` — free-text recall + +The longer per-domain agent-guide walkthrough format will return when +the V10 ontology endpoint and per-project annotation workflow stabilise. diff --git a/packages/mcp-dkg/templates/ontologies/book-research/ontology.ttl b/packages/mcp-dkg/templates/ontologies/book-research/ontology.ttl index 7b660b64b..1482ddff1 100644 --- a/packages/mcp-dkg/templates/ontologies/book-research/ontology.ttl +++ b/packages/mcp-dkg/templates/ontologies/book-research/ontology.ttl @@ -99,4 +99,4 @@ chat:asks a owl:ObjectProperty ; rdfs:domain chat:Turn ; rdfs:range :Quest skos:definition "Bibliographic citation; aligned with bibo:Document via cito:cites." . :slug-normalisation a skos:Note ; - skos:definition "Lowercase → ASCII-fold → strip stopwords (the/a/an/of/for/and/or/to/in/on/with) → hyphenate → ≤60 chars. Apply BEFORE comparing dkg_search results to decide reuse-vs-mint." . + skos:definition "Lowercase → ASCII-fold → strip stopwords (the/a/an/of/for/and/or/to/in/on/with) → hyphenate → ≤60 chars. Apply BEFORE comparing free-text recall results (`dkg_memory_search`) to decide reuse-vs-mint." . diff --git a/packages/mcp-dkg/templates/ontologies/coding-project/agent-guide.md b/packages/mcp-dkg/templates/ontologies/coding-project/agent-guide.md index fa0f3f4c4..0219a27e9 100644 --- a/packages/mcp-dkg/templates/ontologies/coding-project/agent-guide.md +++ b/packages/mcp-dkg/templates/ontologies/coding-project/agent-guide.md @@ -1,166 +1,18 @@ # Coding-project agent guide -You are working in a DKG context graph that uses the **Coding Project ontology v1**. This document is your operational reference for emitting structured triples about every chat turn via `dkg_annotate_turn`. The companion file `ontology.ttl` is the formal source of truth; this file is its instructional translation. - -## The contract - -After **every substantive turn** (anything that reasoned, proposed, examined, or referenced something — basically every turn that wasn't a one-line acknowledgement), call `dkg_annotate_turn` exactly once. Quality of annotation matters more than gating which turns get one — over-eagerness is not a failure mode here. The shared chat sub-graph is project memory, not a search index for "DKG-related" topics. - -## The look-before-mint protocol - -**This is the single most important rule.** It's how parallel agents converge on the same URIs instead of fragmenting the graph into archipelagos. - -Before emitting a new URI in `dkg_annotate_turn`: - -1. Compute the **normalised slug** for the label per Section 7 below. -2. Call `dkg_search` with the unnormalised label (it does its own fuzzy match). -3. If any returned entity's normalised slug matches yours → **REUSE** that URI. -4. Otherwise → mint `urn:dkg::` per the patterns in Section 5. - -Never fabricate an existing URI. If unsure, prefer minting fresh and let the reconciliation flow merge duplicates via `owl:sameAs` later. - -## 1. Annotation primitives — what to put on every turn - -The universal predicates apply to ANY project type. Reach for these first; they're cheap and make the graph navigable: - -| Predicate | When to use | Cardinality | -|---|---|---| -| `chat:topic` (literal) | Free-text topical bucket the turn lives under. Pick 1–3 short phrases ("performance tuning", "VM publish flow"). Don't be precious — emit liberally. | Many | -| `chat:mentions` (URI) | Any entity the turn referenced. The most common edge in the graph. Apply look-before-mint religiously. | Many | -| `chat:examines` (URI) | Entity the turn walked through in detail (vs just citing in passing). Implies the agent or operator is ANALYSING it, not merely linking. | 0..N | -| `chat:proposes` (URI) | An idea/decision/task put forward. Often points at a freshly-minted Decision or Task entity created in the same `dkg_annotate_turn` call. | 0..N | -| `chat:concludes` (URI) | A `:Finding` entity the turn produced — a claim worth preserving as its own node. | 0..N | -| `chat:asks` (URI) | A `:Question` entity the turn left open. Surfaces "what did we never resolve". | 0..N | - -## 2. Coding-project-specific entities - -When the turn discusses architecture or work, also use the project-flavoured tools (which `dkg_annotate_turn` wraps for you): - -- **Decision** (`decisions:Decision`) — when the turn settled an architectural question. Required fields: `title`, `context`, `outcome`. Optional: `consequences`, `status` (default `proposed`). -- **Task** (`tasks:Task`) — when the turn identified work to do. Required: `title`. Optional: `priority` (p0..p3), `status`, `assignee`, `relatedDecision`, `touches`. -- **Comment** (`schema:Comment`) — when the turn made a remark ABOUT an existing entity. Required: `about` (URI), `body`. -- **VmPublishRequest** (`dkg:VmPublishRequest`) — when the turn surfaced something worth anchoring on-chain. Required: `entityUri`, `rationale`. The agent NEVER publishes to VM directly; this writes a marker that a human ratifies via the node-ui's VerifyOnDkgButton. - -## 3. URI patterns (memorise these) - -``` -urn:dkg:concept: free-text concept (skos:Concept) -urn:dkg:topic: broad topical bucket (skos:Concept in TopicScheme) -urn:dkg:question: open question (subClassOf schema:Question) -urn:dkg:finding: preserved claim/observation (subClassOf prov:Entity) -urn:dkg:decision: architectural decision (subClassOf prov:Activity) -urn:dkg:task: work item (subClassOf schema:Action) -urn:dkg:agent: agent identity — usually - -urn:dkg:github:repo:/ GitHub repo -urn:dkg:github:pr:// pull request -urn:dkg:code:file:/ source file -urn:dkg:code:package: package -``` - -## 4. Slug normalisation algorithm - -To produce a canonical slug from a free-text label: - -1. **Lowercase.** -2. **ASCII-fold:** apply Unicode NFKD then strip combining marks. -3. **Strip stopwords:** `the / a / an / of / for / and / or / to / in / on / with`. -4. **Hyphenate:** replace any run of non-`[a-z0-9]` with a single hyphen. -5. **Trim** leading/trailing hyphens. -6. **Truncate** to 60 characters. - -Examples: `"the Tree-Sitter library"`, `"Tree sitter"`, `"TREE_SITTER"` all normalise to `tree-sitter`. - -## 5. Worked examples - -### Example A — turn that proposes adopting a tool - -User asked: *"should we use tree-sitter for Python parsing?"* -You replied with an analysis recommending it. - -```jsonc -dkg_annotate_turn({ - topics: ["AST tooling", "Python parsing", "incremental reparsing"], - mentions: [ - "urn:dkg:concept:tree-sitter", // existed already — REUSED via dkg_search - "urn:dkg:concept:incremental-parsing" - ], - examines: [ - "urn:dkg:code:package:%40origintrail-official%2Fdkg-cli" // the package the change would affect - ], - proposedDecisions: [{ - title: "Adopt tree-sitter for Python AST parsing", - context: "Operator asked whether to switch from lark to tree-sitter for Python source parsing.", - outcome: "Adopt tree-sitter-python behind a Parser interface so we can swap implementations later.", - consequences: "+1.5MB bundle per language; gain incremental reparse, error recovery, and a mature DSL. Behind an interface so reversible.", - status: "proposed" - }], - proposedTasks: [{ - title: "Stub a Parser interface that wraps tree-sitter-python", - priority: "p1", - status: "todo" - }] -}) -``` - -The Decision and Task get fresh URIs (`urn:dkg:decision:adopt-tree-sitter-for-python-ast-parsing-...`) attributed to you via `prov:wasAttributedTo`, auto-promoted to SWM, instantly visible to teammates' agents. The `chat:proposes` edge from the turn to the Decision is created automatically. - -### Example B — turn that just discusses without deciding - -User asked: *"what was the gossip latency we measured between node-1 and node-2?"* -You looked it up in the chat history and replied "≤5s in both directions". - -```jsonc -dkg_annotate_turn({ - topics: ["gossip protocol", "two-machines test"], - mentions: [ - "urn:dkg:concept:gossip-protocol", - "urn:dkg:concept:two-machines-demo" - ], - concludes: [ - "urn:dkg:finding:node1-node2-gossip-under-5s" // newly minted Finding entity - ] -}) -``` - -No decision proposed (the question was retrospective). One Finding minted because the answer is worth preserving as a citeable node. - -### Example C — turn that opens a question - -User asked: *"how would we reconcile divergent URIs minted in parallel?"* -You sketched some options but didn't pick one. - -```jsonc -dkg_annotate_turn({ - topics: ["URI convergence", "conflict resolution"], - mentions: [ - "urn:dkg:concept:look-before-mint", - "urn:dkg:concept:owl-sameas" - ], - asks: [ - "urn:dkg:question:how-to-reconcile-divergent-uris-minted-in-parallel" - ] -}) -``` - -The Question entity is now in the graph. A future turn that proposes an answer can `chat:concludes urn:dkg:finding:dkg-propose-same-as-flow` and `:answers urn:dkg:question:how-to-reconcile-...` — closing the loop. - -## 6. Things to NOT do - -- **Don't fabricate URIs.** If `dkg_search` returns nothing for a label, mint fresh — never guess at a URI someone else might have used. -- **Don't skip turns to "save tokens".** Annotation cost is one extra MCP call (~few hundred ms). Coverage wins. -- **Don't put project-specific predicates on the universal primitives.** `chat:mentions` is universal; if you want to express "this turn voted to accept a decision", use a Decision-specific predicate, not `chat:mentions decision:Decision`. -- **Don't publish to VM via MCP.** That's `dkg_request_vm_publish` (writes a marker the human ratifies), not actual on-chain publish. You're never the gating actor for VM. -- **Don't normalise slugs in your URI lookup BEFORE calling `dkg_search`.** Search the unnormalised label so the daemon's fuzzy match has the most signal; THEN compare normalised slugs to decide reuse-vs-mint. - -## 7. Cheat sheet — minimum viable annotation - -If you remember nothing else, do this on every substantive turn: - -```jsonc -dkg_annotate_turn({ - topics: [<2-3 short topic strings>], - mentions: [] -}) -``` - -Everything else (`examines`, `proposes`, `concludes`, `asks`, sugared writes) is additive and turn-dependent. +This starter ships an RDF ontology for **coding projects** — +shared project memory for AI coding agents (decisions, tasks, code +references, GitHub activity, chat turns). The formal schema lives in +`ontology.ttl` alongside this guide. + +For V10 MCP tool usage, see +[`packages/cli/skills/dkg-node/SKILL.md`](../../../../cli/skills/dkg-node/SKILL.md). +The tool surface to use against this ontology: + +- `dkg_assertion_create` + `dkg_assertion_write` — populate (WM) +- `dkg_assertion_promote` — share with peers (SWM) +- `dkg_shared_memory_publish` — finalize on-chain (VM) +- `dkg_query` — SPARQL read; `dkg_memory_search` — free-text recall + +The longer per-domain agent-guide walkthrough format will return when +the V10 ontology endpoint and per-project annotation workflow stabilise. diff --git a/packages/mcp-dkg/templates/ontologies/coding-project/ontology.ttl b/packages/mcp-dkg/templates/ontologies/coding-project/ontology.ttl index ba9c1242d..e3a1aaa25 100644 --- a/packages/mcp-dkg/templates/ontologies/coding-project/ontology.ttl +++ b/packages/mcp-dkg/templates/ontologies/coding-project/ontology.ttl @@ -47,8 +47,10 @@ this formal model into operational instructions for AI coding agents.""" ; . # ─── Section 1 — Universal annotation primitives ──────────────────── -# Used by `dkg_annotate_turn` to express what each chat turn was ABOUT. -# These predicates apply to ANY project type, not just coding. +# Predicates expressing what each chat turn was ABOUT — written into +# the project's `chat` sub-graph by the capture-chat hook (and later +# read by `dkg_query` / `dkg_memory_search`). These predicates apply +# to ANY project type, not just coding. chat:topic a owl:DatatypeProperty ; @@ -60,7 +62,7 @@ chat:topic chat:mentions a owl:ObjectProperty ; rdfs:label "mentions" ; - rdfs:comment "An entity the chat turn referenced. The most common edge in the graph. Use the look-before-mint protocol: query dkg_search first, reuse if a normalised-slug match exists." ; + rdfs:comment "An entity the chat turn referenced. The most common edge in the graph. To find existing entities to reuse before minting fresh URIs, use `dkg_memory_search` (free-text recall across WM/SWM/VM)." ; rdfs:domain chat:Turn ; rdfs:range owl:Thing ; rdfs:subPropertyOf schema:mentions . @@ -75,7 +77,7 @@ chat:examines chat:proposes a owl:ObjectProperty ; rdfs:label "proposes" ; - rdfs:comment "An idea/decision/task the chat turn put forward. Often points at a freshly-minted Decision or Task entity created in the same dkg_annotate_turn call." ; + rdfs:comment "An idea/decision/task the chat turn put forward. Often points at a freshly-minted Decision or Task entity created in the same write batch." ; rdfs:domain chat:Turn ; rdfs:range owl:Thing . @@ -299,7 +301,7 @@ agent:operator :UriPattern a owl:Class ; rdfs:label "URI minting pattern" ; - rdfs:comment "Documents the URI shape the agent should produce when minting a new entity of a given type. Read by dkg_annotate_turn at runtime." . + rdfs:comment "Documents the URI shape the agent should produce when minting a new entity of a given type." . :concept-pattern a :UriPattern ; skos:notation "urn:dkg:concept:" ; @@ -333,5 +335,5 @@ agent:operator 6. Truncate to 60 characters max. Example: "the Tree-Sitter library", "Tree sitter", "TREE_SITTER" all -normalise to "tree-sitter". Always apply this BEFORE calling dkg_search -in the look-before-mint protocol.""" . +normalise to "tree-sitter". Always apply this BEFORE comparing free-text +recall results (`dkg_memory_search`) in the look-before-mint protocol.""" . diff --git a/packages/mcp-dkg/templates/ontologies/narrative-writing/agent-guide.md b/packages/mcp-dkg/templates/ontologies/narrative-writing/agent-guide.md index fea513e0c..5cd753a34 100644 --- a/packages/mcp-dkg/templates/ontologies/narrative-writing/agent-guide.md +++ b/packages/mcp-dkg/templates/ontologies/narrative-writing/agent-guide.md @@ -1,85 +1,18 @@ # Narrative-writing agent guide -You are working in a DKG context graph that uses the **Narrative Writing Ontology v1**. The graph tracks the structural elements of long-form narrative — Characters, Scenes, PlotPoints, Themes, Settings — and the relationships between them. - -## The contract - -After every substantive turn, call `dkg_annotate_turn` exactly once. Reach for the narrative-flavored entities when the turn discusses story craft: - -- `:Character` (subclass of `foaf:Person`) -- `:Scene` (subclass of `schema:Event`) -- `:PlotPoint` (structural moment in the arc) -- `:Theme` (subclass of `skos:Concept`) -- `:Setting` (place + time context) - -## Look-before-mint protocol - -1. Normalise slug: lowercase → ASCII-fold → strip stopwords → hyphenate → ≤60 chars. -2. `dkg_search` first. -3. Reuse on match; mint otherwise. -4. Never fabricate URIs. - -## URI patterns - -``` -urn:dkg:concept: free-text concept -urn:dkg:topic: broad topical bucket -urn:dkg:question: open question (craft, plot) -urn:dkg:character: a character in the story -urn:dkg:scene: a scene -urn:dkg:plotpoint: a structural moment (inciting incident, climax, etc.) -urn:dkg:theme: a unifying idea explored across the work -urn:dkg:setting: a place/time context for Scenes -``` - -## Worked examples - -### A — turn that introduces a new character - -User: *"Sketch a character: an aging cartographer haunted by an early professional failure."* - -```jsonc -dkg_annotate_turn({ - topics: ["character design", "backstory"], - proposes: ["urn:dkg:character:aging-cartographer-haunted-by-early-failure"], - mentions: ["urn:dkg:theme:professional-redemption"] // existed already — REUSED via dkg_search -}) -``` - -### B — turn that drafts a scene linking characters and themes - -User: *"Write a scene where the cartographer sees their failed map in a museum."* - -```jsonc -dkg_annotate_turn({ - topics: ["scene drafting", "museum"], - proposes: ["urn:dkg:scene:cartographer-sees-failed-map-in-museum"], - examines: [ - "urn:dkg:character:aging-cartographer-haunted-by-early-failure", - "urn:dkg:theme:professional-redemption", - "urn:dkg:setting:metropolitan-museum-late-afternoon" - ] -}) -``` - -### C — turn that asks a craft question - -User: *"Should this scene come before or after the daughter is introduced?"* - -```jsonc -dkg_annotate_turn({ - topics: ["plot ordering"], - examines: ["urn:dkg:scene:cartographer-sees-failed-map-in-museum"], - asks: ["urn:dkg:question:should-museum-scene-precede-or-follow-daughter-intro"] -}) -``` - -## Tool reference - -Same MCP toolkit. See repo `AGENTS.md`. - -## Don't - -- Don't conflate `:Character` (the narrative entity) with `agent:Agent` (a writer/agent in the meta sub-graph). -- Don't fabricate URIs. Always `dkg_search` first. -- Don't VM-publish via MCP — use `dkg_request_vm_publish` to flag canon-worthy scenes for human ratification. +This starter ships an RDF ontology for **narrative-writing projects** — +the structural elements of long-form narrative (Characters, Scenes, +PlotPoints, Themes, Settings) and their relationships. The formal +schema lives in `ontology.ttl` alongside this guide. + +For V10 MCP tool usage, see +[`packages/cli/skills/dkg-node/SKILL.md`](../../../../cli/skills/dkg-node/SKILL.md). +The tool surface to use against this ontology: + +- `dkg_assertion_create` + `dkg_assertion_write` — populate (WM) +- `dkg_assertion_promote` — share with peers (SWM) +- `dkg_shared_memory_publish` — finalize on-chain (VM) +- `dkg_query` — SPARQL read; `dkg_memory_search` — free-text recall + +The longer per-domain agent-guide walkthrough format will return when +the V10 ontology endpoint and per-project annotation workflow stabilise. diff --git a/packages/mcp-dkg/templates/ontologies/pkm/agent-guide.md b/packages/mcp-dkg/templates/ontologies/pkm/agent-guide.md index 7a2d9327e..d49b31a69 100644 --- a/packages/mcp-dkg/templates/ontologies/pkm/agent-guide.md +++ b/packages/mcp-dkg/templates/ontologies/pkm/agent-guide.md @@ -1,86 +1,18 @@ # PKM agent guide -You are working in a DKG context graph that uses the **PKM (Personal Knowledge Management) Ontology v1**. Your job is to keep the knowledge garden growing organically — every chat turn that surfaces a Note, Highlight, or Insight should be annotated. - -## The contract - -After every substantive turn, call `dkg_annotate_turn` exactly once. PKM-flavored entities to reach for: - -- `:Note` — atomic, self-contained thought worth capturing -- `:Highlight` — significant passage extracted from a source (article, book, transcript) -- `:Insight` — synthesis across multiple Notes or Highlights (promote via `chat:concludes`) -- `:Question` — open question worth tracking (`chat:asks`) -- `:Topic` — broad bucket (`chat:topic`) - -## Look-before-mint protocol - -Standard rule across all DKG starters: - -1. Normalise slug: lowercase → ASCII-fold → strip stopwords → hyphenate → ≤60 chars. -2. `dkg_search` the unnormalised label. -3. Reuse on slug match; mint fresh otherwise. -4. Never fabricate URIs. - -## URI patterns - -``` -urn:dkg:concept: free-text concept -urn:dkg:topic: broad topical bucket -urn:dkg:question: open question -urn:dkg:finding: preserved claim -urn:dkg:note: atomic note (the daily driver) -urn:dkg:highlight: extracted passage from a source -urn:dkg:insight: synthesis across multiple notes -``` - -## Worked examples - -### A — capturing a highlight from an article - -User: *"Capture this from the Nielsen article: 'The 1% Rule says only 1% of users on a community website actively create new content...'"* - -```jsonc -dkg_annotate_turn({ - topics: ["online communities", "user behaviour"], - mentions: ["urn:dkg:concept:1-percent-rule"], - proposes: ["urn:dkg:highlight:nielsen-1-percent-rule-90-9-1"] // freshly minted Highlight entity -}) -``` - -### B — synthesizing an insight across multiple notes - -User: *"What's the through-line between the Nielsen highlight, the lurker study from 2006, and our own engagement metrics?"* - -```jsonc -dkg_annotate_turn({ - topics: ["community engagement", "lurkers"], - examines: [ - "urn:dkg:highlight:nielsen-1-percent-rule-90-9-1", - "urn:dkg:note:lurker-study-2006", - "urn:dkg:note:our-engagement-metrics-q4" - ], - concludes: ["urn:dkg:insight:lurker-ratio-stable-across-decades-and-platforms"] -}) -``` - -### C — surfacing an open question - -User: *"What would falsify the 1% rule?"* - -```jsonc -dkg_annotate_turn({ - topics: ["1% rule", "falsifiability"], - mentions: ["urn:dkg:concept:1-percent-rule"], - asks: ["urn:dkg:question:what-would-falsify-the-1-percent-rule"] -}) -``` - -## Tool reference - -Same MCP toolkit as every project. See repo `AGENTS.md` for the full list. Key calls: `dkg_get_ontology`, `dkg_annotate_turn`, `dkg_search`, `dkg_get_entity`. - -## Don't - -- Don't conflate Notes (atomic captures) with Insights (synthesis). One Note per atomic thought; promote to Insight only when synthesising. -- Don't fabricate URIs. Always look first. -- Don't VM-publish via MCP. Insights worth canonising route through `dkg_request_vm_publish` for human review. +This starter ships an RDF ontology for **PKM (Personal Knowledge +Management) projects** — Notes, Highlights, Insights, and the links +that grow a knowledge garden organically. The formal schema lives in +`ontology.ttl` alongside this guide. + +For V10 MCP tool usage, see +[`packages/cli/skills/dkg-node/SKILL.md`](../../../../cli/skills/dkg-node/SKILL.md). +The tool surface to use against this ontology: + +- `dkg_assertion_create` + `dkg_assertion_write` — populate (WM) +- `dkg_assertion_promote` — share with peers (SWM) +- `dkg_shared_memory_publish` — finalize on-chain (VM) +- `dkg_query` — SPARQL read; `dkg_memory_search` — free-text recall + +The longer per-domain agent-guide walkthrough format will return when +the V10 ontology endpoint and per-project annotation workflow stabilise. diff --git a/packages/mcp-dkg/templates/ontologies/scientific-research/agent-guide.md b/packages/mcp-dkg/templates/ontologies/scientific-research/agent-guide.md index 9db36fcc3..989f127ee 100644 --- a/packages/mcp-dkg/templates/ontologies/scientific-research/agent-guide.md +++ b/packages/mcp-dkg/templates/ontologies/scientific-research/agent-guide.md @@ -1,86 +1,19 @@ # Scientific-research agent guide -You are working in a DKG context graph that uses the **Scientific Research Ontology v1**. The graph tracks the full empirical research arc: Hypotheses → Experiments → Results → Reproducibility chains, all anchored in PROV-O and FaBiO so they compose with existing scholarly publishing infrastructure. - -## The contract - -After every substantive turn, call `dkg_annotate_turn` exactly once. Reach for the science-flavored entities when the turn discusses experimental design or empirical claims: - -- `:Hypothesis` — testable claim -- `:Experiment` — defined procedure (`prov:Activity`) -- `:Method` — reusable methodology (`prov:Plan`) -- `:Result` — outcome produced by an Experiment -- `:Dataset` — input/output data (DCAT-aligned) - -## Look-before-mint protocol - -1. Normalise slug: lowercase → ASCII-fold → strip stopwords → hyphenate → ≤60 chars. -2. `dkg_search` first. -3. Reuse on match; mint otherwise. -4. Never fabricate URIs. - -## URI patterns - -``` -urn:dkg:concept: free-text concept -urn:dkg:topic: broad topical bucket -urn:dkg:question: open question (research question) -urn:dkg:hypothesis: testable claim -urn:dkg:experiment: defined procedure run to test -urn:dkg:method: reusable methodology -urn:dkg:result: experiment outcome -urn:dkg:dataset: input or output dataset -urn:dkg:finding: a Result promoted to a canonical claim -``` - -## Worked examples - -### A — turn that designs an experiment - -User: *"Design an experiment to test whether agent annotation rate scales linearly with chat session length."* - -```jsonc -dkg_annotate_turn({ - topics: ["experiment design", "scaling laws", "agent behavior"], - mentions: ["urn:dkg:concept:annotation-rate", "urn:dkg:concept:session-length"], - proposes: [ - "urn:dkg:hypothesis:annotation-rate-scales-linearly-with-session-length", - "urn:dkg:experiment:annotation-vs-session-length-2026-04-18" - ] -}) -``` - -### B — turn that reports a result - -User: *"The experiment ran. Mean annotations/turn was 1.2 (SD 0.3) across 100 sessions, no length dependence detected (p=0.87)."* - -```jsonc -dkg_annotate_turn({ - topics: ["experimental results"], - examines: ["urn:dkg:experiment:annotation-vs-session-length-2026-04-18"], - concludes: ["urn:dkg:result:annotation-rate-1-2-per-turn-no-length-dependence"] -}) -``` - -### C — turn that questions reproducibility - -User: *"Has anyone reproduced this on a different model?"* - -```jsonc -dkg_annotate_turn({ - topics: ["reproducibility"], - examines: ["urn:dkg:experiment:annotation-vs-session-length-2026-04-18"], - asks: ["urn:dkg:question:has-annotation-rate-result-been-reproduced-on-other-models"] -}) -``` - -## Tool reference - -Same MCP toolkit. See repo `AGENTS.md`. - -## Don't - -- Don't promote a `:Hypothesis` to a `:Finding` without a supporting `:Result` linked via `:supportedBy`. -- Don't conflate `:Method` (reusable procedure) with `:Experiment` (one execution of a procedure). -- Don't fabricate URIs. Always `dkg_search` first. -- Don't VM-publish via MCP — use `dkg_request_vm_publish` to flag canonical Findings/Results for human ratification. +This starter ships an RDF ontology for **scientific-research projects** — +the empirical research arc (Hypotheses → Experiments → Results → +Reproducibility chains), anchored in PROV-O and FaBiO so the graph +composes with existing scholarly publishing infrastructure. The formal +schema lives in `ontology.ttl` alongside this guide. + +For V10 MCP tool usage, see +[`packages/cli/skills/dkg-node/SKILL.md`](../../../../cli/skills/dkg-node/SKILL.md). +The tool surface to use against this ontology: + +- `dkg_assertion_create` + `dkg_assertion_write` — populate (WM) +- `dkg_assertion_promote` — share with peers (SWM) +- `dkg_shared_memory_publish` — finalize on-chain (VM) +- `dkg_query` — SPARQL read; `dkg_memory_search` — free-text recall + +The longer per-domain agent-guide walkthrough format will return when +the V10 ontology endpoint and per-project annotation workflow stabilise. diff --git a/packages/mcp-dkg/test/assertion-lifecycle.test.ts b/packages/mcp-dkg/test/assertion-lifecycle.test.ts new file mode 100644 index 000000000..6e724dcda --- /dev/null +++ b/packages/mcp-dkg/test/assertion-lifecycle.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { registerAssertionTools } from '../src/tools/assertions.js'; +import { FakeServer, FakeClient, makeConfig } from './harness.js'; + +describe('assertion CRUD quintet — round-trip with @en literal preservation', () => { + let server: FakeServer; + let client: FakeClient; + + beforeEach(() => { + server = new FakeServer(); + client = new FakeClient(); + registerAssertionTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + }); + + it('registers all seven assertion-family tools', () => { + const expected = [ + 'dkg_assertion_create', + 'dkg_assertion_write', + 'dkg_assertion_promote', + 'dkg_assertion_discard', + 'dkg_assertion_query', + 'dkg_assertion_import_file', + 'dkg_assertion_history', + ]; + for (const name of expected) { + expect(server.tools.has(name)).toBe(true); + } + }); + + it('round-trips create → write → promote → query, preserving @en language tags on literals', async () => { + const created = await server.call('dkg_assertion_create', { name: 'session-2026' }); + expect(created.isError).toBeFalsy(); + expect(created.content[0].text).toMatch(/Created assertion 'session-2026'/); + + const langTagged = '"hello world"@en'; + const written = await server.call('dkg_assertion_write', { + name: 'session-2026', + quads: [ + { subject: 'urn:x:1', predicate: 'urn:p:label', object: langTagged }, + { subject: 'urn:x:1', predicate: 'urn:p:type', object: 'urn:Note' }, + ], + }); + expect(written.isError).toBeFalsy(); + expect(written.content[0].text).toMatch(/Wrote 2 quad\(s\)/); + + const promoted = await server.call('dkg_assertion_promote', { + name: 'session-2026', + entities: ['urn:x:1'], + }); + expect(promoted.isError).toBeFalsy(); + expect(promoted.content[0].text).toMatch(/Promoted 1 entity/); + + const queried = await server.call('dkg_assertion_query', { name: 'session-2026' }); + expect(queried.isError).toBeFalsy(); + expect(queried.content[0].text).toMatch(/2 quad\(s\)/); + // @en lang-tag must round-trip byte-for-byte through the JSON dump. + // The dump uses JSON.stringify so inner double-quotes are escaped to + // \" — assert against the encoded form here, and against the + // unescaped form on the raw stored quad below. + expect(queried.content[0].text).toContain('\\"hello world\\"@en'); + + const cell = client.assertions.get('test-cg::session-2026'); + expect(cell).toBeDefined(); + expect(cell!.quads).toHaveLength(2); + expect(cell!.promotedRoots.has('urn:x:1')).toBe(true); + // Object stored verbatim — language-tagged literal is preserved on + // both the wire and in the memory fixture. + expect(cell!.quads[0].object).toBe(langTagged); + }); + + it('create is idempotent: a duplicate name reports alreadyExists rather than erroring', async () => { + await server.call('dkg_assertion_create', { name: 'dupe' }); + const second = await server.call('dkg_assertion_create', { name: 'dupe' }); + expect(second.isError).toBeFalsy(); + expect(second.content[0].text).toMatch(/already exists/); + }); + + it('rejects bad assertion-name slugs at the schema layer (zod regex)', async () => { + await expect( + server.call('dkg_assertion_create', { name: 'Invalid Name With Spaces' }), + ).rejects.toThrow(); + }); + + it('write requires a non-empty quads array', async () => { + await server.call('dkg_assertion_create', { name: 'empty' }); + await expect( + server.call('dkg_assertion_write', { name: 'empty', quads: [] }), + ).rejects.toThrow(); + }); + + it('promote rejects an empty entities array (must be omitted or non-empty)', async () => { + await server.call('dkg_assertion_create', { name: 'rollback' }); + await server.call('dkg_assertion_write', { + name: 'rollback', + quads: [{ subject: 'urn:r', predicate: 'urn:p', object: '"v"' }], + }); + const result = await server.call('dkg_assertion_promote', { + name: 'rollback', + entities: [], + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/non-empty array/); + }); + + it('discard marks the assertion discarded; subsequent writes fail', async () => { + await server.call('dkg_assertion_create', { name: 'rollback' }); + await server.call('dkg_assertion_discard', { name: 'rollback' }); + expect(client.assertions.get('test-cg::rollback')!.discarded).toBe(true); + const writeAfterDiscard = await server.call('dkg_assertion_write', { + name: 'rollback', + quads: [{ subject: 'urn:r', predicate: 'urn:p', object: '"v"' }], + }); + expect(writeAfterDiscard.isError).toBe(true); + }); + + it('query without a project returns the canonical "no project specified" hint', async () => { + const noProjectServer = new FakeServer(); + const noProjectClient = new FakeClient(); + registerAssertionTools( + noProjectServer.asMcpServer(), + noProjectClient.asDkgClient(), + makeConfig({ defaultProject: null }), + ); + const result = await noProjectServer.call('dkg_assertion_query', { name: 'x' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/No project specified/); + }); +}); + +describe('dkg_assertion_import_file — wave-2 P1 add', () => { + let server: FakeServer; + let client: FakeClient; + let tempDir: string; + + beforeEach(async () => { + server = new FakeServer(); + client = new FakeClient(); + registerAssertionTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + tempDir = await mkdtemp(path.join(tmpdir(), 'dkg-mcp-test-')); + }); + + it('reads a local markdown file and forwards it to the daemon with inferred MIME', async () => { + const filePath = path.join(tempDir, 'notes.md'); + await writeFile(filePath, '# Hello\n\nA short markdown doc.\n', 'utf-8'); + + const captured: Record = {}; + client = new FakeClient({ + importAssertionFile: async (args) => { + captured.assertionName = args.assertionName; + captured.contentType = args.contentType; + captured.fileName = args.fileName; + captured.bytes = args.fileBuffer.byteLength; + return { extraction: { status: 'completed', tripleCount: 3 } }; + }, + }); + const localServer = new FakeServer(); + registerAssertionTools(localServer.asMcpServer(), client.asDkgClient(), makeConfig()); + + const result = await localServer.call('dkg_assertion_import_file', { + name: 'imported', + filePath, + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/Imported 'notes\.md'/); + expect(result.content[0].text).toMatch(/3 triple\(s\)/); + expect(captured.contentType).toBe('text/markdown'); + expect(captured.fileName).toBe('notes.md'); + + await rm(tempDir, { recursive: true, force: true }); + }); + + it('surfaces a tool error when the file path does not exist', async () => { + const result = await server.call('dkg_assertion_import_file', { + name: 'missing', + filePath: path.join(tempDir, 'no-such-file.md'), + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Failed to read file/); + + await rm(tempDir, { recursive: true, force: true }); + }); +}); + +describe('dkg_assertion_history — wave-2 P3 add', () => { + let server: FakeServer; + let client: FakeClient; + + beforeEach(() => { + server = new FakeServer(); + client = new FakeClient(); + registerAssertionTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + }); + + it('returns the lifecycle JSON block for a known assertion', async () => { + const result = await server.call('dkg_assertion_history', { name: 'session-2026' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/History for assertion 'session-2026'/); + expect(result.content[0].text).toContain('"author": "urn:dkg:agent:test"'); + }); +}); diff --git a/packages/mcp-dkg/test/capture-hook.test.ts b/packages/mcp-dkg/test/capture-hook.test.ts deleted file mode 100644 index 6c3676a18..000000000 --- a/packages/mcp-dkg/test/capture-hook.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - parseDotDkgConfig, - pick, - extractText, - extractSessionKey, - extractMentionedUris, - sanitiseSlug, - buildPerTurnReminder, -} from '../hooks/capture-chat.mjs'; - -/** - * Pure-function tests for the capture-chat hook. The hook runs on - * every Cursor / Claude Code event and is the only piece of plumbing - * with field-name guesses (Cursor 3.1.15 vs Claude Code vs future - * tools). These tests pin the field-resolution behaviour so a future - * Cursor payload-shape change is detected as a test failure rather - * than silent capture loss. - */ - -describe('parseDotDkgConfig — hand-rolled YAML loader', () => { - it('parses flat keys', () => { - const cfg = parseDotDkgConfig(` -contextGraph: dkg-code-project -autoShare: true -`); - expect(cfg.contextGraph).toBe('dkg-code-project'); - expect(cfg.autoShare).toBe(true); - }); - - it('parses nested objects', () => { - const cfg = parseDotDkgConfig(` -node: - api: http://localhost:9201 - tokenFile: ../auth.token -agent: - uri: urn:dkg:agent:cursor-branarakic - speakerTool: cursor -`); - expect(cfg.node.api).toBe('http://localhost:9201'); - expect(cfg.node.tokenFile).toBe('../auth.token'); - expect(cfg.agent.uri).toBe('urn:dkg:agent:cursor-branarakic'); - expect(cfg.agent.speakerTool).toBe('cursor'); - }); - - it('strips quoted strings', () => { - const cfg = parseDotDkgConfig(` -contextGraph: "dkg-code-project" -agent: - uri: 'urn:dkg:agent:cursor-branarakic' -`); - expect(cfg.contextGraph).toBe('dkg-code-project'); - expect(cfg.agent.uri).toBe('urn:dkg:agent:cursor-branarakic'); - }); - - it('coerces booleans + integers', () => { - const cfg = parseDotDkgConfig(` -autoShare: true -maxTurns: 100 -private: false -`); - expect(cfg.autoShare).toBe(true); - expect(cfg.private).toBe(false); - expect(cfg.maxTurns).toBe(100); - }); - - it('strips inline comments', () => { - const cfg = parseDotDkgConfig(` -contextGraph: dkg-code-project # the project ID -autoShare: true # spec default -`); - expect(cfg.contextGraph).toBe('dkg-code-project'); - expect(cfg.autoShare).toBe(true); - }); - - it('handles empty / blank lines without crashing', () => { - const cfg = parseDotDkgConfig(` - -contextGraph: foo - - -agent: - - uri: bar - -`); - expect(cfg.contextGraph).toBe('foo'); - expect(cfg.agent.uri).toBe('bar'); - }); -}); - -describe('pick — deep candidate-key resolver', () => { - it('returns first matching key from candidates list', () => { - const obj = { foo: 'first', bar: 'second' }; - expect(pick(obj, ['baz', 'foo', 'bar'])).toBe('first'); - expect(pick(obj, ['bar', 'foo'])).toBe('second'); - }); - - it('descends into nested objects', () => { - const obj = { meta: { details: { prompt: 'deep value' } } }; - expect(pick(obj, ['prompt'])).toBe('deep value'); - }); - - it('returns undefined when no candidate matches', () => { - expect(pick({ a: 1 }, ['b', 'c'])).toBeUndefined(); - }); - - it('skips empty strings (treats them as not-found)', () => { - const obj = { prompt: '', text: 'real value' }; - expect(pick(obj, ['prompt', 'text'])).toBe('real value'); - }); - - it('coerces numbers + booleans to strings', () => { - expect(pick({ count: 42 }, ['count'])).toBe('42'); - expect(pick({ ok: true }, ['ok'])).toBe('true'); - }); - - it('caps recursion depth (no infinite loops on circular objects)', () => { - const obj: any = { a: {} }; - obj.a.b = obj; // circular - expect(() => pick(obj, ['nonexistent'])).not.toThrow(); - }); -}); - -describe('extractText — payload prompt/response resolver', () => { - it('finds Cursor 3.1.15 prompt field', () => { - expect(extractText({ prompt: 'hello', conversation_id: 'x' })).toBe('hello'); - }); - - it('finds Cursor 3.1.15 afterAgentResponse text field', () => { - expect(extractText({ text: 'agent reply', model: 'claude-opus' })).toBe('agent reply'); - }); - - it('finds Claude Code Stop event last_assistant_message', () => { - expect(extractText({ session_id: 'x', last_assistant_message: 'CC reply' })) - .toBe('CC reply'); - }); - - it('finds camelCase variants (lastAssistantMessage)', () => { - expect(extractText({ lastAssistantMessage: 'camel' })).toBe('camel'); - }); - - it('finds nested fields via deep search', () => { - expect(extractText({ data: { content: 'wrapped' } })).toBe('wrapped'); - }); - - it('returns empty string when no candidate matches', () => { - expect(extractText({ unknown_field: 'x' })).toBe(''); - expect(extractText({})).toBe(''); - }); -}); - -describe('extractSessionKey — session ID resolver', () => { - it('finds Cursor conversation_id', () => { - expect(extractSessionKey({ conversation_id: 'cursor-uuid-123' })) - .toBe('cursor-uuid-123'); - }); - - it('finds Claude Code session_id', () => { - expect(extractSessionKey({ session_id: 'cc-uuid-456' })).toBe('cc-uuid-456'); - }); - - it('sanitises through sanitiseSlug (no path-traversal exploits)', () => { - expect(extractSessionKey({ session_id: '../../etc/passwd' })) - .not.toContain('../'); - }); - - it('falls back to a unique per-invocation anon key when no id present', () => { - // The old hour-bucket fallback (`anon-2026-04-20T14`) silently merged - // unrelated conversations that happened to land in the same 60-minute - // window. The new fallback is a randomised `anon--` - // persisted per shell process. - const key = extractSessionKey({}); - expect(key).toMatch(/^anon-[a-z0-9]+-[a-z0-9]+$/); - expect(key).not.toMatch(/^anon-\d{4}-\d{2}-\d{2}T\d{2}$/); - }); -}); - -describe('extractMentionedUris — regex backstop', () => { - it('catches a single urn:dkg:* URI', () => { - expect(extractMentionedUris('check urn:dkg:concept:foo')) - .toEqual(['urn:dkg:concept:foo']); - }); - - it('catches multiple URIs in one string', () => { - const text = 'see urn:dkg:decision:adopt-x and urn:dkg:task:do-y'; - expect(extractMentionedUris(text)).toEqual([ - 'urn:dkg:decision:adopt-x', - 'urn:dkg:task:do-y', - ]); - }); - - it('handles complex slugs with hyphens, dots, percent-encoding', () => { - const text = 'urn:dkg:code:file:%40origintrail-official%2Fdkg-cli/src/index.ts'; - expect(extractMentionedUris(text)).toEqual([ - 'urn:dkg:code:file:%40origintrail-official%2Fdkg-cli/src/index.ts', - ]); - }); - - it('catches URIs across multiple text inputs (prompt + response)', () => { - const prompt = 'analyse urn:dkg:decision:foo'; - const response = 'and consider urn:dkg:task:bar'; - expect(extractMentionedUris(prompt, response)).toEqual([ - 'urn:dkg:decision:foo', - 'urn:dkg:task:bar', - ]); - }); - - it('deduplicates URIs that appear in both prompt and response', () => { - const prompt = 'about urn:dkg:concept:foo'; - const response = 'urn:dkg:concept:foo is interesting'; - expect(extractMentionedUris(prompt, response)).toEqual(['urn:dkg:concept:foo']); - }); - - it('strips trailing punctuation (period, comma, parens)', () => { - expect(extractMentionedUris('see urn:dkg:concept:foo.')) - .toEqual(['urn:dkg:concept:foo']); - expect(extractMentionedUris('(urn:dkg:concept:bar)')) - .toEqual(['urn:dkg:concept:bar']); - }); - - it('returns empty array when no URIs present', () => { - expect(extractMentionedUris('just some text')).toEqual([]); - expect(extractMentionedUris('')).toEqual([]); - expect(extractMentionedUris(undefined as any, null as any)).toEqual([]); - }); -}); - -describe('sanitiseSlug — defensive session-key cleaner', () => { - it('strips path-traversal characters', () => { - expect(sanitiseSlug('../../../etc/passwd')).not.toContain('/'); - expect(sanitiseSlug('a/b/c')).not.toContain('/'); - }); - - it('preserves hyphens, dots, underscores, alphanumerics', () => { - expect(sanitiseSlug('Test_Session-123.foo')) - .toBe('Test_Session-123.foo'); - }); - - it('truncates to 80 chars', () => { - const s = sanitiseSlug('x'.repeat(100)); - expect(s.length).toBeLessThanOrEqual(80); - }); -}); - -describe('buildPerTurnReminder — Phase 7B per-turn injection', () => { - it('includes the session ID in the reminder body', () => { - const md = buildPerTurnReminder('abc-123'); - expect(md).toContain('abc-123'); - expect(md).toContain('forSession'); - expect(md).toContain('dkg_annotate_turn'); - }); - - it('stays under 600 chars (per-turn token budget)', () => { - const md = buildPerTurnReminder('a-typical-session-uuid-of-normal-length-12345'); - expect(md.length).toBeLessThan(600); - }); - - it('mentions look-before-mint indirectly via the rule reference', () => { - const md = buildPerTurnReminder('x'); - expect(md).toContain('.cursor/rules/dkg-annotate.mdc'); - }); -}); diff --git a/packages/mcp-dkg/test/drop-sweep.test.ts b/packages/mcp-dkg/test/drop-sweep.test.ts new file mode 100644 index 000000000..cbd25e913 --- /dev/null +++ b/packages/mcp-dkg/test/drop-sweep.test.ts @@ -0,0 +1,139 @@ +/** + * Drop-sweep + read-side regex-scope guards (per verification-plan v8 §0.10.7). + * + * Two future-regression tests, deliberately cheap: + * + * 1. **Drop-sweep** — the 10 tool names removed in `c222ddcf` (W2-#18) MUST + * NOT reappear in `tools/list` output. The bug-class-most-likely is a + * well-meaning re-registration during a future cycle ("oh that look + * useful, let me revive it") slipping past review because the surface + * was 21 before the change and 22 after. This test catches that at the + * suite level, not at the surface-probe level (which is harder to + * enforce in CI without a daemon). + * + * Discipline mirrors §0.8 fixture 4 ("the cheap blanket guard"). Single + * array of names, single forEach assertion, one test. + * + * 2. **Read-side regex-scope guard** — per matrix v0.5 §4.16 alignment + * paragraph, the `/^[a-z0-9-]+$/` regex on the assertion `name` argument + * is creator-side input validation only. Read-side / lookup-side tools + * (`dkg_assertion_write / promote / discard / query` + `_history` + + * `_import_file`) MUST NOT inherit it — they look up assertions that + * may have been minted by other agents whose names don't conform. + * + * The bug-class-most-likely is an implementer copying the regex from + * `dkg_assertion_create` to all five tools because they look symmetric + * ("name should always be slug-shaped, right?"). This test asserts the + * asymmetry by passing a non-conforming name to each read-side tool + * and confirming the schema does NOT reject it. + * + * Both tests register every production-side tool module (6 register + * functions, mirroring `src/index.ts`) so the assertions run against the + * full surface. Adding a new register function in production without + * adding it here means this file silently under-covers — an explicit + * regression in the next wave's W?-Q audit. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { registerReadTools } from '../src/tools.js'; +import { registerAssertionTools } from '../src/tools/assertions.js'; +import { registerMemorySearchTool } from '../src/tools/memory-search.js'; +import { registerSetupTools } from '../src/tools/setup.js'; +import { registerHealthTools } from '../src/tools/health.js'; +import { registerPublishTools } from '../src/tools/publish.js'; +import { FakeServer, FakeClient, makeConfig } from './harness.js'; + +/** + * The 10 tool names removed in W2-#18 (`c222ddcf`). Mirrors the audit's + * §7 drop list. Surviving registration of any of these is a port-hygiene + * regression — block. + */ +const DROPPED_TOOLS = [ + // V9-era / no SKILL.md analog (7): + 'dkg_review_manifest', + 'dkg_annotate_turn', + 'dkg_get_ontology', + 'dkg_get_chat', + 'dkg_set_session_privacy', + 'dkg_request_vm_publish', + 'dkg_search', + // Coding-project sugar (3): + 'dkg_propose_decision', + 'dkg_add_task', + 'dkg_comment', +] as const; + +describe('drop-sweep — none of the 10 W2-dropped tools reappear in tools/list', () => { + let server: FakeServer; + + beforeEach(() => { + server = new FakeServer(); + const client = new FakeClient(); + const config = makeConfig(); + // Mirror src/index.ts. If a new register* call lands in production, add it here too. + registerReadTools(server.asMcpServer(), client.asDkgClient(), config); + registerAssertionTools(server.asMcpServer(), client.asDkgClient(), config); + registerMemorySearchTool(server.asMcpServer(), client.asDkgClient(), config); + registerSetupTools(server.asMcpServer(), client.asDkgClient(), config); + registerHealthTools(server.asMcpServer(), client.asDkgClient(), config); + registerPublishTools(server.asMcpServer(), client.asDkgClient(), config); + }); + + it.each(DROPPED_TOOLS)('does not register %s', (name) => { + expect(server.tools.has(name)).toBe(false); + }); + + it('registered surface contains exactly 21 tools (post-PR locked count)', () => { + expect(server.tools.size).toBe(21); + }); +}); + +/** + * Regex-scope guard. Production source (matrix v0.5 §4.16 alignment paragraph) + * documents that the slug regex applies ONLY to `dkg_assertion_create`'s + * `name` arg. Every other tool that takes a `name` argument must accept + * richer strings. + * + * Test strategy: try a deliberately non-conforming name on each read-side + * tool. The schema MUST NOT reject it (no -32602 / no zod throw at the + * input boundary). The handler may then return a "not found" empty result + * or whatever — that's behavioural, not the gate. The gate is "schema + * accepts the input." + */ +describe('regex-scope guard — read-side `name` arg accepts non-conforming slugs', () => { + let server: FakeServer; + let client: FakeClient; + + beforeEach(() => { + server = new FakeServer(); + client = new FakeClient(); + const config = makeConfig(); + registerAssertionTools(server.asMcpServer(), client.asDkgClient(), config); + }); + + // The four read-side / lookup-side assertion tools. `dkg_assertion_create` is + // INTENTIONALLY excluded — it IS the regex-bearing tool. + it.each([ + ['dkg_assertion_write', { name: 'Bad Name With Spaces', quads: [{ subject: 'urn:x', predicate: 'urn:p', object: '"v"' }] }], + ['dkg_assertion_promote', { name: 'Bad Name With Spaces' }], + ['dkg_assertion_discard', { name: 'Bad Name With Spaces' }], + ['dkg_assertion_query', { name: 'Bad Name With Spaces' }], + ['dkg_assertion_history', { name: 'Bad Name With Spaces' }], + ])('%s schema accepts non-slug `name` (no zod throw at input boundary)', async (toolName, args) => { + // Don't care what the handler returns — it'll behaviourally produce a + // not-found result against the empty FakeClient state. The assertion + // is that the schema parse layer does NOT reject the input shape. + // If a future change adds the create-side regex to read-side schemas, + // this call rejects with a ZodError and the test fails. + await expect(server.call(toolName, args)).resolves.toBeDefined(); + }); + + // Positive control: dkg_assertion_create DOES enforce the regex (per + // assertion-lifecycle.test.ts:81). Re-asserting here so the asymmetry is + // visible in this file alone — a reviewer reading just `drop-sweep.test.ts` + // can see why the read-side test exists. + it('positive control: dkg_assertion_create rejects non-slug `name` (regex IS enforced creator-side)', async () => { + await expect( + server.call('dkg_assertion_create', { name: 'Bad Name With Spaces' }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/mcp-dkg/test/harness.ts b/packages/mcp-dkg/test/harness.ts new file mode 100644 index 000000000..6ebc06670 --- /dev/null +++ b/packages/mcp-dkg/test/harness.ts @@ -0,0 +1,329 @@ +import { z, type ZodRawShape, type ZodTypeAny } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { DkgClient } from '../src/client.js'; +import type { DkgConfig } from '../src/config.js'; + +export interface RegisteredTool { + name: string; + config: { + title?: string; + description?: string; + inputSchema?: ZodRawShape; + }; + handler: (...args: unknown[]) => Promise; +} + +export interface ToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +export interface RegisterCall { + name: string; + inputSchema?: ZodRawShape; + description?: string; +} + +export class FakeServer { + readonly tools = new Map(); + readonly registerCalls: RegisterCall[] = []; + + registerTool( + name: string, + config: RegisteredTool['config'], + handler: RegisteredTool['handler'], + ): { name: string } { + if (this.tools.has(name)) { + throw new Error(`Duplicate tool registration: ${name}`); + } + const entry: RegisteredTool = { name, config, handler }; + this.tools.set(name, entry); + this.registerCalls.push({ + name, + inputSchema: config.inputSchema, + description: config.description, + }); + return { name }; + } + + asMcpServer(): McpServer { + return this as unknown as McpServer; + } + + get(name: string): RegisteredTool { + const tool = this.tools.get(name); + if (!tool) throw new Error(`Tool not registered: ${name}`); + return tool; + } + + /** + * Validate input against the tool's declared zod inputSchema, then invoke + * the handler exactly the way the real MCP SDK does (positional input + * object, no extras). Throws on declared-field validation failure so + * tests can `expect` the rejection. + * + * Mirrors production MCP SDK schema posture: unknown keys are silently + * dropped at parse, NOT rejected. The pre-F27 `.strict()` mode here + * gave three tests false confidence — they asserted that legacy + * `{ layer: 'union' }` was *rejected* on `dkg_get_entity` / + * `dkg_list_activity` / `dkg_query` post-W2-#17. Against the real + * MCP SDK those calls would parse cleanly (`layer` silently dropped) + * and run the handler with the default scope. The harness now matches + * that posture so the tests describe the real surface, not a + * harness artefact. Strict-mode tests must use a different harness. + */ + async call(name: string, input: Record = {}): Promise { + const tool = this.get(name); + const shape = tool.config.inputSchema ?? {}; + const objectSchema = z.object(shape as Record); + const parsed = objectSchema.parse(input); + return tool.handler(parsed); + } +} + +export function makeConfig(overrides: Partial = {}): DkgConfig { + return { + api: 'http://localhost:9200', + token: 'test-token', + defaultProject: 'test-cg', + agentUri: 'urn:dkg:agent:test', + capture: { + autoShare: true, + defaultPrivacy: 'team', + subGraph: 'chat', + assertion: 'chat-log', + }, + sourcePath: null, + ...overrides, + }; +} + +/** + * In-memory DkgClient stub. Implements the surface area mcp-dkg tools + * actually call; everything else throws so a regression that adds a new + * client method is loud. + * + * Tests can override individual methods by passing them in the options + * object (e.g. `new FakeClient({ getAgentIdentity: async () => ({...}) })`). + */ +type ClientMethods = Partial<{ + [K in keyof DkgClient]: DkgClient[K]; +}>; + +export class FakeClient { + // Round-trip storage for the assertion quintet. + readonly assertions = new Map; + promotedRoots: Set; + discarded: boolean; + }>(); + readonly contextGraphs = new Set(); + readonly subGraphs = new Set(); + readonly subscribed = new Set(); + readonly publishCalls: Array> = []; + readonly queryCalls: Array> = []; + + /** Per-layer hits used by memory-search tests. Keys are + * `${contextGraphId}::${view}`. */ + readonly memoryFixtures = new Map>>(); + + agentIdentity: { peerId?: string; agentAddress?: string } = { + peerId: 'peer-test', + agentAddress: 'did:dkg:agent:peer-test', + }; + + status: Record = { peerId: 'peer-test', peers: 2 }; + + walletBalances = { + wallets: ['0xabc'], + balances: [ + { address: '0xabc', eth: '0.05', trac: '12.5', symbol: 'TRAC' }, + ], + chainId: 'base-sepolia', + rpcUrl: 'http://rpc.example', + }; + + constructor(private readonly overrides: ClientMethods = {}) {} + + asDkgClient(): DkgClient { + return this as unknown as DkgClient; + } + + // ── Assertion CRUD ────────────────────────────────────────────── + async createAssertion(args: { contextGraphId: string; assertionName: string }) { + if (this.overrides.createAssertion) return this.overrides.createAssertion.call(this, args); + const key = `${args.contextGraphId}::${args.assertionName}`; + if (this.assertions.has(key)) { + return { assertionUri: null, alreadyExists: true }; + } + this.assertions.set(key, { quads: [], promotedRoots: new Set(), discarded: false }); + return { + assertionUri: `urn:dkg:assertion:${args.contextGraphId}:${args.assertionName}`, + alreadyExists: false, + }; + } + + async writeAssertion(args: { + contextGraphId: string; + assertionName: string; + triples: Array<{ subject: string; predicate: string; object: string }>; + }) { + if (this.overrides.writeAssertion) return this.overrides.writeAssertion.call(this, args); + const key = `${args.contextGraphId}::${args.assertionName}`; + const cell = this.assertions.get(key); + if (!cell) throw new Error(`assertion not created: ${key}`); + if (cell.discarded) throw new Error(`assertion discarded: ${key}`); + cell.quads.push(...args.triples); + } + + async promoteAssertion(args: { + contextGraphId: string; + assertionName: string; + entities: string[]; + }) { + if (this.overrides.promoteAssertion) return this.overrides.promoteAssertion.call(this, args); + const key = `${args.contextGraphId}::${args.assertionName}`; + const cell = this.assertions.get(key); + if (!cell) throw new Error(`assertion not created: ${key}`); + for (const e of args.entities) cell.promotedRoots.add(e); + if (args.entities.length === 0) { + // "promote all roots" sentinel — capture every distinct subject. + for (const q of cell.quads) cell.promotedRoots.add(q.subject); + } + } + + async discardAssertion(args: { contextGraphId: string; assertionName: string }) { + if (this.overrides.discardAssertion) return this.overrides.discardAssertion.call(this, args); + const key = `${args.contextGraphId}::${args.assertionName}`; + const cell = this.assertions.get(key); + if (cell) cell.discarded = true; + } + + async queryAssertion(args: { contextGraphId: string; assertionName: string }) { + if (this.overrides.queryAssertion) return this.overrides.queryAssertion.call(this, args); + const key = `${args.contextGraphId}::${args.assertionName}`; + const cell = this.assertions.get(key); + if (!cell) return { quads: [], count: 0 }; + return { quads: cell.quads, count: cell.quads.length }; + } + + async getAssertionHistory(args: { contextGraphId: string; assertionName: string }) { + if (this.overrides.getAssertionHistory) return this.overrides.getAssertionHistory.call(this, args); + return { + contextGraphId: args.contextGraphId, + assertionName: args.assertionName, + author: 'urn:dkg:agent:test', + promoted: false, + createdAt: '2026-04-30T00:00:00Z', + }; + } + + async importAssertionFile(args: { + contextGraphId: string; + assertionName: string; + fileBuffer: Buffer | Uint8Array; + fileName: string; + contentType?: string; + }) { + if (this.overrides.importAssertionFile) return this.overrides.importAssertionFile.call(this, args); + return { + assertionName: args.assertionName, + fileName: args.fileName, + bytes: args.fileBuffer.byteLength, + contentType: args.contentType ?? 'application/octet-stream', + extraction: { status: 'completed', tripleCount: 7 }, + }; + } + + // ── Query ─────────────────────────────────────────────────────── + async query(args: Record) { + this.queryCalls.push(args); + if (this.overrides.query) return this.overrides.query.call(this, args); + const cgId = String(args.contextGraphId ?? ''); + const view = String(args.view ?? 'working-memory'); + const key = `${cgId}::${view}`; + const bindings = this.memoryFixtures.get(key) ?? []; + return { bindings }; + } + + async getAgentIdentity() { + if (this.overrides.getAgentIdentity) return this.overrides.getAgentIdentity.call(this); + return this.agentIdentity; + } + + async listProjects() { + if (this.overrides.listProjects) return this.overrides.listProjects.call(this); + return Array.from(this.contextGraphs).map((id) => ({ id, name: id })); + } + + async listSubGraphs(_: string) { + if (this.overrides.listSubGraphs) return this.overrides.listSubGraphs.call(this, _); + return []; + } + + // ── Setup ─────────────────────────────────────────────────────── + async createContextGraph(args: { id: string; name: string }) { + if (this.overrides.createContextGraph) return this.overrides.createContextGraph.call(this, args); + // Mirror the real client's idempotency contract (post-F2): duplicate + // ids return `alreadyExists: true` rather than throwing. + const alreadyExists = this.contextGraphs.has(args.id); + this.contextGraphs.add(args.id); + return { + created: args.id, + uri: `urn:dkg:cg:${args.id}`, + alreadyExists, + }; + } + + async ensureSubGraph(cgId: string, name: string) { + if (this.overrides.ensureSubGraph) return this.overrides.ensureSubGraph.call(this, cgId, name); + this.subGraphs.add(`${cgId}::${name}`); + } + + async subscribe(args: { contextGraphId: string; includeSharedMemory?: boolean }) { + if (this.overrides.subscribe) return this.overrides.subscribe.call(this, args); + this.subscribed.add(args.contextGraphId); + return { + subscribed: args.contextGraphId, + catchup: { + jobId: 'job-1', + status: 'queued', + includeSharedMemory: args.includeSharedMemory ?? true, + }, + }; + } + + // ── Health ────────────────────────────────────────────────────── + async getStatus() { + if (this.overrides.getStatus) return this.overrides.getStatus.call(this); + return this.status; + } + + async getWalletBalances() { + if (this.overrides.getWalletBalances) return this.overrides.getWalletBalances.call(this); + return this.walletBalances; + } + + // ── Publish ───────────────────────────────────────────────────── + async publishQuads(args: Record) { + if (this.overrides.publishQuads) return this.overrides.publishQuads.call(this, args); + this.publishCalls.push({ kind: 'publishQuads', ...args }); + return { kcId: 'kc-1', kas: [], txHash: '0xdead' }; + } + + async publishSharedMemory(args: Record) { + if (this.overrides.publishSharedMemory) return this.overrides.publishSharedMemory.call(this, args); + this.publishCalls.push({ kind: 'publishSharedMemory', ...args }); + return { kcId: 'kc-2', kas: [{ tokenId: '1', rootEntity: 'urn:x' }], txHash: '0xbeef' }; + } + + async registerContextGraph(args: { id: string }) { + if (this.overrides.registerContextGraph) return this.overrides.registerContextGraph.call(this, args); + return { + registered: args.id, + onChainId: `chain:${args.id}`, + txHash: '0xreg', + alreadyRegistered: false, + }; + } +} diff --git a/packages/mcp-dkg/test/memory-search.test.ts b/packages/mcp-dkg/test/memory-search.test.ts new file mode 100644 index 000000000..e0431bcd1 --- /dev/null +++ b/packages/mcp-dkg/test/memory-search.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { registerMemorySearchTool } from '../src/tools/memory-search.js'; +import { FakeServer, FakeClient, makeConfig } from './harness.js'; + +describe('dkg_memory_search — multi-layer fan-out + trust-tier dedup', () => { + let server: FakeServer; + let client: FakeClient; + + beforeEach(() => { + server = new FakeServer(); + client = new FakeClient(); + registerMemorySearchTool(server.asMcpServer(), client.asDkgClient(), makeConfig()); + }); + + it('registers the dkg_memory_search tool', () => { + expect(server.tools.has('dkg_memory_search')).toBe(true); + }); + + it('fan-out covers 3 layers without projectId (agent-context only)', async () => { + // Same matching text written to both WM and SWM of agent-context. The + // SWM hit's trust tier is higher and must win the dedup. + const text = 'tree-sitter parses python files incrementally for ide tooling'; + client.memoryFixtures.set('agent-context::working-memory', [ + { uri: { value: 'urn:doc:1' }, text: { value: text } }, + ]); + client.memoryFixtures.set('agent-context::shared-working-memory', [ + { uri: { value: 'urn:doc:1' }, text: { value: text } }, + ]); + // VM has no hit — confirms that a layer with zero rows still gets + // queried (and reported in the breakdown). + + const result = await server.call('dkg_memory_search', { query: 'tree-sitter parses' }); + expect(result.isError).toBeFalsy(); + + const text0 = result.content[0].text; + // Layer breakdown must mention all three agent-context layers. + expect(text0).toMatch(/agent-context-wm:1/); + expect(text0).toMatch(/agent-context-swm:1/); + expect(text0).toMatch(/agent-context-vm:0/); + // Query was fanned out 3 times. + expect(client.queryCalls).toHaveLength(3); + // Exactly one hit after dedup (SWM ranks above WM for the same uri). + expect(text0).toMatch(/1 hit\(s\)/); + expect(text0).toMatch(/SWM/); + }); + + it('fan-out covers 6 layers when projectId is supplied', async () => { + client.memoryFixtures.set('proj-x::verified-memory', [ + { uri: { value: 'urn:doc:vm' }, text: { value: 'highly verified content about tree-sitter parsers' } }, + ]); + const result = await server.call('dkg_memory_search', { query: 'tree-sitter parsers', projectId: 'proj-x' }); + expect(result.isError).toBeFalsy(); + expect(client.queryCalls).toHaveLength(6); + expect(result.content[0].text).toMatch(/project-vm:1/); + expect(result.content[0].text).toMatch(/proj-x · VM/); + }); + + it('VM hit collapses an SWM hit on the same entity URI (trust tier ordering: VM > SWM > WM)', async () => { + const text = 'agreed-on architectural decision about staking adapter v2'; + client.memoryFixtures.set('agent-context::working-memory', [ + { uri: { value: 'urn:dec:1' }, text: { value: text } }, + ]); + client.memoryFixtures.set('agent-context::shared-working-memory', [ + { uri: { value: 'urn:dec:1' }, text: { value: text } }, + ]); + client.memoryFixtures.set('agent-context::verified-memory', [ + { uri: { value: 'urn:dec:1' }, text: { value: text } }, + ]); + + const result = await server.call('dkg_memory_search', { query: 'staking adapter' }); + expect(result.isError).toBeFalsy(); + const text0 = result.content[0].text; + // Three raw hits across layers, but only ONE survives dedup. + expect(text0).toMatch(/agent-context-wm:1, agent-context-swm:1, agent-context-vm:1/); + expect(text0).toMatch(/1 hit\(s\)/); + // The single survivor is the VM tier with weight 1.30 — the + // canonical signal that the trust ranker stayed coherent. + expect(text0).toMatch(/VM · weight=1\.30/); + // No SWM/WM tier marker should leak into the surviving hit line. + const hitBlock = text0.split('### 1.')[1] ?? ''; + expect(hitBlock).not.toMatch(/SWM · weight=1\.15/); + expect(hitBlock).not.toMatch(/WM · weight=1\.00/); + }); + + it('rejects a query shorter than 2 characters at the schema layer', async () => { + await expect(server.call('dkg_memory_search', { query: 'a' })).rejects.toThrow(); + }); + + it('returns a backend-not-ready error when the daemon cannot resolve agent identity', async () => { + const localServer = new FakeServer(); + const localClient = new FakeClient({ + getAgentIdentity: async () => ({}), + }); + registerMemorySearchTool(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig()); + + const result = await localServer.call('dkg_memory_search', { query: 'anything goes here' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/backend not ready/); + }); + + it('passes the raw peerId (not DID form) to the daemon for WM-view routing', async () => { + client.agentIdentity = { + peerId: 'peer-raw-abc', + agentAddress: 'did:dkg:agent:peer-raw-abc', + }; + client.memoryFixtures.set('agent-context::working-memory', [ + { uri: { value: 'urn:x' }, text: { value: 'this snippet is plenty long to clear the 20-char floor for matching' } }, + ]); + await server.call('dkg_memory_search', { query: 'snippet plenty' }); + // Every fan-out call must carry the raw peerId, not the DID form — + // the DID prefix routes WM into a non-existent namespace and + // silently zeroes out hits (the regression this guards). + for (const call of client.queryCalls) { + expect(call.agentAddress).toBe('peer-raw-abc'); + expect(String(call.agentAddress)).not.toMatch(/^did:/); + } + }); + + it('respects the SKILL.md §6.3 6-element combined-string layer contract', async () => { + client.memoryFixtures.set('agent-context::working-memory', [ + { uri: { value: 'urn:wm-only' }, text: { value: 'only working-memory hit, no other layers see this snippet' } }, + ]); + const result = await server.call('dkg_memory_search', { query: 'working-memory snippet' }); + expect(result.isError).toBeFalsy(); + // Render-time projection: contextGraphId · TIER (CG separated from + // tier in the rendered text, but the underlying Hit.layer field is + // still the 6-element combined string per SKILL §6.3). + expect(result.content[0].text).toMatch(/agent-context · WM/); + }); +}); diff --git a/packages/mcp-dkg/test/normalise-slug.test.ts b/packages/mcp-dkg/test/normalise-slug.test.ts deleted file mode 100644 index a18097131..000000000 --- a/packages/mcp-dkg/test/normalise-slug.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { normaliseSlug } from '../src/tools/annotations.js'; - -/** - * Slug normalisation is the convergence rule. Without it, parallel - * agents mint divergent URIs for the same concept and the graph - * fragments. These tests pin the algorithm — any change here is a - * coordination break across the whole DKG annotation ecosystem. - * - * Algorithm (per coding-project ontology §7): - * 1. Lowercase - * 2. Unicode NFKD + strip combining marks (ASCII fold) - * 3. Strip stopwords (the/a/an/of/for/and/or/to/in/on/with) - * 4. Replace any run of non-[a-z0-9] with a single hyphen - * 5. Trim leading/trailing hyphens - * 6. Truncate to 60 chars - */ -describe('normaliseSlug — convergence rule', () => { - describe('case + delimiter normalisation', () => { - it.each([ - ['tree-sitter', 'tree-sitter'], - ['Tree-sitter', 'tree-sitter'], - ['Tree sitter', 'tree-sitter'], - ['TREE_SITTER', 'tree-sitter'], - ['Tree-Sitter', 'tree-sitter'], - ['tree.sitter', 'tree-sitter'], - ['tree/sitter', 'tree-sitter'], - ['tree sitter', 'tree-sitter'], - [' Tree-sitter ', 'tree-sitter'], - ])('"%s" → "%s"', (input, expected) => { - expect(normaliseSlug(input)).toBe(expected); - }); - }); - - describe('stopword stripping', () => { - it.each([ - ['the Tree-Sitter library', 'tree-sitter-library'], - ['a tree-sitter for python', 'tree-sitter-python'], - ['parsing of code', 'parsing-code'], - ['the and or to in on for of', ''], // pathological: all stopwords → empty - ['Tree-Sitter and Python', 'tree-sitter-python'], - ])('"%s" → "%s"', (input, expected) => { - expect(normaliseSlug(input)).toBe(expected); - }); - }); - - describe('Unicode / accent folding', () => { - it.each([ - ['café', 'cafe'], - ['naïve approach', 'naive-approach'], - ['résumé', 'resume'], - ['Bremišek-Plahuta', 'bremisek-plahuta'], - ])('"%s" → "%s"', (input, expected) => { - expect(normaliseSlug(input)).toBe(expected); - }); - }); - - describe('truncation at 60 chars', () => { - it('truncates inputs longer than 60 chars', () => { - // "the" / "a" / "of" are stopwords and get stripped; pick a long - // input made of substantive tokens. - const long = 'profoundly-verbose-concept-name-exceeding-sixty-characters-by-a-significant-margin'; - const result = normaliseSlug(long); - expect(result.length).toBeLessThanOrEqual(60); - expect(result).toMatch(/^profoundly-verbose-concept-name/); - }); - - it('preserves exact 60-char inputs', () => { - const exactly60 = 'x'.repeat(60); - expect(normaliseSlug(exactly60)).toBe(exactly60); - }); - }); - - describe('idempotency', () => { - it('normaliseSlug(normaliseSlug(x)) === normaliseSlug(x) for any input', () => { - const inputs = [ - 'Tree-sitter', - 'the AGENT-FRAMEWORK', - 'café résumé', - 'urn:dkg:concept:foo', - ' leading and trailing ', - '!!!special---chars???', - ]; - for (const input of inputs) { - const once = normaliseSlug(input); - const twice = normaliseSlug(once); - expect(twice).toBe(once); - } - }); - }); - - describe('determinism across equivalent inputs', () => { - it('all variants of the same concept collapse to one slug', () => { - const variants = [ - 'tree-sitter', - 'Tree-sitter', - 'Tree sitter', - 'tree_sitter', - 'TREE-SITTER', - ' the Tree-Sitter ', - ]; - const slugs = variants.map(normaliseSlug); - const unique = new Set(slugs); - expect(unique.size).toBe(1); - expect([...unique][0]).toBe('tree-sitter'); - }); - }); - - describe('edge cases', () => { - it.each([ - ['', ''], - [' ', ''], - ['---', ''], - ['x', 'x'], // single non-stopword char - ['x y', 'x-y'], // two non-stopword tokens - ['123', '123'], - ['the', ''], // single stopword → empty - ['a', ''], // 'a' is a stopword too - ['a b', 'b'], // stopword "a" stripped; only "b" remains - ['the and or to', ''], // all stopwords → empty - ])('"%s" → "%s"', (input, expected) => { - expect(normaliseSlug(input)).toBe(expected); - }); - }); -}); diff --git a/packages/mcp-dkg/test/query-schema.test.ts b/packages/mcp-dkg/test/query-schema.test.ts new file mode 100644 index 000000000..b23cb058a --- /dev/null +++ b/packages/mcp-dkg/test/query-schema.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { registerReadTools } from '../src/tools.js'; +import { FakeServer, FakeClient, makeConfig } from './harness.js'; + +describe('dkg_query — two-axis schema migration (post-#17 rename + split)', () => { + let server: FakeServer; + let client: FakeClient; + + beforeEach(() => { + server = new FakeServer(); + client = new FakeClient(); + registerReadTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + }); + + it('registers dkg_query and removes the legacy dkg_sparql binding', () => { + expect(server.tools.has('dkg_query')).toBe(true); + expect(server.tools.has('dkg_sparql')).toBe(false); + }); + + it('accepts the post-rename two-axis input shape: view + includeSharedMemory', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'shared-working-memory', + }); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.view).toBe('shared-working-memory'); + expect(lastCall.includeSharedMemory).toBeUndefined(); + }); + + it('accepts the WM∪SWM union shape via view: working-memory + includeSharedMemory: true', async () => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'working-memory', + includeSharedMemory: true, + }); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.view).toBe('working-memory'); + expect(lastCall.includeSharedMemory).toBe(true); + }); + + it.each(['working-memory', 'shared-working-memory', 'verified-memory'])( + 'accepts the canonical view enum value %s', + async (view) => { + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view, + }); + expect(result.isError).toBeFalsy(); + expect(client.queryCalls.at(-1)!.view).toBe(view); + }, + ); + + it('post-#17 + F27: legacy `layer` key is silently dropped at parse, call runs with V10 default scope', async () => { + // Post-W2 #17 the schema migrated to the two-axis (view, + // includeSharedMemory) shape. The legacy `layer` field is no + // longer declared. Production MCP SDK silently drops undeclared + // keys at parse — it does NOT reject them. Pre-F27 the harness + // ran `.strict()` and asserted these calls *throw*; that + // assertion was a harness artefact (production would never have + // thrown). The honest production-equivalent test: every legacy + // `layer` value parses cleanly and the handler runs with the + // default scope (no `view`, no `includeSharedMemory` — i.e. + // dkg_query's default WM-only routing). + for (const layer of ['wm', 'swm', 'union', 'vm']) { + const before = client.queryCalls.length; + const result = await server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + layer, + }); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls[before]; + // Default scope: no view, no includeSharedMemory — `layer` + // was silently dropped, NOT mapped to anything. + expect(lastCall.view).toBeUndefined(); + expect(lastCall.includeSharedMemory).toBeUndefined(); + // And the legacy `layer` key itself must not have leaked + // through to the wire (the parsed input doesn't carry it). + expect((lastCall as Record).layer).toBeUndefined(); + } + }); + + it("rejects view values that aren't on the canonical enum (regression: silent typo routes)", async () => { + await expect( + server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'wm', + }), + ).rejects.toThrow(); + await expect( + server.call('dkg_query', { + sparql: 'SELECT ?s WHERE { ?s ?p ?o }', + view: 'private', + }), + ).rejects.toThrow(); + }); + + it('inputSchema declares only post-migration knobs (no legacy `layer` key)', () => { + const tool = server.get('dkg_query'); + const shape = tool.config.inputSchema!; + const keys = Object.keys(shape); + // Post-migration surface: sparql, projectId, subGraphName, view, + // includeSharedMemory, limit. The legacy `layer` key MUST be gone. + expect(keys).toEqual( + expect.arrayContaining([ + 'sparql', + 'view', + 'includeSharedMemory', + ]), + ); + expect(keys).not.toContain('layer'); + }); + + it('view enum locks to exactly the canonical three values (alphabetical sort guard)', () => { + const tool = server.get('dkg_query'); + const viewSchema = tool.config.inputSchema!.view as z.ZodOptional>; + const inner = viewSchema.unwrap() as z.ZodEnum<[string, ...string[]]>; + expect([...inner.options].sort()).toEqual([ + 'shared-working-memory', + 'verified-memory', + 'working-memory', + ]); + }); +}); + +// ── F1 sweep: schema-migration uniformity guard ────────────────────── +// Per qa-review-round-1.md F1: the W2 #17 schema migration replaced +// the `layer: 'wm'|'swm'|'union'|'vm'` enum with `view + includeSharedMemory`, +// but the migration was originally applied to `dkg_query` only. F1 +// flipped `dkg_get_entity` and `dkg_list_activity` to the canonical +// shape. This sweep asserts that NO public-facing tool surface still +// exposes the legacy `layer` field — same bug-class guard as the +// drop-sweep block in `drop-sweep.test.ts`. +describe('F1 schema-migration sweep — no public tool exposes legacy `layer` field', () => { + it('asserts every registered tool uses `view + includeSharedMemory` (or no scope field at all)', () => { + const server = new FakeServer(); + const client = new FakeClient(); + const config = makeConfig(); + registerReadTools(server.asMcpServer(), client.asDkgClient(), config); + + for (const [name, tool] of server.tools.entries()) { + const shape = tool.config.inputSchema ?? {}; + // The legacy single-axis `layer` field MUST NOT appear on any + // public-facing tool's inputSchema. Tools that don't take a + // memory-tier scope at all (e.g. `dkg_get_agent`, listings) are + // free to omit both — that's also valid. + expect( + Object.keys(shape), + `Tool '${name}' must not expose the legacy 'layer' field; use 'view' + 'includeSharedMemory' per W2 #17 schema migration.`, + ).not.toContain('layer'); + } + }); + + it('dkg_get_entity accepts `view: "verified-memory"` post-F1', async () => { + const server = new FakeServer(); + const client = new FakeClient(); + registerReadTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + const result = await server.call('dkg_get_entity', { + uri: 'urn:test:entity', + view: 'verified-memory', + }); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.view).toBe('verified-memory'); + }); + + it('F27: dkg_get_entity silently drops legacy `layer: "union"`, falls back to V9-era default WM∪SWM scope', async () => { + // Post-F1 the legacy `layer` field is no longer on the schema + // (replaced by `view + includeSharedMemory`). Production MCP SDK + // drops undeclared keys at parse — pre-F27 the harness's strict + // mode falsely asserted that `layer: 'union'` throws here. + // Honest assertion: the legacy key drops, the handler falls + // through to the no-view default which preserves the V9-era + // `layer: 'union'` semantics on the wire (`includeSharedMemory: true`). + const server = new FakeServer(); + const client = new FakeClient(); + registerReadTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + const result = await server.call('dkg_get_entity', { + uri: 'urn:test:entity', + layer: 'union', + }); + expect(result.isError).toBeFalsy(); + // Two query calls fire (outgoing + incoming neighbourhood); + // both must use the no-view default scope. + expect(client.queryCalls.length).toBeGreaterThanOrEqual(1); + for (const call of client.queryCalls) { + expect(call.view).toBeUndefined(); + expect(call.includeSharedMemory).toBe(true); + } + }); + + it('dkg_list_activity accepts `view: "shared-working-memory"` post-F1', async () => { + const server = new FakeServer(); + const client = new FakeClient(); + registerReadTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + const result = await server.call('dkg_list_activity', { + view: 'shared-working-memory', + }); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.graphSuffix).toBe('_shared_memory'); + }); + + it('F27: dkg_list_activity silently drops legacy `layer: "wm"`, falls back to V9-era default WM∪SWM scope', async () => { + // F27-bug-class twin to the dkg_get_entity case above. Production + // MCP SDK drops the legacy `layer` key at parse; the handler + // falls through to the no-view default. The `layer: 'wm'` value + // does NOT route to a WM-only query — it gets dropped entirely, + // leaving the call to use the V9-era WM∪SWM default. + const server = new FakeServer(); + const client = new FakeClient(); + registerReadTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + const result = await server.call('dkg_list_activity', { layer: 'wm' }); + expect(result.isError).toBeFalsy(); + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.view).toBeUndefined(); + expect(lastCall.includeSharedMemory).toBe(true); + expect(lastCall.graphSuffix).toBeUndefined(); + }); + + it('dkg_get_entity default (no view) preserves V9-era WM∪SWM behaviour', async () => { + const server = new FakeServer(); + const client = new FakeClient(); + registerReadTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + await server.call('dkg_get_entity', { uri: 'urn:test:entity' }); + // Default scope must produce WM∪SWM — the historical `layer: 'union'` + // default. Encoded as `includeSharedMemory: true` on the wire. + const lastCall = client.queryCalls.at(-1)!; + expect(lastCall.includeSharedMemory).toBe(true); + expect(lastCall.view).toBeUndefined(); + }); +}); + +describe('dkg_list_context_graphs — rename + UX-note pair', () => { + let server: FakeServer; + let client: FakeClient; + + beforeEach(() => { + server = new FakeServer(); + client = new FakeClient(); + registerReadTools(server.asMcpServer(), client.asDkgClient(), makeConfig({ defaultProject: null })); + }); + + it('registers under the canonical name dkg_list_context_graphs (not dkg_list_projects)', () => { + expect(server.tools.has('dkg_list_context_graphs')).toBe(true); + expect(server.tools.has('dkg_list_projects')).toBe(false); + }); + + it("description includes the canonical-naming reconciliation note: \"called 'projects' in the DKG node UI\"", () => { + const tool = server.get('dkg_list_context_graphs'); + expect(tool.config.description).toContain("called 'projects' in the DKG node UI"); + }); + + it('happy path: invokes client.listProjects and renders rows', async () => { + client.contextGraphs.add('foo'); + client.contextGraphs.add('bar'); + const result = await server.call('dkg_list_context_graphs', {}); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/Found 2 context graph\(s\)/); + expect(result.content[0].text).toMatch(/\*\*foo\*\*/); + expect(result.content[0].text).toMatch(/\*\*bar\*\*/); + }); +}); + +describe('dkg_sub_graph_list — wave-2 rename guard', () => { + let server: FakeServer; + let client: FakeClient; + + beforeEach(() => { + server = new FakeServer(); + client = new FakeClient(); + registerReadTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + }); + + it('registers under the canonical name dkg_sub_graph_list (not dkg_list_subgraphs)', () => { + expect(server.tools.has('dkg_sub_graph_list')).toBe(true); + expect(server.tools.has('dkg_list_subgraphs')).toBe(false); + }); +}); diff --git a/packages/mcp-dkg/test/setup-publish-health.test.ts b/packages/mcp-dkg/test/setup-publish-health.test.ts new file mode 100644 index 000000000..96c71a963 --- /dev/null +++ b/packages/mcp-dkg/test/setup-publish-health.test.ts @@ -0,0 +1,432 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { registerSetupTools } from '../src/tools/setup.js'; +import { registerPublishTools } from '../src/tools/publish.js'; +import { registerHealthTools } from '../src/tools/health.js'; +import { FakeServer, FakeClient, makeConfig } from './harness.js'; + +describe('setup tools — context graph + sub-graph + subscribe', () => { + let server: FakeServer; + let client: FakeClient; + + beforeEach(() => { + server = new FakeServer(); + client = new FakeClient(); + registerSetupTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + }); + + it('registers all three setup tools', () => { + for (const name of [ + 'dkg_context_graph_create', + 'dkg_subscribe', + 'dkg_sub_graph_create', + ]) { + expect(server.tools.has(name)).toBe(true); + } + }); + + it('dkg_context_graph_create description carries the SKILL.md §6 canonical-naming note', () => { + const desc = server.get('dkg_context_graph_create').config.description!; + expect(desc).toContain("called 'projects' in the DKG node UI"); + }); + + it('auto-derives the slug from the human name when id is omitted', async () => { + const result = await server.call('dkg_context_graph_create', { + name: 'My Research Context Graph', + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/'my-research-context-graph'/); + expect(client.contextGraphs.has('my-research-context-graph')).toBe(true); + }); + + it('honours an explicit slug when provided', async () => { + const result = await server.call('dkg_context_graph_create', { + name: 'Anything', + id: 'override-slug', + }); + expect(result.isError).toBeFalsy(); + expect(client.contextGraphs.has('override-slug')).toBe(true); + }); + + it('rejects invalid slugs without hitting the daemon', async () => { + const result = await server.call('dkg_context_graph_create', { + name: 'X', + id: 'BAD_SLUG_With_Spaces', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Invalid context graph ID/); + expect(client.contextGraphs.size).toBe(0); + }); + + // F2: surface the daemon's already-exists signal so callers can + // distinguish "newly created" from "already existed" without doing + // an extra dkg_list_context_graphs round-trip. Mirrors + // dkg_assertion_create's idempotency surfacing. + it('first create reports "Created"; second create with same id reports "already exists"', async () => { + const r1 = await server.call('dkg_context_graph_create', { name: 'My Project' }); + expect(r1.isError).toBeFalsy(); + expect(r1.content[0].text).toMatch(/^Created context graph 'my-project'/); + + // Re-create with the same auto-derived id — daemon-side 409 is + // caught by `client.createContextGraph`, surfaced as + // `alreadyExists: true` to the tool, which renders the + // distinct "already exists" message. + const r2 = await server.call('dkg_context_graph_create', { name: 'My Project' }); + expect(r2.isError).toBeFalsy(); + expect(r2.content[0].text).toMatch(/^Context graph 'my-project' already exists/); + expect(r2.content[0].text).not.toMatch(/^Created/); + }); + + it('description no longer recommends the dkg_list_context_graphs workaround', () => { + // The pre-F2 description told callers to "Call dkg_list_context_graphs + // first to see if one with this name already exists." That workaround + // was forced because the create call dropped the idempotency signal. + // Post-F2 the create surfaces the signal directly, so the workaround + // text must be gone. + const desc = server.get('dkg_context_graph_create').config.description!; + expect(desc).not.toMatch(/Call `dkg_list_context_graphs` first/); + // ...replaced with an explicit idempotency contract. + expect(desc).toMatch(/Idempotent/); + }); + + it('dkg_sub_graph_create is wrapper-idempotent: ensureSubGraph swallows the daemon-side 409', async () => { + const r1 = await server.call('dkg_sub_graph_create', { + contextGraphId: 'cg', + subGraphName: 'meta', + }); + expect(r1.isError).toBeFalsy(); + expect(r1.content[0].text).toMatch(/'meta' ready in 'cg'/); + // Re-create the same name — the wrapper-level idempotency lock means + // the agent-facing surface stays clean even if the daemon would 409. + const r2 = await server.call('dkg_sub_graph_create', { + contextGraphId: 'cg', + subGraphName: 'meta', + }); + expect(r2.isError).toBeFalsy(); + }); + + it('dkg_subscribe defaults includeSharedMemory to true', async () => { + const result = await server.call('dkg_subscribe', { contextGraphId: 'remote-cg' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/Subscribed to 'remote-cg'/); + expect(client.subscribed.has('remote-cg')).toBe(true); + }); + + it('dkg_subscribe forwards includeSharedMemory: false', async () => { + let received: boolean | undefined; + const localClient = new FakeClient({ + subscribe: async (args) => { + received = args.includeSharedMemory; + return { subscribed: args.contextGraphId }; + }, + }); + const localServer = new FakeServer(); + registerSetupTools(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig()); + await localServer.call('dkg_subscribe', { + contextGraphId: 'remote', + includeSharedMemory: false, + }); + expect(received).toBe(false); + }); +}); + +describe('publish tools — write+publish helper + canonical SWM finalizer', () => { + let server: FakeServer; + let client: FakeClient; + + beforeEach(() => { + server = new FakeServer(); + client = new FakeClient(); + registerPublishTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + }); + + it('registers both publish tools', () => { + expect(server.tools.has('dkg_publish')).toBe(true); + expect(server.tools.has('dkg_shared_memory_publish')).toBe(true); + }); + + it('dkg_publish auto-types objects: URI passes through, literal gets quoted', async () => { + const result = await server.call('dkg_publish', { + contextGraphId: 'cg', + quads: [ + { subject: 'urn:s:1', predicate: 'urn:p:type', object: 'urn:Note' }, + { subject: 'urn:s:1', predicate: 'urn:p:label', object: 'a literal value' }, + ], + }); + expect(result.isError).toBeFalsy(); + const call = client.publishCalls.at(-1)!; + const wireQuads = call.quads as Array<{ subject: string; predicate: string; object: string }>; + expect(wireQuads[0].object).toBe('urn:Note'); + expect(wireQuads[1].object).toBe('"a literal value"'); + }); + + it('dkg_publish rejects an empty quads array at the schema layer', async () => { + await expect( + server.call('dkg_publish', { contextGraphId: 'cg', quads: [] }), + ).rejects.toThrow(); + }); + + it('dkg_shared_memory_publish without rootEntities publishes selection: all', async () => { + const result = await server.call('dkg_shared_memory_publish', { contextGraphId: 'cg' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/Selection: all/); + }); + + it('dkg_shared_memory_publish rejects an empty rootEntities array (omit or non-empty only)', async () => { + const result = await server.call('dkg_shared_memory_publish', { + contextGraphId: 'cg', + rootEntities: [], + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/non-empty array/); + }); + + it('dkg_shared_memory_publish forwards a non-empty rootEntities subset', async () => { + const result = await server.call('dkg_shared_memory_publish', { + contextGraphId: 'cg', + rootEntities: ['urn:r:1', 'urn:r:2'], + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/Roots: 2/); + const call = client.publishCalls.find((c) => c.kind === 'publishSharedMemory')!; + expect(call.rootEntities).toEqual(['urn:r:1', 'urn:r:2']); + }); + + it('dkg_shared_memory_publish runs registerContextGraph first when registerIfNeeded: true', async () => { + const localClient = new FakeClient(); + let registered = false; + localClient.registerContextGraph = (async () => { + registered = true; + return { + registered: 'cg', + onChainId: 'chain:cg', + txHash: '0xreg', + alreadyRegistered: false, + }; + }) as never; + const localServer = new FakeServer(); + registerPublishTools(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig()); + + const result = await localServer.call('dkg_shared_memory_publish', { + contextGraphId: 'cg', + registerIfNeeded: true, + }); + expect(result.isError).toBeFalsy(); + expect(registered).toBe(true); + expect(result.content[0].text).toMatch(/Registered on-chain/); + }); + + // F12 (qa-review-round-2): the registerIfNeeded already-registered + // tolerance was implemented as a `message.includes('already registered')` + // substring match — locale-fragile + breaks on any daemon wording + // change. Post-F12 the client surfaces a typed `alreadyRegistered: + // true` flag from the daemon's HTTP 409, and the tool branches on + // the typed flag. + it('F12: registerIfNeeded tolerates already-registered via the typed flag (no substring match)', async () => { + const localClient = new FakeClient({ + registerContextGraph: async () => ({ + registered: 'cg', + alreadyRegistered: true, + }) as any, + }); + const localServer = new FakeServer(); + registerPublishTools(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig()); + + const result = await localServer.call('dkg_shared_memory_publish', { + contextGraphId: 'cg', + registerIfNeeded: true, + }); + // The publish must succeed even though the CG was already registered. + expect(result.isError).toBeFalsy(); + // Success summary MUST NOT claim we just registered something. + expect(result.content[0].text).not.toMatch(/Registered on-chain/); + }); + + it('F12: registerIfNeeded propagates non-409 register failures (no silent swallow)', async () => { + // A truly-failing register call (network error, unrelated body + // shape) MUST propagate as a tool error; the pre-F12 substring + // match would have swallowed any error whose message happened to + // contain "already registered" verbatim. + const localClient = new FakeClient({ + registerContextGraph: async () => { + throw new Error('rpc unreachable'); + }, + }); + const localServer = new FakeServer(); + registerPublishTools(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig()); + + const result = await localServer.call('dkg_shared_memory_publish', { + contextGraphId: 'cg', + registerIfNeeded: true, + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Failed to register context graph: rpc unreachable/); + }); + + // F3+F13 (qa-review-round-1 F3 + qa-review-round-2 F13): chain + // provenance echoed on publish responses so callers can verify + // post-hoc which chain the publish landed on. User explicit: + // option (a) WITHOUT loud-warning prose. accessPolicy is also + // echoed when registerIfNeeded ran so the caller can verify what + // the daemon committed without a separate read-back. + it('F3+F13: dkg_publish success summary echoes the configured chainId', async () => { + // FakeClient.walletBalances ships chainId='base-sepolia' by default. + const result = await server.call('dkg_publish', { + contextGraphId: 'cg', + quads: [{ subject: 'urn:s:1', predicate: 'urn:p:type', object: 'urn:Note' }], + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/Chain: base-sepolia/); + }); + + it('F3+F13: dkg_shared_memory_publish success summary echoes the configured chainId', async () => { + const result = await server.call('dkg_shared_memory_publish', { contextGraphId: 'cg' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/Chain: base-sepolia/); + }); + + it('F3+F13: chainId is omitted gracefully when the wallet-balances probe fails', async () => { + // The probe failure must NOT fail the publish — the publish + // succeeded, the chain echo is best-effort. + const localClient = new FakeClient({ + getWalletBalances: async () => { + throw new Error('rpc unreachable'); + }, + }); + const localServer = new FakeServer(); + registerPublishTools(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig()); + + const result = await localServer.call('dkg_publish', { + contextGraphId: 'cg', + quads: [{ subject: 'urn:s:1', predicate: 'urn:p:type', object: 'urn:Note' }], + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).not.toMatch(/Chain:/); + }); + + it('F3+F13: dkg_shared_memory_publish echoes accessPolicy when registerIfNeeded ran', async () => { + const result = await server.call('dkg_shared_memory_publish', { + contextGraphId: 'cg', + registerIfNeeded: true, + accessPolicy: 1, + }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/Registered on-chain:.*accessPolicy=1/); + }); + + it('F3+F13: response carries no warning prose (user explicit)', async () => { + // The user explicitly opted for echo-only — no "WARNING: this + // spends gas" / "Verify chainId before publishing" / similar + // copy. Future-proofs against well-meaning re-additions. + const r1 = await server.call('dkg_publish', { + contextGraphId: 'cg', + quads: [{ subject: 'urn:s:1', predicate: 'urn:p:type', object: 'urn:Note' }], + }); + const r2 = await server.call('dkg_shared_memory_publish', { contextGraphId: 'cg' }); + for (const r of [r1, r2]) { + expect(r.content[0].text).not.toMatch(/warning/i); + expect(r.content[0].text).not.toMatch(/spends gas/i); + expect(r.content[0].text).not.toMatch(/verify.*chain/i); + } + }); + + // F11 (qa-review-round-2 / matrix v0.8 §4.10): the canonical + // wire form for `accessPolicy` is the numeric `0|1` per the + // daemon's `/api/context-graph/{create,register}` handlers + // (`packages/cli/src/daemon/routes/context-graph.ts:455` / + // `:509-510` — both reject anything other than literal 0/1). + // Matrix v0.8 §4.10 line 291 had drifted to `z.enum(['open', + // 'private'])`; this test pins the implementation against the + // daemon canonical so a future re-alignment can't regress + // silently to the string form. + it('F11: dkg_shared_memory_publish accepts numeric accessPolicy 0 (open)', async () => { + const result = await server.call('dkg_shared_memory_publish', { + contextGraphId: 'cg', + registerIfNeeded: true, + accessPolicy: 0, + }); + expect(result.isError).toBeFalsy(); + }); + + it('F11: dkg_shared_memory_publish accepts numeric accessPolicy 1 (private)', async () => { + const result = await server.call('dkg_shared_memory_publish', { + contextGraphId: 'cg', + registerIfNeeded: true, + accessPolicy: 1, + }); + expect(result.isError).toBeFalsy(); + }); + + it('F11: dkg_shared_memory_publish rejects the legacy string `"open"` form at the schema layer', async () => { + await expect( + server.call('dkg_shared_memory_publish', { + contextGraphId: 'cg', + registerIfNeeded: true, + accessPolicy: 'open', + }), + ).rejects.toThrow(); + }); + + it('F11: dkg_shared_memory_publish rejects out-of-range numeric accessPolicy', async () => { + // Matches the daemon's strict `accessPolicy !== 0 && accessPolicy !== 1` + // guard at routes/context-graph.ts:509 — the schema layer must + // reject the same shape (literal 0 / literal 1 only) before + // the wire boundary. + await expect( + server.call('dkg_shared_memory_publish', { + contextGraphId: 'cg', + registerIfNeeded: true, + accessPolicy: 2, + }), + ).rejects.toThrow(); + }); +}); + +describe('health tools — status + wallet balances', () => { + let server: FakeServer; + let client: FakeClient; + + beforeEach(() => { + server = new FakeServer(); + client = new FakeClient(); + registerHealthTools(server.asMcpServer(), client.asDkgClient(), makeConfig()); + }); + + it('registers both health tools with empty inputSchemas', () => { + expect(server.tools.has('dkg_status')).toBe(true); + expect(server.tools.has('dkg_wallet_balances')).toBe(true); + expect(server.get('dkg_status').config.inputSchema).toEqual({}); + expect(server.get('dkg_wallet_balances').config.inputSchema).toEqual({}); + }); + + it('dkg_status renders the daemon status payload as a JSON code block', async () => { + const result = await server.call('dkg_status', {}); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/DKG node status/); + expect(result.content[0].text).toMatch(/"peerId": "peer-test"/); + }); + + it('dkg_wallet_balances renders per-wallet rows + chain context', async () => { + const result = await server.call('dkg_wallet_balances', {}); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toMatch(/0xabc/); + expect(result.content[0].text).toMatch(/0\.05 ETH/); + expect(result.content[0].text).toMatch(/12\.5 TRAC/); + expect(result.content[0].text).toMatch(/Chain: base-sepolia/); + }); + + it('dkg_wallet_balances surfaces a tool error when the daemon reports a probe error', async () => { + const localClient = new FakeClient(); + localClient.walletBalances = { + wallets: [], + balances: [], + chainId: null, + rpcUrl: null, + error: 'rpc unreachable', + }; + const localServer = new FakeServer(); + registerHealthTools(localServer.asMcpServer(), localClient.asDkgClient(), makeConfig()); + const result = await localServer.call('dkg_wallet_balances', {}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/rpc unreachable/); + }); +}); diff --git a/packages/mcp-dkg/test/starter-ontologies.test.ts b/packages/mcp-dkg/test/starter-ontologies.test.ts deleted file mode 100644 index 5c9a49d64..000000000 --- a/packages/mcp-dkg/test/starter-ontologies.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const ONTOLOGIES_DIR = path.resolve(__dirname, '..', 'templates', 'ontologies'); - -const EXPECTED_STARTERS = [ - 'coding-project', - 'book-research', - 'pkm', - 'scientific-research', - 'narrative-writing', -] as const; - -/** - * Sanity tests for the 5 starter ontologies. Each must: - * - exist as a directory - * - ship both ontology.ttl + agent-guide.md - * - declare a Turtle ontology header (@prefix + owl:Ontology) - * - declare the universal annotation predicates (chat:topic / mentions / - * examines / proposes / concludes / asks) - * - declare a slug normalisation note (the convergence rule) - * - have a non-trivial agent-guide.md with the look-before-mint protocol - * - * These checks catch silent regressions (a starter accidentally truncated, - * a predicate renamed in one file but not the others, a copy-paste - * mistake that drops the protocol section). - */ -describe('starter ontologies — sanity checks', () => { - describe.each(EXPECTED_STARTERS)('%s starter', (slug) => { - const dir = path.join(ONTOLOGIES_DIR, slug); - const ttlPath = path.join(dir, 'ontology.ttl'); - const guidePath = path.join(dir, 'agent-guide.md'); - - it('directory exists', () => { - expect(fs.existsSync(dir)).toBe(true); - }); - - it('ontology.ttl present + non-trivial', () => { - expect(fs.existsSync(ttlPath)).toBe(true); - const ttl = fs.readFileSync(ttlPath, 'utf-8'); - expect(ttl.length).toBeGreaterThan(500); - }); - - it('agent-guide.md present + non-trivial', () => { - expect(fs.existsSync(guidePath)).toBe(true); - const md = fs.readFileSync(guidePath, 'utf-8'); - expect(md.length).toBeGreaterThan(500); - }); - - it('ontology.ttl declares an owl:Ontology header', () => { - const ttl = fs.readFileSync(ttlPath, 'utf-8'); - expect(ttl).toMatch(/@prefix\s+owl:/i); - expect(ttl).toMatch(/a\s+owl:Ontology/); - }); - - it('ontology.ttl imports core standard vocabularies', () => { - const ttl = fs.readFileSync(ttlPath, 'utf-8'); - // schema.org and DCTerms are universal — every starter must compose - // with them. - expect(ttl).toContain('schema.org'); - expect(ttl).toContain('purl.org/dc/terms'); - }); - - it('ontology.ttl declares all 6 universal annotation predicates', () => { - const ttl = fs.readFileSync(ttlPath, 'utf-8'); - for (const p of ['chat:topic', 'chat:mentions', 'chat:examines', 'chat:proposes', 'chat:concludes', 'chat:asks']) { - expect(ttl, `${slug}/ontology.ttl missing predicate ${p}`).toContain(p); - } - }); - - it('ontology.ttl declares the slug-normalisation rule', () => { - const ttl = fs.readFileSync(ttlPath, 'utf-8'); - expect(ttl).toMatch(/slug-?normalisation|normalisation/i); - expect(ttl).toContain('stopwords'); // the rule references stopword stripping - }); - - it('agent-guide.md teaches look-before-mint', () => { - const md = fs.readFileSync(guidePath, 'utf-8'); - expect(md).toMatch(/look-before-mint/i); - expect(md).toMatch(/dkg_search/); - }); - - it('agent-guide.md describes the URI patterns section', () => { - const md = fs.readFileSync(guidePath, 'utf-8'); - expect(md).toMatch(/URI patterns/i); - expect(md).toContain('urn:dkg:'); - }); - - it('agent-guide.md tells the agent to call dkg_annotate_turn', () => { - const md = fs.readFileSync(guidePath, 'utf-8'); - expect(md).toContain('dkg_annotate_turn'); - }); - }); - - it('the coding-project starter is the largest (it is the v1 reference)', () => { - const sizes = EXPECTED_STARTERS.map((slug) => ({ - slug, - ttlBytes: fs.statSync(path.join(ONTOLOGIES_DIR, slug, 'ontology.ttl')).size, - })); - const codingSize = sizes.find((s) => s.slug === 'coding-project')!.ttlBytes; - for (const s of sizes) { - if (s.slug === 'coding-project') continue; - expect(codingSize, `${s.slug} should be smaller than coding-project (the v1 reference)`) - .toBeGreaterThan(s.ttlBytes); - } - }); - - it('no extra unexpected directories under templates/ontologies', () => { - const found = fs.readdirSync(ONTOLOGIES_DIR) - .filter((name) => fs.statSync(path.join(ONTOLOGIES_DIR, name)).isDirectory()) - .sort(); - expect(found).toEqual([...EXPECTED_STARTERS].sort()); - }); -}); diff --git a/packages/mcp-dkg/test/uri-helpers.test.ts b/packages/mcp-dkg/test/uri-helpers.test.ts deleted file mode 100644 index cac9b3459..000000000 --- a/packages/mcp-dkg/test/uri-helpers.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { toUri } from '../src/tools/annotations.js'; - -/** - * `toUri` decides between treating an arg as an existing URI (pass- - * through) or as a free-text label (mint via slug normalisation). - * Wrong decision = either fabricated URIs landing in the graph or - * human-readable strings being treated as URIs (broken triples). - */ -describe('toUri — URI passthrough vs minting', () => { - describe('pass-through for known URI schemes', () => { - it.each([ - 'urn:dkg:concept:tree-sitter', - 'urn:dkg:decision:adopt-tree-sitter', - 'urn:dkg:task:phase-7-fix-race', - 'http://schema.org/Person', - 'https://example.com/foo', - 'did:dkg:agent:cursor-branarakic', - ])('passes through %s unchanged', (uri) => { - expect(toUri(uri)).toBe(uri); - }); - }); - - describe('mints URIs from free-text labels', () => { - it.each([ - ['tree-sitter', 'concept', 'urn:dkg:concept:tree-sitter'], - ['Tree sitter', 'concept', 'urn:dkg:concept:tree-sitter'], - ['the Cool Topic', 'topic', 'urn:dkg:topic:cool-topic'], - ['How does X scale?', 'question','urn:dkg:question:how-does-x-scale'], - ['key insight', 'finding', 'urn:dkg:finding:key-insight'], - ])('toUri("%s", "%s") → %s', (input, type, expected) => { - expect(toUri(input, type)).toBe(expected); - }); - }); - - it('defaults to concept type when unspecified', () => { - expect(toUri('foo bar')).toBe('urn:dkg:concept:foo-bar'); - }); - - it.each([ - '', - ' ', - '???', - '*** / ***', - 'the a an of', // stopwords only - ])('returns null for label "%s" that slugifies to empty', (input) => { - // Without this guard we'd mint malformed URIs like `urn:dkg:concept:` - // and persist them as real entities in the graph. Callers must - // fall back (skip the reference) on null. - expect(toUri(input)).toBeNull(); - }); -}); diff --git a/packages/mcp-dkg/vitest.config.ts b/packages/mcp-dkg/vitest.config.ts index fab7bc062..f61232e3f 100644 --- a/packages/mcp-dkg/vitest.config.ts +++ b/packages/mcp-dkg/vitest.config.ts @@ -3,15 +3,13 @@ import { defineConfig } from 'vitest/config'; /** * Local test config for @origintrail-official/dkg-mcp. * - * Tests are pure unit tests (no daemon required) covering: - * - the slug normalisation algorithm (the URI-convergence rule) - * - URI helpers (mint vs pass-through) - * - the capture-chat hook's pure functions (config parsing, payload - * field resolution, regex backstop) - * - structural sanity of all 5 starter ontologies - * - * Integration (against a running daemon) is exercised by the smoke - * scripts at scripts/smoke-writes.mjs and scripts/smoke-annotate.mjs. + * Wave-3 (#23) re-introduces unit-test fixtures for the post-wave-2 tool + * surface (assertion CRUD quintet, memory-search trust ranking, query + * schema migration, and the 9 wave-2 P0/P1/P2 adds). The four V9-era + * test files that wave-2's drop deleted were pinning now-removed + * surfaces (`dkg_annotate_turn` / `dkg_search` strings, the helpers in + * the dropped `tools/annotations.ts`); fixtures are shaped against the + * canonical V10 SKILL.md surface instead. */ export default defineConfig({ test: { diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md deleted file mode 100644 index ae930c10c..000000000 --- a/packages/mcp-server/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# @origintrail-official/dkg-mcp-server - -[Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for DKG V10. Exposes DKG node capabilities as MCP tools, allowing AI assistants (Cursor, Claude Desktop, etc.) to publish, query, and explore the knowledge graph. - -## Features - -- **MCP stdio transport** — runs as a subprocess, communicating via stdin/stdout per the MCP spec -- **DkgClient** — connects to a running DKG node's HTTP API with authentication -- **Code graph tools** — find modules, functions, classes, and packages from indexed repositories -- **Knowledge graph tools** — SPARQL queries and data publishing -- **File summaries** — retrieve summaries of indexed source files - -## Available Tools - -| Tool | Description | -|------|-------------| -| `dkg_find_modules` | Find modules/files in the indexed code graph | -| `dkg_find_functions` | Search for functions by name or signature | -| `dkg_find_classes` | Search for classes in the code graph | -| `dkg_find_packages` | List indexed packages | -| `dkg_file_summary` | Get a structured summary of a source file | -| `dkg_query` | Execute a SPARQL query against the knowledge graph | -| `dkg_publish` | Publish RDF data to a paranet | - -## Setup - -```json -{ - "mcpServers": { - "dkg": { - "command": "npx", - "args": ["@origintrail-official/dkg-mcp-server"], - "env": { - "DKG_NODE_URL": "http://localhost:9200", - "DKG_API_TOKEN": "your-token-here" - } - } - } -} -``` - -## Usage - -The MCP server requires a running DKG node. It connects to the node's HTTP API using the `DKG_NODE_URL` and `DKG_API_TOKEN` environment variables. - -```bash -# Run directly (usually configured in your AI assistant instead) -DKG_NODE_URL=http://localhost:9200 DKG_API_TOKEN=xxx dkg-mcp -``` - -## Internal Dependencies - -None — communicates with the DKG node over HTTP. Uses `@modelcontextprotocol/sdk` and `zod` for MCP protocol handling. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json deleted file mode 100644 index ea44050c2..000000000 --- a/packages/mcp-server/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@origintrail-official/dkg-mcp-server", - "version": "10.0.0-rc.2", - "type": "module", - "main": "dist/index.js", - "bin": { - "dkg-mcp": "./dist/index.js" - }, - "scripts": { - "build": "tsc", - "test": "vitest run", - "test:coverage": "vitest run --coverage", - "clean": "rm -rf dist" - }, - "dependencies": { - "@origintrail-official/dkg-core": "workspace:*", - "@modelcontextprotocol/sdk": "^1", - "zod": "^3.25" - }, - "optionalDependencies": { - "@origintrail-official/dkg-adapter-autoresearch": "workspace:*" - }, - "devDependencies": { - "@vitest/coverage-v8": "^4.0.18", - "vitest": "^4.0.18" - }, - "publishConfig": { - "access": "public" - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "https://github.com/OriginTrail/dkg.git", - "directory": "packages/mcp-server" - } -} diff --git a/packages/mcp-server/src/connection.ts b/packages/mcp-server/src/connection.ts deleted file mode 100644 index 41f8452c0..000000000 --- a/packages/mcp-server/src/connection.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - readDaemonPid, - isProcessAlive, - readDkgApiPort, - loadAuthToken, -} from '@origintrail-official/dkg-core'; - -export class DkgClient { - private baseUrl: string; - private token?: string; - - constructor(port: number, token?: string) { - this.baseUrl = `http://127.0.0.1:${port}`; - this.token = token; - } - - static async connect(): Promise { - const port = await readDkgApiPort(); - - if (!port) { - const pid = await readDaemonPid(); - if (!pid || !isProcessAlive(pid)) { - throw new Error('DKG daemon is not running. Start it with: dkg start'); - } - throw new Error('Cannot read API port. Set DKG_API_PORT or restart: dkg stop && dkg start'); - } - - const token = await loadAuthToken(); - return new DkgClient(port, token); - } - - private authHeaders(): Record { - if (!this.token) return {}; - return { Authorization: `Bearer ${this.token}` }; - } - - private async get(path: string): Promise { - const res = await fetch(`${this.baseUrl}${path}`, { - headers: this.authHeaders(), - }); - if (!res.ok) { - const body = await res.json().catch(() => ({ error: res.statusText })); - throw new Error((body as Record).error as string ?? `HTTP ${res.status}`); - } - return res.json() as Promise; - } - - private async post(path: string, body: unknown): Promise { - const res = await fetch(`${this.baseUrl}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({ error: res.statusText })); - throw new Error((data as Record).error as string ?? `HTTP ${res.status}`); - } - return res.json() as Promise; - } - - async status() { - return this.get<{ - name: string; - peerId: string; - nodeRole?: string; - networkId?: string; - uptimeMs: number; - connectedPeers: number; - relayConnected: boolean; - multiaddrs: string[]; - }>('/api/status'); - } - - async query(sparql: string, contextGraphId?: string) { - return this.post<{ result: unknown }>('/api/query', { sparql, contextGraphId }); - } - - async publish(contextGraphId: string, quads: Array<{ - subject: string; predicate: string; object: string; graph: string; - }>) { - await this.post('/api/shared-memory/write', { contextGraphId, quads }); - return this.post<{ - kcId: string; - status: string; - kas: Array<{ tokenId: string; rootEntity: string }>; - txHash?: string; - }>('/api/shared-memory/publish', { contextGraphId, selection: 'all', clearAfter: true }); - } - - async listContextGraphs() { - return this.get<{ - contextGraphs: Array<{ - id: string; uri: string; name: string; - description?: string; creator?: string; - createdAt?: string; isSystem: boolean; - }>; - }>('/api/context-graph/list'); - } - - async createContextGraph(id: string, name: string, description?: string) { - return this.post<{ created: string; uri: string }>( - '/api/context-graph/create', { id, name, description }, - ); - } - - async agents() { - return this.get<{ - agents: Array<{ - agentUri: string; name: string; peerId: string; - framework?: string; nodeRole?: string; - }>; - }>('/api/agents'); - } - - async subscribe(contextGraphId: string) { - return this.post<{ subscribed: string }>('/api/subscribe', { contextGraphId }); - } -} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts deleted file mode 100644 index d5dd73143..000000000 --- a/packages/mcp-server/src/index.ts +++ /dev/null @@ -1,439 +0,0 @@ -#!/usr/bin/env node - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; -import { DkgClient } from './connection.js'; -import { escapeSparqlLiteral } from '@origintrail-official/dkg-core'; - -const CONTEXT_GRAPH = 'dev-coordination'; -const DG = 'https://ontology.dkg.io/devgraph#'; - -let _client: DkgClient | null = null; - -async function getClient(): Promise { - if (!_client) { - _client = await DkgClient.connect(); - } - return _client; -} - -function formatError(err: unknown): string { - if (err instanceof Error) return err.message; - return String(err); -} - -// --------------------------------------------------------------------------- -// Result formatting — strip verbose URIs, return compact markdown -// --------------------------------------------------------------------------- - -type Bindings = Array>; - -function parseBindings(raw: unknown): Bindings { - const obj = raw as { bindings?: Bindings }; - return obj?.bindings ?? []; -} - -/** Strip datatype suffixes and URI prefixes from SPARQL result values */ -function cleanValue(v: string): string { - // Typed literals: "42"^^ → 42 - const typedMatch = v.match(/^"(.+?)"\^\^<.+>$/); - if (typedMatch) return typedMatch[1]; - // Plain literals: "foo" → foo - if (v.startsWith('"') && v.endsWith('"')) return v.slice(1, -1); - // URIs: strip known prefixes - return v - .replace(DG, '') - .replace('file:', '') - .replace('symbol:', '') - .replace('pkg:', ''); -} - -/** Format SPARQL bindings as a compact markdown table */ -function toTable(bindings: Bindings, columns?: string[]): string { - if (bindings.length === 0) return '(no results)'; - const cols = columns ?? Object.keys(bindings[0]); - const rows = bindings.map(row => - '| ' + cols.map(c => cleanValue(row[c] ?? '')).join(' | ') + ' |' - ); - const header = '| ' + cols.join(' | ') + ' |'; - const sep = '| ' + cols.map(() => '---').join(' | ') + ' |'; - return [header, sep, ...rows].join('\n'); -} - -async function sparql(query: string): Promise { - const client = await getClient(); - const result = await client.query(query, CONTEXT_GRAPH); - return parseBindings(result.result); -} - -function ok(text: string) { - return { content: [{ type: 'text' as const, text }] }; -} - -function err(text: string) { - return { content: [{ type: 'text' as const, text }], isError: true as const }; -} - -const SPARQL_ONLY = process.env.DKG_SPARQL_ONLY === '1'; - -const server = new McpServer({ - name: 'dkg', - version: '9.2.0', -}); - -// --------------------------------------------------------------------------- -// dkg_find_modules — Find code modules by keyword in path -// --------------------------------------------------------------------------- - -if (!SPARQL_ONLY) server.registerTool( - 'dkg_find_modules', - { - title: 'Find Code Modules', - description: - 'Search the code graph for source files matching a keyword in their path. ' + - 'Returns file paths, line counts, and containing package.', - inputSchema: { - keyword: z.string().describe('Substring to match in file paths (case-insensitive)'), - limit: z.number().optional().default(30).describe('Max results (default 30)'), - }, - }, - async ({ keyword, limit }) => { - try { - const q = `SELECT ?path ?lines ?pkg WHERE { - ?m a <${DG}CodeModule> ; <${DG}path> ?path ; <${DG}lineCount> ?lines ; <${DG}containedIn> ?p . - ?p <${DG}name> ?pkg . - FILTER(CONTAINS(LCASE(?path), LCASE("${esc(keyword)}"))) - } ORDER BY ?path LIMIT ${limit ?? 30}`; - const rows = await sparql(q); - return ok(`Found ${rows.length} modules matching "${keyword}":\n\n${toTable(rows, ['path', 'lines', 'pkg'])}`); - } catch (e) { return err(`Error: ${formatError(e)}`); } - }, -); - -// --------------------------------------------------------------------------- -// dkg_find_functions — Find functions/methods by name -// --------------------------------------------------------------------------- - -if (!SPARQL_ONLY) server.registerTool( - 'dkg_find_functions', - { - title: 'Find Functions', - description: - 'Search the code graph for functions or methods matching a name keyword. ' + - 'Returns function name, signature, file path, and optional return type.', - inputSchema: { - keyword: z.string().describe('Substring to match in function names'), - module: z.string().optional().describe('Optional path substring to narrow to specific files'), - limit: z.number().optional().default(20).describe('Max results (default 20)'), - }, - }, - async ({ keyword, module, limit }) => { - try { - const moduleFilter = module - ? `FILTER(CONTAINS(LCASE(?path), LCASE("${esc(module)}")))` - : ''; - const q = `SELECT ?name ?sig ?path ?ret WHERE { - ?f a <${DG}Function> ; <${DG}name> ?name ; <${DG}definedIn> ?mod . - ?mod <${DG}path> ?path . - OPTIONAL { ?f <${DG}signature> ?sig } - OPTIONAL { ?f <${DG}returnType> ?ret } - FILTER(CONTAINS(LCASE(?name), LCASE("${esc(keyword)}"))) - ${moduleFilter} - } ORDER BY ?path ?name LIMIT ${limit ?? 20}`; - const rows = await sparql(q); - return ok(`Found ${rows.length} functions matching "${keyword}":\n\n${toTable(rows, ['name', 'sig', 'path'])}`); - } catch (e) { return err(`Error: ${formatError(e)}`); } - }, -); - -// --------------------------------------------------------------------------- -// dkg_find_classes — Find classes by name -// --------------------------------------------------------------------------- - -if (!SPARQL_ONLY) server.registerTool( - 'dkg_find_classes', - { - title: 'Find Classes', - description: - 'Search the code graph for classes matching a name keyword. ' + - 'Returns class name, file path, parent class (extends), and interfaces (implements).', - inputSchema: { - keyword: z.string().optional().default('').describe('Substring to match in class names (empty = all classes)'), - module: z.string().optional().describe('Optional path substring to narrow to specific files'), - limit: z.number().optional().default(30).describe('Max results (default 30)'), - }, - }, - async ({ keyword, module, limit }) => { - try { - const nameFilter = keyword - ? `FILTER(CONTAINS(LCASE(?name), LCASE("${esc(keyword)}")))` - : ''; - const moduleFilter = module - ? `FILTER(CONTAINS(LCASE(?path), LCASE("${esc(module)}")))` - : ''; - const q = `SELECT ?name ?path ?extends ?implements WHERE { - ?c a <${DG}Class> ; <${DG}name> ?name ; <${DG}definedIn> ?mod . - ?mod <${DG}path> ?path . - OPTIONAL { ?c <${DG}extends> ?extends } - OPTIONAL { ?c <${DG}implements> ?implements } - ${nameFilter} - ${moduleFilter} - } ORDER BY ?name LIMIT ${limit ?? 30}`; - const rows = await sparql(q); - return ok(`Found ${rows.length} classes:\n\n${toTable(rows, ['name', 'path', 'extends', 'implements'])}`); - } catch (e) { return err(`Error: ${formatError(e)}`); } - }, -); - -// --------------------------------------------------------------------------- -// dkg_find_packages — List packages and their dependencies -// --------------------------------------------------------------------------- - -if (!SPARQL_ONLY) server.registerTool( - 'dkg_find_packages', - { - title: 'Find Packages', - description: - 'Search the code graph for workspace packages. ' + - 'Returns package names, paths, and their workspace dependencies.', - inputSchema: { - keyword: z.string().optional().default('').describe('Substring to match in package names (empty = all packages)'), - }, - }, - async ({ keyword }) => { - try { - const nameFilter = keyword - ? `FILTER(CONTAINS(LCASE(?name), LCASE("${esc(keyword)}")))` - : ''; - const q = `SELECT ?name ?pkgPath ?dep WHERE { - ?p a <${DG}Package> ; <${DG}name> ?name ; <${DG}path> ?pkgPath . - OPTIONAL { ?p <${DG}dependsOn> ?d . ?d <${DG}name> ?dep } - ${nameFilter} - } ORDER BY ?name`; - const rows = await sparql(q); - - // Group deps by package for compact output - const byPkg = new Map(); - for (const row of rows) { - const name = cleanValue(row.name ?? ''); - const existing = byPkg.get(name); - if (!existing) { - byPkg.set(name, { path: cleanValue(row.pkgPath ?? ''), deps: [] }); - } - const dep = row.dep ? cleanValue(row.dep) : ''; - if (dep && !byPkg.get(name)!.deps.includes(dep)) { - byPkg.get(name)!.deps.push(dep); - } - } - - const lines = [...byPkg.entries()].map(([name, info]) => - `- **${name}** (${info.path})${info.deps.length ? `\n deps: ${info.deps.join(', ')}` : ''}` - ); - return ok(`Found ${byPkg.size} packages:\n\n${lines.join('\n')}`); - } catch (e) { return err(`Error: ${formatError(e)}`); } - }, -); - -// --------------------------------------------------------------------------- -// dkg_file_summary — Get a compact summary of a file without reading it -// --------------------------------------------------------------------------- - -if (!SPARQL_ONLY) server.registerTool( - 'dkg_file_summary', - { - title: 'File Summary', - description: - 'Get a compact summary of a source file from the code graph: its functions, ' + - 'classes, imports, line count, and containing package — without reading the full file.', - inputSchema: { - path: z.string().describe('Exact or partial file path (e.g. "extensions/discord/src/plugin.ts")'), - }, - }, - async ({ path: filePath }) => { - try { - const escaped = esc(filePath); - - // Module metadata - const modQ = `SELECT ?path ?lines ?pkg WHERE { - ?m a <${DG}CodeModule> ; <${DG}path> ?path ; <${DG}lineCount> ?lines ; <${DG}containedIn> ?p . - ?p <${DG}name> ?pkg . - FILTER(CONTAINS(?path, "${escaped}")) - } LIMIT 1`; - const mods = await sparql(modQ); - if (mods.length === 0) return ok(`No module found matching "${filePath}".`); - - const exactPath = cleanValue(mods[0].path); - - // Functions in this module - const fnQ = `SELECT ?name ?sig ?ret WHERE { - ?f a <${DG}Function> ; <${DG}name> ?name ; <${DG}definedIn> ?mod . - ?mod <${DG}path> "${exactPath}" . - OPTIONAL { ?f <${DG}signature> ?sig } - OPTIONAL { ?f <${DG}returnType> ?ret } - } ORDER BY ?name`; - const fns = await sparql(fnQ); - - // Classes in this module - const clsQ = `SELECT ?name ?extends ?implements WHERE { - ?c a <${DG}Class> ; <${DG}name> ?name ; <${DG}definedIn> ?mod . - ?mod <${DG}path> "${exactPath}" . - OPTIONAL { ?c <${DG}extends> ?extends } - OPTIONAL { ?c <${DG}implements> ?implements } - }`; - const classes = await sparql(clsQ); - - // Imports - const impQ = `SELECT ?imp WHERE { - ?mod a <${DG}CodeModule> ; <${DG}path> "${exactPath}" ; <${DG}imports> ?i . - ?i <${DG}path> ?imp . - }`; - const imports = await sparql(impQ); - - const parts: string[] = []; - parts.push(`**${exactPath}** (${cleanValue(mods[0].lines)} lines, package: ${cleanValue(mods[0].pkg)})`); - - if (fns.length > 0) { - parts.push(`\n**Functions (${fns.length}):**`); - for (const fn of fns) { - const sig = fn.sig ? cleanValue(fn.sig) : cleanValue(fn.name); - parts.push(`- ${sig}`); - } - } - - if (classes.length > 0) { - parts.push(`\n**Classes (${classes.length}):**`); - for (const cls of classes) { - let line = `- ${cleanValue(cls.name)}`; - if (cls.extends) line += ` extends ${cleanValue(cls.extends)}`; - if (cls.implements) line += ` implements ${cleanValue(cls.implements)}`; - parts.push(line); - } - } - - if (imports.length > 0) { - parts.push(`\n**Imports (${imports.length}):** ${imports.map(i => cleanValue(i.imp)).join(', ')}`); - } - - return ok(parts.join('\n')); - } catch (e) { return err(`Error: ${formatError(e)}`); } - }, -); - -// --------------------------------------------------------------------------- -// dkg_query — Advanced SPARQL fallback -// --------------------------------------------------------------------------- - -server.registerTool( - 'dkg_query', - { - title: SPARQL_ONLY ? 'DKG Code Graph Query' : 'SPARQL Query (advanced)', - description: SPARQL_ONLY - ? 'Execute a SPARQL query against the indexed code graph. ' + - 'The graph contains the full codebase structure. ' + - 'Prefix: devgraph = . ' + - 'Types: CodeModule, Function, Class, Package, Contract. ' + - 'Properties: path, name, lineCount, signature, definedIn, containedIn, ' + - 'imports, dependsOn, extends, implements, hasMethod, parameter, returnType. ' + - 'Results are returned as compact markdown tables.' - : 'Execute a raw SPARQL query against the code graph. Use this only when the ' + - 'other tools (dkg_find_modules, dkg_find_functions, dkg_find_classes, ' + - 'dkg_find_packages, dkg_file_summary) cannot express your query. ' + - 'Common prefixes: devgraph = , ' + - 'types: CodeModule, Function, Class, Package, Contract. ' + - 'Properties: path, name, lineCount, signature, definedIn, containedIn, ' + - 'imports, dependsOn, extends, implements, hasMethod, parameter, returnType.', - inputSchema: { - sparql: z.string().describe('The SPARQL SELECT query to execute'), - }, - }, - async ({ sparql: query }) => { - try { - const rows = await sparql(query); - return ok(toTable(rows)); - } catch (e) { return err(`Query error: ${formatError(e)}`); } - }, -); - -// --------------------------------------------------------------------------- -// dkg_publish — Publish knowledge to the graph -// --------------------------------------------------------------------------- - -server.registerTool( - 'dkg_publish', - { - title: 'Publish to DKG', - description: - 'Publish RDF quads (knowledge) to the dev-coordination context graph. ' + - 'Use this to record architectural decisions, session summaries, or other knowledge.', - inputSchema: { - quads: z.array(z.object({ - subject: z.string().describe('Subject URI'), - predicate: z.string().describe('Predicate URI'), - object: z.string().describe('Object URI or literal value'), - graph: z.string().describe('Named graph URI'), - })).describe('Array of RDF quads to publish'), - }, - }, - async ({ quads }) => { - try { - const client = await getClient(); - const result = await client.publish(CONTEXT_GRAPH, quads); - return ok(`Published ${quads.length} quads. KC: ${result.kcId}, status: ${result.status}`); - } catch (e) { return err(`Publish error: ${formatError(e)}`); } - }, -); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const esc = escapeSparqlLiteral; - -// --------------------------------------------------------------------------- -// Adapter loading — DKG_ADAPTERS=autoresearch,other,... -// --------------------------------------------------------------------------- - -type AdapterRegisterFn = ( - server: McpServer, - getClient: () => Promise, - contextGraphId?: string, -) => void; - -const ADAPTER_MAP: Record = { - autoresearch: '@origintrail-official/dkg-adapter-autoresearch', -}; - -async function loadAdapters() { - const raw = process.env.DKG_ADAPTERS ?? ''; - const names = raw.split(',').map(s => s.trim()).filter(Boolean); - for (const name of names) { - const pkg = ADAPTER_MAP[name] ?? name; - try { - const mod = await import(pkg) as { registerTools?: AdapterRegisterFn }; - if (typeof mod.registerTools === 'function') { - mod.registerTools(server, getClient); - process.stderr.write(`[dkg-mcp] adapter loaded: ${name}\n`); - } else { - process.stderr.write(`[dkg-mcp] adapter ${name}: no registerTools export, skipped\n`); - } - } catch (e) { - process.stderr.write(`[dkg-mcp] adapter ${name} failed to load: ${formatError(e)}\n`); - } - } -} - -// --------------------------------------------------------------------------- -// Start the server -// --------------------------------------------------------------------------- - -async function main() { - await loadAdapters(); - const transport = new StdioServerTransport(); - await server.connect(transport); -} - -main().catch((err) => { - process.stderr.write(`DKG MCP server fatal error: ${formatError(err)}\n`); - process.exit(1); -}); diff --git a/packages/mcp-server/test/connection.test.ts b/packages/mcp-server/test/connection.test.ts deleted file mode 100644 index deec7cfd6..000000000 --- a/packages/mcp-server/test/connection.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, writeFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { DkgClient } from '../src/connection.js'; - -function jsonRes(data: unknown, ok = true): Response { - return { - ok, - status: ok ? 200 : 422, - statusText: ok ? 'OK' : 'Unprocessable', - json: async () => data, - } as Response; -} - -interface FetchCall { url: string; init?: RequestInit } - -function createTrackingFetch(responses: Array Response)>) { - const calls: FetchCall[] = []; - const queue = [...responses]; - const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = String(input); - calls.push({ url, init }); - const next = queue.shift(); - if (!next) throw new Error(`No more fetch responses queued for: ${url}`); - return typeof next === 'function' ? next() : next; - }; - return { fn: fn as typeof globalThis.fetch, calls }; -} - -describe('DkgClient', () => { - const originalFetch = globalThis.fetch; - const originalDkgHome = process.env.DKG_HOME; - const originalDkgApiPort = process.env.DKG_API_PORT; - let tempDir: string; - - beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'dkg-conn-test-')); - process.env.DKG_HOME = tempDir; - delete process.env.DKG_API_PORT; - }); - - afterEach(async () => { - globalThis.fetch = originalFetch; - if (originalDkgHome !== undefined) { - process.env.DKG_HOME = originalDkgHome; - } else { - delete process.env.DKG_HOME; - } - if (originalDkgApiPort !== undefined) { - process.env.DKG_API_PORT = originalDkgApiPort; - } else { - delete process.env.DKG_API_PORT; - } - await rm(tempDir, { recursive: true }).catch(() => {}); - }); - - describe('connect', () => { - it('returns client when API port is available', async () => { - process.env.DKG_API_PORT = '9201'; - await writeFile(join(tempDir, 'auth.token'), 'tok\n'); - const c = await DkgClient.connect(); - expect(c).toBeInstanceOf(DkgClient); - }); - - it('throws when daemon is not running', async () => { - await expect(DkgClient.connect()).rejects.toThrow(/not running/); - }); - - it('throws when port unreadable but process alive', async () => { - await writeFile(join(tempDir, 'daemon.pid'), String(process.pid)); - await expect(DkgClient.connect()).rejects.toThrow(/Cannot read API port/); - }); - }); - - describe('HTTP helpers', () => { - it('status sends bearer token when set', async () => { - const { fn, calls } = createTrackingFetch([ - jsonRes({ - name: 'n', - peerId: 'p', - uptimeMs: 1, - connectedPeers: 0, - relayConnected: false, - multiaddrs: [], - }), - ]); - globalThis.fetch = fn; - const c = new DkgClient(9200, 'secret'); - await c.status(); - expect(calls).toHaveLength(1); - expect(calls[0].url).toBe('http://127.0.0.1:9200/api/status'); - expect((calls[0].init?.headers as Record)?.Authorization).toBe('Bearer secret'); - }); - - it('get surfaces non-JSON error body', async () => { - const { fn } = createTrackingFetch([ - { - ok: false, - status: 500, - statusText: 'Err', - json: async () => { throw new Error('not json'); }, - } as Response, - ]); - globalThis.fetch = fn; - const c = new DkgClient(9200); - await expect(c.status()).rejects.toThrow(/Err/); - }); - - it('post sends JSON body', async () => { - const { fn, calls } = createTrackingFetch([ - jsonRes({ result: { bindings: [] } }), - ]); - globalThis.fetch = fn; - const c = new DkgClient(9200); - await c.query('SELECT * WHERE { ?s ?p ?o }'); - expect(calls).toHaveLength(1); - expect(calls[0].url).toBe('http://127.0.0.1:9200/api/query'); - expect(calls[0].init?.method).toBe('POST'); - expect(calls[0].init?.body).toBe( - JSON.stringify({ sparql: 'SELECT * WHERE { ?s ?p ?o }', contextGraphId: undefined }), - ); - }); - - it('post propagates API error string', async () => { - const { fn } = createTrackingFetch([ - jsonRes({ error: 'bad query' }, false), - ]); - globalThis.fetch = fn; - const c = new DkgClient(9200); - await expect(c.query('x')).rejects.toThrow('bad query'); - }); - - it('covers publish, listContextGraphs, createContextGraph, agents, subscribe', async () => { - const { fn, calls } = createTrackingFetch([ - jsonRes({}), - jsonRes({ kcId: '1', status: 'ok', kas: [] }), - jsonRes({ contextGraphs: [] }), - jsonRes({ created: '1', uri: 'u' }), - jsonRes({ agents: [] }), - jsonRes({ subscribed: 'cg' }), - ]); - globalThis.fetch = fn; - const c = new DkgClient(9200); - await c.publish('cg', []); - await c.listContextGraphs(); - await c.createContextGraph('id', 'name', 'desc'); - await c.agents(); - await c.subscribe('cg'); - expect(calls.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/packages/mcp-server/test/mcp-server-extra.test.ts b/packages/mcp-server/test/mcp-server-extra.test.ts deleted file mode 100644 index 788e059f2..000000000 --- a/packages/mcp-server/test/mcp-server-extra.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * packages/mcp-server — extra QA coverage. - * - * Findings covered (see .test-audit/BUGS_FOUND.md): - * - * K-1 HIDES-BUG `tools.test.ts` inlines a copy of registration logic and - * never imports the production entry point. A tool removed - * from production would still pass. We replace that gap - * with a STATIC parity check: scan `src/index.ts` for every - * `server.registerTool('name', …)` call and pin the list - * against what tools.test.ts expects to exist. If a tool - * is added / removed in production, this test fails. - * - * K-2 SPEC-GAP No `mcp_auth` tool exists in the mcp-server package. If - * the spec requires it (see BUGS_FOUND.md K-2) this test - * stays RED until the tool is added. Per QA policy, a red - * test is the bug evidence. - * // PROD-BUG: mcp_auth is absent — see BUGS_FOUND.md K-2 - * - * K-3 SPEC-GAP The existing `connection.test.ts` mocks `globalThis.fetch` - * and never exercises a real HTTP socket. We spin up a real - * Node http.Server on localhost, connect a real DkgClient, - * and assert: - * - reconnect after server restart succeeds (transport - * lifecycle), - * - a rotated bearer token is sent on the NEXT request - * (token refresh), - * - connection refused on a dead port surfaces as an - * error (no silent hang). - * - * Per QA policy: no production-code edits. - */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { readFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; -import http, { type IncomingMessage, type Server, type ServerResponse } from 'node:http'; -import { AddressInfo } from 'node:net'; -import { DkgClient } from '../src/connection.js'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const PROD_SRC = resolve(HERE, '..', 'src', 'index.ts'); - -function extractRegisteredToolNames(src: string): string[] { - // Matches server.registerTool('name', OR server.registerTool("name", - const re = /server\.registerTool\(\s*['"]([a-zA-Z0-9_\-.]+)['"]/g; - const names = new Set(); - let m: RegExpExecArray | null; - while ((m = re.exec(src)) !== null) names.add(m[1]); - return [...names].sort(); -} - -// ───────────────────────────────────────────────────────────────────────────── -// K-1 Production tool-list parity -// ───────────────────────────────────────────────────────────────────────────── -describe('[K-1] production parity — tool list scanned from src/index.ts', () => { - let prodSource: string; - let prodTools: string[]; - - beforeAll(async () => { - prodSource = await readFile(PROD_SRC, 'utf8'); - prodTools = extractRegisteredToolNames(prodSource); - }); - - it('registers exactly the 7 expected production tools', () => { - // This is the SAME list that tools.test.ts asserts its inline copy against. - // If production drops or renames any tool, the two lists diverge and this - // test fails (whereas tools.test.ts — which uses a hand-rolled clone — - // would still pass). - expect(prodTools).toEqual([ - 'dkg_file_summary', - 'dkg_find_classes', - 'dkg_find_functions', - 'dkg_find_modules', - 'dkg_find_packages', - 'dkg_publish', - 'dkg_query', - ]); - }); - - it('each tool name begins with the dkg_ prefix (namespace safety)', () => { - for (const name of prodTools) { - expect(name, `tool name "${name}" must start with dkg_`).toMatch(/^dkg_/); - } - }); - - it('tool names are unique (no double registration in source)', () => { - const re = /server\.registerTool\(\s*['"]([a-zA-Z0-9_\-.]+)['"]/g; - const seen: string[] = []; - let m: RegExpExecArray | null; - while ((m = re.exec(prodSource)) !== null) seen.push(m[1]); - // seen includes duplicates if any; set strips them — if different, dup. - expect(seen.length).toBe(new Set(seen).size); - }); - - it('at least one tool is gated on DKG_SPARQL_ONLY (smoke check on conditional registration block)', () => { - // The production server registers the 5 find/summary tools only when - // DKG_SPARQL_ONLY is unset. Pin the fact that the conditional block - // exists so a "registered unconditionally" regression surfaces here. - expect(prodSource).toMatch(/if\s*\(\s*!SPARQL_ONLY\s*\)/); - }); -}); - -// K-2 (mcp_auth tool) sentinel removed — the mcp-server entry point does -// not yet expose an `mcp_auth` tool; feature is unimplemented, see v10 -// roadmap `BUGS_FOUND.md` K-2. - -// ───────────────────────────────────────────────────────────────────────────── -// K-3 MCP transport lifecycle over REAL HTTP (no fetch mock) -// ───────────────────────────────────────────────────────────────────────────── -describe('[K-3] DkgClient lifecycle against a REAL http.Server', () => { - let server: Server; - let port: number; - let seenAuthHeaders: string[]; - let statusCalls: number; - - function handler(req: IncomingMessage, res: ServerResponse) { - seenAuthHeaders.push(String(req.headers.authorization ?? '')); - if (req.url === '/api/status' && req.method === 'GET') { - statusCalls++; - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ - name: 'real-test', peerId: 'P', uptimeMs: 1, - connectedPeers: 0, relayConnected: false, multiaddrs: [], - })); - return; - } - if (req.url === '/api/query' && req.method === 'POST') { - let body = ''; - req.on('data', (c) => body += String(c)); - req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ result: { bindings: [{ s: '"from-real-server"' }] } })); - }); - return; - } - res.writeHead(404); - res.end(JSON.stringify({ error: 'not found' })); - } - - function listen(): Promise { - return new Promise((resolveP) => { - const srv = http.createServer(handler); - srv.listen(0, '127.0.0.1', () => resolveP(srv)); - }); - } - - beforeAll(async () => { - seenAuthHeaders = []; - statusCalls = 0; - server = await listen(); - port = (server.address() as AddressInfo).port; - }); - - afterAll(async () => { - await new Promise((r) => server.close(() => r())); - }); - - it('performs a real HTTP round-trip to /api/status (no fetch mock)', async () => { - const client = new DkgClient(port, 'tok-initial'); - const s = await client.status(); - expect(s.name).toBe('real-test'); - expect(statusCalls).toBeGreaterThanOrEqual(1); - expect(seenAuthHeaders.at(-1)).toBe('Bearer tok-initial'); - }); - - it('a rotated bearer token is used on the NEXT real request (token refresh)', async () => { - const client = new DkgClient(port, 'tok-rotated'); - await client.status(); - expect(seenAuthHeaders.at(-1)).toBe('Bearer tok-rotated'); - }); - - it('POST /api/query carries the JSON body on the wire', async () => { - const client = new DkgClient(port, 'tok-q'); - const result = await client.query('SELECT ?s WHERE { ?s ?p ?o }', 'cg-x'); - expect((result.result as any).bindings[0].s).toBe('"from-real-server"'); - }); - - it('reconnects to a restarted server (real transport lifecycle)', async () => { - // Stop the server to simulate a daemon restart. - await new Promise((r) => server.close(() => r())); - - // First attempt against the dead port must fail fast (not hang). - // Pin to transport-layer error vocabulary so a bug that makes status() - // return a falsy result (or throw something unrelated) doesn't pass. - const dead = new DkgClient(port, 'tok'); - await expect(dead.status()).rejects.toThrow( - /ECONNREFUSED|refused|connect|fetch|ENOTFOUND|ETIMEDOUT|socket|network|closed|reset/i, - ); - - // Bring up a NEW server (possibly a different port — fine; DkgClient is - // per-port so we rebuild it too, mirroring the "daemon restart → fresh - // connect" flow). - server = await listen(); - port = (server.address() as AddressInfo).port; - - const fresh = new DkgClient(port, 'tok-fresh'); - const s = await fresh.status(); - expect(s.name).toBe('real-test'); - }); - - it('connection refused on an unused port surfaces as an Error (no silent hang)', async () => { - // Use an intentionally dead port (choose port 1 — always privileged and - // unlikely to be listening as a DKG daemon). - // Pin to transport-layer error vocabulary: a bare `.toBeDefined()` would - // satisfy on any rejection (e.g. a client bug that throws a TypeError - // before even attempting the socket connect). - const client = new DkgClient(1, 'tok'); - await expect(client.status()).rejects.toThrow( - /ECONNREFUSED|refused|connect|fetch|EACCES|EPERM|network|socket|closed/i, - ); - }); -}); diff --git a/packages/mcp-server/test/tools.test.ts b/packages/mcp-server/test/tools.test.ts deleted file mode 100644 index ef6f582c8..000000000 --- a/packages/mcp-server/test/tools.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; - -interface FnCall { args: unknown[] } - -function trackingFn(defaultReturn?: T) { - const calls: FnCall[] = []; - const overrides: Array<() => Promise> = []; - const fn = async (...args: unknown[]) => { - calls.push({ args }); - const override = overrides.shift(); - if (override) return override(); - return defaultReturn; - }; - return { - fn, - calls, - nextResolve(value: unknown) { overrides.push(() => Promise.resolve(value)); }, - nextReject(err: Error) { overrides.push(() => Promise.reject(err)); }, - }; -} - -const statusData = { - name: 'test-node', - peerId: '12D3KooWTest', - uptimeMs: 60000, - connectedPeers: 3, - relayConnected: true, - multiaddrs: ['/ip4/127.0.0.1/tcp/9000'], -}; - -const queryResultData = { - result: { bindings: [{ s: 'urn:session:1', summary: '"Fixed tests"' }] }, -}; - -const publishResultData = { - kcId: 'kc-123', - status: 'confirmed', - kas: [{ tokenId: '1', rootEntity: 'urn:session:1' }], -}; - -async function createServerAndClient() { - const statusFn = trackingFn(statusData); - const queryFn = trackingFn(queryResultData); - const publishFn = trackingFn(publishResultData); - const listParanetsFn = trackingFn(); - const createParanetFn = trackingFn(); - const agentsFn = trackingFn(); - const subscribeFn = trackingFn(); - - const trackingClient = { - status: statusFn.fn, - query: queryFn.fn, - publish: publishFn.fn, - listParanets: listParanetsFn.fn, - createParanet: createParanetFn.fn, - agents: agentsFn.fn, - subscribe: subscribeFn.fn, - }; - - const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js'); - const { z } = await import('zod'); - const { escapeSparqlLiteral } = await import('@origintrail-official/dkg-core'); - - const server = new McpServer({ name: 'dkg-test', version: '9.0.0' }); - - const PARANET = 'dev-coordination'; - const DG = 'https://ontology.dkg.io/devgraph#'; - const esc = escapeSparqlLiteral; - - async function getClient() { - return trackingClient; - } - - function formatError(err: unknown): string { - if (err instanceof Error) return err.message; - return String(err); - } - - type Bindings = Array>; - - function parseBindings(raw: unknown): Bindings { - const obj = raw as { bindings?: Bindings }; - return obj?.bindings ?? []; - } - - function cleanValue(v: string): string { - const typedMatch = v.match(/^"(.+?)"\^\^<.+>$/); - if (typedMatch) return typedMatch[1]; - if (v.startsWith('"') && v.endsWith('"')) return v.slice(1, -1); - return v.replace(DG, '').replace('file:', '').replace('symbol:', '').replace('pkg:', ''); - } - - function toTable(bindings: Bindings, columns?: string[]): string { - if (bindings.length === 0) return '(no results)'; - const cols = columns ?? Object.keys(bindings[0]); - const rows = bindings.map(row => - '| ' + cols.map(c => cleanValue(row[c] ?? '')).join(' | ') + ' |' - ); - const header = '| ' + cols.join(' | ') + ' |'; - const sep = '| ' + cols.map(() => '---').join(' | ') + ' |'; - return [header, sep, ...rows].join('\n'); - } - - async function sparql(query: string): Promise { - const client = await getClient(); - const result = await client.query(query, PARANET) as { result: unknown }; - return parseBindings(result.result); - } - - function ok(text: string) { - return { content: [{ type: 'text' as const, text }] }; - } - - function err(text: string) { - return { content: [{ type: 'text' as const, text }], isError: true as const }; - } - - server.registerTool('dkg_find_modules', { - title: 'Find Code Modules', - description: 'Search the code graph for source files matching a keyword.', - inputSchema: { - keyword: z.string(), - limit: z.number().optional().default(30), - }, - }, async ({ keyword, limit }) => { - try { - const q = `SELECT ?path ?lines ?pkg WHERE { - ?m a <${DG}CodeModule> ; <${DG}path> ?path ; <${DG}lineCount> ?lines ; <${DG}containedIn> ?p . - ?p <${DG}name> ?pkg . - FILTER(CONTAINS(LCASE(?path), LCASE("${esc(keyword)}"))) - } ORDER BY ?path LIMIT ${limit ?? 30}`; - const rows = await sparql(q); - return ok(`Found ${rows.length} modules matching "${keyword}":\n\n${toTable(rows, ['path', 'lines', 'pkg'])}`); - } catch (e) { return err(`Error: ${formatError(e)}`); } - }); - - server.registerTool('dkg_find_functions', { - title: 'Find Functions', - description: 'Search the code graph for functions.', - inputSchema: { - keyword: z.string(), - module: z.string().optional(), - limit: z.number().optional().default(20), - }, - }, async ({ keyword, module, limit }) => { - try { - const moduleFilter = module ? `FILTER(CONTAINS(LCASE(?path), LCASE("${esc(module)}")))` : ''; - const q = `SELECT ?name ?sig ?path ?ret WHERE { - ?f a <${DG}Function> ; <${DG}name> ?name ; <${DG}definedIn> ?mod . - ?mod <${DG}path> ?path . - OPTIONAL { ?f <${DG}signature> ?sig } - OPTIONAL { ?f <${DG}returnType> ?ret } - FILTER(CONTAINS(LCASE(?name), LCASE("${esc(keyword)}"))) - ${moduleFilter} - } ORDER BY ?path ?name LIMIT ${limit ?? 20}`; - const rows = await sparql(q); - return ok(`Found ${rows.length} functions matching "${keyword}":\n\n${toTable(rows, ['name', 'sig', 'path'])}`); - } catch (e) { return err(`Error: ${formatError(e)}`); } - }); - - server.registerTool('dkg_find_classes', { - title: 'Find Classes', - description: 'Search the code graph for classes.', - inputSchema: { - keyword: z.string().optional().default(''), - module: z.string().optional(), - limit: z.number().optional().default(30), - }, - }, async ({ keyword, module, limit }) => { - try { - const nameFilter = keyword ? `FILTER(CONTAINS(LCASE(?name), LCASE("${esc(keyword)}")))` : ''; - const moduleFilter = module ? `FILTER(CONTAINS(LCASE(?path), LCASE("${esc(module)}")))` : ''; - const q = `SELECT ?name ?path ?extends ?implements WHERE { - ?c a <${DG}Class> ; <${DG}name> ?name ; <${DG}definedIn> ?mod . - ?mod <${DG}path> ?path . - OPTIONAL { ?c <${DG}extends> ?extends } - OPTIONAL { ?c <${DG}implements> ?implements } - ${nameFilter} - ${moduleFilter} - } ORDER BY ?name LIMIT ${limit ?? 30}`; - const rows = await sparql(q); - return ok(`Found ${rows.length} classes:\n\n${toTable(rows, ['name', 'path', 'extends', 'implements'])}`); - } catch (e) { return err(`Error: ${formatError(e)}`); } - }); - - server.registerTool('dkg_find_packages', { - title: 'Find Packages', - description: 'Search the code graph for workspace packages.', - inputSchema: { keyword: z.string().optional().default('') }, - }, async ({ keyword }) => { - try { - const nameFilter = keyword ? `FILTER(CONTAINS(LCASE(?name), LCASE("${esc(keyword)}")))` : ''; - const q = `SELECT ?name ?pkgPath ?dep WHERE { - ?p a <${DG}Package> ; <${DG}name> ?name ; <${DG}path> ?pkgPath . - OPTIONAL { ?p <${DG}dependsOn> ?d . ?d <${DG}name> ?dep } - ${nameFilter} - } ORDER BY ?name`; - const rows = await sparql(q); - return ok(`Found ${rows.length} package rows`); - } catch (e) { return err(`Error: ${formatError(e)}`); } - }); - - server.registerTool('dkg_file_summary', { - title: 'File Summary', - description: 'Get a compact summary of a source file from the code graph.', - inputSchema: { path: z.string() }, - }, async ({ path: filePath }) => { - try { - const escaped = esc(filePath); - const q = `SELECT ?path ?lines ?pkg WHERE { - ?m a <${DG}CodeModule> ; <${DG}path> ?path ; <${DG}lineCount> ?lines ; <${DG}containedIn> ?p . - ?p <${DG}name> ?pkg . - FILTER(CONTAINS(?path, "${escaped}")) - } LIMIT 1`; - const rows = await sparql(q); - if (rows.length === 0) return ok(`No module found matching "${filePath}".`); - return ok(`**${cleanValue(rows[0].path)}** (${cleanValue(rows[0].lines)} lines)`); - } catch (e) { return err(`Error: ${formatError(e)}`); } - }); - - server.registerTool('dkg_query', { - title: 'SPARQL Query (advanced)', - description: 'Execute a raw SPARQL query.', - inputSchema: { sparql: z.string() }, - }, async ({ sparql: query }) => { - try { - const rows = await sparql(query); - return ok(toTable(rows)); - } catch (e) { return err(`Query error: ${formatError(e)}`); } - }); - - server.registerTool('dkg_publish', { - title: 'Publish to DKG', - description: 'Publish RDF quads to the dev-coordination paranet.', - inputSchema: { - quads: z.array(z.object({ - subject: z.string(), - predicate: z.string(), - object: z.string(), - graph: z.string(), - })), - }, - }, async ({ quads }) => { - try { - const client = await getClient(); - const result = await client.publish(PARANET, quads) as { kcId: string; status: string }; - return ok(`Published ${quads.length} quads. KC: ${result.kcId}, status: ${result.status}`); - } catch (e) { return err(`Publish error: ${formatError(e)}`); } - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - const client = new Client({ name: 'test-client', version: '1.0.0' }); - await client.connect(clientTransport); - - return { client, queryFn, publishFn }; -} - -describe('DKG MCP Server Tools', () => { - let client: Client; - let queryFn: ReturnType; - let publishFn: ReturnType; - - beforeEach(async () => { - ({ client, queryFn, publishFn } = await createServerAndClient()); - }); - - it('registers all expected tools', async () => { - const { tools } = await client.listTools(); - const names = tools.map(t => t.name).sort(); - expect(names).toEqual([ - 'dkg_file_summary', - 'dkg_find_classes', - 'dkg_find_functions', - 'dkg_find_modules', - 'dkg_find_packages', - 'dkg_publish', - 'dkg_query', - ]); - }); - - it('dkg_query forwards SPARQL to client.query and returns formatted result', async () => { - const result = await client.callTool({ - name: 'dkg_query', - arguments: { sparql: 'SELECT ?s WHERE { ?s a devgraph:Session }' }, - }); - const content = result.content as Array<{ type: string; text: string }>; - expect(content).toHaveLength(1); - - expect(queryFn.calls).toHaveLength(1); - expect(queryFn.calls[0].args[0]).toBe('SELECT ?s WHERE { ?s a devgraph:Session }'); - expect(queryFn.calls[0].args[1]).toBe('dev-coordination'); - - expect(content[0].text).toContain('Fixed tests'); - }); - - it('dkg_publish forwards quads to client.publish and returns confirmation', async () => { - const quads = [{ - subject: '', - predicate: '', - object: '', - graph: '', - }]; - - const result = await client.callTool({ - name: 'dkg_publish', - arguments: { quads }, - }); - const content = result.content as Array<{ type: string; text: string }>; - - expect(publishFn.calls).toHaveLength(1); - expect(publishFn.calls[0].args[0]).toBe('dev-coordination'); - expect(publishFn.calls[0].args[1]).toEqual(quads); - expect(content[0].text).toContain('kc-123'); - expect(content[0].text).toContain('confirmed'); - }); - - it('dkg_find_modules builds correct SPARQL with escaped keyword', async () => { - queryFn.nextResolve({ - result: { bindings: [{ path: '"src/node.ts"', lines: '"200"', pkg: '"core"' }] }, - }); - - await client.callTool({ - name: 'dkg_find_modules', - arguments: { keyword: 'node' }, - }); - - expect(queryFn.calls.length).toBeGreaterThan(0); - const calledSparql = queryFn.calls[0].args[0] as string; - expect(calledSparql).toContain('CodeModule'); - expect(calledSparql).toContain('node'); - expect(calledSparql).toContain('LIMIT'); - }); - - it('dkg_query returns error content when client.query throws', async () => { - queryFn.nextReject(new Error('Connection refused')); - - const result = await client.callTool({ - name: 'dkg_query', - arguments: { sparql: 'SELECT * WHERE { ?s ?p ?o }' }, - }); - const content = result.content as Array<{ type: string; text: string }>; - expect(content[0].text).toContain('Connection refused'); - }); - - it('dkg_publish returns error content when client.publish throws', async () => { - publishFn.nextReject(new Error('Insufficient TRAC')); - - const result = await client.callTool({ - name: 'dkg_publish', - arguments: { - quads: [{ subject: 'urn:x', predicate: 'urn:p', object: '"v"', graph: 'urn:g' }], - }, - }); - const content = result.content as Array<{ type: string; text: string }>; - expect(content[0].text).toContain('Insufficient TRAC'); - }); - - it('dkg_file_summary returns "no module found" for non-existent file', async () => { - queryFn.nextResolve({ result: { bindings: [] } }); - - const result = await client.callTool({ - name: 'dkg_file_summary', - arguments: { path: 'nonexistent/file.ts' }, - }); - const content = result.content as Array<{ type: string; text: string }>; - expect(content[0].text).toContain('No module found'); - }); - - it('dkg_find_functions passes module filter when provided', async () => { - queryFn.nextResolve({ result: { bindings: [] } }); - - await client.callTool({ - name: 'dkg_find_functions', - arguments: { keyword: 'publish', module: 'publisher' }, - }); - - const calledSparql = queryFn.calls[0].args[0] as string; - expect(calledSparql).toContain('publisher'); - expect(calledSparql).toContain('publish'); - }); -}); diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json deleted file mode 100644 index d231bbc57..000000000 --- a/packages/mcp-server/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "composite": true - }, - "include": ["src"] -} diff --git a/packages/mcp-server/vitest.config.ts b/packages/mcp-server/vitest.config.ts deleted file mode 100644 index 2bced3a95..000000000 --- a/packages/mcp-server/vitest.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import { kosavaMcpServerCoverage } from '../../vitest.coverage'; - -export default defineConfig({ - test: { - include: ['test/**/*.test.ts'], - coverage: { - provider: 'v8', - reporter: ['text', 'html', 'lcov', 'json-summary'], - reportsDirectory: './coverage', - include: ['src/**/*.ts'], - exclude: ['src/index.ts'], - thresholds: kosavaMcpServerCoverage, - }, - }, -}); diff --git a/packages/node-ui/src/ui/components/Modals/CreateProjectModal.tsx b/packages/node-ui/src/ui/components/Modals/CreateProjectModal.tsx index 3b5288b44..abf09267e 100644 --- a/packages/node-ui/src/ui/components/Modals/CreateProjectModal.tsx +++ b/packages/node-ui/src/ui/components/Modals/CreateProjectModal.tsx @@ -39,8 +39,9 @@ export function CreateProjectModal({ open, onClose }: CreateProjectModalProps) { const [agentAddress, setAgentAddress] = useState(null); // Phase 8: after CG + ontology + manifest publish, transition into a // wire-workspace step so the curator can populate their own workspace - // (and thus use dkg_add_task etc. from their own Cursor agent) - // without dropping to the terminal. `wiredCgId` flips the modal body + // (and thus run the canonical V10 write flow — `dkg_assertion_create` + // + `dkg_assertion_write` + `dkg_assertion_promote` — from their own + // Cursor agent) without dropping to the terminal. `wiredCgId` flips the modal body // into the WireWorkspacePanel; `wiredProjectName` lets the panel // suggest a default workspace path like `~/code/`. const [wiredCgId, setWiredCgId] = useState(null); diff --git a/packages/node-ui/src/ui/components/Workspace/WireWorkspacePanel.tsx b/packages/node-ui/src/ui/components/Workspace/WireWorkspacePanel.tsx index b1a2b6a9d..0a33f71c8 100644 --- a/packages/node-ui/src/ui/components/Workspace/WireWorkspacePanel.tsx +++ b/packages/node-ui/src/ui/components/Workspace/WireWorkspacePanel.tsx @@ -240,7 +240,7 @@ export function WireWorkspacePanel({
Workspace wired.
Open {workspaceRoot} in {toolSelection === 'claude-code' ? 'Claude Code' : (toolSelection === 'both' ? 'Cursor or Claude Code' : 'Cursor')}. On the first chat, your agent will see the project ontology - {variant === 'join' ? ', tasks, and decisions' : ' you publish via dkg_add_task'}. + {variant === 'join' ? ', tasks, and decisions' : ' you publish via the canonical assertion-write flow'}.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdb8bca51..48281e756 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -509,25 +509,6 @@ importers: vitest: specifier: ^4.0.18 version: 4.0.18(@types/node@22.19.11)(happy-dom@20.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) - - packages/mcp-server: - dependencies: - '@modelcontextprotocol/sdk': - specifier: ^1 - version: 1.27.1(zod@3.25.76) - '@origintrail-official/dkg-core': - specifier: workspace:* - version: link:../core - zod: - specifier: ^3.25 - version: 3.25.76 - devDependencies: - '@vitest/coverage-v8': - specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@22.19.11)(happy-dom@20.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) - vitest: - specifier: ^4.0.18 - version: 4.0.18(@types/node@22.19.11)(happy-dom@20.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) optionalDependencies: '@origintrail-official/dkg-adapter-autoresearch': specifier: workspace:* diff --git a/scripts/import-integration-tasks.mjs b/scripts/import-integration-tasks.mjs deleted file mode 100644 index 5b6ad7b78..000000000 --- a/scripts/import-integration-tasks.mjs +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env node -/** - * Import the Cursor & Claude Code DKG-integration plan (PR #224) as live - * tasks into the `tasks` sub-graph of `dkg-code-project`. - * - * Maps every todo from - * /Users/aleatoric/.cursor/plans/cursor_and_claude_code_dkg_integration_3dcf2a9e.plan.md - * to a `tasks:Task` with a stable `urn:dkg:task:cursor-dkg-` URI. - * - * Writes under a dedicated assertion name (`integration-board`) so reruns - * replace the integration tasks without touching the 33-task seed written - * by `import-tasks.mjs`. - * - * Usage: - * node scripts/import-integration-tasks.mjs # write + promote to SWM - * node scripts/import-integration-tasks.mjs --dry-run # dump triples, no write - * node scripts/import-integration-tasks.mjs --no-promote - */ - -import path from 'node:path'; -import fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { makeClient, parseArgs, resolveToken } from './lib/dkg-daemon.mjs'; -import { - Tasks, - Github, - Agent, - Common, - XSD, - createTripleSink, - uri, - lit, -} from './lib/ontology.mjs'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '..'); - -const args = parseArgs(); -const API_BASE = (args.api ?? process.env.DEVNET_API ?? 'http://localhost:9201').replace(/\/$/, ''); -const PROJECT_ID = args.project ?? 'dkg-code-project'; -const SUBGRAPH = args.subgraph ?? 'tasks'; -const ASSERTION_NAME = args.assertion ?? 'integration-board'; -const REPO = args.repo ?? 'OriginTrail/dkg-v9'; -const [OWNER, REPO_NAME] = REPO.split('/'); -const PR_NUMBER = Number(args.pr ?? 224); -const DRY_RUN = args['dry-run'] === 'true'; -const NO_PROMOTE = args['no-promote'] === 'true'; -const OUT_FILE = args.out ?? null; - -// The agent populating these tasks — same URI the capture hook uses so -// `prov:wasAttributedTo` points at the Cursor session that's actually -// driving the work. -const AGENT_SLUG = args.agent ?? 'cursor-branarakic'; - -const prUri = Github.uri.pr(OWNER, REPO_NAME, PR_NUMBER); -const agentUri = Agent.uri.agent(AGENT_SLUG); - -// ── Status calibration ────────────────────────────────────────────── -// -// Every plan todo is represented below. Status reflects current repo -// reality on `feat/cursor-dkg-integration` (commits b10f4dea, af713630, -// 67ac08b0) plus what we've empirically observed working in-session -// (MCP read tools all return, capture hook is writing chat turns, the -// 💬 sub-graph is populated). Phases 4/5/6 remain todo; Phase 2 install -// recipes are in_progress pending Claude Code + README polish. - -const TASKS = [ - // ─── Phase 0 — pre-flight ───────────────────────────────────── - { - slug: 'cursor-dkg-phase0-spike', - title: 'Phase 0 · Verify Cursor afterAgentResponse payload shape', - status: 'done', priority: 'p1', estimate: 1, - createdAt: '2026-04-18T08:00:00Z', - }, - - // ─── Phase 1 — ontology + profile ───────────────────────────── - { - slug: 'cursor-dkg-chat-ontology', - title: 'Phase 1 · Extend ontology.mjs with chat namespace + ConversationTurn/Session shortcuts', - status: 'done', priority: 'p1', estimate: 1, - createdAt: '2026-04-18T08:30:00Z', - }, - { - slug: 'cursor-dkg-chat-profile-binding', - title: 'Phase 1 · Register chat SubGraphBinding + ConversationTurn EntityTypeBinding in profile', - status: 'done', priority: 'p1', estimate: 1, - dependsOn: ['cursor-dkg-chat-ontology'], - createdAt: '2026-04-18T09:00:00Z', - }, - - // ─── Phase 2 — MCP read server ──────────────────────────────── - { - slug: 'cursor-dkg-mcp-scaffold', - title: 'Phase 2 · Scaffold packages/mcp-dkg (TS, stdio transport, MCP SDK, bin entrypoint, config loader)', - status: 'done', priority: 'p1', estimate: 3, - createdAt: '2026-04-18T09:30:00Z', - }, - { - slug: 'cursor-dkg-mcp-list-tools', - title: 'Phase 2 · Implement dkg_list_projects + dkg_list_subgraphs MCP tools', - status: 'done', priority: 'p1', estimate: 1, - dependsOn: ['cursor-dkg-mcp-scaffold'], - createdAt: '2026-04-18T10:00:00Z', - }, - { - slug: 'cursor-dkg-mcp-read-tools', - title: 'Phase 2 · Implement dkg_sparql + dkg_get_entity + dkg_search MCP tools', - status: 'done', priority: 'p1', estimate: 2, - dependsOn: ['cursor-dkg-mcp-scaffold'], - createdAt: '2026-04-18T10:30:00Z', - }, - { - slug: 'cursor-dkg-mcp-activity-tools', - title: 'Phase 2 · Implement dkg_list_activity + dkg_get_agent MCP tools', - status: 'done', priority: 'p1', estimate: 2, - dependsOn: ['cursor-dkg-mcp-scaffold'], - createdAt: '2026-04-18T11:00:00Z', - }, - { - slug: 'cursor-dkg-mcp-chat-tool', - title: 'Phase 2 · Implement dkg_get_chat MCP tool (SPARQL over chat sub-graph)', - status: 'done', priority: 'p1', estimate: 1, - dependsOn: ['cursor-dkg-mcp-scaffold'], - createdAt: '2026-04-18T11:30:00Z', - }, - { - slug: 'cursor-dkg-install-recipes', - title: 'Phase 2 · Install recipes + capture-hook wiring for both Cursor and Claude Code documented in packages/mcp-dkg/README.md', - status: 'done', priority: 'p2', estimate: 2, - dependsOn: ['cursor-dkg-mcp-scaffold'], - createdAt: '2026-04-18T12:00:00Z', - }, - - // ─── Phase 3 — capture hook ─────────────────────────────────── - { - slug: 'cursor-dkg-capture-hook-script', - title: 'Phase 3 · Write .cursor/hooks/capture-chat.mjs (stdin → /api/assertion/chat-log/import-file → /promote)', - status: 'done', priority: 'p1', estimate: 2, - createdAt: '2026-04-18T12:30:00Z', - }, - { - slug: 'cursor-dkg-hooks-config', - title: 'Phase 3 · Wire .cursor/hooks.json (sessionStart/End + beforeSubmitPrompt + afterAgentResponse)', - status: 'done', priority: 'p1', estimate: 1, - dependsOn: ['cursor-dkg-capture-hook-script'], - createdAt: '2026-04-18T13:00:00Z', - }, - { - slug: 'cursor-dkg-claude-code-parity', - title: 'Phase 3 · Claude Code parity — hooks merged into ~/.claude/settings.json, event-name aliases + last_assistant_message in capture-chat.mjs', - status: 'done', priority: 'p2', estimate: 1, - dependsOn: ['cursor-dkg-capture-hook-script'], - createdAt: '2026-04-18T13:30:00Z', - }, - { - slug: 'cursor-dkg-verify-write-endpoint', - title: 'Phase 3 · Verify existing /api/assertion//write supports incremental turn appends (no new endpoint)', - status: 'done', priority: 'p1', estimate: 1, - createdAt: '2026-04-18T13:30:00Z', - }, - - // ─── Phase 4 — two-machine wiring (THE HEADLINE GOAL) ───────── - { - slug: 'cursor-dkg-two-machines-wiring', - title: 'Phase 4 · Second-machine wiring on devnet-node-2 (:9202) — .dkg/config.node2.yaml + laptop2 agent registered', - status: 'done', priority: 'p0', estimate: 4, - dependsOn: ['cursor-dkg-capture-hook-script', 'cursor-dkg-mcp-chat-tool'], - dueDate: '2026-04-19', - createdAt: '2026-04-18T14:00:00Z', - }, - { - slug: 'cursor-dkg-gossip-verification', - title: 'Phase 4 · Auto-promote + gossip verified bidirectionally — turn on A → SWM on B ≤5s (both directions)', - status: 'done', priority: 'p0', estimate: 2, - dependsOn: ['cursor-dkg-two-machines-wiring'], - dueDate: '2026-04-19', - createdAt: '2026-04-18T14:00:00Z', - }, - - // ─── Phase 5 — review surface polish ────────────────────────── - { - slug: 'cursor-dkg-ui-polish', - title: 'Phase 5 · Profile rows landed: ChatSession + ChatTurn EntityTypeBindings, chat-privacy/tool FilterChips, "Chat shared with me" SavedQuery — all visible via SPARQL on meta sub-graph', - status: 'done', priority: 'p2', estimate: 1, - dependsOn: ['cursor-dkg-chat-profile-binding'], - createdAt: '2026-04-18T14:30:00Z', - }, - { - slug: 'cursor-dkg-eod-demo', - title: 'Phase 5 · EOD demo validated — cross-agent dkg_get_chat on node2 returns branarakic tree-sitter discussion from gossiped SWM', - status: 'done', priority: 'p2', estimate: 1, - dependsOn: ['cursor-dkg-gossip-verification', 'cursor-dkg-ui-polish'], - createdAt: '2026-04-18T14:30:00Z', - }, - - // ─── Phase 6 — agent write tools (deferred) ─────────────────── - { - slug: 'cursor-dkg-write-tools', - title: 'Phase 6 · Write tools landed: dkg_propose_decision / dkg_add_task / dkg_comment / dkg_request_vm_publish / dkg_set_session_privacy — all auto-promote WM→SWM, gossip to node2 ≤5s', - status: 'done', priority: 'p2', estimate: 6, - dependsOn: ['cursor-dkg-eod-demo'], - createdAt: '2026-04-18T15:00:00Z', - }, - - // ─── Phase 7 — agent annotations + project ontology + URI convergence ─ - { - slug: 'cursor-dkg-coding-project-ontology', - title: 'Phase 7 · Author coding-project starter ontology (formal Turtle/OWL + agent-guide.md) at packages/mcp-dkg/templates/ontologies/coding-project/', - status: 'done', priority: 'p1', estimate: 3, - dependsOn: ['cursor-dkg-write-tools'], - createdAt: '2026-04-18T19:00:00Z', - }, - { - slug: 'cursor-dkg-import-ontology-script', - title: 'Phase 7 · scripts/import-ontology.mjs loads .ttl + .md into meta/project-ontology assertion + auto-promotes to SWM', - status: 'done', priority: 'p1', estimate: 1, - dependsOn: ['cursor-dkg-coding-project-ontology'], - createdAt: '2026-04-18T19:10:00Z', - }, - { - slug: 'cursor-dkg-mcp-get-ontology', - title: 'Phase 7 · dkg_get_ontology MCP tool — returns formal .ttl + agent guide markdown for the project ontology', - status: 'done', priority: 'p1', estimate: 1, - dependsOn: ['cursor-dkg-import-ontology-script'], - createdAt: '2026-04-18T19:20:00Z', - }, - { - slug: 'cursor-dkg-mcp-annotate-turn', - title: 'Phase 7 · dkg_annotate_turn MCP tool — batch-emits chat:topic/mentions/examines/proposes/concludes/asks + sugar over Phase 6 writes (proposedDecisions/Tasks/comments/vmPublishRequests)', - status: 'done', priority: 'p1', estimate: 3, - dependsOn: ['cursor-dkg-mcp-get-ontology'], - createdAt: '2026-04-18T19:30:00Z', - }, - { - slug: 'cursor-dkg-hook-mention-regex', - title: 'Phase 7 · Mention-regex backstop in capture-chat.mjs — auto-emits chat:mentions for any urn:dkg:* in turn text (defensive backstop if agent forgets dkg_annotate_turn)', - status: 'done', priority: 'p2', estimate: 1, - dependsOn: ['cursor-dkg-mcp-annotate-turn'], - createdAt: '2026-04-18T19:40:00Z', - }, - { - slug: 'cursor-dkg-hook-agent-self-register', - title: 'Phase 7 · capture-chat.mjs auto-registers agent in meta/agent-self-register- on first sessionStart — fixes operator-B onboarding pinch point', - status: 'done', priority: 'p2', estimate: 1, - dependsOn: ['cursor-dkg-mcp-annotate-turn'], - createdAt: '2026-04-18T19:45:00Z', - }, - { - slug: 'cursor-dkg-hook-session-context', - title: 'Phase 7 · capture-chat.mjs returns additionalContext on sessionStart — annotation protocol summary + 30 most-recent entities → agent boots with conventions in working context', - status: 'done', priority: 'p1', estimate: 2, - dependsOn: ['cursor-dkg-hook-agent-self-register'], - createdAt: '2026-04-18T19:50:00Z', - }, - { - slug: 'cursor-dkg-cursor-rule', - title: 'Phase 7 · .cursor/rules/dkg-annotate.mdc with alwaysApply:true — annotation contract + look-before-mint + slug normalisation rule + URI patterns', - status: 'done', priority: 'p2', estimate: 1, - dependsOn: ['cursor-dkg-mcp-annotate-turn'], - createdAt: '2026-04-18T20:00:00Z', - }, - { - slug: 'cursor-dkg-agents-md', - title: 'Phase 7 · AGENTS.md sibling for Claude Code/Continue/etc. — same protocol, more comprehensive than the Cursor rule', - status: 'done', priority: 'p2', estimate: 1, - dependsOn: ['cursor-dkg-cursor-rule'], - createdAt: '2026-04-18T20:05:00Z', - }, - { - slug: 'cursor-dkg-starter-ontologies', - title: 'Phase 7 · 4 additional starter ontologies (book-research, pkm, scientific-research, narrative-writing) shipped as ttl+md pairs in packages/mcp-dkg/templates/ontologies/', - status: 'done', priority: 'p2', estimate: 3, - dependsOn: ['cursor-dkg-coding-project-ontology'], - createdAt: '2026-04-18T20:15:00Z', - }, - { - slug: 'cursor-dkg-create-project-modal-wire', - title: 'Phase 7 · CreateProjectModal ontology picker wired (community + agent enabled with starter dropdown; upload deferred). Bundled starters via Vite import.meta.glob; calls /api/assertion/project-ontology/write+promote on CG creation', - status: 'done', priority: 'p2', estimate: 2, - dependsOn: ['cursor-dkg-starter-ontologies'], - createdAt: '2026-04-18T20:30:00Z', - }, - { - slug: 'cursor-dkg-inbound-invite-investigation', - title: 'Phase 7 · Investigated passive inbound-invite UX (gap confirmed: requires daemon CONTEXT_GRAPH_INVITED event + sseBroadcast). Spec + minimum-fix plan documented in packages/mcp-dkg/docs/INBOUND_INVITES.md for Phase 8', - status: 'done', priority: 'p3', estimate: 1, - createdAt: '2026-04-18T20:45:00Z', - }, - { - slug: 'cursor-dkg-reconciliation-doc', - title: 'Phase 7 · Spec for future dkg_propose_same_as reconciliation flow documented in packages/mcp-dkg/docs/RECONCILIATION.md (Phase 8 work — repair pathway for the rare cases where look-before-mint loses a race)', - status: 'done', priority: 'p3', estimate: 1, - createdAt: '2026-04-18T20:50:00Z', - }, -]; - -const sink = createTripleSink(); -const { emit } = sink; - -for (const t of TASKS) { - const id = Tasks.uri.task(t.slug); - emit(uri(id), uri(Common.type), uri(Tasks.T.Task)); - emit(uri(id), uri(Common.name), lit(t.title)); - emit(uri(id), uri(Common.label), lit(t.title)); - emit(uri(id), uri(Common.title), lit(t.title)); - emit(uri(id), uri(Tasks.P.status), lit(t.status)); - emit(uri(id), uri(Tasks.P.priority), lit(t.priority)); - if (typeof t.estimate === 'number') { - emit(uri(id), uri(Tasks.P.estimate), lit(t.estimate, XSD.int)); - } - if (t.dueDate) { - emit(uri(id), uri(Tasks.P.dueDate), lit(t.dueDate, 'http://www.w3.org/2001/XMLSchema#date')); - } - emit( - uri(id), - '', - lit(t.createdAt ?? '2026-04-18T12:00:00Z', XSD.dateTime), - ); - for (const dep of t.dependsOn ?? []) { - emit(uri(id), uri(Tasks.P.dependsOn), uri(Tasks.uri.task(dep))); - } - // Every integration task is linked to PR #224 so the tasks cluster - // around it in the graph viz and UI cross-references pick them up. - emit(uri(id), uri(Tasks.P.relatedPR), uri(prUri)); - emit(uri(id), uri(Agent.Prov.wasAttributedTo), uri(agentUri)); -} - -console.log(`[integration-tasks] Produced ${sink.size()} triples from ${TASKS.length} tasks.`); - -if (OUT_FILE) { - const nt = sink.triples.map(t => `${t.subject} ${t.predicate} ${t.object} .`).join('\n') + '\n'; - fs.writeFileSync(OUT_FILE, nt); - console.log(`[integration-tasks] Wrote N-Triples to ${OUT_FILE}`); -} - -if (DRY_RUN) { - console.log('[integration-tasks] --dry-run set; not importing.'); - process.exit(0); -} - -const token = resolveToken(REPO_ROOT); -const client = makeClient({ apiBase: API_BASE, token }); - -const { cgId } = await client.ensureProject({ - id: PROJECT_ID, - name: 'DKG Code memory', - description: 'Shared context graph for the dkg-v9 monorepo itself.', -}); -await client.ensureSubGraph(cgId, SUBGRAPH); -await client.writeAssertion( - { - contextGraphId: cgId, - assertionName: ASSERTION_NAME, - subGraphName: SUBGRAPH, - triples: sink.triples, - }, - { label: 'integration-tasks' }, -); -console.log( - `[integration-tasks] Wrote ${sink.size()} triples into ${cgId}/${SUBGRAPH}/${ASSERTION_NAME}.`, -); - -if (!NO_PROMOTE) { - const taskUris = TASKS.map(t => Tasks.uri.task(t.slug)); - try { - await client.promote({ - contextGraphId: cgId, - assertionName: ASSERTION_NAME, - subGraphName: SUBGRAPH, - entities: taskUris, - }); - console.log(`[integration-tasks] Promoted ${taskUris.length} tasks WM → SWM.`); - } catch (err) { - console.warn(`[integration-tasks] Promote skipped: ${err.message}`); - } -} diff --git a/scripts/import-ontology.mjs b/scripts/import-ontology.mjs index 4a5342809..06fdeeb77 100644 --- a/scripts/import-ontology.mjs +++ b/scripts/import-ontology.mjs @@ -10,13 +10,14 @@ * * Stores them as literals on a single `prov:Entity` node in the * `meta/project-ontology` assertion, then auto-promotes to SWM so all - * subscribed nodes (and their agents) can fetch via `dkg_get_ontology`. + * subscribed nodes (and their agents) can read it back via `dkg_query` + * against the `meta` sub-graph. * * Why store as literals (and not as parsed RDF triples expanded into - * the graph)? v1 simplicity: agents fetch via dkg_get_ontology, get - * back two strings, parse them in the agent's own context. The - * ontology is metadata about the graph, not query-target data. v2 may - * additionally parse the .ttl into the graph for SPARQLability. + * the graph)? v1 simplicity: agents fetch the two literals, parse them + * in the agent's own context. The ontology is metadata about the graph, + * not query-target data. v2 may additionally parse the .ttl into the + * graph for SPARQLability. * * Usage: * node scripts/import-ontology.mjs # writes to dkg-code-project from coding-project starter diff --git a/scripts/send-test-chat-turn.mjs b/scripts/send-test-chat-turn.mjs deleted file mode 100644 index 224bed2c8..000000000 --- a/scripts/send-test-chat-turn.mjs +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env node -/** - * Synthetic chat-turn writer for the Phase 4 two-machines smoke test. - * - * Simulates what the capture-chat.mjs hook would emit when the second- - * machine user types something at their Cursor. Writes a minimal - * `chat:Session` + `chat:Turn` via the canonical assertion-write path, - * promotes to SWM, and prints the turn URI. - * - * Defaults target devnet node-2 (port 9202) and attribute to the laptop2 - * agent so a subsequent `dkg_get_chat` on node-1 proves cross-node - * gossip replication — the headline demo claim. - * - * Usage: - * node scripts/send-test-chat-turn.mjs - * node scripts/send-test-chat-turn.mjs --prompt="tree-sitter for python?" --reply="..." - * node scripts/send-test-chat-turn.mjs --api=http://localhost:9201 --agent=cursor-branarakic - */ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { makeClient, parseArgs, resolveToken } from './lib/dkg-daemon.mjs'; -import { Agent, createTripleSink, uri, lit } from './lib/ontology.mjs'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '..'); - -const args = parseArgs(); -const API_BASE = (args.api ?? process.env.DEVNET_API ?? 'http://localhost:9202').replace(/\/$/, ''); -const NODE_ID = Number(args['node-id'] ?? process.env.DEVNET_NODE_ID ?? 2); -const PROJECT_ID = args.project ?? 'dkg-code-project'; -const SUBGRAPH = args.subgraph ?? 'chat'; -const ASSERTION_NAME = args.assertion ?? 'chat-log'; -const AGENT_SLUG = args.agent ?? 'cursor-branarakic-laptop2'; -const SESSION_ID = args.session ?? `phase4-smoke-${Date.now()}`; -const PROMPT = args.prompt - ?? 'Phase 4 smoke test — can node-1 see this turn via gossip from node-2?'; -const REPLY = args.reply - ?? 'If you are reading this via dkg_get_chat on node-1, then yes: cross-node SWM gossip is working. Attribution should read "Cursor — branarakic (laptop 2)".'; - -const NS = { - rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', - rdfs: 'http://www.w3.org/2000/01/rdf-schema#', - schema: 'http://schema.org/', - dcterms: 'http://purl.org/dc/terms/', - xsd: 'http://www.w3.org/2001/XMLSchema#', - prov: 'http://www.w3.org/ns/prov#', - chat: 'http://dkg.io/ontology/chat/', -}; -const T = { Session: NS.chat + 'Session', Turn: NS.chat + 'Turn' }; -const P = { - type: NS.rdf + 'type', - label: NS.rdfs + 'label', - name: NS.schema + 'name', - created: NS.dcterms + 'created', - modified: NS.dcterms + 'modified', - attributed: NS.prov + 'wasAttributedTo', - inSession: NS.chat + 'inSession', - turnIndex: NS.chat + 'turnIndex', - userPrompt: NS.chat + 'userPrompt', - assistantResponse: NS.chat + 'assistantResponse', - speakerTool: NS.chat + 'speakerTool', -}; - -const sessionUri = `urn:dkg:chat:session:${SESSION_ID}`; -const turnUri = `${sessionUri}#turn:1`; -const agentUri = Agent.uri.agent(AGENT_SLUG); -const now = new Date().toISOString(); - -const sink = createTripleSink(); -const { emit } = sink; - -emit(uri(sessionUri), uri(P.type), uri(T.Session)); -emit(uri(sessionUri), uri(P.label), lit(`Smoke ${SESSION_ID}`)); -emit(uri(sessionUri), uri(P.name), lit(`Smoke ${SESSION_ID}`)); -emit(uri(sessionUri), uri(P.created), lit(now, NS.xsd + 'dateTime')); -emit(uri(sessionUri), uri(P.attributed), uri(agentUri)); -emit(uri(sessionUri), uri(P.speakerTool), lit('cursor')); - -emit(uri(turnUri), uri(P.type), uri(T.Turn)); -emit(uri(turnUri), uri(P.label), lit(`Turn 1 of ${SESSION_ID}`)); -emit(uri(turnUri), uri(P.inSession), uri(sessionUri)); -emit(uri(turnUri), uri(P.turnIndex), lit(1, NS.xsd + 'integer')); -emit(uri(turnUri), uri(P.created), lit(now, NS.xsd + 'dateTime')); -emit(uri(turnUri), uri(P.modified), lit(now, NS.xsd + 'dateTime')); -emit(uri(turnUri), uri(P.attributed), uri(agentUri)); -emit(uri(turnUri), uri(P.userPrompt), lit(PROMPT)); -emit(uri(turnUri), uri(P.assistantResponse), lit(REPLY)); -emit(uri(turnUri), uri(P.speakerTool), lit('cursor')); - -console.log(`[smoke] Produced ${sink.size()} triples for ${turnUri}.`); - -const token = resolveToken(REPO_ROOT, { nodeId: NODE_ID }); -const client = makeClient({ apiBase: API_BASE, token }); -const cgId = await client.toCanonicalCgId(PROJECT_ID); -await client.ensureSubGraph(cgId, SUBGRAPH); -await client.writeAssertion( - { - contextGraphId: cgId, - assertionName: ASSERTION_NAME, - subGraphName: SUBGRAPH, - triples: sink.triples, - }, - { label: 'smoke-turn' }, -); -try { - await client.promote({ - contextGraphId: cgId, - assertionName: ASSERTION_NAME, - subGraphName: SUBGRAPH, - entities: [sessionUri, turnUri], - }); - console.log('[smoke] Promoted session + turn to SWM.'); -} catch (err) { - console.warn(`[smoke] Promote skipped: ${err.message}`); -} - -console.log(`[smoke] DONE - api: ${API_BASE} - agent: ${agentUri} - session URI: ${sessionUri} - turn URI: ${turnUri}`); diff --git a/vitest.config.ts b/vitest.config.ts index cda041950..6709597e5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ 'packages/agent', 'packages/cli', 'packages/mcp-dkg', - 'packages/mcp-server', 'packages/node-ui', 'packages/network-sim', 'packages/graph-viz',