e
+ Graph health and size from memory/graph/catalog.json.
+ โ
valid ยท โ ๏ธ warning ยท โ invalid ยท ยท unknown
+ Now: ${glyph} ${NODE_COUNT}n/${EDGE_COUNT}e (${GRAPH_STATUS})
+
+๐
+ Pending curator proposals in memory/maintenance/proposals/ (excludes *.applied.json).
+ Dim when 0, yellow when > 0 - a review is waiting.
+ Now: ${PROPOSAL_COUNT}
+
+๐ค
+ 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}
+
+โ๏ธ
+ 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:
+ memoryFormat:
+
+Graph
+ status:
+ nodes/edges: /
+ last validated:
+
+Logs (managed)
+ -
+ ...
+
+Proposals
+ pending:
+
+
+Sessions
+ active counters:
+```
+
+## 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/.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 `.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/.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
+`/pam-layer/statusline/pam-statusline.sh` and wires it into
+`/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 /.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/.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
+// /.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 Override scope; install into /.claude/ (or 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 - 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/.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 .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 - : ${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);
+});