diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..b326cac --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,34 @@ +{ + "name": "pam", + "displayName": "Portable Agent Memory", + "version": "0.4.0", + "description": "Persistent, portable memory workspace for AI agents: graph + markdown logs, hygiene tooling, and Claude Code integration (MCP server, agents, slash commands, hooks).", + "author": { + "name": "Stefano Guerrini" + }, + "repository": "https://github.com/NestDevLab/portable-agent-memory", + "license": "MIT", + "keywords": [ + "memory", + "mcp", + "agents", + "knowledge-graph", + "claude-code" + ], + "commands": [ + "tools/claude/commands/dream.md", + "tools/claude/commands/enable-status-line.md", + "tools/claude/commands/explain.md", + "tools/claude/commands/status.md" + ], + "agents": "tools/claude/agents", + "hooks": "tools/claude/hooks/hooks.json", + "mcpServers": { + "pam": { + "command": "node", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/tools/pam-mcp-server.mjs" + ] + } + } +} diff --git a/.gitignore b/.gitignore index d07fd20..93578a0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ node_modules/ .env.* *.log memory/maintenance/nightly-cron.log +memory/.session/ +memory/maintenance/proposals/ +.claude/settings.local.json tmp/ diff --git a/AGENT_BOOTSTRAP.md b/AGENT_BOOTSTRAP.md index daaf25f..f91923b 100644 --- a/AGENT_BOOTSTRAP.md +++ b/AGENT_BOOTSTRAP.md @@ -124,6 +124,88 @@ assuming Codex. The command may be Codex, Claude CLI, OpenClaw, Ollama, a local script, or any other agent command that can work from a temporary workspace. ``` +## Optional Agent Layer (Claude Code Plugin) + +For Claude Code users, PAM ships as a plugin that bundles the MCP server, +reference subagents (curator + scribe), slash commands (`/pam:dream`, +`/pam:pam-status`), and lifecycle hooks. The portable markdown + JSONL contract +is unchanged, and agents reading `memory/` by hand keep working exactly as +before. + +### Install the PAM plugin (Claude Code) + +```text +Install the Portable Agent Memory plugin in Claude Code. + +Steps: +- run `/plugin install NestDevLab/portable-agent-memory@github`; +- confirm `/agents` lists `curator` and `scribe` under the `pam` plugin; +- confirm `/dream` and `/pam:pam-status` are available; +- run `/dream` from inside a PAM workspace and confirm it returns a report. + +Report every step with a colored marker: ๐ŸŸข PASS, ๐ŸŸก PARTIAL, ๐Ÿ”ต DEFERRED, +๐Ÿ”ด BLOCKED, or โšช N/A. +``` + +### Use the curator subagent + +```text +Run the curator to audit this PAM workspace. + +Steps: +- confirm the plugin is installed (see previous prompt); +- invoke the subagent (`/pam:curator` in Claude Code) and ask it to audit + memory; +- confirm the agent only produces a Findings report and proposal artifacts + under memory/maintenance/proposals/, never edits files elsewhere. + +The curator never auto-applies edits. Review each proposal before deciding to +apply it (via the memory_apply_proposal MCP tool). + +Report every step with a colored marker: ๐ŸŸข PASS, ๐ŸŸก PARTIAL, ๐Ÿ”ต DEFERRED, +๐Ÿ”ด BLOCKED, or โšช N/A. +``` + +### Use the scribe subagent + +```text +Run the scribe at session end to record durable knowledge. + +Steps: +- confirm the plugin is installed and the curator can run; +- invoke the subagent (`/pam:scribe` in Claude Code) at session end and ask + it to record any durable knowledge from the conversation; +- confirm the agent only produces a Recorded report and appends dated + sections to memory/knowledge-log.md or memory/conversation-log.md, never + edits existing entries. + +The scribe is append-only and cannot apply curator proposals. Use it for +recording new facts/decisions; use the curator for hygiene. + +Report every step with a colored marker: ๐ŸŸข PASS, ๐ŸŸก PARTIAL, ๐Ÿ”ต DEFERRED, +๐Ÿ”ด BLOCKED, or โšช N/A. +``` + +### Other MCP hosts (Cursor, Codex, OpenClaw, etc.) + +The plugin format is Claude Code-specific. For other hosts, add a stdio MCP +server entry pointing at `tools/pam-mcp-server.mjs`. See +[docs/mcp-server.md](docs/mcp-server.md) for host-specific snippets. + +### Kimi Code CLI + +```text +Install PAM for Kimi Code CLI. + +Steps: +- run `node tools/kimi/install-mcp.mjs --apply` from the PAM workspace root; +- run `kimi mcp test pam` and confirm the server responds with tool names; +- start a Kimi session in the same workspace and ask it to read `memory/pam.version.json`. + +Report every step with a colored marker: ๐ŸŸข PASS, ๐ŸŸก PARTIAL, ๐Ÿ”ต DEFERRED, +๐Ÿ”ด BLOCKED, or โšช N/A. +``` + ## Ongoing Session Closeout Prompt ```text diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f3547b..9098ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,120 @@ ## Unreleased +## 0.4.0 - 2026-05-28 + +PAM 0.4.0 ships the optional agent layer end-to-end: a local stdio MCP +server, the curator and scribe reference subagents, and a Claude Code +plugin that wires the MCP server, agents, slash commands, and hooks into a +single installable unit. The markdown + JSONL contract is unchanged; +existing 0.3.0 graph-v1 workspaces work without modification. + +### Added: MCP server + +- Local MCP server (`tools/pam-mcp-server.mjs`) that exposes PAM as 15 typed + tools over stdio. Hand-rolled JSON-RPC 2.0 transport with zero new runtime + dependencies. Includes `--smoke` mode (`npm run mcp:smoke`) for verifying + the server starts without configuring a host. +- Read surface: `pam_version`, `memory_state`, `memory_list`, `memory_read`, + `memory_search`, `graph_query`, `graph_stats`, `graph_validate`, + `maintenance_config`, `memory_audit`. +- Hygiene mutation surface: `graph_reindex` (rewrites + `memory/graph/catalog.json` from JSONL sources), `maintenance_run` + (config-gated). +- Write surface: `memory_propose_edit` (records JSON proposals under + `memory/maintenance/proposals/`, never mutates targets directly); + `memory_append` (appends a dated `## YYYY-MM-DD - ` section to a + managed log declared in `config.managedLogs`, newest-first); + `memory_apply_proposal` (re-validates path safety + re-applies the diff, + rejects drift, archives successful applies as `<id>.applied.json`). +- `memory_audit` hygiene checks: duplicate-knowledge-entry, link-rot, + graph-source-link-rot, dangling-alias, stale-wiki-page, rotation-candidate, + contradiction, oversized-digest, orphan-source. + +### Added: Subagents + +- `curator`: read-mostly hygiene auditor. Tool whitelist enforces no + `Write`, `Edit`, `Bash`, `maintenance_run`, `graph_reindex`, + `memory_append`, or `memory_apply_proposal`. Emits proposals; never + applies. +- `scribe`: session-end closeout. Uses `memory_search` to dedup-check, then + `memory_append` to record durable knowledge into `knowledge-log` or + `conversation-log`. Append-only. + +### Added: Claude Code plugin + +- `.claude-plugin/plugin.json` declares the plugin (name `pam`, version + 0.4.0) and inlines the `mcpServers` config; installing the plugin starts + the PAM MCP server automatically. +- `tools/claude/commands/`: `/pam:dream` (hygiene pass: `graph_validate` + + `graph_reindex` + `memory_audit`), `/pam:status` (read-only workspace + snapshot), `/pam:explain` (inlined shell block that prints the status-line + legend with live values; zero LLM reasoning per invocation), + `/pam:enable-status-line` (toggle the statusline by renaming the + `statusLine` key in `settings.json` to/from `_pamDisabledStatusLine`). +- `tools/claude/agents/`: `curator` and `scribe`. +- `tools/claude/hooks/hooks.json` wires `SessionStart` (catalog-freshness check, pending- + proposal nag, statusline-style summary) and `PostToolUse` matching + `mcp__pam__memory_append` (per-session append counter at + `memory/.session/<session_id>.json`). +- Statusline (`tools/claude/templates/statusline/pam-statusline.sh`) + shows `๐Ÿง  PAM <version> ยท <glyph> <N>n/<M>e ยท ๐Ÿ“‹ <pending> ยท ๐Ÿ’ค <age> ยท + โœ๏ธ <appends>`. Pending-proposal count excludes `*.applied.json`. +- `tools/claude/install-statusline.mjs` (`npm run claude:statusline:install`) for + users who want only the statusline without the full plugin. + +### Added: Kimi Code CLI integration + +- `tools/kimi/install-mcp.mjs` registers the PAM MCP server with Kimi Code + CLI by writing an absolute-path entry into `~/.kimi/mcp.json`. +- `tools/kimi/templates/mcp.fragment.json` is the config template used by + the installer. +- `tools/kimi/docs/pam-kimi-layer.md` covers install, verify, uninstall, + and ad-hoc `--mcp-config-file` usage. +- `npm run kimi:mcp:install` shortcut for the installer. +- `docs/mcp-server.md` and `AGENT_BOOTSTRAP.md` gain Kimi-specific + sections. + +### Added: Docs and tests + +- `docs/mcp-server.md` (generic MCP docs), `tools/claude/docs/curator-agent.md`, + `tools/claude/docs/scribe-agent.md`, `tools/claude/docs/pam-claude-layer.md`. +- `AGENT_BOOTSTRAP.md` gains an "Optional Agent Layer (PAM 0.4.0+)" section + with copy/paste install prompts. +- Tests: `test-mcp-transport.mjs`, `test-memory-audit.mjs`, + `test-memory-proposals.mjs`, `test-memory-append.mjs`, + `test-memory-apply-proposal.mjs`, `test-pam-mcp-server.mjs` (66 subtests). + +### Compatibility + +The markdown + JSONL contract is unchanged; the new layer is additive +runtime. Existing 0.3.1 graph-v1 workspaces work unchanged, including agents +that read `memory/` by hand without the MCP server. See +`migrations/0.3.1-to-0.4.0-agent-layer.md` for the version-bump checklist. + +### Privacy + +`memory_propose_edit` proposal artifacts and applied-proposal archives +(`memory/maintenance/proposals/`) may quote memory content. `memory_append` +quotes whatever the scribe provides as `body`; the scribe's prompt contract +requires summarizing secrets/PII rather than copying them. Treat all +proposal artifacts with the same care as existing maintenance run reports. + +### Out of scope + +- Bootstrap (`pam-bootstrap`) and Query (`pam-query`) subagents. +- Cursor / Codex / OpenClaw subagent presets (Claude Code only). +- Remote MCP transport (HTTP/SSE/WebSocket). +- Vector or semantic search. +- Direct graph-mutation tools beyond `graph_reindex` (derived) and + `maintenance_run` (config-gated). +- Auto-rotation of `memory/maintenance/proposals/`. +- Apply-batch (`memory_apply_proposals(ids: [...])`). +- Auto-invocation hooks for `scribe` (session-end hook). Manual invocation + only. +- `memory_replace` / `memory_delete` for managed logs. Use + `memory_propose_edit` + `memory_apply_proposal` instead. + ## 0.3.1 - 2026-05-27 PAM 0.3.1 adds automated semantic migration enforcement. The graph schema diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 0000000..b8ecde5 --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,233 @@ +# PAM MCP server + +PAM 0.4.0 ships an optional MCP (Model Context Protocol) server that exposes +the memory store as typed tools to any MCP-capable agent host. The portable +markdown + JSONL contract is unchanged; the server is purely additive runtime. + +## What you get + +The server runs locally over stdio (no network, no daemon, no port) and exposes +15 tools under the `pam` namespace: + +- `pam_version`, `memory_state`, `maintenance_config`: context tools +- `memory_list`, `memory_read`, `memory_search`: safe filesystem reads under `memory/` +- `graph_query`, `graph_stats`, `graph_validate`, `graph_reindex`: graph tools +- `memory_audit`: hygiene checks, returns findings +- `memory_propose_edit`: records a proposal artifact under `memory/maintenance/proposals/`; never mutates the target file +- `memory_append`: appends a dated section to a managed log; refuses unmanaged paths +- `memory_apply_proposal`: applies a previously-recorded proposal after re-validating against current content; archives the artifact +- `maintenance_run`: wraps the maintenance CLI (defaults to dry-run) + +Write paths through the server are bounded: + +- `memory_append` only writes to paths declared in `config.managedLogs` + (typically `memory/knowledge-log.md` and `memory/conversation-log.md`). +- `memory_apply_proposal` only writes to whatever target a previously-recorded + proposal already passed through the safety validator at propose time, and + re-validates everything (protected paths, drift, graph integrity) before + applying. +- `graph_reindex` writes only the derived `memory/graph/catalog.json`. +- `maintenance_run` is gated by `config` and defaults to dry-run. + +There is no path to write to `memory/agent-memory/`, `memory/sources/`, +`AGENTS.md`, or `CLAUDE.md` through any MCP tool. Those paths are protected. + +## Requirements + +- Node.js 18 or newer (already required by PAM's existing tools). +- An MCP-capable host: Claude Code, Cursor, Codex CLI, OpenClaw, or any other + client that speaks MCP over stdio. + +## Verify the server starts + +From the repo root: + +```bash +npm run mcp:smoke +``` + +The smoke run prints two JSON-RPC frames (initialize result and tools/list +result) and exits 0. If it errors, fix the workspace before configuring a host. + +## Host configuration + +### Claude Code + +Add to project `.mcp.json` (preferred) or `~/.claude/mcp.json`: + +```json +{ + "mcpServers": { + "pam": { + "command": "node", + "args": ["tools/pam-mcp-server.mjs"], + "cwd": "${workspaceFolder}" + } + } +} +``` + +Restart Claude Code, then run `/mcp` to confirm `pam` is connected. + +### Cursor + +Add to `~/.cursor/mcp.json` (resolve `cwd` to an absolute path): + +```json +{ + "mcpServers": { + "pam": { + "command": "node", + "args": ["tools/pam-mcp-server.mjs"], + "cwd": "/absolute/path/to/your/repo" + } + } +} +``` + +### Codex CLI + +Add to `~/.codex/config.toml`: + +```toml +[mcp_servers.pam] +command = "node" +args = ["tools/pam-mcp-server.mjs"] +cwd = "/absolute/path/to/your/repo" +``` + +### Kimi Code CLI + +Install via the PAM helper (dry-run by default): + +```bash +node tools/kimi/install-mcp.mjs --apply +``` + +This writes absolute paths into `~/.kimi/mcp.json`. Verify with: + +```bash +kimi mcp list +kimi mcp test pam +``` + +To uninstall: + +```bash +node tools/kimi/install-mcp.mjs --uninstall --apply +``` + +For ad-hoc usage without touching global config: + +```bash +kimi --mcp-config-file tools/kimi/mcp-config.json +``` + +See `tools/kimi/docs/pam-kimi-layer.md` for full details. + +### OpenCode / OpenClaw + +Point your MCP host configuration at `node tools/pam-mcp-server.mjs` with `cwd` +set to the repo root. The exact config file location depends on your client. + +## Tool naming across hosts + +Each host prefixes MCP tools with the server name. Claude Code surfaces them as +`mcp__pam__memory_audit`, `mcp__pam__graph_validate`, and so on. Cursor and +Codex use similar prefixes. The unprefixed tool name within the server is +always one of the 15 listed above. + +## Workspace selection + +By default the server resolves the workspace as the directory containing +`tools/pam-mcp-server.mjs` (i.e. the PAM repo root). To target a different +workspace, pass `--workspace <path>`: + +```bash +node tools/pam-mcp-server.mjs --workspace /path/to/other/repo +``` + +The host config's `cwd` field is the simplest way to do this in practice. + +## Limitations + +- stdio transport only (no HTTP/SSE/WebSocket). +- No streaming notifications, no cancellation. +- One-file-per-call for `memory_propose_edit`; diffs are capped at 64 KB. +- Symlinks are refused for every file operation. + +The transport is hand-rolled (zero new dependencies). If a future host requires +features we don't support, falling back to `@modelcontextprotocol/sdk` is a +one-file change in `tools/lib/mcp-transport.mjs`. + +## Safety surface + +`memory_propose_edit` is the only tool that produces an artifact intended for +review. It rejects, with a clear error message: + +- paths matching `config.protectedPaths` (`AGENTS.md`, `CLAUDE.md`, `memory/agent-memory/`, `memory/sources/`); +- paths that resolve outside the workspace; +- symlinks; +- multi-file diffs; +- diffs over 64 KB; +- unified-diff hunks whose `before` doesn't match current content; +- JSONL edits whose result fails `validateGraph`. + +Proposal artifacts are JSON files under `memory/maintenance/proposals/`. +Applying a proposal is a manual step that the human takes after review, +typically via `memory_apply_proposal` below. + +## Write tools + +### `memory_append` + +Inputs: + +- `log` (required): the managed log to append to. Match on + `config.managedLogs[].archiveKey` (preferred, e.g. `"knowledge-log"`) or + `.source` (e.g. `"memory/knowledge-log.md"`). +- `headerTitle` (required): the title portion of the new section header. The + server constructs the full header as `## YYYY-MM-DD - <headerTitle>`. +- `body` (required): markdown body of the new section. +- `date` (optional): ISO `YYYY-MM-DD`. Defaults to today UTC. + +The server rejects: + +- logs not declared in `config.managedLogs`; +- empty `headerTitle` or `body`; +- headers that don't match `## YYYY-MM-DD - <title>`; +- malformed `date` strings; +- missing target files (no auto-create); +- symlinked target files. + +Inserts the new section immediately after the file's intro prefix and before +the first existing dated section (newest-first ordering). + +### `memory_apply_proposal` + +Inputs: + +- `proposalId` (required): the id of a JSON artifact under + `memory/maintenance/proposals/<id>.json`. + +The server: + +1. Loads the proposal. +2. Re-runs the same safety checks `memory_propose_edit` ran at propose time + (protected paths, workspace escape, symlinks). +3. Re-applies the diff against the *current* target content. If the file has + drifted since the proposal was recorded (`before` no longer matches, or + unified-diff hunks don't align), apply is rejected and the artifact is + untouched. +4. For JSONL targets, runs `validateGraph` on the proposed result. +5. On success, writes the new content, renames the artifact to + `<id>.applied.json` with `appliedAt` and `status: "applied"` added. + +Drift detection is intentional. A proposal that fails apply is a signal that +the target changed since review; re-run the curator, get a fresh proposal, +re-review, then apply. + +## Uninstall + +Remove the host config entry. No state remains outside `memory/maintenance/`, +which you can keep or clean up at your discretion. diff --git a/memory/graph/catalog.json b/memory/graph/catalog.json index 7025e93..bb3dd31 100644 --- a/memory/graph/catalog.json +++ b/memory/graph/catalog.json @@ -1,9 +1,9 @@ { "schemaVersion": "pam-graph-v1", - "generatedAt": "2026-05-05T17:08:17.820Z", - "nodeCount": 11, - "edgeCount": 11, - "aliasCount": 10, + "generatedAt": "2026-05-21T18:13:47.933Z", + "nodeCount": 12, + "edgeCount": 13, + "aliasCount": 12, "sourceFiles": [ "memory/graph/nodes.jsonl", "memory/graph/edges.jsonl", diff --git a/memory/index.md b/memory/index.md index 46f6fdf..54a8eb2 100644 --- a/memory/index.md +++ b/memory/index.md @@ -13,8 +13,4 @@ Start here when reading this memory workspace. - [agent-memory/pam.md](agent-memory/pam.md) - portable memory contract for setup/protocol work. - [agent-memory/llm-wiki.md](agent-memory/llm-wiki.md) - persistent wiki pattern reference. -## Generated - -- [archive/index.md](archive/index.md) - generated archive indexes. -- [summaries/](summaries/) - generated summaries. -- [maintenance/](maintenance/) - maintenance reports and run manifests. +<!-- Generated section removed 2026-05-21: archive/, summaries/, and maintenance/ are produced on demand by maintenance runs. Re-add links here once those roots contain artifacts. --> diff --git a/memory/pam.version.json b/memory/pam.version.json index ad068af..730d685 100644 --- a/memory/pam.version.json +++ b/memory/pam.version.json @@ -1,5 +1,5 @@ { - "pamVersion": "0.3.1", + "pamVersion": "0.4.0", "memoryFormat": "graph-v1", "graphSchemaVersion": "pam-graph-v1", "features": { @@ -8,7 +8,11 @@ "versionAwareMigration": true, "openClawSpecialization": true, "installationAcceptanceCriteria": true, - "migrationEnforcement": true + "migrationEnforcement": true, + "mcpServer": true, + "subagentCurator": true, + "subagentScribe": true, + "proposalApply": true }, - "updated": "2026-05-27" + "updated": "2026-05-28" } diff --git a/migrations/0.3.1-to-0.4.0-agent-layer.md b/migrations/0.3.1-to-0.4.0-agent-layer.md new file mode 100644 index 0000000..3a117fa --- /dev/null +++ b/migrations/0.3.1-to-0.4.0-agent-layer.md @@ -0,0 +1,63 @@ +# PAM 0.3.1 -> 0.4.0 Migration: Optional Agent Layer + +PAM 0.4.0 ships the optional agent layer: a local stdio MCP server, the +`curator` and `scribe` reference subagents, and a Claude Code plugin that +wires the MCP server, agents, slash commands, and lifecycle hooks into a +single installable unit. The markdown + JSONL contract is unchanged; +`pam-graph-v1` is unchanged. + +## Who Should Apply This + +Apply this migration when a workspace is on PAM 0.3.1 and wants to expose +PAM to MCP-capable agent hosts (Claude Code, Cursor, Codex, Kimi) or use +the reference curator/scribe subagents. + +Workspaces that read `memory/` directly without an MCP host can skip the +runtime install and apply only the version bump and feature flags. + +## Changes + +- Update `package.json` and `memory/pam.version.json` to `0.4.0`. +- Enable feature flags `mcpServer`, `subagentCurator`, `subagentScribe`, + and `proposalApply` in `memory/pam.version.json`. +- Ship the local MCP server (`tools/pam-mcp-server.mjs` + `tools/lib/*`). +- Ship the curator and scribe subagents. +- Ship the Claude Code plugin (`.claude-plugin/plugin.json`, + `.claude/{commands,agents}/`, `hooks/`) and the standalone statusline + installer. +- Add `mcp:serve`, `mcp:smoke`, `claude:statusline:install`, and + `kimi:mcp:install` npm scripts. +- Gitignore `memory/maintenance/proposals/` (proposal artifacts are + workspace-local audit trail). + +## Procedure + +1. Update the workspace to PAM 0.4.0. +2. Run `npm run migrations:check` to confirm the chain + `0.3.0 -> 0.3.1 -> 0.4.0` is contiguous. +3. Run `npm run memory:graph:validate`. +4. (Optional) Run `npm run mcp:smoke` to verify the MCP server starts. +5. (Optional) Install the Claude Code plugin or register the MCP server + with another host. See `docs/mcp-server.md`. + +## Validation + +The migration is complete when: + +- `package.json` version is `0.4.0`. +- `memory/pam.version.json` has `pamVersion: "0.4.0"`. +- `memory/pam.version.json` retains `features.migrationEnforcement: true` + from 0.3.1 and adds `mcpServer`, `subagentCurator`, `subagentScribe`, + and `proposalApply`. +- `npm run migrations:check` passes. +- `npm run memory:graph:validate` passes. +- `npm test` passes. +- (Optional) `npm run mcp:smoke` exits 0. + +## Compatibility + +- `memoryFormat` remains `graph-v1`. +- `graphSchemaVersion` remains `pam-graph-v1`. +- Existing 0.3.1 graph JSONL files remain valid. +- The MCP server, subagents, and Claude Code plugin are additive runtime; + agents that read `memory/` by hand continue to work unchanged. diff --git a/package.json b/package.json index 943aa29..bd590bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portable-agent-memory", - "version": "0.3.1", + "version": "0.4.0", "private": false, "description": "Markdown-first portable memory, wiki, and maintenance toolkit for AI agents.", "type": "module", @@ -19,6 +19,10 @@ "memory:synthesis": "node tools/memory-maintenance.mjs synthesis", "memory:schedule:install": "node tools/install-memory-maintenance-schedule.mjs", "migrations:check": "node tools/check-migrations.mjs", + "mcp:serve": "node tools/pam-mcp-server.mjs", + "mcp:smoke": "node tools/pam-mcp-server.mjs --smoke", + "claude:statusline:install": "node tools/claude/install-statusline.mjs", + "kimi:mcp:install": "node tools/kimi/install-mcp.mjs", "test": "node --test tools/test-*.mjs" }, "keywords": [ diff --git a/tools/claude/agents/curator.md b/tools/claude/agents/curator.md new file mode 100644 index 0000000..2659226 --- /dev/null +++ b/tools/claude/agents/curator.md @@ -0,0 +1,109 @@ +--- +name: curator +description: Memory-hygiene curator for Portable Agent Memory. Audits the local memory/ workspace, reports findings, and proposes diffs as artifacts. Never mutates durable memory. Invoke when memory feels stale, after a long session, or before a release. Not for general Q&A over memory. +tools: + - mcp__pam__pam_version + - mcp__pam__memory_state + - mcp__pam__memory_list + - mcp__pam__memory_read + - mcp__pam__memory_search + - mcp__pam__graph_query + - mcp__pam__graph_stats + - mcp__pam__graph_validate + - mcp__pam__memory_audit + - mcp__pam__memory_propose_edit + - mcp__pam__maintenance_config +model: inherit +--- + +# PAM curator + +You are the curator for a Portable Agent Memory workspace. Your job is to find rot in the memory store and propose targeted fixes as JSON artifacts. You never apply changes; the human reviews proposals and decides what lands. + +## Safety contract (non-negotiable) + +You have no `Write`, `Edit`, or `Bash`. The only way you can touch durable memory is via `memory_propose_edit`, which records a proposal under `memory/maintenance/proposals/<id>.json` and refuses to mutate the target file. + +`maintenance_run` and `graph_reindex` are deliberately not in your toolset. Running maintenance is the human's decision; you only audit and propose. + +Never: +- Claim a change was applied. Use "proposed", "recorded as", "would suggest". +- Propose edits to paths returned by `maintenance_config.protectedPaths` (typically `AGENTS.md`, `CLAUDE.md`, `memory/agent-memory/`, `memory/sources/`). Surface those as advice for the human instead. +- Invent file paths, anchors, or finding ids. Always quote evidence pulled from a tool call. +- Propose more than ten edits in a single run. Cap yourself and explain what you deferred. + +## Boot sequence + +Run these calls in order before doing anything else. Abort with a clear message if any step fails. + +1. `pam_version`: confirm you can read the workspace version. If `pam.parseError` is non-null, stop and report. +2. `memory_state`: confirm `state === "graph-v1"`. If `state` is `partial`, `markdown-v0`, or `unknown`, stop and tell the human PAM needs to be upgraded before curation is safe. +3. `graph_validate`: confirm `ok === true`. If errors exist, list them and stop. Repairing graph integrity is a manual job; curating on top of an invalid graph is unsafe. +4. `graph_stats`: read counts so you can size your run. +5. `maintenance_config`: capture `protectedPaths` and `managedLogs` for use in every later decision. + +## Audit pass + +Call `memory_audit` with no `checks` argument to run the full set. Group the returned findings by severity (`error`, `warning`, `info`). + +For each finding, choose exactly one disposition with a one-sentence justification: + +- `propose-edit`: you can write a deterministic `replace` op against a clear anchor that fixes the problem with low risk. Use only for `warning`-severity findings, never for `info`. +- `surface-only`: the finding is real but the fix is judgmental or destructive (e.g. removing an orphan source, retiring a stale wiki page). Describe what you would suggest and stop. +- `defer`: the evidence is too thin to act on. Say so explicitly. + +`info`-severity findings (stale wiki pages, orphan sources, oversized digests, contradictions) are always `surface-only`. Never auto-propose against them. + +## Proposing edits + +Prefer the `replace` op shape: + +```json +{ + "kind": "replace", + "anchor": { "headerLine": "## 2026-04-12 - Worker retries" }, + "before": "## 2026-04-12 - Worker retries\n\nFive retries.\n", + "after": "## 2026-04-12 - Worker retries\n\nFive retries per upstream timeout (superseded by 2026-04-13).\n" +} +``` + +The `before` must match the file byte-for-byte at the anchor; `memory_propose_edit` will reject mismatches. Read the file with `memory_read` first if you need to confirm. + +Include the originating finding id(s) in `findingIds` so the human can trace each proposal back to its evidence. + +After human review, proposals can be applied via the `memory_apply_proposal` MCP tool (PAM 0.4.0+). That tool re-validates the diff against current content and rejects drift, so reviews stay safe. The curator itself never applies proposals; that's a separate human-gated step. + +Never use `unified-diff` unless you have a strong reason; the `replace` op is easier to review and less prone to alignment errors. + +Before every call, check the target path against `maintenance_config.protectedPaths`. If protected, do not call `memory_propose_edit`; surface the issue in your report and move on. + +## Output format + +Return a structured report: + +``` +# Curator run - <ISO date> + +## Summary +- workspace state: <from memory_state> +- graph: <node/edge/alias counts from graph_stats> +- findings: <total> (<errors>/<warnings>/<infos>) +- proposals recorded: <n> (paths listed below) + +## Findings +### <finding.id> - <finding.check> (<severity>) +- summary: <finding.summary> +- evidence: <quoted from finding.evidence> +- disposition: propose-edit | surface-only | defer +- justification: <one sentence> +- proposal: <proposalPath> (only if disposition is propose-edit) +``` + +End with one paragraph the human can act on: which proposals to review first, which surface-only findings need a judgment call, and anything you deferred. + +## When not to run + +Decline (politely, with a one-line reason) if: +- the workspace is not graph-v1 (boot step 2 fails); +- `graph_validate.ok === false`; +- the human is asking a question that is not memory hygiene; point them at a query workflow instead. You are not a general Q&A agent. diff --git a/tools/claude/agents/scribe.md b/tools/claude/agents/scribe.md new file mode 100644 index 0000000..5c15329 --- /dev/null +++ b/tools/claude/agents/scribe.md @@ -0,0 +1,101 @@ +--- +name: scribe +description: Records new durable knowledge into Portable Agent Memory logs at session end. Dedup-checks via memory_search before appending. Never modifies existing entries, never applies curator proposals. Invoke when the session produced facts, decisions, preferences, or troubleshooting notes worth preserving. +tools: + - mcp__pam__pam_version + - mcp__pam__memory_state + - mcp__pam__memory_search + - mcp__pam__graph_query + - mcp__pam__maintenance_config + - mcp__pam__memory_append +model: inherit +--- + +# PAM scribe + +You are the scribe for a Portable Agent Memory workspace. Your job is to read the current conversation, extract durable knowledge, and record it as new dated sections in the appropriate log. You only append; you never modify existing entries and never apply curator proposals. + +## Safety contract (non-negotiable) + +You have no `Write`, `Edit`, `Bash`, `memory_propose_edit`, `memory_apply_proposal`, `memory_audit`, or `maintenance_run`. The only way you can touch durable memory is `memory_append`, which writes new dated sections to paths declared in `maintenance_config.managedLogs` (typically `memory/conversation-log.md` and `memory/knowledge-log.md`). Every other path is unreachable from your toolset. + +Never: +- Modify or rewrite an existing log entry. Append-only. +- Apply curator proposals (that is a different role). +- Append to a log that is not in `maintenance_config.managedLogs`. +- Copy sensitive material verbatim. Summarize secrets, tokens, and PII. +- Duplicate an entry. Always `memory_search` first; if a similar entry exists, skip and note it. + +## Boot sequence + +1. `pam_version`: confirm you can read the workspace version. +2. `memory_state`: confirm `state === "graph-v1"`. Abort if not. +3. `maintenance_config`: capture `managedLogs` so you know which logs are writable. + +## What to record + +From `memory/agent-memory/pam.md` ยง4.2, durable memory includes: + +- decisions and rationale; +- facts (project, user, team, environment); +- preferences (user style, project conventions); +- troubleshooting notes (problem + fix + signal); +- architecture notes; +- timelines and milestones; +- glossary entries; +- unresolved questions; +- known conflicts. + +Skip ephemeral context: in-progress reasoning, intermediate tool output, conversational chatter, anything already obvious from `git log` or the code. + +## Which log + +- `conversation-log`: narrative session summaries; what was asked, what was checked, current status, blockers, next likely actions. Expected to rotate on retention. +- `knowledge-log`: durable facts/decisions/procedures that should outlive a session. + +When in doubt: if the entry would be useful six months from now, use `knowledge-log`. If it only matters to the next session, use `conversation-log`. + +## Dedup check + +For every candidate, call `memory_search` with a short query covering the topic before appending. If a relevant entry exists: + +- exact match โ†’ skip and note in the "Skipped (already recorded)" section. +- close but stale โ†’ skip and surface a recommendation that the curator (or a human) review whether to supersede. + +You cannot edit existing entries. If superseding is needed, surface the suggestion; don't try to work around the restriction. + +## Append + +Use `memory_append` with: + +- `log`: the `archiveKey` (e.g. `"knowledge-log"`). +- `headerTitle`: a short, specific title. The MCP server constructs the header as `## YYYY-MM-DD - <headerTitle>` and rejects malformed shapes. +- `body`: concise markdown. Use bullet structure from PAM's templates when relevant (e.g. for knowledge entries: "Confirmed facts:", "Assumptions:", "Open questions:"). For conversation entries: "User ask:", "Current status:", "Confirmed:", "Assumptions:", "Blockers:", "Next:". +- `date`: omit to default to today UTC; supply only when backdating a deliberate correction. + +## Output format + +Return a structured report: + +``` +# Scribe run - <ISO date> + +## Recorded +- <log>/<anchor> - <one-line summary> + +## Skipped (already recorded) +- <topic> - found existing entry: <path>:<headerLine> + +## Surfaced (for human or curator) +- <topic> - <one-line note on why this needs review, not an append> +``` + +End with one sentence summarizing how many entries you wrote and what kind. + +## When not to run + +Decline (politely, one line) if: + +- the workspace is not `graph-v1`; +- `maintenance_config.managedLogs` is empty; +- the session produced no durable knowledge; say so and stop. Better to record nothing than to pad memory with noise. diff --git a/tools/claude/commands/dream.md b/tools/claude/commands/dream.md new file mode 100644 index 0000000..72248e7 --- /dev/null +++ b/tools/claude/commands/dream.md @@ -0,0 +1,52 @@ +--- +description: PAM memory hygiene pass; refresh graph catalog and run audit. Read-mostly (only writes memory/graph/catalog.json). +allowed-tools: + - mcp__pam__pam_version + - mcp__pam__memory_state + - mcp__pam__graph_stats + - mcp__pam__graph_validate + - mcp__pam__graph_reindex + - mcp__pam__memory_audit + - mcp__pam__maintenance_config +--- + +# /dream - PAM hygiene pass + +Run a memory hygiene cycle for the current PAM workspace. This is the same routine that would run on a nightly cron, made available manually. + +## What to do + +Perform these steps in order and report results inline: + +1. **Confirm workspace.** Call `mcp__pam__memory_state` and `mcp__pam__pam_version`. If no PAM workspace is detected in the current directory, stop and say so plainly; do not invent state. +2. **Validate integrity.** Call `mcp__pam__graph_validate` to check the JSONL sources. Capture any errors/warnings verbatim. +3. **Refresh graph catalog.** Call `mcp__pam__graph_reindex` (no `dryRun`) to rewrite `memory/graph/catalog.json` from the JSONL sources. This is the only mutation in a /dream run. +4. **Re-read catalog stats.** Call `mcp__pam__graph_stats` to capture the post-reindex `nodeCount` / `edgeCount` and confirm the rewrite landed. +5. **Audit memory.** Call `mcp__pam__memory_audit`. Capture every warning and error verbatim; do not paraphrase, do not filter. +6. **Cross-check config.** Call `mcp__pam__maintenance_config` to see which logs are managed and whether any thresholds are relevant to the warnings you saw. + +## Output format + +Produce a single concise report with these sections, in this order: + +``` +๐Ÿง  PAM dream report + +Graph + status: <valid|warning|invalid> + nodes/edges: <N>/<M> + catalog rewritten: <path or "no change"> + +Audit findings + <bullet list of every warning/error; "none" if clean> + +Recommended next step + <one line: do nothing, run /pam:curator, run /pam:scribe, etc.> +``` + +## Hard constraints + +- **No mutations** beyond the catalog refresh `graph_reindex` performs (a single rewrite of `memory/graph/catalog.json`). +- **Do not** invoke `mcp__pam__memory_propose_edit`, `mcp__pam__memory_append`, or any tool that writes to the logs. If the audit suggests changes are needed, name them in "Recommended next step". Do not perform them. +- **Do not** spawn other agents. +- If a tool call errors, report the error verbatim and continue with the remaining steps. diff --git a/tools/claude/commands/enable-status-line.md b/tools/claude/commands/enable-status-line.md new file mode 100644 index 0000000..38b58a7 --- /dev/null +++ b/tools/claude/commands/enable-status-line.md @@ -0,0 +1,67 @@ +--- +description: Enable, disable, or toggle the PAM status line by editing the active settings.json. Reversible; disabling moves the entry to _pamDisabledStatusLine rather than deleting it. +argument-hint: "[on|off|toggle]" +allowed-tools: + - Read + - Edit + - Bash(ls:*) + - Bash(test:*) + - Bash(jq:*) + - Bash(cat:*) +--- + +# /pam-enable-status-line; turn the PAM status line on/off + +Toggle (or explicitly set) the PAM status line. The status line is configured via `statusLine.command` in a `settings.json` file, pointing at `pam-layer/statusline/pam-statusline.sh`. + +When disabled, the entry is **moved** to `_pamDisabledStatusLine` (not deleted), so a future enable restores the same command without needing to reinstall the layer. + +## Argument + +- `on`: enable the PAM status line (no-op if already enabled). +- `off`: disable the PAM status line (no-op if already disabled). +- `toggle` *(default when no argument given)*: flip the current state. + +## What to do + +1. Parse `$ARGUMENTS`. Default to `toggle` if empty. Reject anything other than `on`/`off`/`toggle`. +2. Locate the active settings file in this order; pick the first that contains a PAM-layer status line config (either `statusLine.command` or `_pamDisabledStatusLine.command` matching the regex `pam-layer/statusline/pam-statusline\.sh$`): + - `./.claude/settings.json` (project scope) + - `~/.claude/settings.json` (user scope) + If neither file matches, report: "No PAM status line is configured in project or user settings. The plugin does not ship the status line (Claude Code requires it at user scope); install it separately with `node tools/claude/install-statusline.mjs --apply` from the PAM repo." and stop. +3. Determine current state from that file: + - **enabled** - `statusLine.command` is the pam-layer statusline + - **disabled** - `_pamDisabledStatusLine.command` is the pam-layer statusline +4. Resolve the action: + - target `on` from disabled โ†’ rename key `_pamDisabledStatusLine` โ†’ `statusLine` + - target `off` from enabled โ†’ rename key `statusLine` โ†’ `_pamDisabledStatusLine` + - target `toggle` โ†’ flip whichever side is set + - already in target state โ†’ say so and exit without writing +5. Apply with a single `Edit` against `settings.json`. Match a long enough surrounding context that the replacement is unambiguous. Preserve indentation and trailing comma style. Do not touch any other key. +6. Confirm the change. + +## Output format + +``` +PAM status line: <action taken> + settings: <absolute path> + before: <enabled|disabled> + after: <enabled|disabled> + command: <statusline path> + +Restart Claude Code to see the new status line state. +``` + +If no change was needed, print just: + +``` +PAM status line already <enabled|disabled>. Nothing to do. + settings: <absolute path> +``` + +## Hard constraints + +- **Only edit the `statusLine` / `_pamDisabledStatusLine` keys whose command path ends in `pam-layer/statusline/pam-statusline.sh`.** Never overwrite a non-PAM `statusLine` that some other tool installed. +- Do not delete the key; rename it. The whole point of `_pamDisabledStatusLine` is that re-enabling is a single rename, not a reinstall. +- Do not modify any other field in `settings.json` (hooks, permissions, theme, etc.). +- Do not edit both project and user settings in one run. Stop at the first match. diff --git a/tools/claude/commands/explain.md b/tools/claude/commands/explain.md new file mode 100644 index 0000000..e67c81c --- /dev/null +++ b/tools/claude/commands/explain.md @@ -0,0 +1,111 @@ +--- +description: Print the PAM status-line legend with live values from the current workspace. Read-only; just runs a shell script and echoes its output. +allowed-tools: + - Bash(bash:*) +--- + +# /pam-explain + +Run the block below and print its stdout verbatim. Do not add commentary, headers, summaries, or follow-up suggestions. + +```bash +bash <<'PAM_EXPLAIN_EOF' +set -u +pam_root="" +dir="$PWD" +while [ "$dir" != "/" ]; do + [ -f "$dir/memory/pam.version.json" ] && { pam_root="$dir"; break; } + dir=$(dirname "$dir") +done +if [ -z "$pam_root" ]; then + printf 'Not in a PAM workspace - the status line only shows the generic `[model] dir ยท branch ยท NN%%%% ctx` row.\n' + exit 0 +fi + +have_jq=0 +command -v jq >/dev/null 2>&1 && have_jq=1 +read_json() { + if [ "$have_jq" -eq 1 ] && [ -f "$1" ]; then + local v; v=$(jq -r "$2 // empty" "$1" 2>/dev/null) + [ -n "$v" ] && { printf '%s' "$v"; return; } + fi + printf '%s' "$3" +} + +PAM_VERSION=$(read_json "$pam_root/memory/pam.version.json" '.pamVersion' "?") +CATALOG="$pam_root/memory/graph/catalog.json" +GRAPH_STATUS=$(read_json "$CATALOG" '.health.status' "unknown") +NODE_COUNT=$(read_json "$CATALOG" '.nodeCount' "?") +EDGE_COUNT=$(read_json "$CATALOG" '.edgeCount' "?") +GENERATED_AT=$(read_json "$CATALOG" '.generatedAt' "") + +case "$GRAPH_STATUS" in + valid) glyph="โœ…" ;; + warning|warn) glyph="โš ๏ธ" ;; + invalid|error) glyph="โŒ" ;; + *) glyph="ยท" ;; +esac + +PROPOSAL_COUNT=0 +prop_dir="$pam_root/memory/maintenance/proposals" +if [ -d "$prop_dir" ]; then + PROPOSAL_COUNT=$(find "$prop_dir" -maxdepth 1 -type f -name '*.json' ! -name '*.applied.json' 2>/dev/null | wc -l | tr -d ' ') +fi + +AGE_LABEL="unknown" +if [ -n "$GENERATED_AT" ]; then + gen_epoch="" + if date -j -f "%Y-%m-%dT%H:%M:%S" "${GENERATED_AT%.*}" +%s >/dev/null 2>&1; then + gen_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${GENERATED_AT%.*}" +%s 2>/dev/null) + elif date -d "$GENERATED_AT" +%s >/dev/null 2>&1; then + gen_epoch=$(date -d "$GENERATED_AT" +%s 2>/dev/null) + fi + if [ -n "$gen_epoch" ]; then + now_epoch=$(date +%s) + age_days=$(( (now_epoch - gen_epoch) / 86400 )) + if [ "$age_days" -le 0 ]; then AGE_LABEL="today" + elif [ "$age_days" -eq 1 ]; then AGE_LABEL="1d" + else AGE_LABEL="${age_days}d" + fi + fi +fi + +APPENDS=0; SESSION_NOTE="" +session_dir="$pam_root/memory/.session" +if [ -d "$session_dir" ]; then + latest=$(ls -t "$session_dir"/*.json 2>/dev/null | head -n 1) + if [ -n "$latest" ] && [ "$have_jq" -eq 1 ]; then + APPENDS=$(jq -r '.appends // 0' "$latest" 2>/dev/null || printf '0') + SESSION_NOTE=" (from $(basename "$latest"))" + fi +fi + +cat <<EOF +๐Ÿง  PAM status line legend + +๐Ÿง  PAM <pamVersion> + PAM tooling version from memory/pam.version.json. + Now: ${PAM_VERSION} + +<โœ…|โš ๏ธ|โŒ|ยท> <N>n/<M>e + Graph health and size from memory/graph/catalog.json. + โœ… valid ยท โš ๏ธ warning ยท โŒ invalid ยท ยท unknown + Now: ${glyph} ${NODE_COUNT}n/${EDGE_COUNT}e (${GRAPH_STATUS}) + +๐Ÿ“‹ <P> + Pending curator proposals in memory/maintenance/proposals/ (excludes *.applied.json). + Dim when 0, yellow when > 0 - a review is waiting. + Now: ${PROPOSAL_COUNT} + +๐Ÿ’ค <today|Nd> + Age of memory/graph/catalog.json (last graph_reindex). + Goes yellow past ~7 days - that's the threshold that suggests a /dream run. + Now: ${AGE_LABEL} + +โœ๏ธ <K> + Successful memory_append calls in this session (post-memory-append hook). + Hidden in the status line when 0. + Now: ${APPENDS}${SESSION_NOTE} +EOF +PAM_EXPLAIN_EOF +``` diff --git a/tools/claude/commands/status.md b/tools/claude/commands/status.md new file mode 100644 index 0000000..6f0a912 --- /dev/null +++ b/tools/claude/commands/status.md @@ -0,0 +1,55 @@ +--- +description: Dump the current state of the PAM workspace (version, graph, proposals, session activity). Read-only. +allowed-tools: + - mcp__pam__pam_version + - mcp__pam__memory_state + - mcp__pam__graph_stats + - mcp__pam__maintenance_config + - Bash(ls:*) + - Bash(find:*) + - Bash(cat:*) + - Bash(jq:*) +--- + +# /pam-status - PAM workspace state + +Print a one-shot status snapshot for the current PAM workspace. Read-only. + +## What to do + +1. Call `mcp__pam__pam_version` and `mcp__pam__memory_state`. If no PAM workspace is detected, say so and stop. +2. Call `mcp__pam__graph_stats` for live node/edge counts and health. +3. Call `mcp__pam__maintenance_config` to list managed logs and thresholds. +4. Count pending proposals: `find memory/maintenance/proposals -maxdepth 1 -name '*.json' ! -name '*.applied.json' 2>/dev/null | wc -l`. +5. Report active session activity by reading any `memory/.session/*.json` files (these are per-session append counters written by the post-memory-append hook). + +## Output format + +``` +๐Ÿง  PAM status + +Version + pamVersion: <x.y.z> + memoryFormat: <format> + +Graph + status: <valid|warning|invalid> + nodes/edges: <N>/<M> + last validated: <ISO date> + +Logs (managed) + <path> - <size, last modified> + ... + +Proposals + pending: <N> + <list filenames if any> + +Sessions + active counters: <list of session_id โ†’ appends, most recent first> +``` + +## Hard constraints + +- **Read-only.** Do not call `graph_validate`, `memory_append`, `memory_propose_edit`, `memory_apply_proposal`, or any other write tool. +- Keep the report tight (this is meant to be glanceable). diff --git a/tools/claude/docs/curator-agent.md b/tools/claude/docs/curator-agent.md new file mode 100644 index 0000000..ef4c0cc --- /dev/null +++ b/tools/claude/docs/curator-agent.md @@ -0,0 +1,101 @@ +# curator subagent + +A Claude Code subagent that audits the local Portable Agent Memory workspace +and proposes hygiene fixes as JSON artifacts. The curator never mutates +durable memory; the human reviews proposals and decides what lands. + +## Install + +Install the PAM plugin (ships the MCP server, agents, slash commands, and hooks +in one package): + +``` +/plugin install NestDevLab/portable-agent-memory@github +``` + +The curator agent becomes available as `/pam:curator`. Run `/agents` to +confirm it appears in the list. + +## What it does + +When invoked (`/pam:curator` in Claude Code), the curator: + +1. Runs a boot sequence (`pam_version`, `memory_state`, `graph_validate`, + `graph_stats`, `maintenance_config`). Aborts on any failure. +2. Runs `memory_audit` over the workspace. +3. For each finding, decides one of: `propose-edit` (record a JSON proposal), + `surface-only` (report, no action), or `defer` (evidence too thin). +4. Records proposals via `memory_propose_edit`, which writes + `memory/maintenance/proposals/<id>.json` and never touches the target file. +5. Returns a structured "Findings" report. + +## What it does not do + +By design, the curator cannot: + +- write to any path outside `memory/maintenance/proposals/`; +- run `maintenance_run` or `graph_reindex` (not in its tool whitelist); +- propose edits to `AGENTS.md`, `CLAUDE.md`, `memory/agent-memory/`, or + `memory/sources/` (protected paths, enforced by the MCP server); +- answer general questions about memory content (it's a hygiene agent, not a + Q&A agent); +- claim a change was applied (only "proposed", "recorded as", "would suggest"). + +Safety is enforced by the tool whitelist in the subagent's frontmatter and by +the MCP server's protected-path checks, not by prompt discipline alone. + +## When to invoke + +- After a long working session where new knowledge entries piled up. +- Before a release, to catch link rot and stale wiki pages. +- Periodically (weekly or monthly) when the workspace grows large. + +Skip it for one-shot tasks; running an audit on a small, freshly-curated +workspace produces no findings and wastes a turn. + +## Applying proposals + +The curator only proposes. To apply a proposal: + +1. Read the proposal JSON under `memory/maintenance/proposals/`. +2. Verify the rationale and the `before`/`after` content. +3. Apply via the `memory_apply_proposal` MCP tool, which re-validates path + safety, re-applies the diff against current content, rejects drift, and + archives the artifact as `<id>.applied.json`. + +Applied proposals are not auto-rotated. Delete the `.applied.json` archives +once they're no longer useful as audit trail. + +## Output shape + +``` +# Curator run - 2026-05-21 + +## Summary +- workspace state: graph-v1 +- graph: 11 nodes / 11 edges / 10 aliases +- findings: 4 (0/2/2) +- proposals recorded: 1 (memory/maintenance/proposals/proposal-2026-05-21T...-abc123.json) + +## Findings +### audit-2026-05-21-0001-link-rot - link-rot (warning) +- summary: Broken markdown link to ../old-runbook.md +- evidence: ../old-runbook.md +- disposition: propose-edit +- justification: Target file is renamed; replacement is deterministic. +- proposal: memory/maintenance/proposals/proposal-2026-05-21T....json + +### audit-2026-05-21-0002-orphan-source - orphan-source (info) +- summary: Source file is not referenced from the graph. +- evidence: memory/sources/draft-2025-q4.md +- disposition: surface-only +- justification: Deletion is judgmental; ask the human whether to archive. +``` + +## Out of scope (for now) + +A future PAM release may add: + +- `pam-query`: a Q&A agent that resolves "what did we decide about X" with + graph citations; +- `pam-bootstrap`: an installer agent that audits a new repo and seeds PAM. diff --git a/tools/claude/docs/pam-claude-layer.md b/tools/claude/docs/pam-claude-layer.md new file mode 100644 index 0000000..eaec8c6 --- /dev/null +++ b/tools/claude/docs/pam-claude-layer.md @@ -0,0 +1,106 @@ +# PAM Claude Code integration + +PAM ships as a Claude Code plugin that bundles the MCP server, two reference +subagents (`curator`, `scribe`), slash commands, and lifecycle hooks. The status +line is the one piece Claude Code does not allow plugins to own, so it has its +own small installer. + +## Plugin (recommended) + +```text +/plugin install NestDevLab/portable-agent-memory@github +``` + +This loads: + +- **MCP server** `pam` (provides `pam_version`, `memory_state`, `graph_*`, `memory_*`, `maintenance_*` tools). +- **Subagents**: `/pam:curator` (audit + propose) and `/pam:scribe` (append durable knowledge at session end). +- **Slash commands**: `/pam:dream`, `/pam:status`, `/pam:explain`, `/pam:enable-status-line`. +- **Hooks**: + - `SessionStart`: checks catalog freshness and pending proposals on launch; prints a short nudge when something needs attention. `PAM_STALE_DAYS` overrides the default 7-day threshold. + - `PostToolUse` matching `mcp__pam__memory_append`: bumps a per-session counter at `memory/.session/<session_id>.json`. Never blocks the tool call. + +After install, restart Claude Code so it picks up the plugin. Run `/agents` to +confirm `curator` and `scribe` are listed under `pam`. + +## Status line (separate) + +Claude Code requires the `statusLine` setting to live outside plugins, so PAM +ships a small installer that drops a statusline script into +`<claude-dir>/pam-layer/statusline/pam-statusline.sh` and wires it into +`<claude-dir>/settings.json`. + +```bash +# Dry-run (default): prints the plan without writing anything. +node tools/claude/install-statusline.mjs + +# Install into ~/.claude/ (default scope: user). +node tools/claude/install-statusline.mjs --apply + +# Install into <cwd>/.claude/ instead. +node tools/claude/install-statusline.mjs --scope project --apply + +# Install into a custom directory. +node tools/claude/install-statusline.mjs --target /path/to/repo --apply +``` + +The status line is silent outside PAM workspaces, so it is safe to install +user-wide. Inside a workspace it prints: + +``` +[Opus] my-project ยท main ยท 12% ctx +๐Ÿง  PAM 0.4.0 ยท โœ… 11n/11e ยท ๐Ÿ“‹ 0 ยท ๐Ÿ’ค 2d ยท โœ๏ธ 3 +``` + +- `โœ… 11n/11e`: graph status with node/edge counts from `memory/graph/catalog.json`. Yellow/red on warning/invalid. +- `๐Ÿ“‹ N`: pending curator proposals in `memory/maintenance/proposals/`. Yellow when N > 0. +- `๐Ÿ’ค Nd`: age of the last graph validation. Yellow when older than ~6 days. +- `โœ๏ธ N`: `memory_append` calls this session (written by the plugin's post-memory-append hook). + +### Uninstall the status line + +```bash +node tools/claude/install-statusline.mjs --uninstall # dry-run +node tools/claude/install-statusline.mjs --uninstall --apply # remove files; clean settings.json +``` + +Uninstall only touches entries whose `command` path lives under the layer dir, +so it won't disturb unrelated `statusLine` settings. + +## Requirements + +- `jq` on PATH. The scripts degrade gracefully if it's missing (statusline reads `?` for unknown counts; hooks exit silently), but you'll lose most of the PAM signal. +- A PAM workspace (a directory containing `memory/pam.version.json`). The statusline auto-detects from `workspace.current_dir` then `workspace.project_dir`; the hooks walk up from `cwd` looking for `memory/pam.version.json`. + +## Plugin layout + +``` +.claude-plugin/plugin.json # plugin manifest (root-level discovery) +tools/claude/agents/ # curator.md, scribe.md +tools/claude/commands/ # dream.md, status.md, explain.md, enable-status-line.md +tools/claude/hooks/ # session-start.sh, post-memory-append.sh, hooks.json +tools/claude/templates/ # statusline assets and settings fragment +tools/claude/install-statusline.mjs # status line installer +tools/pam-mcp-server.mjs # generic MCP server (used by all hosts) +``` + +The repo also adds `memory/.session/` to `.gitignore` since the post-memory-append hook writes per-session counters there. + +## Customization + +- **Statusline thresholds and glyphs** - edit `pam-statusline.sh` (in `~/.claude/pam-layer/statusline/` after install). The script is short and free of dependencies on PAM internals. +- **SessionStart staleness threshold** - set `PAM_STALE_DAYS` in your shell environment before launching Claude Code (default: 7). +- **Multiple PAM workspaces** - the hooks find the PAM root by walking up from `cwd`. Switching directories inside a single Claude Code session works as long as both directories are PAM workspaces. + +## Other MCP hosts + +The plugin format is Claude Code-specific. For Cursor, Codex, OpenClaw, and +others, register the stdio MCP server (`tools/pam-mcp-server.mjs`) by hand. +See [../../docs/mcp-server.md](../../docs/mcp-server.md) for host-specific snippets. + +## Relationship to the MCP server and subagents + +The plugin is the canonical Claude Code distribution of PAM. The MCP server +remains the only component that writes to durable memory; subagents and slash +commands invoke MCP tools and never bypass them. The status line is purely +advisory; it reads files but never mutates them. diff --git a/tools/claude/docs/scribe-agent.md b/tools/claude/docs/scribe-agent.md new file mode 100644 index 0000000..db48db0 --- /dev/null +++ b/tools/claude/docs/scribe-agent.md @@ -0,0 +1,90 @@ +# scribe subagent + +A Claude Code subagent that records new durable knowledge into PAM logs at +session end. The scribe is append-only; it never modifies existing entries and +never applies curator proposals. + +## Install + +Install the PAM plugin (ships the MCP server, agents, slash commands, and hooks +in one package): + +``` +/plugin install NestDevLab/portable-agent-memory@github +``` + +The scribe agent becomes available as `/pam:scribe`. Run `/agents` to +confirm it appears in the list. + +## What it does + +When invoked (`/pam:scribe` in Claude Code, typically at session +closeout), the scribe: + +1. Runs a short boot sequence (`pam_version`, `memory_state`, + `maintenance_config`). +2. Reads the conversation context and identifies candidates worth recording + (decisions, facts, preferences, troubleshooting, architecture notes, + timelines, glossary entries, unresolved questions, known conflicts). +3. For each candidate, calls `memory_search` to check for an existing entry. + Skips duplicates. +4. Calls `memory_append` with the appropriate log + (`knowledge-log` for durable facts, `conversation-log` for ephemeral + session summaries). +5. Returns a structured Recorded / Skipped / Surfaced report. + +## What it does not do + +By design, the scribe cannot: + +- write outside `config.managedLogs` (enforced by the MCP server); +- modify existing entries (no `memory_propose_edit`, no `memory_apply_proposal` + in its whitelist); +- run hygiene audits or maintenance; +- claim a change was applied that wasn't recorded; +- copy sensitive material verbatim (prompt contract requires summarization). + +Safety is enforced by the tool whitelist in the subagent's frontmatter and by +`memory_append`'s managed-log allowlist, not by prompt discipline alone. + +## When to invoke + +- At session end, when the conversation produced facts, decisions, or + preferences worth preserving across sessions. +- After resolving a tricky bug, to capture the cause and fix. +- After making an architectural decision, to record the rationale. + +Skip it for purely exploratory or read-only sessions that didn't change your +understanding of the project. + +## Output shape + +``` +# Scribe run - 2026-05-21 + +## Recorded +- knowledge-log/## 2026-05-21 - Worker retry policy - 5 retries per upstream timeout (after deadlock analysis). +- conversation-log/## 2026-05-21 - PAM scribe rollout - designed scribe, recorded curator proposals. + +## Skipped (already recorded) +- ECS deploy procedure - found existing entry: memory/knowledge-log.md:## 2026-03-12 - ECS deploy procedure. + +## Surfaced (for human or curator) +- "Worker retry budget" claim conflicts with 2026-03-04 knowledge entry; recommend a curator review. +``` + +## How it pairs with the curator + +- **Scribe** (this agent) adds new entries. +- **Curator** (`/pam:curator`) audits existing entries and proposes fixes. +- **Apply step** (human + `memory_apply_proposal`) lands curator fixes. + +Run scribe at session end; run curator weekly or before a release. + +## Out of scope (for now) + +- Automatic invocation via Claude Code hooks (manual `/pam:scribe` only). +- Writing to arbitrary paths outside `config.managedLogs` (use + `memory_propose_edit` for that, via the curator). +- Recording graph nodes/edges/aliases (use curator proposals against the JSONL + files). diff --git a/tools/claude/hooks/hooks.json b/tools/claude/hooks/hooks.json new file mode 100644 index 0000000..d0e4ae8 --- /dev/null +++ b/tools/claude/hooks/hooks.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/tools/claude/hooks/session-start.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "mcp__pam__memory_append", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/tools/claude/hooks/post-memory-append.sh" + } + ] + } + ] + } +} diff --git a/tools/claude/hooks/post-memory-append.sh b/tools/claude/hooks/post-memory-append.sh new file mode 100755 index 0000000..93a8c57 --- /dev/null +++ b/tools/claude/hooks/post-memory-append.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# post-memory-append.sh - PAM session-activity tracker. +# +# Wired as a PostToolUse hook matching `mcp__pam__memory_append`. Reads the +# hook JSON from stdin, locates the PAM workspace, and increments a per-session +# counter at memory/.session/<session_id>.json. Failure is silent: this hook +# must never block a tool call. + +set -u + +input=$(cat) + +# Bail out quietly if jq is missing - we won't crash the tool call. +command -v jq >/dev/null 2>&1 || exit 0 + +SESSION_ID=$(printf '%s' "$input" | jq -r '.session_id // ""') +CWD=$(printf '%s' "$input" | jq -r '.cwd // ""') +TOOL_NAME=$(printf '%s' "$input" | jq -r '.tool_name // ""') + +# Defense in depth: only count actual append calls. +case "$TOOL_NAME" in + mcp__pam__memory_append) ;; + *) exit 0 ;; +esac + +[ -z "$SESSION_ID" ] && exit 0 +[ -z "$CWD" ] && CWD="$PWD" + +# Walk up from CWD looking for the PAM root. +pam_root="" +dir="$CWD" +while [ "$dir" != "/" ] && [ -n "$dir" ]; do + if [ -f "$dir/memory/pam.version.json" ]; then + pam_root="$dir" + break + fi + dir=$(dirname "$dir") +done + +[ -z "$pam_root" ] && exit 0 + +session_dir="$pam_root/memory/.session" +mkdir -p "$session_dir" 2>/dev/null || exit 0 + +# Refuse session IDs that could escape the .session directory. +case "$SESSION_ID" in + ""|*/*|*\\*|*..*) exit 0 ;; +esac + +session_file="$session_dir/$SESSION_ID.json" +prev=0 +if [ -f "$session_file" ]; then + prev=$(jq -r '.appends // 0' "$session_file" 2>/dev/null || echo 0) +fi +next=$((prev + 1)) +now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Atomic-ish write via tmp file. +tmp="$session_file.tmp.$$" +printf '{"sessionId":"%s","appends":%d,"updatedAt":"%s"}\n' \ + "$SESSION_ID" "$next" "$now" > "$tmp" 2>/dev/null || exit 0 +mv "$tmp" "$session_file" 2>/dev/null || rm -f "$tmp" + +exit 0 diff --git a/tools/claude/hooks/session-start.sh b/tools/claude/hooks/session-start.sh new file mode 100755 index 0000000..6849d89 --- /dev/null +++ b/tools/claude/hooks/session-start.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# session-start.sh - PAM workspace warning on session start. +# +# Wired as a SessionStart hook. When the session opens inside a PAM workspace, +# checks catalog freshness and pending proposals. If anything is worth knowing, +# prints a short additionalContext payload that Claude Code injects into the +# session. Silent otherwise. + +set -u + +input=$(cat 2>/dev/null || true) + +command -v jq >/dev/null 2>&1 || exit 0 + +CWD=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null) +[ -z "$CWD" ] && CWD="$PWD" + +# Walk up from CWD to find the PAM root. +pam_root="" +dir="$CWD" +while [ "$dir" != "/" ] && [ -n "$dir" ]; do + if [ -f "$dir/memory/pam.version.json" ]; then + pam_root="$dir" + break + fi + dir=$(dirname "$dir") +done + +[ -z "$pam_root" ] && exit 0 + +# --- gather signals ---------------------------------------------------------- +STALE_DAYS=${PAM_STALE_DAYS:-7} + +catalog="$pam_root/memory/graph/catalog.json" +age_days="" +status="" +if [ -f "$catalog" ]; then + status=$(jq -r '.health.status // ""' "$catalog" 2>/dev/null) + generated_at=$(jq -r '.generatedAt // ""' "$catalog" 2>/dev/null) + if [ -n "$generated_at" ]; then + gen_epoch="" + if date -j -f "%Y-%m-%dT%H:%M:%S" "${generated_at%.*}" +%s >/dev/null 2>&1; then + gen_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${generated_at%.*}" +%s 2>/dev/null) + elif date -d "$generated_at" +%s >/dev/null 2>&1; then + gen_epoch=$(date -d "$generated_at" +%s 2>/dev/null) + fi + if [ -n "$gen_epoch" ]; then + now_epoch=$(date +%s) + age_days=$(( (now_epoch - gen_epoch) / 86400 )) + fi + fi +fi + +proposal_count=0 +if [ -d "$pam_root/memory/maintenance/proposals" ]; then + proposal_count=$(find "$pam_root/memory/maintenance/proposals" -maxdepth 1 -type f -name '*.json' ! -name '*.applied.json' 2>/dev/null | wc -l | tr -d ' ') +fi + +# Build a punch list. Skip if everything is fresh. +lines=() +if [ -n "$age_days" ] && [ "$age_days" -gt "$STALE_DAYS" ]; then + lines+=("- Graph catalog is ${age_days} days old (threshold: ${STALE_DAYS}d). Run \`/dream\` to refresh.") +fi +if [ "$proposal_count" -gt 0 ]; then + lines+=("- ${proposal_count} pending curator proposal(s) in memory/maintenance/proposals/. Review before applying.") +fi +if [ "$status" = "invalid" ] || [ "$status" = "error" ]; then + lines+=("- Graph catalog status is \`${status}\`. Run \`/dream\` or \`mcp__pam__graph_validate\` to investigate.") +fi + +[ ${#lines[@]} -eq 0 ] && exit 0 + +# Compose additionalContext JSON the SessionStart hook contract expects. +context="PAM workspace notes:" +for line in "${lines[@]}"; do + context="${context} +${line}" +done + +jq -n --arg ctx "$context" '{ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: $ctx + } +}' diff --git a/tools/claude/install-statusline.mjs b/tools/claude/install-statusline.mjs new file mode 100644 index 0000000..9fb14c8 --- /dev/null +++ b/tools/claude/install-statusline.mjs @@ -0,0 +1,261 @@ +#!/usr/bin/env node +// install-pam-statusline.mjs +// +// Installs the PAM status line into ~/.claude/ (user scope) or +// <cwd>/.claude/ (project scope). Claude Code requires the statusLine to be +// configured outside of plugins, so this installer remains separate from the +// PAM Claude Code plugin (which ships the MCP server, agents, slash commands, +// and hooks). +// +// Dry-run by default. Pass --apply to actually write files. +// +// Usage: +// node tools/claude/install-statusline.mjs # dry-run, user scope +// node tools/claude/install-statusline.mjs --apply # install to ~/.claude/ +// node tools/claude/install-statusline.mjs --scope project --apply +// node tools/claude/install-statusline.mjs --target /path/to/.claude --apply +// node tools/claude/install-statusline.mjs --apply --force # overwrite existing files +// node tools/claude/install-statusline.mjs --apply --uninstall + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const TEMPLATE_ROOT = path.join(REPO_ROOT, "tools", "claude", "templates"); + +function parseArgs(argv) { + const out = { apply: false, force: false, scope: "user", target: null, uninstall: false }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--apply") out.apply = true; + else if (a === "--force") out.force = true; + else if (a === "--uninstall") out.uninstall = true; + else if (a === "--scope") { + out.scope = argv[i + 1]; + i += 1; + } else if (a === "--target") { + out.target = argv[i + 1]; + i += 1; + } else if (a === "--help" || a === "-h") { + printHelp(); + process.exit(0); + } + } + return out; +} + +function printHelp() { + process.stdout.write(`install-pam-statusline.mjs - install the PAM Claude Code status line. + +The MCP server, subagents, slash commands, and hooks ship as a Claude Code +plugin. Install the plugin separately: + + /plugin install stefanoguerrini/portable-agent-memory@github + +This script only installs the status line, which Claude Code requires to be +configured outside of plugins. + +Options: + --apply Actually write files. Without this flag, runs in dry-run mode. + --scope user|project Where to install. Default: user (~/.claude/). + --target <path> Override scope; install into <path>/.claude/ (or <path> if it ends in .claude). + --force Overwrite existing files and settings.statusLine. + --uninstall Remove the status line (deletes files; cleans matching settings entries). + -h, --help Show this help. +`); +} + +function resolveClaudeDir({ scope, target }) { + if (target) { + const abs = path.resolve(target); + return path.basename(abs) === ".claude" ? abs : path.join(abs, ".claude"); + } + if (scope === "project") return path.join(process.cwd(), ".claude"); + return path.join(os.homedir(), ".claude"); +} + +function loadFragment(layerDir) { + const raw = fs.readFileSync(path.join(TEMPLATE_ROOT, "settings.fragment.json"), "utf8"); + return JSON.parse(raw.replaceAll("{{LAYER_DIR}}", layerDir)); +} + +function listCopyOps(claudeDir) { + const layerDir = path.join(claudeDir, "pam-layer"); + const ops = [ + { + from: path.join(TEMPLATE_ROOT, "statusline", "pam-statusline.sh"), + to: path.join(layerDir, "statusline", "pam-statusline.sh"), + mode: 0o755, + }, + ]; + return { layerDir, ops }; +} + +function mergeSettings(existing, fragment, { force }) { + const next = { ...existing }; + const warnings = []; + if (fragment.statusLine) { + if (existing.statusLine && !force) { + warnings.push( + `settings.statusLine already set (command: ${existing.statusLine.command ?? "?"}); leaving it untouched. Pass --force to overwrite.` + ); + } else { + next.statusLine = fragment.statusLine; + } + } + return { next, warnings }; +} + +function removeFromSettings(existing, layerDir) { + const next = { ...existing }; + const removed = []; + const layerPrefix = layerDir.endsWith("/") ? layerDir : `${layerDir}/`; + const looksLikeOurs = (cmd) => + typeof cmd === "string" && (cmd.startsWith(layerPrefix) || cmd.startsWith(layerDir)); + if (next.statusLine && looksLikeOurs(next.statusLine.command)) { + delete next.statusLine; + removed.push("statusLine"); + } + if (next._pamDisabledStatusLine && looksLikeOurs(next._pamDisabledStatusLine.command)) { + delete next._pamDisabledStatusLine; + removed.push("_pamDisabledStatusLine"); + } + return { next, removed }; +} + +function planInstall(args) { + const claudeDir = resolveClaudeDir(args); + const { layerDir, ops } = listCopyOps(claudeDir); + const settingsPath = path.join(claudeDir, "settings.json"); + const existing = fs.existsSync(settingsPath) + ? JSON.parse(fs.readFileSync(settingsPath, "utf8")) + : {}; + const fragment = loadFragment(layerDir); + const { next, warnings } = mergeSettings(existing, fragment, { force: args.force }); + return { claudeDir, layerDir, settingsPath, ops, existing, next, warnings }; +} + +function planUninstall(args) { + const claudeDir = resolveClaudeDir(args); + const { layerDir, ops } = listCopyOps(claudeDir); + const settingsPath = path.join(claudeDir, "settings.json"); + const existing = fs.existsSync(settingsPath) + ? JSON.parse(fs.readFileSync(settingsPath, "utf8")) + : {}; + const { next, removed } = removeFromSettings(existing, layerDir); + return { claudeDir, layerDir, settingsPath, ops, existing, next, removed }; +} + +function doInstall(args) { + const plan = planInstall(args); + const { claudeDir, layerDir, settingsPath, ops, existing, next, warnings } = plan; + const stdout = process.stdout; + + stdout.write(`Install plan (${args.apply ? "apply" : "dry-run"})\n`); + stdout.write(` claude dir: ${claudeDir}\n`); + stdout.write(` layer dir: ${layerDir}\n`); + stdout.write(` settings: ${settingsPath}\n\n`); + + stdout.write("Files:\n"); + for (const op of ops) { + const exists = fs.existsSync(op.to); + const tag = exists ? (args.force ? "OVERWRITE" : "SKIP (exists)") : "WRITE"; + stdout.write(` [${tag}] ${op.to}\n`); + } + stdout.write("\n"); + + if (warnings.length > 0) { + stdout.write("Notes:\n"); + for (const w of warnings) stdout.write(` - ${w}\n`); + stdout.write("\n"); + } + + if (!args.apply) { + stdout.write("Re-run with --apply to write these files. Add --force to overwrite existing.\n"); + return; + } + + for (const op of ops) { + if (fs.existsSync(op.to) && !args.force) continue; + fs.mkdirSync(path.dirname(op.to), { recursive: true }); + fs.copyFileSync(op.from, op.to); + if (op.mode != null) fs.chmodSync(op.to, op.mode); + } + + fs.mkdirSync(claudeDir, { recursive: true }); + const settingsJson = `${JSON.stringify(next, null, 2)}\n`; + if (JSON.stringify(existing) !== JSON.stringify(next)) { + if (fs.existsSync(settingsPath)) { + fs.copyFileSync(settingsPath, `${settingsPath}.bak`); + stdout.write(`backed up existing settings.json to ${settingsPath}.bak\n`); + } + fs.writeFileSync(settingsPath, settingsJson, "utf8"); + stdout.write(`wrote ${settingsPath}\n`); + } else { + stdout.write(`settings.json unchanged (no merges needed)\n`); + } + + stdout.write("\nDone. Restart Claude Code to pick up the new statusline.\n"); + stdout.write("For the MCP server, agents, slash commands, and hooks, install the plugin:\n"); + stdout.write(" /plugin install stefanoguerrini/portable-agent-memory@github\n"); +} + +function doUninstall(args) { + const plan = planUninstall(args); + const { layerDir, settingsPath, ops, existing, next, removed } = plan; + const stdout = process.stdout; + + stdout.write(`Uninstall plan (${args.apply ? "apply" : "dry-run"})\n`); + stdout.write(` layer dir: ${layerDir}\n`); + stdout.write(` settings: ${settingsPath}\n\n`); + + stdout.write("Files to remove:\n"); + for (const op of ops) { + const exists = fs.existsSync(op.to); + stdout.write(` [${exists ? "REMOVE" : "skip (missing)"}] ${op.to}\n`); + } + stdout.write("\n"); + if (removed.length > 0) { + stdout.write("Settings entries to clean:\n"); + for (const r of removed) stdout.write(` - ${r}\n`); + stdout.write("\n"); + } + if (!args.apply) { + stdout.write("Re-run with --apply to perform the uninstall.\n"); + return; + } + + for (const op of ops) { + if (fs.existsSync(op.to)) fs.unlinkSync(op.to); + } + for (const sub of ["statusline"]) { + const dir = path.join(layerDir, sub); + if (fs.existsSync(dir)) { + try { fs.rmdirSync(dir); } catch { /* not empty - leave it */ } + } + } + if (fs.existsSync(layerDir)) { + try { fs.rmdirSync(layerDir); } catch { /* not empty - leave it */ } + } + + if (JSON.stringify(existing) !== JSON.stringify(next)) { + fs.copyFileSync(settingsPath, `${settingsPath}.bak`); + fs.writeFileSync(settingsPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); + stdout.write(`wrote ${settingsPath} (backup: ${settingsPath}.bak)\n`); + } + stdout.write("\nDone.\n"); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.uninstall) doUninstall(args); + else doInstall(args); +} + +if (process.argv[1] === __filename) { + main(); +} diff --git a/tools/claude/templates/settings.fragment.json b/tools/claude/templates/settings.fragment.json new file mode 100644 index 0000000..881da8e --- /dev/null +++ b/tools/claude/templates/settings.fragment.json @@ -0,0 +1,7 @@ +{ + "statusLine": { + "type": "command", + "command": "{{LAYER_DIR}}/statusline/pam-statusline.sh", + "padding": 1 + } +} diff --git a/tools/claude/templates/statusline/pam-statusline.sh b/tools/claude/templates/statusline/pam-statusline.sh new file mode 100755 index 0000000..d7f4940 --- /dev/null +++ b/tools/claude/templates/statusline/pam-statusline.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# pam-statusline.sh - PAM-aware statusline for Claude Code. +# +# Reads JSON session data from stdin and prints up to two lines: +# line 1: [model] dir ยท branch ยท NN% ctx +# line 2: PAM-specific segment, only when memory/pam.version.json is present. +# +# Silent (no PAM line) outside a PAM workspace, so this is safe to install +# user-wide in ~/.claude/settings.json. + +set -u +input=$(cat) + +# --- jq helper (graceful fallback if jq missing) ----------------------------- +_jq() { + if command -v jq >/dev/null 2>&1; then + printf '%s' "$input" | jq -r "$1" 2>/dev/null + else + printf '' + fi +} + +MODEL=$(_jq '.model.display_name // "Claude"') +CWD=$(_jq '.workspace.current_dir // .cwd // ""') +PROJECT=$(_jq '.workspace.project_dir // ""') +PCT=$(_jq '.context_window.used_percentage // 0' | cut -d. -f1) +SESSION_ID=$(_jq '.session_id // ""') + +DIR_LABEL="${CWD##*/}" + +# Git branch (cheap; skip if not in a repo). +BRANCH="" +if command -v git >/dev/null 2>&1 && [ -n "$CWD" ]; then + BRANCH=$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || true) +fi + +# --- line 1: generic --------------------------------------------------------- +line1="[$MODEL] $DIR_LABEL" +[ -n "$BRANCH" ] && line1="$line1 ยท $BRANCH" +line1="$line1 ยท ${PCT}% ctx" +printf '%s\n' "$line1" + +# --- detect PAM workspace ---------------------------------------------------- +pam_root="" +for candidate in "$CWD" "$PROJECT"; do + [ -z "$candidate" ] && continue + if [ -f "$candidate/memory/pam.version.json" ]; then + pam_root="$candidate" + break + fi +done + +[ -z "$pam_root" ] && exit 0 + +# --- PAM signals ------------------------------------------------------------- +PAM_VERSION="" +GRAPH_STATUS="?" +NODE_COUNT="?" +EDGE_COUNT="?" +GENERATED_AT="" + +if command -v jq >/dev/null 2>&1; then + PAM_VERSION=$(jq -r '.pamVersion // ""' "$pam_root/memory/pam.version.json" 2>/dev/null) + if [ -f "$pam_root/memory/graph/catalog.json" ]; then + GRAPH_STATUS=$(jq -r '.health.status // "unknown"' "$pam_root/memory/graph/catalog.json" 2>/dev/null) + NODE_COUNT=$(jq -r '.nodeCount // 0' "$pam_root/memory/graph/catalog.json" 2>/dev/null) + EDGE_COUNT=$(jq -r '.edgeCount // 0' "$pam_root/memory/graph/catalog.json" 2>/dev/null) + GENERATED_AT=$(jq -r '.generatedAt // ""' "$pam_root/memory/graph/catalog.json" 2>/dev/null) + fi +fi + +# Pending proposals (count *.json files; tolerate missing dir). +PROPOSAL_COUNT=0 +if [ -d "$pam_root/memory/maintenance/proposals" ]; then + PROPOSAL_COUNT=$(find "$pam_root/memory/maintenance/proposals" -maxdepth 1 -type f -name '*.json' ! -name '*.applied.json' 2>/dev/null | wc -l | tr -d ' ') +fi + +# Session activity (appends this session). +APPENDS=0 +if [ -n "$SESSION_ID" ] && [ -f "$pam_root/memory/.session/$SESSION_ID.json" ]; then + if command -v jq >/dev/null 2>&1; then + APPENDS=$(jq -r '.appends // 0' "$pam_root/memory/.session/$SESSION_ID.json" 2>/dev/null) + fi +fi + +# Catalog age in days. +AGE_LABEL="" +if [ -n "$GENERATED_AT" ]; then + # Parse ISO 8601 -> epoch; fall back silently if `date` flavor disagrees. + gen_epoch="" + if date -j -f "%Y-%m-%dT%H:%M:%S" "${GENERATED_AT%.*}" +%s >/dev/null 2>&1; then + gen_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${GENERATED_AT%.*}" +%s 2>/dev/null) + elif date -d "$GENERATED_AT" +%s >/dev/null 2>&1; then + gen_epoch=$(date -d "$GENERATED_AT" +%s 2>/dev/null) + fi + if [ -n "$gen_epoch" ]; then + now_epoch=$(date +%s) + age_days=$(( (now_epoch - gen_epoch) / 86400 )) + if [ "$age_days" -le 0 ]; then + AGE_LABEL="today" + elif [ "$age_days" -eq 1 ]; then + AGE_LABEL="1d" + else + AGE_LABEL="${age_days}d" + fi + fi +fi + +# --- colors (ANSI) ----------------------------------------------------------- +RESET=$'\033[0m' +DIM=$'\033[2m' +RED=$'\033[31m' +YELLOW=$'\033[33m' +GREEN=$'\033[32m' + +graph_color="$GREEN" +graph_glyph="โœ…" +case "$GRAPH_STATUS" in + valid) graph_color="$GREEN"; graph_glyph="โœ…" ;; + warning|warn) graph_color="$YELLOW"; graph_glyph="โš ๏ธ" ;; + invalid|error) graph_color="$RED"; graph_glyph="โŒ" ;; + *) graph_color="$DIM"; graph_glyph="ยท" ;; +esac + +prop_color="$DIM" +[ "$PROPOSAL_COUNT" -gt 0 ] && prop_color="$YELLOW" + +age_color="$DIM" +case "$AGE_LABEL" in + ""|today|1d|2d|3d|4d|5d|6d) ;; + *) age_color="$YELLOW" ;; +esac + +# --- line 2: PAM segment ---------------------------------------------------- +line2="${DIM}๐Ÿง  PAM" +[ -n "$PAM_VERSION" ] && line2="$line2 $PAM_VERSION" +line2="$line2${RESET} ยท ${graph_color}${graph_glyph} ${NODE_COUNT}n/${EDGE_COUNT}e${RESET}" +line2="$line2 ยท ${prop_color}๐Ÿ“‹ ${PROPOSAL_COUNT}${RESET}" +[ -n "$AGE_LABEL" ] && line2="$line2 ยท ${age_color}๐Ÿ’ค ${AGE_LABEL}${RESET}" +[ "$APPENDS" -gt 0 ] && line2="$line2 ยท ${GREEN}โœ๏ธ ${APPENDS}${RESET}" + +printf '%s\n' "$line2" diff --git a/tools/kimi/docs/pam-kimi-layer.md b/tools/kimi/docs/pam-kimi-layer.md new file mode 100644 index 0000000..f8ffce1 --- /dev/null +++ b/tools/kimi/docs/pam-kimi-layer.md @@ -0,0 +1,90 @@ +# PAM Kimi Code CLI Integration + +PAM works with Kimi Code CLI via the Model Context Protocol (MCP). The integration is a thin config layer: Kimi speaks to the same `tools/pam-mcp-server.mjs` that Claude Code, Cursor, and Codex CLI use. + +## Install + +From the repo root (or any directory inside your PAM workspace): + +```bash +# Dry-run (default): preview what will be written. +node tools/kimi/install-mcp.mjs + +# Install into ~/.kimi/mcp.json +node tools/kimi/install-mcp.mjs --apply + +# Overwrite an existing pam entry +node tools/kimi/install-mcp.mjs --apply --force +``` + +The installer resolves absolute paths for: +- the MCP server (`tools/pam-mcp-server.mjs`) +- your PAM workspace root + +and writes them into `~/.kimi/mcp.json`. Because the paths are absolute, a later `git pull` in your PAM workspace updates the server code without requiring any reconfiguration. + +## Verify + +```bash +kimi mcp list +kimi mcp test pam +``` + +If the test prints tool names (`pam_version`, `memory_append`, `graph_query`, etc.), the integration is live. + +## Uninstall + +```bash +node tools/kimi/install-mcp.mjs --uninstall --apply +``` + +This removes the `mcpServers.pam` entry from `~/.kimi/mcp.json` and restores the backup. + +## Ad-hoc usage (no global install) + +If you prefer not to touch `~/.kimi/mcp.json`, create a project-local config: + +```bash +kimi --mcp-config-file tools/kimi/mcp-config.json +``` + +A minimal project-local config looks like: + +```json +{ + "mcpServers": { + "pam": { + "command": "node", + "args": [ + "tools/pam-mcp-server.mjs" + ] + } + } +} +``` + +## Updating PAM + +Because the installer writes absolute paths, simply run: + +```bash +git pull +``` + +in your PAM workspace. The MCP server binary and tool definitions update automatically. Kimi will use the latest version on its next session. + +## What you get + +The same 15 typed tools available to other MCP hosts: + +- **Context**: `pam_version`, `memory_state`, `maintenance_config` +- **Reads**: `memory_list`, `memory_read`, `memory_search` +- **Graph**: `graph_query`, `graph_stats`, `graph_validate`, `graph_reindex` +- **Hygiene / Write**: `memory_audit`, `memory_propose_edit`, `memory_append`, `memory_apply_proposal` +- **Maintenance**: `maintenance_run` + +## Requirements + +- Kimi Code CLI (`kimi`) installed and on PATH +- Node.js 18+ (already required by PAM) +- A PAM workspace (`memory/pam.version.json` present) diff --git a/tools/kimi/install-mcp.mjs b/tools/kimi/install-mcp.mjs new file mode 100644 index 0000000..7b3fb0e --- /dev/null +++ b/tools/kimi/install-mcp.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +// install-mcp.mjs +// +// Registers the PAM MCP server with Kimi Code CLI. +// Writes an absolute-path entry into ~/.kimi/mcp.json so Kimi can discover +// PAM tools (memory_*, graph_*, maintenance_*, etc.) in any session. +// +// Dry-run by default. Pass --apply to write. +// +// Usage: +// node tools/kimi/install-mcp.mjs # dry-run +// node tools/kimi/install-mcp.mjs --apply # write ~/.kimi/mcp.json +// node tools/kimi/install-mcp.mjs --apply --force # overwrite existing pam entry +// node tools/kimi/install-mcp.mjs --apply --uninstall + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const TEMPLATE_ROOT = path.join(REPO_ROOT, "tools", "kimi", "templates"); +const KIMI_DIR = path.join(os.homedir(), ".kimi"); +const MCP_CONFIG_PATH = path.join(KIMI_DIR, "mcp.json"); + +function parseArgs(argv) { + const out = { apply: false, force: false, uninstall: false }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--apply") out.apply = true; + else if (a === "--force") out.force = true; + else if (a === "--uninstall") out.uninstall = true; + else if (a === "--help" || a === "-h") { + printHelp(); + process.exit(0); + } + } + return out; +} + +function printHelp() { + process.stdout.write(`install-mcp.mjs - register PAM with Kimi Code CLI. + +Registers the PAM stdio MCP server so Kimi can use PAM tools in any session. +The config is written to ~/.kimi/mcp.json with absolute paths. + +Options: + --apply Actually write the config. Without this flag, runs in dry-run mode. + --force Overwrite an existing pam MCP entry. + --uninstall Remove the pam MCP entry and restore the backup. + -h, --help Show this help. +`); +} + +function resolveWorkspaceRoot() { + let dir = REPO_ROOT; + while (dir !== "/" && dir !== "" && dir !== ".") { + if (fs.existsSync(path.join(dir, "memory", "pam.version.json"))) { + return dir; + } + dir = path.dirname(dir); + } + return process.cwd(); +} + +function loadMcpConfig() { + if (fs.existsSync(MCP_CONFIG_PATH)) { + return JSON.parse(fs.readFileSync(MCP_CONFIG_PATH, "utf8")); + } + return {}; +} + +function buildFragment(workspaceRoot) { + const serverPath = path.join(workspaceRoot, "tools", "pam-mcp-server.mjs"); + const raw = fs.readFileSync(path.join(TEMPLATE_ROOT, "mcp.fragment.json"), "utf8"); + return JSON.parse( + raw + .replaceAll("{{SERVER_PATH}}", serverPath) + .replaceAll("{{WORKSPACE_PATH}}", workspaceRoot) + ); +} + +function mergeMcpConfig(existing, fragment, { force }) { + const next = { ...existing }; + const warnings = []; + if (!next.mcpServers) next.mcpServers = {}; + + if (next.mcpServers.pam && !force) { + warnings.push( + `mcpServers.pam already configured (command: ${next.mcpServers.pam.command ?? "?"}); leaving it untouched. Pass --force to overwrite.` + ); + } else { + next.mcpServers = { ...next.mcpServers, ...fragment.mcpServers }; + } + return { next, warnings }; +} + +function removeMcpConfig(existing) { + const next = { ...existing }; + const removed = []; + if (next.mcpServers && next.mcpServers.pam) { + delete next.mcpServers.pam; + removed.push("mcpServers.pam"); + if (Object.keys(next.mcpServers).length === 0) { + delete next.mcpServers; + } + } + return { next, removed }; +} + +function planInstall(args) { + const workspaceRoot = resolveWorkspaceRoot(); + const existing = loadMcpConfig(); + const fragment = buildFragment(workspaceRoot); + const { next, warnings } = mergeMcpConfig(existing, fragment, { force: args.force }); + return { workspaceRoot, existing, next, warnings }; +} + +function planUninstall() { + const existing = loadMcpConfig(); + const { next, removed } = removeMcpConfig(existing); + return { existing, next, removed }; +} + +function doInstall(args) { + const plan = planInstall(args); + const { workspaceRoot, existing, next, warnings } = plan; + const stdout = process.stdout; + + stdout.write(`Install plan (${args.apply ? "apply" : "dry-run"})\n`); + stdout.write(` workspace: ${workspaceRoot}\n`); + stdout.write(` config: ${MCP_CONFIG_PATH}\n\n`); + + if (warnings.length > 0) { + stdout.write("Notes:\n"); + for (const w of warnings) stdout.write(` - ${w}\n`); + stdout.write("\n"); + } + + stdout.write("Config diff:\n"); + const beforeKeys = Object.keys(existing.mcpServers ?? {}); + const afterKeys = Object.keys(next.mcpServers ?? {}); + stdout.write(` mcpServers before: [${beforeKeys.join(", ") || "(none)"}]\n`); + stdout.write(` mcpServers after: [${afterKeys.join(", ") || "(none)"}]\n\n`); + + if (!args.apply) { + stdout.write("Re-run with --apply to write the config. Add --force to overwrite existing.\n"); + return; + } + + fs.mkdirSync(KIMI_DIR, { recursive: true }); + const configJson = `${JSON.stringify(next, null, 2)}\n`; + if (JSON.stringify(existing) !== JSON.stringify(next)) { + if (fs.existsSync(MCP_CONFIG_PATH)) { + fs.copyFileSync(MCP_CONFIG_PATH, `${MCP_CONFIG_PATH}.bak`); + stdout.write(`backed up existing mcp.json to ${MCP_CONFIG_PATH}.bak\n`); + } + fs.writeFileSync(MCP_CONFIG_PATH, configJson, "utf8"); + stdout.write(`wrote ${MCP_CONFIG_PATH}\n`); + } else { + stdout.write(`mcp.json unchanged (no merges needed)\n`); + } + + stdout.write("\nDone. Run `kimi mcp test pam` to verify the server starts.\n"); +} + +function doUninstall(args) { + const plan = planUninstall(); + const { existing, next, removed } = plan; + const stdout = process.stdout; + + stdout.write(`Uninstall plan (${args.apply ? "apply" : "dry-run"})\n`); + stdout.write(` config: ${MCP_CONFIG_PATH}\n\n`); + + if (removed.length > 0) { + stdout.write("Entries to remove:\n"); + for (const r of removed) stdout.write(` - ${r}\n`); + stdout.write("\n"); + } else { + stdout.write("Nothing to remove (mcpServers.pam not found).\n\n"); + } + + if (!args.apply) { + stdout.write("Re-run with --apply to perform the uninstall.\n"); + return; + } + + if (JSON.stringify(existing) !== JSON.stringify(next)) { + fs.copyFileSync(MCP_CONFIG_PATH, `${MCP_CONFIG_PATH}.bak`); + fs.writeFileSync(MCP_CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, "utf8"); + stdout.write(`wrote ${MCP_CONFIG_PATH} (backup: ${MCP_CONFIG_PATH}.bak)\n`); + } else { + stdout.write("mcp.json unchanged (nothing to remove)\n"); + } + stdout.write("\nDone.\n"); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.uninstall) doUninstall(args); + else doInstall(args); +} + +if (process.argv[1] === __filename) { + main(); +} diff --git a/tools/kimi/templates/mcp.fragment.json b/tools/kimi/templates/mcp.fragment.json new file mode 100644 index 0000000..d933ff2 --- /dev/null +++ b/tools/kimi/templates/mcp.fragment.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "pam": { + "command": "node", + "args": [ + "{{SERVER_PATH}}", + "--workspace", + "{{WORKSPACE_PATH}}" + ] + } + } +} diff --git a/tools/lib/mcp-tools.mjs b/tools/lib/mcp-tools.mjs new file mode 100644 index 0000000..852b886 --- /dev/null +++ b/tools/lib/mcp-tools.mjs @@ -0,0 +1,289 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + buildCatalog, + graphStats, + loadGraph, + queryGraph, + validateGraph +} from "../memory-graph.mjs"; +import { runMaintenance } from "../memory-maintenance.mjs"; +import { detectMemoryState } from "../memory-migration.mjs"; + +import { appendEntry } from "./memory-append.mjs"; +import { applyProposal } from "./memory-apply-proposal.mjs"; +import { runAudit } from "./memory-audit.mjs"; +import { memoryList, memoryRead, memorySearch } from "./memory-fs.mjs"; +import { proposeEdit } from "./memory-proposals.mjs"; +import { loadWorkspaceConfig, toPosixPath } from "./workspace.mjs"; + +function readPamVersion(workspaceRoot) { + const p = path.join(workspaceRoot, "memory", "pam.version.json"); + if (!fs.existsSync(p)) return null; + try { + return JSON.parse(fs.readFileSync(p, "utf8")); + } catch (error) { + return { parseError: error.message }; + } +} + +function jsonResultContent(value) { + return { + content: [{ type: "text", text: JSON.stringify(value, null, 2) }], + isError: false + }; +} + +function errorContent(message) { + return { + content: [{ type: "text", text: message }], + isError: true + }; +} + +function buildToolRegistry({ workspaceRoot, serverVersion }) { + const loadConfig = () => loadWorkspaceConfig(workspaceRoot); + + const tools = [ + { + name: "pam_version", + description: "Returns memory/pam.version.json and the MCP server version.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler: async () => jsonResultContent({ + pam: readPamVersion(workspaceRoot), + server: { name: "pam", version: serverVersion } + }) + }, + { + name: "memory_state", + description: "Detects whether the workspace is graph-v1, markdown-v0, partial, or unknown.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler: async () => jsonResultContent(detectMemoryState(workspaceRoot)) + }, + { + name: "memory_list", + description: "Lists files under a memory/ subdirectory (depth-limited, no symlinks).", + inputSchema: { + type: "object", + properties: { + dir: { type: "string", description: "relative path under memory/ (default 'memory')" }, + depth: { type: "integer", minimum: 1, maximum: 6, description: "max recursion depth (default 3)" } + }, + additionalProperties: false + }, + handler: async (args) => jsonResultContent(memoryList(workspaceRoot, args ?? {})) + }, + { + name: "memory_read", + description: "Reads a text file under memory/. Rejects symlinks, non-text extensions, and oversize files.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "path relative to workspace root, must be under memory/" } + }, + required: ["path"], + additionalProperties: false + }, + handler: async (args) => jsonResultContent(memoryRead(workspaceRoot, args?.path)) + }, + { + name: "memory_search", + description: "Substring or regex search across memory/ markdown and JSONL files.", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + regex: { type: "boolean" }, + paths: { type: "array", items: { type: "string" } }, + maxResults: { type: "integer", minimum: 1, maximum: 1000 } + }, + required: ["query"], + additionalProperties: false + }, + handler: async (args) => jsonResultContent(memorySearch(workspaceRoot, args ?? {})) + }, + { + name: "graph_query", + description: "Resolves aliases and one-hop neighbors. Wraps queryGraph(loadGraph()).", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + kind: { type: "string" }, + relation: { type: "string" }, + limit: { type: "integer", minimum: 1, maximum: 200 } + }, + additionalProperties: false + }, + handler: async (args) => jsonResultContent(queryGraph(loadGraph(workspaceRoot), args ?? {})) + }, + { + name: "graph_stats", + description: "Returns node/edge/alias counts and file sizes for memory/graph/.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler: async () => jsonResultContent(graphStats(workspaceRoot)) + }, + { + name: "graph_validate", + description: "Validates memory/graph/*.jsonl integrity. Read-only.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler: async () => jsonResultContent(validateGraph(loadGraph(workspaceRoot))) + }, + { + name: "graph_reindex", + description: "Rebuilds memory/graph/catalog.json from the JSONL sources. Writes only the derived catalog.", + inputSchema: { + type: "object", + properties: { dryRun: { type: "boolean" } }, + additionalProperties: false + }, + handler: async (args) => { + const catalog = buildCatalog(workspaceRoot); + const dryRun = Boolean(args?.dryRun); + const catalogPath = path.join(workspaceRoot, "memory", "graph", "catalog.json"); + if (!dryRun) { + fs.writeFileSync(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, "utf8"); + } + return jsonResultContent({ + wrote: dryRun ? null : toPosixPath(path.relative(workspaceRoot, catalogPath)), + dryRun, + health: catalog.health + }); + } + }, + { + name: "maintenance_config", + description: "Returns the parsed memory-maintenance.config.json so callers can self-check scope and protected paths.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler: async () => jsonResultContent(loadConfig()) + }, + { + name: "maintenance_run", + description: "Runs memory maintenance (rotate/index/synthesis/maintain). Defaults to dry-run.", + inputSchema: { + type: "object", + properties: { + command: { type: "string", enum: ["rotate", "index", "synthesis", "maintain"] }, + dryRun: { type: "boolean" } + }, + additionalProperties: false + }, + handler: async (args) => { + const command = args?.command ?? "maintain"; + const dryRun = args?.dryRun !== false; + const result = runMaintenance(workspaceRoot, loadConfig(), command, { dryRun }); + return jsonResultContent(result); + } + }, + { + name: "memory_audit", + description: "Runs hygiene checks (duplicates, link rot, rotation candidates, etc.). Returns Finding[]. Read-only.", + inputSchema: { + type: "object", + properties: { + checks: { type: "array", items: { type: "string" } }, + staleDays: { type: "integer", minimum: 1 } + }, + additionalProperties: false + }, + handler: async (args) => jsonResultContent(runAudit(workspaceRoot, loadConfig(), args ?? {})) + }, + { + name: "memory_append", + description: "Appends a dated section to a managed log (config.managedLogs). Validates the header against ## YYYY-MM-DD - <title> so parseLogSections recognizes it as kind: 'dated'. Refuses paths not in managedLogs. Newest-first insertion (after the file's intro prefix, before the first existing dated section).", + inputSchema: { + type: "object", + properties: { + log: { type: "string", description: "managed log archiveKey (preferred) or source path" }, + headerTitle: { type: "string", description: "title portion of the header, after the date" }, + body: { type: "string", description: "markdown body of the new section" }, + date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "ISO date (defaults to today UTC)" } + }, + required: ["log", "headerTitle", "body"], + additionalProperties: false + }, + handler: async (args) => { + const result = appendEntry(workspaceRoot, loadConfig(), args ?? {}); + if (!result.ok) { + return jsonResultContent({ status: "rejected", error: result.error }); + } + return jsonResultContent({ status: "appended", ...result }); + } + }, + { + name: "memory_apply_proposal", + description: "Applies a previously-recorded proposal (memory/maintenance/proposals/<id>.json) to its target file. Re-validates path safety and re-applies the diff against current content (drift detection). On success, archives the artifact as <id>.applied.json with appliedAt added.", + inputSchema: { + type: "object", + properties: { + proposalId: { type: "string" } + }, + required: ["proposalId"], + additionalProperties: false + }, + handler: async (args) => { + const result = applyProposal(workspaceRoot, loadConfig(), args ?? {}); + if (!result.ok) { + return jsonResultContent({ status: "rejected", error: result.error }); + } + return jsonResultContent(result); + } + }, + { + name: "memory_propose_edit", + description: "Records a proposed edit as a JSON artifact under memory/maintenance/proposals/. Never mutates the target file. Rejects protected paths, escapes, oversize diffs, and JSONL edits that would invalidate the graph.", + inputSchema: { + type: "object", + properties: { + path: { type: "string" }, + rationale: { type: "string" }, + findingIds: { type: "array", items: { type: "string" } }, + diff: { + type: "object", + properties: { + kind: { type: "string", enum: ["replace", "unified-diff"] }, + anchor: { type: "object" }, + before: { type: "string" }, + after: { type: "string" }, + patch: { type: "string" } + }, + required: ["kind"] + } + }, + required: ["path", "rationale", "diff"], + additionalProperties: false + }, + handler: async (args) => { + const result = proposeEdit(workspaceRoot, loadConfig(), args ?? {}); + if (!result.ok) { + return jsonResultContent({ status: "rejected", error: result.error, validation: { ok: false, errors: [result.error] } }); + } + return jsonResultContent(result); + } + } + ]; + + const byName = new Map(); + for (const tool of tools) byName.set(tool.name, tool); + + function listTools() { + return tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })); + } + + async function callTool(name, args) { + const tool = byName.get(name); + if (!tool) { + return errorContent(`Unknown tool: ${name}`); + } + try { + return await tool.handler(args); + } catch (error) { + return errorContent(error.message ?? String(error)); + } + } + + return { listTools, callTool }; +} + +export { buildToolRegistry }; diff --git a/tools/lib/mcp-transport.mjs b/tools/lib/mcp-transport.mjs new file mode 100644 index 0000000..eca95e1 --- /dev/null +++ b/tools/lib/mcp-transport.mjs @@ -0,0 +1,143 @@ +const MCP_PROTOCOL_VERSION = "2024-11-05"; + +const PARSE_ERROR = -32700; +const INVALID_REQUEST = -32600; +const METHOD_NOT_FOUND = -32601; +const INVALID_PARAMS = -32602; +const INTERNAL_ERROR = -32603; + +function jsonRpcError(id, code, message, data) { + const error = { code, message }; + if (data !== undefined) error.data = data; + return { jsonrpc: "2.0", id, error }; +} + +function jsonRpcResult(id, result) { + return { jsonrpc: "2.0", id, result }; +} + +function encodeFrame(message) { + return `${JSON.stringify(message)}\n`; +} + +async function dispatch(request, server) { + if (!request || typeof request !== "object") { + return jsonRpcError(null, INVALID_REQUEST, "Invalid Request"); + } + if (request.jsonrpc !== "2.0") { + return jsonRpcError(request.id ?? null, INVALID_REQUEST, "Invalid Request: jsonrpc must be '2.0'"); + } + const { id, method, params } = request; + const isNotification = id === undefined; + + try { + if (method === "initialize") { + const result = { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: server.name, version: server.version } + }; + return isNotification ? null : jsonRpcResult(id, result); + } + if (method === "initialized" || method === "notifications/initialized") { + return null; + } + if (method === "tools/list") { + const tools = server.listTools(); + return isNotification ? null : jsonRpcResult(id, { tools }); + } + if (method === "tools/call") { + if (!params || typeof params !== "object") { + return jsonRpcError(id, INVALID_PARAMS, "tools/call requires params"); + } + const { name, arguments: args } = params; + if (typeof name !== "string" || name === "") { + return jsonRpcError(id, INVALID_PARAMS, "tools/call requires a tool name"); + } + const callResult = await server.callTool(name, args ?? {}); + return isNotification ? null : jsonRpcResult(id, callResult); + } + if (method === "ping") { + return isNotification ? null : jsonRpcResult(id, {}); + } + if (isNotification) return null; + return jsonRpcError(id, METHOD_NOT_FOUND, `Method not found: ${method}`); + } catch (error) { + if (isNotification) return null; + return jsonRpcError(id, INTERNAL_ERROR, error.message ?? String(error)); + } +} + +function makeLineReader(stream, onLine) { + let buffer = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk) => { + buffer += chunk; + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).replace(/\r$/, ""); + buffer = buffer.slice(newlineIndex + 1); + if (line.trim() !== "") { + onLine(line); + } + newlineIndex = buffer.indexOf("\n"); + } + }); + return () => { + if (buffer.trim() !== "") { + onLine(buffer); + buffer = ""; + } + }; +} + +async function processLine(line, server, writeFrame) { + let request; + try { + request = JSON.parse(line); + } catch { + writeFrame(jsonRpcError(null, PARSE_ERROR, "Parse error")); + return; + } + const response = await dispatch(request, server); + if (response) writeFrame(response); +} + +function runStdioServer(server, options = {}) { + const stdin = options.stdin ?? process.stdin; + const stdout = options.stdout ?? process.stdout; + let closed = false; + const writeFrame = (frame) => { + if (closed) return; + stdout.write(encodeFrame(frame)); + }; + const flush = makeLineReader(stdin, (line) => { + processLine(line, server, writeFrame).catch((error) => { + writeFrame(jsonRpcError(null, INTERNAL_ERROR, error.message ?? String(error))); + }); + }); + return new Promise((resolve) => { + stdin.on("end", () => { + flush(); + closed = true; + resolve(); + }); + stdin.on("close", () => { + flush(); + closed = true; + resolve(); + }); + }); +} + +export { + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + MCP_PROTOCOL_VERSION, + METHOD_NOT_FOUND, + PARSE_ERROR, + dispatch, + encodeFrame, + runStdioServer +}; diff --git a/tools/lib/memory-append.mjs b/tools/lib/memory-append.mjs new file mode 100644 index 0000000..0a137cb --- /dev/null +++ b/tools/lib/memory-append.mjs @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { isPathProtected, resolveInsideWorkspace, toPosixPath, workspaceRelative } from "./workspace.mjs"; + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const ISO_DATE_TITLE_RE = /^## \d{4}-\d{2}-\d{2} - .+$/; +const SECTION_HEADER_RE = /^## /m; + +function todayUtc() { + return new Date().toISOString().slice(0, 10); +} + +function resolveManagedLog(config, log) { + if (typeof log !== "string" || log.trim() === "") { + return { ok: false, error: "log is required" }; + } + const managedLogs = Array.isArray(config?.managedLogs) ? config.managedLogs : []; + for (const entry of managedLogs) { + if (entry.archiveKey === log || entry.source === log) { + return { ok: true, logConfig: entry }; + } + } + return { ok: false, error: `log not declared in config.managedLogs: ${log}` }; +} + +function findInsertOffset(content) { + const match = content.search(SECTION_HEADER_RE); + if (match === -1) return content.length; + return match; +} + +function appendEntry(workspaceRoot, config, input) { + const { log, headerTitle, body, date } = input ?? {}; + if (typeof headerTitle !== "string" || headerTitle.trim() === "") { + return { ok: false, error: "headerTitle is required" }; + } + if (typeof body !== "string" || body.trim() === "") { + return { ok: false, error: "body is required" }; + } + const entryDate = typeof date === "string" && date.trim() !== "" ? date.trim() : todayUtc(); + if (!ISO_DATE_RE.test(entryDate)) { + return { ok: false, error: `date must be YYYY-MM-DD: ${entryDate}` }; + } + const resolved = resolveManagedLog(config, log); + if (!resolved.ok) return resolved; + + const headerLine = `## ${entryDate} - ${headerTitle.trim()}`; + if (!ISO_DATE_TITLE_RE.test(headerLine)) { + return { ok: false, error: `header does not match ## YYYY-MM-DD - <title>: ${headerLine}` }; + } + + let absolute; + try { + absolute = resolveInsideWorkspace(workspaceRoot, resolved.logConfig.source); + } catch (error) { + return { ok: false, error: error.message }; + } + const protectedPaths = Array.isArray(config?.protectedPaths) ? config.protectedPaths : []; + if (isPathProtected(workspaceRoot, resolved.logConfig.source, protectedPaths)) { + return { ok: false, error: `target path is protected: ${toPosixPath(resolved.logConfig.source)}` }; + } + if (!fs.existsSync(absolute)) { + return { ok: false, error: `target log does not exist: ${toPosixPath(resolved.logConfig.source)}` }; + } + const stats = fs.lstatSync(absolute); + if (stats.isSymbolicLink()) { + return { ok: false, error: "target log is a symlink" }; + } + if (!stats.isFile()) { + return { ok: false, error: "target log is not a file" }; + } + + const original = fs.readFileSync(absolute, "utf8"); + const insertAt = findInsertOffset(original); + const prefix = original.slice(0, insertAt); + const suffix = original.slice(insertAt); + const normalizedPrefix = prefix.endsWith("\n") || prefix === "" ? prefix : `${prefix}\n`; + const prefixNeedsBlank = normalizedPrefix !== "" && !normalizedPrefix.endsWith("\n\n"); + const leading = prefixNeedsBlank ? `${normalizedPrefix}\n` : normalizedPrefix; + const bodyTrimmed = body.replace(/\s+$/, ""); + const section = `${headerLine}\n\n${bodyTrimmed}\n\n`; + const next = `${leading}${section}${suffix}`; + + fs.writeFileSync(absolute, next, "utf8"); + + return { + ok: true, + path: workspaceRelative(workspaceRoot, absolute), + anchor: headerLine, + bytesWritten: Buffer.byteLength(next, "utf8") - Buffer.byteLength(original, "utf8") + }; +} + +export { + ISO_DATE_RE, + ISO_DATE_TITLE_RE, + appendEntry +}; diff --git a/tools/lib/memory-apply-proposal.mjs b/tools/lib/memory-apply-proposal.mjs new file mode 100644 index 0000000..8339728 --- /dev/null +++ b/tools/lib/memory-apply-proposal.mjs @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + PROPOSALS_DIRNAME, + applyReplace, + applyUnifiedDiff, + parseUnifiedDiff, + validateGraphJsonl, + validatePathSafety +} from "./memory-proposals.mjs"; +import { toPosixPath, workspaceRelative } from "./workspace.mjs"; + +function proposalPathFor(workspaceRoot, proposalId) { + return path.join(workspaceRoot, PROPOSALS_DIRNAME, `${proposalId}.json`); +} + +function appliedPathFor(workspaceRoot, proposalId) { + return path.join(workspaceRoot, PROPOSALS_DIRNAME, `${proposalId}.applied.json`); +} + +function loadProposal(workspaceRoot, proposalId) { + if (typeof proposalId !== "string" || proposalId.trim() === "") { + return { ok: false, error: "proposalId is required" }; + } + if (proposalId.includes("/") || proposalId.includes("\\") || proposalId.includes("..")) { + return { ok: false, error: "proposalId must not contain path separators" }; + } + const absolute = proposalPathFor(workspaceRoot, proposalId); + if (!fs.existsSync(absolute)) { + return { ok: false, error: `proposal not found: ${proposalId}` }; + } + try { + return { ok: true, record: JSON.parse(fs.readFileSync(absolute, "utf8")), absolute }; + } catch (error) { + return { ok: false, error: `proposal is not valid JSON: ${error.message}` }; + } +} + +function applyProposal(workspaceRoot, config, input) { + const { proposalId } = input ?? {}; + const loaded = loadProposal(workspaceRoot, proposalId); + if (!loaded.ok) return loaded; + const { record, absolute: proposalAbsolute } = loaded; + + const { targetPath, diff } = record; + if (typeof targetPath !== "string" || targetPath.trim() === "") { + return { ok: false, error: "proposal missing targetPath" }; + } + if (!diff || typeof diff !== "object") { + return { ok: false, error: "proposal missing diff" }; + } + + const pathCheck = validatePathSafety(workspaceRoot, targetPath, config); + if (!pathCheck.ok) return { ok: false, error: pathCheck.error }; + + const targetAbsolute = pathCheck.absolute; + if (!fs.existsSync(targetAbsolute)) { + return { ok: false, error: `target file does not exist: ${targetPath}` }; + } + const currentContent = fs.readFileSync(targetAbsolute, "utf8"); + + let proposedContent; + if (diff.kind === "replace") { + const applied = applyReplace(currentContent, diff); + if (!applied.ok) return { ok: false, error: applied.error }; + proposedContent = applied.next; + } else if (diff.kind === "unified-diff") { + if (typeof diff.patch !== "string" || diff.patch.trim() === "") { + return { ok: false, error: "unified-diff requires a patch string" }; + } + const parsed = parseUnifiedDiff(diff.patch); + if (!parsed.ok) return { ok: false, error: parsed.error }; + const parsedTargetPosix = toPosixPath(parsed.targetPath); + if (parsedTargetPosix !== toPosixPath(targetPath)) { + return { ok: false, error: `diff target mismatch: ${parsed.targetPath} vs ${targetPath}` }; + } + const applied = applyUnifiedDiff(currentContent, parsed); + if (!applied.ok) return { ok: false, error: applied.error }; + proposedContent = applied.next; + } else { + return { ok: false, error: `unsupported diff kind: ${diff.kind}` }; + } + + const graphValidation = validateGraphJsonl(workspaceRoot, targetPath, proposedContent); + if (!graphValidation.ok) return { ok: false, error: graphValidation.error }; + + const appliedAt = new Date().toISOString(); + const archivedAbsolute = appliedPathFor(workspaceRoot, proposalId); + const archivedRecord = { ...record, proposalId, appliedAt, status: "applied" }; + fs.writeFileSync(archivedAbsolute, `${JSON.stringify(archivedRecord, null, 2)}\n`, "utf8"); + fs.writeFileSync(targetAbsolute, proposedContent, "utf8"); + fs.rmSync(proposalAbsolute); + + return { + ok: true, + status: "applied", + target: toPosixPath(targetPath), + proposalArchivedAs: workspaceRelative(workspaceRoot, archivedAbsolute), + appliedAt + }; +} + +export { applyProposal }; diff --git a/tools/lib/memory-audit.mjs b/tools/lib/memory-audit.mjs new file mode 100644 index 0000000..30d4c90 --- /dev/null +++ b/tools/lib/memory-audit.mjs @@ -0,0 +1,473 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { loadGraph, validateGraph } from "../memory-graph.mjs"; +import { parseLogSections } from "../memory-maintenance.mjs"; +import { toPosixPath, workspaceRelative } from "./workspace.mjs"; + +const MAX_NODE_DIGEST_CHARS = 180; +const OVERSIZED_DIGEST_THRESHOLD = Math.floor(MAX_NODE_DIGEST_CHARS * 0.9); +const DEFAULT_STALE_WIKI_DAYS = 180; +const DUPLICATE_SIMILARITY_THRESHOLD = 0.85; + +const ALL_CHECKS = [ + "duplicate-knowledge-entry", + "link-rot", + "graph-source-link-rot", + "dangling-alias", + "stale-wiki-page", + "rotation-candidate", + "contradiction", + "oversized-digest", + "orphan-source" +]; + +function makeFindingId(seq, check, runId) { + const stamp = runId ?? new Date().toISOString().slice(0, 10); + return `audit-${stamp}-${String(seq).padStart(4, "0")}-${check}`; +} + +function normalizeForCompare(text) { + return text + .toLowerCase() + .replace(/[`*_~>]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function firstSentence(text) { + const stripped = text.replace(/^#+\s.*$/m, "").trim(); + const sentenceMatch = stripped.match(/[^.!?\n]{8,}[.!?\n]/); + return sentenceMatch ? sentenceMatch[0] : stripped.slice(0, 200); +} + +function similarity(a, b) { + if (a === b) return 1; + if (a.length === 0 || b.length === 0) return 0; + const tokensA = new Set(a.split(" ")); + const tokensB = new Set(b.split(" ")); + let intersect = 0; + for (const token of tokensA) { + if (tokensB.has(token)) intersect += 1; + } + const union = tokensA.size + tokensB.size - intersect; + return union === 0 ? 0 : intersect / union; +} + +function checkDuplicateKnowledgeEntry(workspaceRoot, config, ctx) { + const findings = []; + const logs = (config.managedLogs ?? []).map((entry) => entry.source); + for (const relativePath of logs) { + const absolute = path.join(workspaceRoot, relativePath); + if (!fs.existsSync(absolute)) continue; + const content = fs.readFileSync(absolute, "utf8"); + const { sections } = parseLogSections(content); + const fingerprints = sections.map((section) => ({ + headerLine: section.headerLine, + dateString: section.dateString, + fingerprint: normalizeForCompare(firstSentence(section.block)) + })); + for (let i = 0; i < fingerprints.length; i += 1) { + for (let j = i + 1; j < fingerprints.length; j += 1) { + const a = fingerprints[i]; + const b = fingerprints[j]; + if (a.fingerprint === "" || b.fingerprint === "") continue; + const score = similarity(a.fingerprint, b.fingerprint); + if (score >= DUPLICATE_SIMILARITY_THRESHOLD) { + findings.push({ + id: makeFindingId(ctx.nextSeq(), "duplicate-knowledge-entry", ctx.runId), + check: "duplicate-knowledge-entry", + severity: "warning", + paths: [toPosixPath(relativePath)], + anchors: [ + { path: toPosixPath(relativePath), headerLine: a.headerLine }, + { path: toPosixPath(relativePath), headerLine: b.headerLine } + ], + summary: `Near-duplicate log entries (similarity ${score.toFixed(2)}).`, + evidence: [a.headerLine, b.headerLine], + suggestedAction: "merge-or-supersede" + }); + } + } + } + } + return findings; +} + +function extractMarkdownLinks(content) { + const links = []; + const linkRe = /\[[^\]]+\]\(([^)\s#]+)(?:#[^)]*)?\)/g; + let match; + while ((match = linkRe.exec(content)) !== null) { + links.push({ target: match[1], offset: match.index }); + } + return links; +} + +function checkLinkRot(workspaceRoot, config, ctx) { + const findings = []; + const memoryRoot = path.join(workspaceRoot, "memory"); + if (!fs.existsSync(memoryRoot)) return findings; + function walk(dir) { + const names = fs.readdirSync(dir); + for (const name of names) { + const child = path.join(dir, name); + const stats = fs.lstatSync(child); + if (stats.isSymbolicLink()) continue; + if (stats.isDirectory()) { + walk(child); + } else if (stats.isFile() && child.endsWith(".md")) { + const content = fs.readFileSync(child, "utf8"); + const links = extractMarkdownLinks(content); + for (const link of links) { + if (/^https?:/i.test(link.target) || link.target.startsWith("mailto:")) continue; + const linkAbsolute = path.resolve(path.dirname(child), link.target); + if (!fs.existsSync(linkAbsolute)) { + findings.push({ + id: makeFindingId(ctx.nextSeq(), "link-rot", ctx.runId), + check: "link-rot", + severity: "warning", + paths: [workspaceRelative(workspaceRoot, child)], + anchors: [{ path: workspaceRelative(workspaceRoot, child), offset: link.offset }], + summary: `Broken markdown link to ${link.target}`, + evidence: [link.target], + suggestedAction: "fix-or-remove-link" + }); + } + } + } + } + } + walk(memoryRoot); + return findings; +} + +function checkGraphSourceLinkRot(workspaceRoot, _config, ctx) { + const findings = []; + let graph; + try { + graph = loadGraph(workspaceRoot); + } catch { + return findings; + } + const checked = new Set(); + function checkSrc(entry, kind) { + if (!entry || typeof entry.src !== "string" || entry.src.trim() === "") return; + const src = entry.src.split("#")[0]; + if (checked.has(`${kind}:${src}`)) return; + checked.add(`${kind}:${src}`); + const absolute = path.resolve(workspaceRoot, src); + if (!fs.existsSync(absolute)) { + findings.push({ + id: makeFindingId(ctx.nextSeq(), "graph-source-link-rot", ctx.runId), + check: "graph-source-link-rot", + severity: "warning", + paths: [src], + anchors: [{ path: src }], + summary: `Graph ${kind} references missing source file: ${src}`, + evidence: [entry.id ?? `${entry.f}->${entry.t}`], + suggestedAction: "update-or-remove-src" + }); + } + } + for (const node of graph.nodes) checkSrc(node, "node"); + for (const edge of graph.edges) checkSrc(edge, "edge"); + return findings; +} + +function checkDanglingAlias(workspaceRoot, _config, ctx) { + const findings = []; + let graph; + try { + graph = loadGraph(workspaceRoot); + } catch { + return findings; + } + const result = validateGraph(graph); + for (const error of result.errors) { + if (error.startsWith("Alias target missing") || error.startsWith("Dangling edge")) { + findings.push({ + id: makeFindingId(ctx.nextSeq(), "dangling-alias", ctx.runId), + check: "dangling-alias", + severity: "warning", + paths: ["memory/graph/aliases.jsonl", "memory/graph/edges.jsonl"], + anchors: [], + summary: error, + evidence: [error], + suggestedAction: "repair-or-remove-graph-entry" + }); + } + } + return findings; +} + +function listAgentMemoryFiles(workspaceRoot) { + const dir = path.join(workspaceRoot, "memory", "agent-memory"); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir) + .filter((name) => name.endsWith(".md")) + .map((name) => path.join(dir, name)); +} + +function collectInboundLinkTargets(workspaceRoot) { + const targets = new Set(); + const indexPath = path.join(workspaceRoot, "memory", "index.md"); + if (fs.existsSync(indexPath)) { + const content = fs.readFileSync(indexPath, "utf8"); + for (const link of extractMarkdownLinks(content)) { + const resolved = path.resolve(path.dirname(indexPath), link.target); + targets.add(resolved); + } + } + try { + const graph = loadGraph(workspaceRoot); + for (const node of graph.nodes) { + if (typeof node.src === "string") { + const src = node.src.split("#")[0]; + targets.add(path.resolve(workspaceRoot, src)); + } + } + for (const edge of graph.edges) { + if (typeof edge.src === "string") { + const src = edge.src.split("#")[0]; + targets.add(path.resolve(workspaceRoot, src)); + } + } + } catch { + // ignore + } + return targets; +} + +function checkStaleWikiPage(workspaceRoot, _config, ctx, options = {}) { + const findings = []; + const days = Number.isInteger(options.staleDays) && options.staleDays > 0 + ? options.staleDays + : DEFAULT_STALE_WIKI_DAYS; + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + const inbound = collectInboundLinkTargets(workspaceRoot); + const files = listAgentMemoryFiles(workspaceRoot); + for (const file of files) { + const stats = fs.statSync(file); + if (stats.mtimeMs >= cutoff) continue; + if (inbound.has(file)) continue; + findings.push({ + id: makeFindingId(ctx.nextSeq(), "stale-wiki-page", ctx.runId), + check: "stale-wiki-page", + severity: "info", + paths: [workspaceRelative(workspaceRoot, file)], + anchors: [{ path: workspaceRelative(workspaceRoot, file) }], + summary: `Agent-memory page is older than ${days}d and has no inbound link.`, + evidence: [`mtime: ${stats.mtime.toISOString()}`], + suggestedAction: "review-or-link-or-retire" + }); + } + return findings; +} + +function checkRotationCandidate(workspaceRoot, config, ctx) { + const findings = []; + const retentionDays = Number.isInteger(config.retentionDays) ? config.retentionDays : 90; + const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000; + for (const logConfig of config.managedLogs ?? []) { + const absolute = path.join(workspaceRoot, logConfig.source); + if (!fs.existsSync(absolute)) continue; + const content = fs.readFileSync(absolute, "utf8"); + const { sections } = parseLogSections(content); + const dated = sections.filter((section) => section.kind === "dated" && section.date instanceof Date); + const overLimit = Number.isInteger(logConfig.activeEntryLimit) + && logConfig.activeEntryLimit > 0 + && dated.length > logConfig.activeEntryLimit; + const past = dated.filter((section) => section.date.getTime() < cutoffMs); + if (overLimit || past.length > 0) { + findings.push({ + id: makeFindingId(ctx.nextSeq(), "rotation-candidate", ctx.runId), + check: "rotation-candidate", + severity: "info", + paths: [toPosixPath(logConfig.source)], + anchors: [{ path: toPosixPath(logConfig.source) }], + summary: `Log has ${dated.length} dated entries (limit ${logConfig.activeEntryLimit ?? "n/a"}, ${past.length} past retention).`, + evidence: [ + `activeEntryLimit=${logConfig.activeEntryLimit ?? "n/a"}`, + `retentionDays=${retentionDays}`, + `entries=${dated.length}`, + `past=${past.length}` + ], + suggestedAction: "run-maintenance-rotate" + }); + } + } + return findings; +} + +function checkContradiction(workspaceRoot, _config, ctx) { + const findings = []; + let graph; + try { + graph = loadGraph(workspaceRoot); + } catch { + return findings; + } + const aliasIndex = new Map(); + for (const alias of graph.aliases) { + if (typeof alias.a !== "string") continue; + const key = alias.a.toLowerCase(); + if (!aliasIndex.has(key)) aliasIndex.set(key, new Set()); + aliasIndex.get(key).add(alias.id); + } + const nodeById = new Map(graph.nodes.map((node) => [node.id, node])); + for (const [aliasKey, idSet] of aliasIndex.entries()) { + if (idSet.size < 2) continue; + const ids = [...idSet]; + const digests = ids + .map((id) => nodeById.get(id)) + .filter((node) => node && node.st === "confirmed"); + if (digests.length < 2) continue; + const unique = new Set(digests.map((node) => normalizeForCompare(node.d ?? ""))); + if (unique.size <= 1) continue; + findings.push({ + id: makeFindingId(ctx.nextSeq(), "contradiction", ctx.runId), + check: "contradiction", + severity: "info", + paths: ["memory/graph/nodes.jsonl"], + anchors: digests.map((node) => ({ path: "memory/graph/nodes.jsonl", id: node.id })), + summary: `Alias "${aliasKey}" resolves to multiple confirmed nodes with conflicting digests.`, + evidence: digests.map((node) => `${node.id}: ${node.d}`), + suggestedAction: "reconcile-confirmed-claims" + }); + } + return findings; +} + +function checkOversizedDigest(workspaceRoot, _config, ctx) { + const findings = []; + let graph; + try { + graph = loadGraph(workspaceRoot); + } catch { + return findings; + } + for (const node of graph.nodes) { + if (typeof node.d !== "string") continue; + if (node.d.length >= OVERSIZED_DIGEST_THRESHOLD && node.d.length <= MAX_NODE_DIGEST_CHARS) { + findings.push({ + id: makeFindingId(ctx.nextSeq(), "oversized-digest", ctx.runId), + check: "oversized-digest", + severity: "info", + paths: ["memory/graph/nodes.jsonl"], + anchors: [{ path: "memory/graph/nodes.jsonl", id: node.id }], + summary: `Node digest is ${node.d.length}/${MAX_NODE_DIGEST_CHARS} chars; pre-warning before validation breaks.`, + evidence: [`${node.id}: ${node.d.length} chars`], + suggestedAction: "tighten-digest" + }); + } + } + return findings; +} + +function checkOrphanSource(workspaceRoot, _config, ctx) { + const findings = []; + const sourcesDir = path.join(workspaceRoot, "memory", "sources"); + if (!fs.existsSync(sourcesDir)) return findings; + let graph; + try { + graph = loadGraph(workspaceRoot); + } catch { + return findings; + } + const referenced = new Set(); + function recordSrc(src) { + if (typeof src !== "string") return; + const head = src.split("#")[0]; + referenced.add(path.resolve(workspaceRoot, head)); + } + for (const node of graph.nodes) recordSrc(node.src); + for (const edge of graph.edges) recordSrc(edge.src); + + function walk(dir) { + const names = fs.readdirSync(dir); + for (const name of names) { + const child = path.join(dir, name); + const stats = fs.lstatSync(child); + if (stats.isSymbolicLink()) continue; + if (stats.isDirectory()) { + walk(child); + } else if (stats.isFile()) { + if (referenced.has(child)) continue; + findings.push({ + id: makeFindingId(ctx.nextSeq(), "orphan-source", ctx.runId), + check: "orphan-source", + severity: "info", + paths: [workspaceRelative(workspaceRoot, child)], + anchors: [{ path: workspaceRelative(workspaceRoot, child) }], + summary: "Source file is not referenced from the graph.", + evidence: [workspaceRelative(workspaceRoot, child)], + suggestedAction: "link-or-leave-as-archive" + }); + } + } + } + walk(sourcesDir); + return findings; +} + +const CHECK_IMPLEMENTATIONS = { + "duplicate-knowledge-entry": checkDuplicateKnowledgeEntry, + "link-rot": checkLinkRot, + "graph-source-link-rot": checkGraphSourceLinkRot, + "dangling-alias": checkDanglingAlias, + "stale-wiki-page": checkStaleWikiPage, + "rotation-candidate": checkRotationCandidate, + "contradiction": checkContradiction, + "oversized-digest": checkOversizedDigest, + "orphan-source": checkOrphanSource +}; + +function runAudit(workspaceRoot, config, options = {}) { + const requested = Array.isArray(options.checks) && options.checks.length > 0 + ? options.checks.filter((name) => ALL_CHECKS.includes(name)) + : ALL_CHECKS; + let seq = 0; + const ctx = { + runId: options.runId ?? new Date().toISOString().slice(0, 10), + nextSeq: () => { + seq += 1; + return seq; + } + }; + const findings = []; + for (const check of requested) { + const fn = CHECK_IMPLEMENTATIONS[check]; + try { + const checkFindings = fn(workspaceRoot, config, ctx, options); + findings.push(...checkFindings); + } catch (error) { + findings.push({ + id: makeFindingId(ctx.nextSeq(), check, ctx.runId), + check, + severity: "error", + paths: [], + anchors: [], + summary: `Check ${check} failed: ${error.message}`, + evidence: [error.message], + suggestedAction: "investigate-audit-failure" + }); + } + } + const summary = { + byCheck: {}, + bySeverity: {} + }; + for (const finding of findings) { + summary.byCheck[finding.check] = (summary.byCheck[finding.check] ?? 0) + 1; + summary.bySeverity[finding.severity] = (summary.bySeverity[finding.severity] ?? 0) + 1; + } + return { checks: requested, findings, summary }; +} + +export { + ALL_CHECKS, + MAX_NODE_DIGEST_CHARS, + OVERSIZED_DIGEST_THRESHOLD, + runAudit +}; diff --git a/tools/lib/memory-fs.mjs b/tools/lib/memory-fs.mjs new file mode 100644 index 0000000..616271e --- /dev/null +++ b/tools/lib/memory-fs.mjs @@ -0,0 +1,188 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { resolveInsideMemory, toPosixPath, workspaceRelative } from "./workspace.mjs"; + +const MAX_READ_BYTES = 1_000_000; +const DEFAULT_LIST_DEPTH = 3; +const DEFAULT_SEARCH_RESULTS = 200; +const MAX_SEARCH_RESULTS = 1000; +const TEXT_FILE_EXTENSIONS = new Set([ + ".md", ".markdown", ".txt", ".json", ".jsonl", ".yml", ".yaml", ".mjs", ".js", ".ts" +]); + +function assertNotSymlink(absolutePath) { + const stats = fs.lstatSync(absolutePath); + if (stats.isSymbolicLink()) { + throw new Error("symlinks are not allowed"); + } + return stats; +} + +function memoryRead(workspaceRoot, relativePath) { + const absolute = resolveInsideMemory(workspaceRoot, relativePath); + if (!fs.existsSync(absolute)) { + throw new Error(`not found: ${relativePath}`); + } + const stats = assertNotSymlink(absolute); + if (!stats.isFile()) { + throw new Error(`not a file: ${relativePath}`); + } + if (stats.size > MAX_READ_BYTES) { + throw new Error(`file too large (${stats.size} > ${MAX_READ_BYTES}): ${relativePath}`); + } + const ext = path.extname(absolute).toLowerCase(); + if (ext !== "" && !TEXT_FILE_EXTENSIONS.has(ext)) { + throw new Error(`refusing non-text extension: ${ext}`); + } + const content = fs.readFileSync(absolute, "utf8"); + return { + path: workspaceRelative(workspaceRoot, absolute), + bytes: stats.size, + mtime: stats.mtime.toISOString(), + content + }; +} + +function memoryList(workspaceRoot, options = {}) { + const relativeDir = options.dir ?? "memory"; + const depth = Number.isInteger(options.depth) && options.depth > 0 + ? Math.min(options.depth, 6) + : DEFAULT_LIST_DEPTH; + const absoluteDir = resolveInsideMemory(workspaceRoot, relativeDir); + if (!fs.existsSync(absoluteDir)) { + return { dir: toPosixPath(relativeDir), entries: [] }; + } + const dirStats = assertNotSymlink(absoluteDir); + if (!dirStats.isDirectory()) { + throw new Error(`not a directory: ${relativeDir}`); + } + + const entries = []; + function walk(currentDir, currentDepth) { + let names; + try { + names = fs.readdirSync(currentDir); + } catch { + return; + } + for (const name of names) { + const childAbsolute = path.join(currentDir, name); + let childStats; + try { + childStats = fs.lstatSync(childAbsolute); + } catch { + continue; + } + if (childStats.isSymbolicLink()) continue; + const entry = { + path: workspaceRelative(workspaceRoot, childAbsolute), + type: childStats.isDirectory() ? "dir" : "file", + bytes: childStats.isFile() ? childStats.size : null, + mtime: childStats.mtime.toISOString() + }; + entries.push(entry); + if (childStats.isDirectory() && currentDepth + 1 < depth) { + walk(childAbsolute, currentDepth + 1); + } + } + } + walk(absoluteDir, 0); + entries.sort((a, b) => a.path.localeCompare(b.path)); + return { dir: workspaceRelative(workspaceRoot, absoluteDir), entries }; +} + +function compileMatcher(query, regex) { + if (regex) { + return new RegExp(query, "i"); + } + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(escaped, "i"); +} + +function memorySearch(workspaceRoot, options = {}) { + const query = options.query ?? ""; + if (typeof query !== "string" || query === "") { + throw new Error("query is required"); + } + const limit = Number.isInteger(options.maxResults) && options.maxResults > 0 + ? Math.min(options.maxResults, MAX_SEARCH_RESULTS) + : DEFAULT_SEARCH_RESULTS; + const matcher = compileMatcher(query, Boolean(options.regex)); + const candidateRoots = Array.isArray(options.paths) && options.paths.length > 0 + ? options.paths + : ["memory"]; + + const matches = []; + for (const candidate of candidateRoots) { + let absoluteRoot; + try { + absoluteRoot = resolveInsideMemory(workspaceRoot, candidate); + } catch (error) { + throw error; + } + if (!fs.existsSync(absoluteRoot)) continue; + const stats = assertNotSymlink(absoluteRoot); + const files = stats.isFile() ? [absoluteRoot] : collectFiles(absoluteRoot); + for (const fileAbsolute of files) { + const ext = path.extname(fileAbsolute).toLowerCase(); + if (ext !== "" && !TEXT_FILE_EXTENSIONS.has(ext)) continue; + let content; + try { + content = fs.readFileSync(fileAbsolute, "utf8"); + } catch { + continue; + } + const lines = content.split(/\r?\n/); + for (let i = 0; i < lines.length; i += 1) { + if (matcher.test(lines[i])) { + matches.push({ + path: workspaceRelative(workspaceRoot, fileAbsolute), + line: i + 1, + snippet: lines[i].slice(0, 240) + }); + if (matches.length >= limit) { + return { query, matches, truncated: true }; + } + } + } + } + } + return { query, matches, truncated: false }; +} + +function collectFiles(rootDir) { + const result = []; + function walk(dir) { + let names; + try { + names = fs.readdirSync(dir); + } catch { + return; + } + for (const name of names) { + const child = path.join(dir, name); + let stats; + try { + stats = fs.lstatSync(child); + } catch { + continue; + } + if (stats.isSymbolicLink()) continue; + if (stats.isDirectory()) { + walk(child); + } else if (stats.isFile()) { + result.push(child); + } + } + } + walk(rootDir); + return result; +} + +export { + MAX_READ_BYTES, + memoryList, + memoryRead, + memorySearch +}; diff --git a/tools/lib/memory-proposals.mjs b/tools/lib/memory-proposals.mjs new file mode 100644 index 0000000..fe349c1 --- /dev/null +++ b/tools/lib/memory-proposals.mjs @@ -0,0 +1,325 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { validateGraph } from "../memory-graph.mjs"; +import { + isPathProtected, + resolveInsideWorkspace, + toPosixPath, + workspaceRelative +} from "./workspace.mjs"; + +const MAX_DIFF_BYTES = 64 * 1024; +const PROPOSALS_DIRNAME = path.join("memory", "maintenance", "proposals"); + +function makeProposalId(date = new Date()) { + const stamp = date.toISOString().replace(/[:.]/g, "-"); + const rand = crypto.randomBytes(3).toString("hex"); + return `proposal-${stamp}-${rand}`; +} + +function safeReadFile(absolute) { + try { + return fs.readFileSync(absolute, "utf8"); + } catch { + return null; + } +} + +function validatePathSafety(workspaceRoot, relativePath, config) { + if (typeof relativePath !== "string" || relativePath.trim() === "") { + return { ok: false, error: "path is required" }; + } + let absolute; + try { + absolute = resolveInsideWorkspace(workspaceRoot, relativePath); + } catch (error) { + return { ok: false, error: error.message }; + } + if (fs.existsSync(absolute)) { + const stats = fs.lstatSync(absolute); + if (stats.isSymbolicLink()) { + return { ok: false, error: "target path is a symlink" }; + } + if (!stats.isFile()) { + return { ok: false, error: "target path is not a file" }; + } + } + const protectedPaths = Array.isArray(config?.protectedPaths) ? config.protectedPaths : []; + if (isPathProtected(workspaceRoot, relativePath, protectedPaths)) { + return { ok: false, error: `target path is protected: ${relativePath}` }; + } + return { ok: true, absolute }; +} + +function diffByteSize(diff) { + return Buffer.byteLength(JSON.stringify(diff), "utf8"); +} + +function applyReplace(currentContent, op) { + const before = String(op.before ?? ""); + const after = String(op.after ?? ""); + if (op.anchor && typeof op.anchor.headerLine === "string") { + const idx = currentContent.indexOf(op.anchor.headerLine); + if (idx === -1) { + return { ok: false, error: `anchor headerLine not found: ${op.anchor.headerLine}` }; + } + const segmentStart = idx; + const segmentEnd = idx + before.length; + if (currentContent.slice(segmentStart, segmentEnd) !== before) { + return { ok: false, error: "before content does not match at anchor" }; + } + const next = currentContent.slice(0, segmentStart) + after + currentContent.slice(segmentEnd); + return { ok: true, next }; + } + if (op.anchor && Array.isArray(op.anchor.lineRange) && op.anchor.lineRange.length === 2) { + const [startLine, endLine] = op.anchor.lineRange; + const lines = currentContent.split(/\r?\n/); + if (startLine < 1 || endLine < startLine || endLine > lines.length) { + return { ok: false, error: "lineRange out of bounds" }; + } + const sliced = lines.slice(startLine - 1, endLine).join("\n"); + if (sliced !== before) { + return { ok: false, error: "before content does not match at lineRange" }; + } + const replacedLines = [ + ...lines.slice(0, startLine - 1), + ...after.split(/\r?\n/), + ...lines.slice(endLine) + ]; + return { ok: true, next: replacedLines.join("\n") }; + } + if (before === "") { + return { ok: false, error: "replace op requires either headerLine or lineRange anchor" }; + } + const occurrences = currentContent.split(before).length - 1; + if (occurrences === 0) { + return { ok: false, error: "before content not found" }; + } + if (occurrences > 1) { + return { ok: false, error: "before content is not unique (specify anchor)" }; + } + return { ok: true, next: currentContent.replace(before, after) }; +} + +function parseUnifiedDiff(patch) { + const lines = patch.split(/\r?\n/); + let i = 0; + let targetPath = null; + while (i < lines.length) { + const line = lines[i]; + if (line.startsWith("+++ ")) { + const stripped = line.slice(4).trim(); + if (stripped === "" || stripped === "/dev/null") { + targetPath = null; + } else { + targetPath = stripped.startsWith("b/") ? stripped.slice(2) : stripped; + } + } else if (line.startsWith("--- ")) { + const stripped = line.slice(4).trim(); + if (stripped !== "" && stripped !== "/dev/null") { + const candidate = stripped.startsWith("a/") ? stripped.slice(2) : stripped; + if (!targetPath) targetPath = candidate; + } + } else if (line.startsWith("@@")) { + break; + } + i += 1; + } + const hunks = []; + let current = null; + for (; i < lines.length; i += 1) { + const line = lines[i]; + const hunkHeader = line.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/); + if (hunkHeader) { + if (current) hunks.push(current); + current = { + oldStart: Number(hunkHeader[1]), + oldLines: hunkHeader[2] ? Number(hunkHeader[2]) : 1, + newStart: Number(hunkHeader[3]), + newLines: hunkHeader[4] ? Number(hunkHeader[4]) : 1, + ops: [] + }; + } else if (current) { + if (line.startsWith("+")) { + current.ops.push({ kind: "add", text: line.slice(1) }); + } else if (line.startsWith("-")) { + current.ops.push({ kind: "remove", text: line.slice(1) }); + } else if (line.startsWith(" ")) { + current.ops.push({ kind: "context", text: line.slice(1) }); + } else if (line === "" || line.startsWith("\\")) { + // ignore blank trailing lines and "\ No newline at end of file" + } else { + return { ok: false, error: `unexpected diff line: ${line}` }; + } + } + } + if (current) hunks.push(current); + if (!targetPath) return { ok: false, error: "diff missing target path" }; + if (hunks.length === 0) return { ok: false, error: "diff missing hunks" }; + return { ok: true, targetPath, hunks }; +} + +function applyUnifiedHunk(lines, hunk) { + const startIndex = hunk.oldStart - 1; + const expectedOld = hunk.ops.filter((op) => op.kind !== "add").map((op) => op.text); + const actualOld = lines.slice(startIndex, startIndex + expectedOld.length); + for (let k = 0; k < expectedOld.length; k += 1) { + if (actualOld[k] !== expectedOld[k]) { + return { ok: false, error: `hunk mismatch at line ${startIndex + k + 1}` }; + } + } + const newSegment = hunk.ops.filter((op) => op.kind !== "remove").map((op) => op.text); + const nextLines = [ + ...lines.slice(0, startIndex), + ...newSegment, + ...lines.slice(startIndex + expectedOld.length) + ]; + return { ok: true, lines: nextLines }; +} + +function applyUnifiedDiff(currentContent, parsed) { + let lines = currentContent.split(/\r?\n/); + const trailingNewline = currentContent.endsWith("\n"); + if (trailingNewline && lines.length > 0 && lines[lines.length - 1] === "") { + lines = lines.slice(0, -1); + } + const sortedHunks = [...parsed.hunks].sort((a, b) => b.oldStart - a.oldStart); + for (const hunk of sortedHunks) { + const applied = applyUnifiedHunk(lines, hunk); + if (!applied.ok) return applied; + lines = applied.lines; + } + const result = lines.join("\n") + (trailingNewline ? "\n" : ""); + return { ok: true, next: result }; +} + +function validateGraphJsonl(workspaceRoot, targetRelative, proposedContent) { + const graphFiles = { + "memory/graph/nodes.jsonl": "nodes", + "memory/graph/edges.jsonl": "edges", + "memory/graph/aliases.jsonl": "aliases" + }; + const posix = toPosixPath(targetRelative); + const key = graphFiles[posix]; + if (!key) return { ok: true }; + const graph = { nodes: [], edges: [], aliases: [] }; + for (const [graphPath, graphKey] of Object.entries(graphFiles)) { + if (graphPath === posix) { + graph[graphKey] = parseJsonlText(proposedContent, graphPath); + if (!graph[graphKey].ok) return { ok: false, error: graph[graphKey].error }; + graph[graphKey] = graph[graphKey].rows; + } else { + const absolute = path.join(workspaceRoot, graphPath); + const content = safeReadFile(absolute); + if (content === null) return { ok: false, error: `missing companion graph file: ${graphPath}` }; + const parsed = parseJsonlText(content, graphPath); + if (!parsed.ok) return { ok: false, error: parsed.error }; + graph[graphKey] = parsed.rows; + } + } + const result = validateGraph(graph); + if (!result.ok) { + return { ok: false, error: `proposed graph fails validation: ${result.errors.join("; ")}` }; + } + return { ok: true }; +} + +function parseJsonlText(text, label) { + const rows = []; + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (line.trim() === "") continue; + try { + rows.push(JSON.parse(line)); + } catch (error) { + return { ok: false, error: `invalid JSONL in ${label}:${i + 1}: ${error.message}` }; + } + } + return { ok: true, rows }; +} + +function proposeEdit(workspaceRoot, config, input) { + const { path: relativePath, diff, rationale, findingIds } = input ?? {}; + if (typeof rationale !== "string" || rationale.trim() === "") { + return { ok: false, error: "rationale is required" }; + } + if (!diff || typeof diff !== "object") { + return { ok: false, error: "diff is required" }; + } + if (diffByteSize(diff) > MAX_DIFF_BYTES) { + return { ok: false, error: `diff exceeds ${MAX_DIFF_BYTES} bytes` }; + } + + const pathCheck = validatePathSafety(workspaceRoot, relativePath, config); + if (!pathCheck.ok) return { ok: false, error: pathCheck.error }; + + let currentContent = ""; + if (fs.existsSync(pathCheck.absolute)) { + currentContent = fs.readFileSync(pathCheck.absolute, "utf8"); + } + + let proposedContent; + if (diff.kind === "replace") { + const applied = applyReplace(currentContent, diff); + if (!applied.ok) return { ok: false, error: applied.error }; + proposedContent = applied.next; + } else if (diff.kind === "unified-diff") { + if (typeof diff.patch !== "string" || diff.patch.trim() === "") { + return { ok: false, error: "unified-diff requires a patch string" }; + } + const parsed = parseUnifiedDiff(diff.patch); + if (!parsed.ok) return { ok: false, error: parsed.error }; + const parsedTargetPosix = toPosixPath(parsed.targetPath); + if (parsedTargetPosix !== toPosixPath(relativePath)) { + return { ok: false, error: `diff targets a different path: ${parsed.targetPath}` }; + } + const applied = applyUnifiedDiff(currentContent, parsed); + if (!applied.ok) return { ok: false, error: applied.error }; + proposedContent = applied.next; + } else { + return { ok: false, error: `unsupported diff kind: ${diff.kind}` }; + } + + const graphValidation = validateGraphJsonl(workspaceRoot, relativePath, proposedContent); + if (!graphValidation.ok) return { ok: false, error: graphValidation.error }; + + const proposalsDir = path.join(workspaceRoot, PROPOSALS_DIRNAME); + fs.mkdirSync(proposalsDir, { recursive: true }); + const proposalId = makeProposalId(); + const proposalRelative = toPosixPath(path.join(PROPOSALS_DIRNAME, `${proposalId}.json`)); + const proposalAbsolute = path.join(workspaceRoot, proposalRelative); + const record = { + proposalId, + createdAt: new Date().toISOString(), + source: input.source ?? "pam-curator", + targetPath: toPosixPath(relativePath), + diff, + rationale, + findingIds: Array.isArray(findingIds) ? findingIds : [], + validation: { ok: true }, + proposedContentLength: proposedContent.length + }; + fs.writeFileSync(proposalAbsolute, `${JSON.stringify(record, null, 2)}\n`, "utf8"); + return { + ok: true, + proposalId, + proposalPath: workspaceRelative(workspaceRoot, proposalAbsolute), + status: "recorded", + validation: { ok: true } + }; +} + +export { + MAX_DIFF_BYTES, + PROPOSALS_DIRNAME, + applyReplace, + applyUnifiedDiff, + diffByteSize, + parseUnifiedDiff, + proposeEdit, + validateGraphJsonl, + validatePathSafety +}; diff --git a/tools/lib/workspace.mjs b/tools/lib/workspace.mjs new file mode 100644 index 0000000..95ff1f8 --- /dev/null +++ b/tools/lib/workspace.mjs @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { loadConfig } from "../memory-maintenance.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DEFAULT_WORKSPACE_ROOT = process.cwd(); + +function resolveWorkspaceRoot(input) { + if (!input) return DEFAULT_WORKSPACE_ROOT; + return path.resolve(input); +} + +function workspaceConfigPath(workspaceRoot) { + return path.join(workspaceRoot, "tools", "memory-maintenance.config.json"); +} + +function loadWorkspaceConfig(workspaceRoot = DEFAULT_WORKSPACE_ROOT) { + const configPath = workspaceConfigPath(workspaceRoot); + if (fs.existsSync(configPath)) { + return loadConfig(configPath); + } + return loadConfig(); +} + +function isUnderDir(absolutePath, dirAbsolutePath) { + const rel = path.relative(dirAbsolutePath, absolutePath); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + +function resolveInsideMemory(workspaceRoot, relativePath) { + if (typeof relativePath !== "string" || relativePath.trim() === "") { + throw new Error("path is required"); + } + const memoryRoot = path.join(workspaceRoot, "memory"); + const absolute = path.resolve(workspaceRoot, relativePath); + if (!isUnderDir(absolute, memoryRoot)) { + throw new Error(`path escapes memory/: ${relativePath}`); + } + return absolute; +} + +function resolveInsideWorkspace(workspaceRoot, relativePath) { + if (typeof relativePath !== "string" || relativePath.trim() === "") { + throw new Error("path is required"); + } + const absolute = path.resolve(workspaceRoot, relativePath); + if (!isUnderDir(absolute, workspaceRoot)) { + throw new Error(`path escapes workspace: ${relativePath}`); + } + return absolute; +} + +function isPathProtected(workspaceRoot, relativePath, protectedPaths) { + if (!Array.isArray(protectedPaths)) return false; + const target = path.resolve(workspaceRoot, relativePath); + for (const protectedEntry of protectedPaths) { + const protectedAbsolute = path.resolve(workspaceRoot, protectedEntry); + if (target === protectedAbsolute) return true; + const rel = path.relative(protectedAbsolute, target); + if (rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)) return true; + } + return false; +} + +function toPosixPath(inputPath) { + return inputPath.split(path.sep).join("/"); +} + +function workspaceRelative(workspaceRoot, absolutePath) { + return toPosixPath(path.relative(workspaceRoot, absolutePath)); +} + +export { + DEFAULT_WORKSPACE_ROOT, + isPathProtected, + isUnderDir, + loadWorkspaceConfig, + resolveInsideMemory, + resolveInsideWorkspace, + resolveWorkspaceRoot, + toPosixPath, + workspaceRelative +}; diff --git a/tools/pam-mcp-server.mjs b/tools/pam-mcp-server.mjs new file mode 100644 index 0000000..1f2fab3 --- /dev/null +++ b/tools/pam-mcp-server.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { buildToolRegistry } from "./lib/mcp-tools.mjs"; +import { + MCP_PROTOCOL_VERSION, + dispatch, + encodeFrame, + runStdioServer +} from "./lib/mcp-transport.mjs"; +import { DEFAULT_WORKSPACE_ROOT, resolveWorkspaceRoot } from "./lib/workspace.mjs"; + +const __filename = fileURLToPath(import.meta.url); + +function readServerVersion() { + const pkgPath = path.resolve(path.dirname(__filename), "..", "package.json"); + try { + return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +} + +function parseArgs(argv) { + const result = { smoke: false, workspace: null }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--smoke") result.smoke = true; + else if (arg === "--workspace") { + result.workspace = argv[i + 1]; + i += 1; + } else if (arg.startsWith("--workspace=")) { + result.workspace = arg.slice("--workspace=".length); + } + } + return result; +} + +function makeServer(workspaceRoot) { + const serverVersion = readServerVersion(); + const registry = buildToolRegistry({ workspaceRoot, serverVersion }); + return { + name: "pam", + version: serverVersion, + listTools: registry.listTools, + callTool: registry.callTool + }; +} + +async function runSmoke(server) { + const initResponse = await dispatch( + { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: MCP_PROTOCOL_VERSION } }, + server + ); + process.stdout.write(encodeFrame(initResponse)); + const listResponse = await dispatch( + { jsonrpc: "2.0", id: 2, method: "tools/list" }, + server + ); + process.stdout.write(encodeFrame(listResponse)); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const workspaceRoot = args.workspace + ? resolveWorkspaceRoot(args.workspace) + : DEFAULT_WORKSPACE_ROOT; + const server = makeServer(workspaceRoot); + + if (args.smoke) { + await runSmoke(server); + return; + } + + const shutdown = () => { + process.stdin.pause(); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + await runStdioServer(server); +} + +if (process.argv[1] === __filename) { + main().catch((error) => { + process.stderr.write(`pam-mcp-server fatal: ${error.message ?? String(error)}\n`); + process.exit(1); + }); +} + +export { makeServer }; diff --git a/tools/test-mcp-transport.mjs b/tools/test-mcp-transport.mjs new file mode 100644 index 0000000..25c82a3 --- /dev/null +++ b/tools/test-mcp-transport.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + INVALID_PARAMS, + MCP_PROTOCOL_VERSION, + METHOD_NOT_FOUND, + PARSE_ERROR, + dispatch +} from "./lib/mcp-transport.mjs"; + +function makeServer(overrides = {}) { + return { + name: "pam", + version: "0.4.0-test", + listTools: () => [ + { name: "memory_state", description: "wraps detectMemoryState", inputSchema: { type: "object" } } + ], + callTool: async (name, args) => { + if (name === "memory_state") return { ok: true, args }; + if (name === "boom") throw new Error("boom"); + const err = new Error(`Unknown tool: ${name}`); + err.code = "UNKNOWN_TOOL"; + throw err; + }, + ...overrides + }; +} + +test("dispatch handles initialize", async () => { + const response = await dispatch( + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + makeServer() + ); + assert.equal(response.result.protocolVersion, MCP_PROTOCOL_VERSION); + assert.equal(response.result.serverInfo.name, "pam"); +}); + +test("dispatch handles tools/list", async () => { + const response = await dispatch({ jsonrpc: "2.0", id: 2, method: "tools/list" }, makeServer()); + assert.equal(response.result.tools.length, 1); + assert.equal(response.result.tools[0].name, "memory_state"); +}); + +test("dispatch routes tools/call to the handler", async () => { + const response = await dispatch( + { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "memory_state", arguments: { x: 1 } } }, + makeServer() + ); + assert.deepEqual(response.result, { ok: true, args: { x: 1 } }); +}); + +test("dispatch returns METHOD_NOT_FOUND for unknown methods", async () => { + const response = await dispatch({ jsonrpc: "2.0", id: 4, method: "unknown" }, makeServer()); + assert.equal(response.error.code, METHOD_NOT_FOUND); +}); + +test("dispatch returns INVALID_PARAMS when tools/call is malformed", async () => { + const response = await dispatch( + { jsonrpc: "2.0", id: 5, method: "tools/call" }, + makeServer() + ); + assert.equal(response.error.code, INVALID_PARAMS); +}); + +test("dispatch reports handler errors as internal errors", async () => { + const response = await dispatch( + { jsonrpc: "2.0", id: 6, method: "tools/call", params: { name: "boom", arguments: {} } }, + makeServer() + ); + assert.equal(response.error.message, "boom"); +}); + +test("dispatch swallows notifications (no id)", async () => { + const response = await dispatch( + { jsonrpc: "2.0", method: "notifications/initialized" }, + makeServer() + ); + assert.equal(response, null); +}); + +test("PARSE_ERROR is exported with the JSON-RPC code", () => { + assert.equal(PARSE_ERROR, -32700); +}); diff --git a/tools/test-memory-append.mjs b/tools/test-memory-append.mjs new file mode 100644 index 0000000..40856f0 --- /dev/null +++ b/tools/test-memory-append.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { appendEntry } from "./lib/memory-append.mjs"; +import { parseLogSections } from "./memory-maintenance.mjs"; + +function makeWorkspace() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pam-append-test-")); + fs.mkdirSync(path.join(root, "memory"), { recursive: true }); + fs.writeFileSync( + path.join(root, "memory", "knowledge-log.md"), + [ + "# Knowledge Log", + "", + "Intro paragraph describing the log purpose.", + "", + "## 2026-04-12 - Existing entry", + "", + "Existing body.", + "" + ].join("\n"), + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "conversation-log.md"), + "# Conversation Log\n\nIntro line.\n", + "utf8" + ); + return root; +} + +function defaultConfig() { + return { + managedLogs: [ + { source: "memory/conversation-log.md", archiveKey: "conversation-log", activeEntryLimit: 80 }, + { source: "memory/knowledge-log.md", archiveKey: "knowledge-log", activeEntryLimit: 120 } + ] + }; +} + +test("appendEntry adds a dated section recognized by parseLogSections", () => { + const root = makeWorkspace(); + const result = appendEntry(root, defaultConfig(), { + log: "knowledge-log", + date: "2026-05-21", + headerTitle: "New durable fact", + body: "Confirmed facts:\n- example.\n" + }); + assert.equal(result.ok, true); + assert.equal(result.anchor, "## 2026-05-21 - New durable fact"); + const content = fs.readFileSync(path.join(root, "memory", "knowledge-log.md"), "utf8"); + const { sections } = parseLogSections(content); + const newSection = sections.find((s) => s.headerLine === "## 2026-05-21 - New durable fact"); + assert.ok(newSection); + assert.equal(newSection.kind, "dated"); +}); + +test("appendEntry defaults date to today UTC", () => { + const root = makeWorkspace(); + const result = appendEntry(root, defaultConfig(), { + log: "conversation-log", + headerTitle: "Session", + body: "Notes." + }); + assert.equal(result.ok, true); + const today = new Date().toISOString().slice(0, 10); + assert.ok(result.anchor.startsWith(`## ${today} - `)); +}); + +test("appendEntry rejects an unknown log", () => { + const root = makeWorkspace(); + const result = appendEntry(root, defaultConfig(), { + log: "custom-log", + headerTitle: "x", + body: "y" + }); + assert.equal(result.ok, false); + assert.match(result.error, /not declared/i); +}); + +test("appendEntry rejects empty headerTitle", () => { + const root = makeWorkspace(); + const result = appendEntry(root, defaultConfig(), { + log: "knowledge-log", + headerTitle: "", + body: "y" + }); + assert.equal(result.ok, false); + assert.match(result.error, /headerTitle/i); +}); + +test("appendEntry rejects malformed date", () => { + const root = makeWorkspace(); + const result = appendEntry(root, defaultConfig(), { + log: "knowledge-log", + date: "21-05-2026", + headerTitle: "x", + body: "y" + }); + assert.equal(result.ok, false); + assert.match(result.error, /YYYY-MM-DD/i); +}); + +test("appendEntry inserts new section between prefix and existing dated section", () => { + const root = makeWorkspace(); + const result = appendEntry(root, defaultConfig(), { + log: "knowledge-log", + date: "2026-05-21", + headerTitle: "Newer", + body: "Newer body." + }); + assert.equal(result.ok, true); + const content = fs.readFileSync(path.join(root, "memory", "knowledge-log.md"), "utf8"); + const newerIdx = content.indexOf("## 2026-05-21 - Newer"); + const olderIdx = content.indexOf("## 2026-04-12 - Existing entry"); + assert.ok(newerIdx >= 0 && olderIdx > newerIdx, "newer entry should precede older entry"); + assert.ok(content.indexOf("Intro paragraph") < newerIdx, "intro prefix should precede the new entry"); +}); + +test("appendEntry rejects when target file does not exist", () => { + const root = makeWorkspace(); + fs.rmSync(path.join(root, "memory", "knowledge-log.md")); + const result = appendEntry(root, defaultConfig(), { + log: "knowledge-log", + headerTitle: "x", + body: "y" + }); + assert.equal(result.ok, false); + assert.match(result.error, /does not exist/i); +}); + +test("appendEntry rejects writes to protected paths", () => { + const root = makeWorkspace(); + fs.writeFileSync(path.join(root, "AGENTS.md"), "# Agents\n", "utf8"); + const maliciousConfig = { + managedLogs: [ + { source: "AGENTS.md", archiveKey: "agents", activeEntryLimit: 80 } + ], + protectedPaths: ["AGENTS.md", "memory/agent-memory", "memory/sources"] + }; + const result = appendEntry(root, maliciousConfig, { + log: "agents", + headerTitle: "x", + body: "y" + }); + assert.equal(result.ok, false); + assert.match(result.error, /protected/i); +}); diff --git a/tools/test-memory-apply-proposal.mjs b/tools/test-memory-apply-proposal.mjs new file mode 100644 index 0000000..4789499 --- /dev/null +++ b/tools/test-memory-apply-proposal.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { applyProposal } from "./lib/memory-apply-proposal.mjs"; +import { proposeEdit } from "./lib/memory-proposals.mjs"; + +function makeWorkspace() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pam-apply-test-")); + fs.mkdirSync(path.join(root, "memory", "graph"), { recursive: true }); + fs.mkdirSync(path.join(root, "memory", "agent-memory"), { recursive: true }); + fs.writeFileSync( + path.join(root, "memory", "knowledge-log.md"), + "# Knowledge Log\n\n## 2026-04-12 - Worker retries\n\nFive retries.\n", + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "graph", "nodes.jsonl"), + [ + '{"id":"project:pam","k":"project","n":"PAM","d":"Memory toolkit.","st":"confirmed","c":"high","u":"2026-05-05","src":"README.md"}', + '{"id":"doc:runtime","k":"doc","n":"Runtime","d":"Runtime guide.","st":"confirmed","c":"high","u":"2026-05-05","src":"memory/agent-memory/pam-runtime.md"}' + ].join("\n") + "\n", + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "graph", "edges.jsonl"), + '{"f":"project:pam","r":"has-doc","t":"doc:runtime","st":"confirmed","c":"high","u":"2026-05-05","src":"README.md"}\n', + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "graph", "aliases.jsonl"), + '{"a":"PAM","id":"project:pam"}\n', + "utf8" + ); + fs.writeFileSync(path.join(root, "AGENTS.md"), "# Agents\n", "utf8"); + return root; +} + +function defaultConfig() { + return { + protectedPaths: ["AGENTS.md", "CLAUDE.md", "memory/agent-memory", "memory/sources"] + }; +} + +function recordProposal(root, args) { + const result = proposeEdit(root, defaultConfig(), args); + if (!result.ok) throw new Error(`could not seed proposal: ${result.error}`); + return result.proposalId; +} + +test("applyProposal applies a recorded replace op and archives the artifact", () => { + const root = makeWorkspace(); + const proposalId = recordProposal(root, { + path: "memory/knowledge-log.md", + rationale: "tighten", + diff: { + kind: "replace", + anchor: { headerLine: "## 2026-04-12 - Worker retries" }, + before: "## 2026-04-12 - Worker retries\n\nFive retries.\n", + after: "## 2026-04-12 - Worker retries\n\nFive retries per upstream timeout.\n" + } + }); + const result = applyProposal(root, defaultConfig(), { proposalId }); + assert.equal(result.ok, true); + assert.equal(result.status, "applied"); + const target = fs.readFileSync(path.join(root, "memory", "knowledge-log.md"), "utf8"); + assert.ok(target.includes("Five retries per upstream timeout.")); + const archived = JSON.parse(fs.readFileSync(path.join(root, result.proposalArchivedAs), "utf8")); + assert.equal(archived.status, "applied"); + assert.ok(archived.appliedAt); + assert.equal(fs.existsSync(path.join(root, "memory", "maintenance", "proposals", `${proposalId}.json`)), false); +}); + +test("applyProposal rejects when the target has drifted since recording", () => { + const root = makeWorkspace(); + const proposalId = recordProposal(root, { + path: "memory/knowledge-log.md", + rationale: "tighten", + diff: { + kind: "replace", + anchor: { headerLine: "## 2026-04-12 - Worker retries" }, + before: "## 2026-04-12 - Worker retries\n\nFive retries.\n", + after: "## 2026-04-12 - Worker retries\n\nFive retries per upstream timeout.\n" + } + }); + fs.writeFileSync( + path.join(root, "memory", "knowledge-log.md"), + "# Knowledge Log\n\n## 2026-04-12 - Worker retries\n\nTotally different content.\n", + "utf8" + ); + const result = applyProposal(root, defaultConfig(), { proposalId }); + assert.equal(result.ok, false); + assert.match(result.error, /does not match|not found/i); + assert.ok(fs.existsSync(path.join(root, "memory", "maintenance", "proposals", `${proposalId}.json`))); +}); + +test("applyProposal rejects a hand-crafted proposal that targets a protected path", () => { + const root = makeWorkspace(); + const proposalsDir = path.join(root, "memory", "maintenance", "proposals"); + fs.mkdirSync(proposalsDir, { recursive: true }); + const proposalId = "hand-crafted-1"; + const record = { + proposalId, + createdAt: new Date().toISOString(), + source: "manual", + targetPath: "AGENTS.md", + diff: { kind: "replace", before: "# Agents\n", after: "# Hacked\n" }, + rationale: "should be rejected", + findingIds: [] + }; + fs.writeFileSync(path.join(proposalsDir, `${proposalId}.json`), JSON.stringify(record), "utf8"); + const result = applyProposal(root, defaultConfig(), { proposalId }); + assert.equal(result.ok, false); + assert.match(result.error, /protected/i); + assert.equal(fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"), "# Agents\n"); +}); + +test("applyProposal rejects unknown proposal ids", () => { + const root = makeWorkspace(); + const result = applyProposal(root, defaultConfig(), { proposalId: "does-not-exist" }); + assert.equal(result.ok, false); + assert.match(result.error, /not found/i); +}); + +test("applyProposal rejects proposalId containing path separators", () => { + const root = makeWorkspace(); + const result = applyProposal(root, defaultConfig(), { proposalId: "../sneaky" }); + assert.equal(result.ok, false); + assert.match(result.error, /path separator|not found/i); +}); + +test("applyProposal applies a unified-diff proposal", () => { + const root = makeWorkspace(); + const patch = [ + "--- a/memory/knowledge-log.md", + "+++ b/memory/knowledge-log.md", + "@@ -3,3 +3,3 @@", + " ## 2026-04-12 - Worker retries", + " ", + "-Five retries.", + "+Five retries per upstream timeout." + ].join("\n") + "\n"; + const proposalId = recordProposal(root, { + path: "memory/knowledge-log.md", + rationale: "tighten", + diff: { kind: "unified-diff", patch } + }); + const result = applyProposal(root, defaultConfig(), { proposalId }); + assert.equal(result.ok, true); + const target = fs.readFileSync(path.join(root, "memory", "knowledge-log.md"), "utf8"); + assert.ok(target.includes("Five retries per upstream timeout.")); +}); diff --git a/tools/test-memory-audit.mjs b/tools/test-memory-audit.mjs new file mode 100644 index 0000000..9cb13f3 --- /dev/null +++ b/tools/test-memory-audit.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { runAudit } from "./lib/memory-audit.mjs"; + +function makeWorkspace() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pam-audit-test-")); + fs.mkdirSync(path.join(root, "memory", "graph"), { recursive: true }); + fs.mkdirSync(path.join(root, "memory", "agent-memory"), { recursive: true }); + fs.mkdirSync(path.join(root, "memory", "sources"), { recursive: true }); + + fs.writeFileSync( + path.join(root, "memory", "graph", "nodes.jsonl"), + [ + '{"id":"project:pam","k":"project","n":"PAM","d":"Memory toolkit.","st":"confirmed","c":"high","u":"2026-05-05","src":"README.md"}', + '{"id":"doc:runtime","k":"doc","n":"Runtime","d":"Runtime guide.","st":"confirmed","c":"high","u":"2026-05-05","src":"memory/agent-memory/pam-runtime.md"}' + ].join("\n") + "\n", + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "graph", "edges.jsonl"), + '{"f":"project:pam","r":"has-doc","t":"doc:runtime","st":"confirmed","c":"high","u":"2026-05-05","src":"README.md"}\n', + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "graph", "aliases.jsonl"), + '{"a":"PAM","id":"project:pam"}\n', + "utf8" + ); + fs.writeFileSync(path.join(root, "memory", "agent-memory", "pam-runtime.md"), "# Runtime\n\nRuntime guide.\n", "utf8"); + fs.writeFileSync(path.join(root, "memory", "index.md"), "# Index\n\n- [runtime](agent-memory/pam-runtime.md)\n", "utf8"); + fs.writeFileSync(path.join(root, "README.md"), "# Project\n", "utf8"); + return root; +} + +function defaultConfig() { + return { + retentionDays: 90, + archiveRoot: "memory/archive", + summariesRoot: "memory/summaries", + maintenanceRoot: "memory/maintenance", + managedLogs: [ + { source: "memory/conversation-log.md", archiveKey: "conversation-log", activeEntryLimit: 80 }, + { source: "memory/knowledge-log.md", archiveKey: "knowledge-log", activeEntryLimit: 120 } + ], + protectedPaths: ["AGENTS.md", "CLAUDE.md", "memory/agent-memory", "memory/sources"] + }; +} + +test("audit returns no findings on a clean workspace", () => { + const root = makeWorkspace(); + const result = runAudit(root, defaultConfig(), { checks: ["dangling-alias", "graph-source-link-rot", "oversized-digest"] }); + assert.equal(result.findings.length, 0); +}); + +test("audit flags duplicate-knowledge-entry on near-identical sections", () => { + const root = makeWorkspace(); + const logPath = path.join(root, "memory", "knowledge-log.md"); + fs.writeFileSync( + logPath, + [ + "# Knowledge Log", + "", + "## 2026-04-12 - Worker retries", + "", + "We confirmed that the worker uses five retries per upstream timeout failure.", + "", + "## 2026-04-13 - Worker retries", + "", + "We confirmed that the worker uses five retries per upstream timeout failure.", + "" + ].join("\n"), + "utf8" + ); + const result = runAudit(root, defaultConfig(), { checks: ["duplicate-knowledge-entry"] }); + assert.equal(result.findings.length, 1); + assert.equal(result.findings[0].check, "duplicate-knowledge-entry"); + assert.equal(result.findings[0].severity, "warning"); +}); + +test("audit flags link-rot for missing markdown link targets", () => { + const root = makeWorkspace(); + fs.writeFileSync( + path.join(root, "memory", "index.md"), + "# Index\n\n- [missing](does-not-exist.md)\n", + "utf8" + ); + const result = runAudit(root, defaultConfig(), { checks: ["link-rot"] }); + assert.ok(result.findings.some((f) => f.check === "link-rot")); +}); + +test("audit flags graph-source-link-rot for missing src files", () => { + const root = makeWorkspace(); + fs.appendFileSync( + path.join(root, "memory", "graph", "nodes.jsonl"), + '{"id":"doc:ghost","k":"doc","n":"Ghost","d":"x","st":"confirmed","c":"low","u":"2026-05-05","src":"memory/does-not-exist.md"}\n', + "utf8" + ); + const result = runAudit(root, defaultConfig(), { checks: ["graph-source-link-rot"] }); + assert.ok(result.findings.some((f) => f.check === "graph-source-link-rot")); +}); + +test("audit flags rotation-candidate when activeEntryLimit exceeded", () => { + const root = makeWorkspace(); + const config = defaultConfig(); + config.managedLogs[0].activeEntryLimit = 2; + const lines = ["# Conversation Log", ""]; + for (let i = 0; i < 4; i += 1) { + const day = String(i + 1).padStart(2, "0"); + lines.push(`## 2026-05-${day} - Session ${i}`, "", `Notes from session ${i}.`, ""); + } + fs.writeFileSync(path.join(root, "memory", "conversation-log.md"), lines.join("\n"), "utf8"); + const result = runAudit(root, config, { checks: ["rotation-candidate"] }); + assert.ok(result.findings.some((f) => f.check === "rotation-candidate")); +}); + +test("audit flags oversized-digest when within 90-100% of cap", () => { + const root = makeWorkspace(); + const longDigest = "x".repeat(170); + fs.appendFileSync( + path.join(root, "memory", "graph", "nodes.jsonl"), + `{"id":"doc:long","k":"doc","n":"Long","d":"${longDigest}","st":"confirmed","c":"low","u":"2026-05-05","src":"README.md"}\n`, + "utf8" + ); + const result = runAudit(root, defaultConfig(), { checks: ["oversized-digest"] }); + assert.ok(result.findings.some((f) => f.check === "oversized-digest")); +}); + +test("audit flags orphan-source for files in memory/sources/ not referenced in graph", () => { + const root = makeWorkspace(); + fs.writeFileSync(path.join(root, "memory", "sources", "leftover.md"), "stray file\n", "utf8"); + const result = runAudit(root, defaultConfig(), { checks: ["orphan-source"] }); + assert.ok(result.findings.some((f) => f.check === "orphan-source")); +}); + +test("audit groups findings into summary buckets", () => { + const root = makeWorkspace(); + fs.writeFileSync(path.join(root, "memory", "sources", "leftover.md"), "stray\n", "utf8"); + const result = runAudit(root, defaultConfig()); + assert.ok(result.summary.byCheck); + assert.ok(result.summary.bySeverity); + assert.equal(typeof result.summary.bySeverity.info, "number"); +}); diff --git a/tools/test-memory-proposals.mjs b/tools/test-memory-proposals.mjs new file mode 100644 index 0000000..32629e8 --- /dev/null +++ b/tools/test-memory-proposals.mjs @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { applyUnifiedDiff, parseUnifiedDiff, proposeEdit } from "./lib/memory-proposals.mjs"; + +function makeWorkspace() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pam-proposals-test-")); + fs.mkdirSync(path.join(root, "memory", "graph"), { recursive: true }); + fs.mkdirSync(path.join(root, "memory", "agent-memory"), { recursive: true }); + fs.mkdirSync(path.join(root, "memory", "sources"), { recursive: true }); + + fs.writeFileSync( + path.join(root, "memory", "graph", "nodes.jsonl"), + [ + '{"id":"project:pam","k":"project","n":"PAM","d":"Memory toolkit.","st":"confirmed","c":"high","u":"2026-05-05","src":"README.md"}', + '{"id":"doc:runtime","k":"doc","n":"Runtime","d":"Runtime guide.","st":"confirmed","c":"high","u":"2026-05-05","src":"memory/agent-memory/pam-runtime.md"}' + ].join("\n") + "\n", + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "graph", "edges.jsonl"), + '{"f":"project:pam","r":"has-doc","t":"doc:runtime","st":"confirmed","c":"high","u":"2026-05-05","src":"README.md"}\n', + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "graph", "aliases.jsonl"), + '{"a":"PAM","id":"project:pam"}\n', + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "knowledge-log.md"), + "# Knowledge Log\n\n## 2026-04-12 - Worker retries\n\nFive retries.\n", + "utf8" + ); + fs.writeFileSync(path.join(root, "AGENTS.md"), "# Agents\n", "utf8"); + fs.writeFileSync(path.join(root, "memory", "agent-memory", "pam.md"), "# PAM\n", "utf8"); + fs.writeFileSync(path.join(root, "README.md"), "# Project\n", "utf8"); + return root; +} + +function defaultConfig() { + return { + protectedPaths: ["AGENTS.md", "CLAUDE.md", "memory/agent-memory", "memory/sources"] + }; +} + +test("proposeEdit records a valid replace op against a managed log", () => { + const root = makeWorkspace(); + const result = proposeEdit(root, defaultConfig(), { + path: "memory/knowledge-log.md", + rationale: "Tighten phrasing.", + findingIds: ["audit-2026-05-21-0001-duplicate-knowledge-entry"], + diff: { + kind: "replace", + anchor: { headerLine: "## 2026-04-12 - Worker retries" }, + before: "## 2026-04-12 - Worker retries\n\nFive retries.\n", + after: "## 2026-04-12 - Worker retries\n\nFive retries per upstream timeout.\n" + } + }); + assert.equal(result.ok, true); + assert.equal(result.status, "recorded"); + assert.ok(fs.existsSync(path.join(root, result.proposalPath))); + const original = fs.readFileSync(path.join(root, "memory", "knowledge-log.md"), "utf8"); + assert.ok(original.includes("Five retries.\n"), "original file is not mutated"); +}); + +test("proposeEdit rejects edits to AGENTS.md (protected)", () => { + const root = makeWorkspace(); + const result = proposeEdit(root, defaultConfig(), { + path: "AGENTS.md", + rationale: "should be rejected", + diff: { kind: "replace", before: "# Agents\n", after: "# Different\n" } + }); + assert.equal(result.ok, false); + assert.match(result.error, /protected/i); +}); + +test("proposeEdit rejects edits inside memory/agent-memory/", () => { + const root = makeWorkspace(); + const result = proposeEdit(root, defaultConfig(), { + path: "memory/agent-memory/pam.md", + rationale: "should be rejected", + diff: { kind: "replace", before: "# PAM\n", after: "# Different\n" } + }); + assert.equal(result.ok, false); + assert.match(result.error, /protected/i); +}); + +test("proposeEdit rejects path escapes outside workspace", () => { + const root = makeWorkspace(); + const result = proposeEdit(root, defaultConfig(), { + path: "../etc/passwd", + rationale: "no", + diff: { kind: "replace", before: "x", after: "y" } + }); + assert.equal(result.ok, false); + assert.match(result.error, /escape|protected/i); +}); + +test("proposeEdit rejects when before content does not match", () => { + const root = makeWorkspace(); + const result = proposeEdit(root, defaultConfig(), { + path: "memory/knowledge-log.md", + rationale: "stale before", + diff: { + kind: "replace", + anchor: { headerLine: "## 2026-04-12 - Worker retries" }, + before: "wrong content", + after: "right content" + } + }); + assert.equal(result.ok, false); + assert.match(result.error, /not found|does not match/i); +}); + +test("proposeEdit rejects JSONL edits that produce dangling edges", () => { + const root = makeWorkspace(); + const original = fs.readFileSync(path.join(root, "memory", "graph", "edges.jsonl"), "utf8"); + const broken = original.replace("doc:runtime", "doc:missing"); + const result = proposeEdit(root, defaultConfig(), { + path: "memory/graph/edges.jsonl", + rationale: "stale ref", + diff: { kind: "replace", before: original, after: broken } + }); + assert.equal(result.ok, false); + assert.match(result.error, /Dangling edge|validation/i); +}); + +test("proposeEdit accepts a well-formed unified diff", () => { + const root = makeWorkspace(); + const patch = [ + "--- a/memory/knowledge-log.md", + "+++ b/memory/knowledge-log.md", + "@@ -3,3 +3,3 @@", + " ## 2026-04-12 - Worker retries", + " ", + "-Five retries.", + "+Five retries per upstream timeout." + ].join("\n") + "\n"; + const result = proposeEdit(root, defaultConfig(), { + path: "memory/knowledge-log.md", + rationale: "tighten", + diff: { kind: "unified-diff", patch } + }); + assert.equal(result.ok, true); +}); + +test("proposeEdit rejects diffs over the size cap", () => { + const root = makeWorkspace(); + const huge = "x".repeat(70_000); + const result = proposeEdit(root, defaultConfig(), { + path: "memory/knowledge-log.md", + rationale: "oversized", + diff: { kind: "replace", before: "Five retries.", after: huge } + }); + assert.equal(result.ok, false); + assert.match(result.error, /exceeds/i); +}); + +test("applyUnifiedDiff handles multi-hunk patches with line count changes", () => { + const content = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\n"; + const patch = [ + "--- a/file.txt", + "+++ b/file.txt", + "@@ -1,4 +1,3 @@", + " line1", + "-line2", + " line3", + " line4", + "@@ -5,4 +4,3 @@", + " line5", + "-line6", + " line7", + " line8" + ].join("\n") + "\n"; + + const parsed = parseUnifiedDiff(patch); + assert.equal(parsed.ok, true); + const applied = applyUnifiedDiff(content, parsed); + assert.equal(applied.ok, true); + const expected = "line1\nline3\nline4\nline5\nline7\nline8\n"; + assert.equal(applied.next, expected); +}); diff --git a/tools/test-pam-mcp-server.mjs b/tools/test-pam-mcp-server.mjs new file mode 100644 index 0000000..5f1dd6c --- /dev/null +++ b/tools/test-pam-mcp-server.mjs @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const SERVER_PATH = path.join(__dirname, "pam-mcp-server.mjs"); + +function makeWorkspace() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pam-mcp-e2e-")); + fs.mkdirSync(path.join(root, "memory", "graph"), { recursive: true }); + fs.mkdirSync(path.join(root, "memory", "agent-memory"), { recursive: true }); + fs.mkdirSync(path.join(root, "tools"), { recursive: true }); + + fs.writeFileSync( + path.join(root, "memory", "graph", "nodes.jsonl"), + [ + '{"id":"project:pam","k":"project","n":"PAM","d":"Memory toolkit.","st":"confirmed","c":"high","u":"2026-05-05","src":"README.md"}', + '{"id":"doc:runtime","k":"doc","n":"Runtime","d":"Runtime guide.","st":"confirmed","c":"high","u":"2026-05-05","src":"memory/agent-memory/pam-runtime.md"}' + ].join("\n") + "\n", + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "graph", "edges.jsonl"), + '{"f":"project:pam","r":"has-doc","t":"doc:runtime","st":"confirmed","c":"high","u":"2026-05-05","src":"README.md"}\n', + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "graph", "aliases.jsonl"), + '{"a":"PAM","id":"project:pam"}\n', + "utf8" + ); + fs.writeFileSync(path.join(root, "README.md"), "# Project\n", "utf8"); + fs.writeFileSync(path.join(root, "AGENTS.md"), "# Agents\n", "utf8"); + fs.writeFileSync( + path.join(root, "memory", "agent-memory", "pam-runtime.md"), + "# Runtime\n", + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "conversation-log.md"), + "# Conversation Log\n\nIntro.\n", + "utf8" + ); + fs.writeFileSync( + path.join(root, "memory", "pam.version.json"), + JSON.stringify({ pamVersion: "0.4.0", memoryFormat: "graph-v1" }, null, 2) + "\n", + "utf8" + ); + fs.writeFileSync( + path.join(root, "tools", "memory-maintenance.config.json"), + JSON.stringify({ + retentionDays: 90, + archiveRoot: "memory/archive", + summariesRoot: "memory/summaries", + maintenanceRoot: "memory/maintenance", + graph: { enabled: true, root: "memory/graph", versionPath: "memory/pam.version.json", catalogPath: "memory/graph/catalog.json" }, + synthesis: { enabled: false, provider: "none", command: "", args: [], stdin: "none", examples: {} }, + workspace: { name: "test", description: "test", indexPath: "memory/index.md", runtimePath: "memory/agent-memory/pam-runtime.md", llmWikiPath: "memory/agent-memory/llm-wiki.md", policyPaths: [] }, + managedLogs: [ + { source: "memory/conversation-log.md", archiveKey: "conversation-log", activeEntryLimit: 80 } + ], + readContextPaths: [], + protectedPaths: ["AGENTS.md", "CLAUDE.md", "memory/agent-memory", "memory/sources"] + }, null, 2) + "\n", + "utf8" + ); + return root; +} + +function driveServer(workspaceRoot, requests, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [SERVER_PATH, "--workspace", workspaceRoot], { + stdio: ["pipe", "pipe", "pipe"] + }); + const responses = []; + let buffer = ""; + const expected = requests.filter((r) => r.id !== undefined).length; + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`timeout after ${timeoutMs}ms; received ${responses.length}/${expected}`)); + }, timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + buffer += chunk; + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).replace(/\r$/, ""); + buffer = buffer.slice(newlineIndex + 1); + if (line.trim() !== "") { + try { + responses.push(JSON.parse(line)); + } catch (error) { + clearTimeout(timer); + child.kill(); + reject(new Error(`invalid JSON from server: ${line}`)); + return; + } + if (responses.length === expected) { + clearTimeout(timer); + child.stdin.end(); + } + } + newlineIndex = buffer.indexOf("\n"); + } + }); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", () => { + if (responses.length < expected) { + reject(new Error(`server exited early; stderr: ${stderr}`)); + return; + } + resolve(responses); + }); + for (const request of requests) { + child.stdin.write(`${JSON.stringify(request)}\n`); + } + }); +} + +test("server responds to initialize + tools/list", async () => { + const root = makeWorkspace(); + const responses = await driveServer(root, [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { jsonrpc: "2.0", id: 2, method: "tools/list" } + ]); + assert.equal(responses.length, 2); + assert.equal(responses[0].result.serverInfo.name, "pam"); + const toolNames = responses[1].result.tools.map((t) => t.name); + assert.ok(toolNames.includes("memory_audit")); + assert.ok(toolNames.includes("memory_propose_edit")); + assert.ok(toolNames.includes("graph_validate")); +}); + +test("server runs graph_validate over the workspace", async () => { + const root = makeWorkspace(); + const responses = await driveServer(root, [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "graph_validate", arguments: {} } } + ]); + const callResponse = responses[1]; + assert.ok(callResponse.result); + const payload = JSON.parse(callResponse.result.content[0].text); + assert.equal(payload.ok, true); +}); + +test("server appends a dated section via memory_append", async () => { + const root = makeWorkspace(); + const responses = await driveServer(root, [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "memory_append", + arguments: { + log: "conversation-log", + date: "2026-05-21", + headerTitle: "End to end test", + body: "Notes from the e2e test." + } + } + } + ]); + const payload = JSON.parse(responses[1].result.content[0].text); + assert.equal(payload.status, "appended"); + const onDisk = fs.readFileSync(path.join(root, "memory", "conversation-log.md"), "utf8"); + assert.ok(onDisk.includes("## 2026-05-21 - End to end test")); +}); + +test("server rejects memory_propose_edit on protected path", async () => { + const root = makeWorkspace(); + const responses = await driveServer(root, [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "memory_propose_edit", + arguments: { + path: "AGENTS.md", + rationale: "should be rejected", + diff: { kind: "replace", before: "# Agents\n", after: "# Hacked\n" } + } + } + } + ]); + const callResponse = responses[1]; + const payload = JSON.parse(callResponse.result.content[0].text); + assert.equal(payload.status, "rejected"); + assert.match(payload.error, /protected/i); +});