# memory-edge Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a `memory-edge` MCP tool to the memory plugin that creates or deletes a typed directed edge between two pre-existing Neo4j nodes that belong to the same account.

**Architecture:** New `tools/memory-edge.ts` exports a single `memoryEdge()` function. It opens one Neo4j session, runs two ownership-check queries (one per endpoint), then runs a MERGE or DELETE query with the relationship type interpolated (backtick-escaped) into Cypher. The tool is registered with `server.tool` in `index.ts` and listed in `PLUGIN.md` and three specialist agent templates.

**Tech Stack:** TypeScript, Neo4j driver (`getSession` from `lib/neo4j.js`), Vitest for unit tests.

---

### Task 1: Create `tools/memory-edge.ts` with core logic

**Files:**
- Create: `maxy-code/platform/plugins/memory/mcp/src/tools/memory-edge.ts`

- [ ] **Step 1: Write the failing test** (see Task 2 — implement this file first so tests have something to import, then add tests)

- [ ] **Step 2: Create the file**

```typescript
import { getSession } from "../lib/neo4j.js";

export interface MemoryEdgeParams {
  action: "create" | "delete";
  fromId: string;
  toId: string;
  relationshipType: string;
  properties?: Record<string, unknown>;
  accountId: string;
}

export type MemoryEdgeResult =
  | { created: boolean }
  | { deleted: boolean }
  | { error: "foreign-node"; elementId: string }
  | { error: "reserved-type"; type: string };

export async function memoryEdge(params: MemoryEdgeParams): Promise<MemoryEdgeResult> {
  const { action, fromId, toId, accountId } = params;

  const rawType = params.relationshipType.toUpperCase().replace(/`/g, "");

  if (rawType.startsWith("_")) {
    process.stderr.write(
      `[memory-edge] action=${action} from=${fromId.slice(0, 8)} to=${toId.slice(0, 8)} type=${rawType} accountId=${accountId?.slice(0, 8) ?? "none"} result=rejected reason=reserved-type type=${rawType}\n`,
    );
    return { error: "reserved-type", type: rawType };
  }

  const session = getSession();
  try {
    const fromCheck = await session.run(
      `MATCH (n) WHERE elementId(n) = $nodeId AND n.accountId = $accountId RETURN elementId(n) AS id`,
      { nodeId: fromId, accountId },
    );
    if (fromCheck.records.length === 0) {
      process.stderr.write(
        `[memory-edge] action=${action} from=${fromId.slice(0, 8)} to=${toId.slice(0, 8)} type=${rawType} accountId=${accountId?.slice(0, 8) ?? "none"} result=rejected reason=foreign-node elementId=${fromId}\n`,
      );
      return { error: "foreign-node", elementId: fromId };
    }

    const toCheck = await session.run(
      `MATCH (n) WHERE elementId(n) = $nodeId AND n.accountId = $accountId RETURN elementId(n) AS id`,
      { nodeId: toId, accountId },
    );
    if (toCheck.records.length === 0) {
      process.stderr.write(
        `[memory-edge] action=${action} from=${fromId.slice(0, 8)} to=${toId.slice(0, 8)} type=${rawType} accountId=${accountId?.slice(0, 8) ?? "none"} result=rejected reason=foreign-node elementId=${toId}\n`,
      );
      return { error: "foreign-node", elementId: toId };
    }

    if (action === "create") {
      const props = params.properties ?? {};
      const q = `
        MATCH (a) WHERE elementId(a) = $fromId
        MATCH (b) WHERE elementId(b) = $toId
        MERGE (a)-[r:\`${rawType}\`]->(b)
        ON CREATE SET r += $props, r.createdAt = datetime()
        RETURN r
      `;
      const result = await session.run(q, { fromId, toId, props });
      const created = result.summary.counters.updates().relationshipsCreated > 0;
      process.stderr.write(
        `[memory-edge] action=create from=${fromId.slice(0, 8)} to=${toId.slice(0, 8)} type=${rawType} accountId=${accountId?.slice(0, 8) ?? "none"} result=${created ? "created" : "idempotent"}\n`,
      );
      return { created };
    } else {
      const q = `
        MATCH (a)-[r:\`${rawType}\`]->(b)
        WHERE elementId(a) = $fromId AND elementId(b) = $toId
        DELETE r
      `;
      const result = await session.run(q, { fromId, toId });
      const deleted = result.summary.counters.updates().relationshipsDeleted > 0;
      process.stderr.write(
        `[memory-edge] action=delete from=${fromId.slice(0, 8)} to=${toId.slice(0, 8)} type=${rawType} accountId=${accountId?.slice(0, 8) ?? "none"} result=${deleted ? "deleted" : "absent"}\n`,
      );
      return { deleted };
    }
  } finally {
    await session.close();
  }
}
```

- [ ] **Step 3: TypeScript check** (run after Task 2 tests pass)

```bash
cd maxy-code/platform/plugins/memory/mcp && npx tsc --noEmit 2>&1 | head -20
```
Expected: no errors for this file (other pre-existing errors are acceptable if present before this change).

---

### Task 2: Unit tests for `memory-edge`

**Files:**
- Create: `maxy-code/platform/plugins/memory/mcp/src/tools/__tests__/memory-edge.test.ts`

- [ ] **Step 1: Write the test file**

```typescript
// @vitest-environment node
import { describe, it, expect, beforeEach, vi } from "vitest";

// Stage session run results in call order. Each entry in the array is the
// return value for one session.run() call.
function makeSession(runResults: Array<{ records: unknown[]; summary?: unknown }>) {
  let i = 0;
  return {
    run: vi.fn().mockImplementation(async () => {
      const res = runResults[i++] ?? { records: [] };
      return res;
    }),
    close: vi.fn().mockResolvedValue(undefined),
  };
}

function ownerOk(id: string) {
  return { records: [{ get: (_: string) => id }] };
}

function ownerFail() {
  return { records: [] };
}

function mergeResult(created: number) {
  return {
    records: [{ get: (_: string) => ({}) }],
    summary: { counters: { updates: () => ({ relationshipsCreated: created, relationshipsDeleted: 0 }) } },
  };
}

function deleteResult(deleted: number) {
  return {
    records: [],
    summary: { counters: { updates: () => ({ relationshipsCreated: 0, relationshipsDeleted: deleted }) } },
  };
}

const ACCOUNT_ID = "acct-1";
const FROM_ID = "4:eid:node-a";
const TO_ID = "4:eid:node-b";

let mockSession: ReturnType<typeof makeSession>;

vi.mock("../../lib/neo4j.js", () => ({
  getSession: () => mockSession,
}));

import { memoryEdge } from "../memory-edge.js";

beforeEach(() => {
  vi.clearAllMocks();
});

// Suppress stderr noise during tests
beforeEach(() => {
  vi.spyOn(process.stderr, "write").mockImplementation(() => true);
});

describe("memory-edge — create", () => {
  it("creates edge on first call — returned created: true", async () => {
    mockSession = makeSession([ownerOk(FROM_ID), ownerOk(TO_ID), mergeResult(1)]);
    const result = await memoryEdge({
      action: "create",
      fromId: FROM_ID,
      toId: TO_ID,
      relationshipType: "RELATES_TO",
      accountId: ACCOUNT_ID,
    });
    expect(result).toEqual({ created: true });
    expect(mockSession.run).toHaveBeenCalledTimes(3);
  });

  it("idempotent on second call — returned created: false", async () => {
    mockSession = makeSession([ownerOk(FROM_ID), ownerOk(TO_ID), mergeResult(0)]);
    const result = await memoryEdge({
      action: "create",
      fromId: FROM_ID,
      toId: TO_ID,
      relationshipType: "RELATES_TO",
      accountId: ACCOUNT_ID,
    });
    expect(result).toEqual({ created: false });
  });

  it("uppercases the relationship type before the Cypher call", async () => {
    mockSession = makeSession([ownerOk(FROM_ID), ownerOk(TO_ID), mergeResult(1)]);
    await memoryEdge({
      action: "create",
      fromId: FROM_ID,
      toId: TO_ID,
      relationshipType: "relates_to",
      accountId: ACCOUNT_ID,
    });
    const mergeCall = mockSession.run.mock.calls[2][0] as string;
    expect(mergeCall).toContain("`RELATES_TO`");
  });
});

describe("memory-edge — delete", () => {
  it("deletes a present edge — returned deleted: true", async () => {
    mockSession = makeSession([ownerOk(FROM_ID), ownerOk(TO_ID), deleteResult(1)]);
    const result = await memoryEdge({
      action: "delete",
      fromId: FROM_ID,
      toId: TO_ID,
      relationshipType: "RELATES_TO",
      accountId: ACCOUNT_ID,
    });
    expect(result).toEqual({ deleted: true });
  });

  it("absent edge is a no-op — returned deleted: false", async () => {
    mockSession = makeSession([ownerOk(FROM_ID), ownerOk(TO_ID), deleteResult(0)]);
    const result = await memoryEdge({
      action: "delete",
      fromId: FROM_ID,
      toId: TO_ID,
      relationshipType: "RELATES_TO",
      accountId: ACCOUNT_ID,
    });
    expect(result).toEqual({ deleted: false });
  });
});

describe("memory-edge — account ownership", () => {
  it("rejects when fromId does not belong to the account — foreign-node error, no mutation", async () => {
    mockSession = makeSession([ownerFail()]);
    const result = await memoryEdge({
      action: "create",
      fromId: FROM_ID,
      toId: TO_ID,
      relationshipType: "RELATES_TO",
      accountId: ACCOUNT_ID,
    });
    expect(result).toEqual({ error: "foreign-node", elementId: FROM_ID });
    expect(mockSession.run).toHaveBeenCalledTimes(1);
  });

  it("rejects when toId does not belong to the account — foreign-node error, no mutation", async () => {
    mockSession = makeSession([ownerOk(FROM_ID), ownerFail()]);
    const result = await memoryEdge({
      action: "create",
      fromId: FROM_ID,
      toId: TO_ID,
      relationshipType: "RELATES_TO",
      accountId: ACCOUNT_ID,
    });
    expect(result).toEqual({ error: "foreign-node", elementId: TO_ID });
    expect(mockSession.run).toHaveBeenCalledTimes(2);
  });
});

describe("memory-edge — reserved type guard", () => {
  it("rejects underscore-prefixed type before opening a session", async () => {
    mockSession = makeSession([]);
    const result = await memoryEdge({
      action: "create",
      fromId: FROM_ID,
      toId: TO_ID,
      relationshipType: "_INTERNAL",
      accountId: ACCOUNT_ID,
    });
    expect(result).toEqual({ error: "reserved-type", type: "_INTERNAL" });
    expect(mockSession.run).not.toHaveBeenCalled();
  });

  it("uppercase-coerces before the reserved check — _soft_delete is also rejected", async () => {
    mockSession = makeSession([]);
    const result = await memoryEdge({
      action: "create",
      fromId: FROM_ID,
      toId: TO_ID,
      relationshipType: "_soft_delete",
      accountId: ACCOUNT_ID,
    });
    expect(result).toEqual({ error: "reserved-type", type: "_SOFT_DELETE" });
  });
});
```

- [ ] **Step 2: Run the tests — expect PASS**

```bash
cd maxy-code/platform/plugins/memory/mcp && npx vitest run src/tools/__tests__/memory-edge.test.ts 2>&1
```

Expected: all tests pass (the implementation in Task 1 is already complete).

- [ ] **Step 3: Run the full test suite — no regressions**

```bash
cd maxy-code/platform/plugins/memory/mcp && npx vitest run 2>&1 | tail -20
```

Expected: same pass count as before this sprint (pre-existing failures are acceptable if they existed before).

- [ ] **Step 4: Commit**

```bash
cd maxy-code/platform/plugins/memory/mcp && git add src/tools/memory-edge.ts src/tools/__tests__/memory-edge.test.ts
git commit -m "$(cat <<'EOF'
feat: add memory-edge tool — create/delete typed edges between existing nodes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Session: 3104d4b4-d03e-4339-b65e-6014223c069e
EOF
)"
```

---

### Task 3: Register in `index.ts`

**Files:**
- Modify: `maxy-code/platform/plugins/memory/mcp/src/index.ts`

- [ ] **Step 1: Add the import** after the `memoryUpdate` import line (around line 36)

Find:
```typescript
import { memoryUpdate } from "./tools/memory-update.js";
```

Add immediately after:
```typescript
import { memoryEdge } from "./tools/memory-edge.js";
```

- [ ] **Step 2: Add the `server.tool` registration** after the closing brace of the `memory-update` block (after line 1111 — search for the block ending with `"Update failed:"`)

Find this exact closing sequence of the memory-update registration:
```typescript
          isError: true,
        };
      }
    }
  );

  eagerTool(server,
    "session-compact-status",
```

Replace with:
```typescript
          isError: true,
        };
      }
    }
  );

  server.tool(
    "memory-edge",
    "Create or delete a typed directed edge between two nodes that already exist in the knowledge graph. Both nodes must belong to the same account. Use action='create' to MERGE the edge (idempotent — returns {created:false} if it already exists) or action='delete' to remove it (returns {deleted:false} if already absent). relationshipType is uppercase-coerced; types starting with underscore are reserved and rejected. Properties are stamped on the edge on CREATE only.",
    {
      action: z.enum(["create", "delete"]).describe("'create' MERGEs the edge (idempotent); 'delete' removes it (no-op if absent)."),
      fromId: z.string().describe("elementId of the source node (from memory-search results)."),
      toId: z.string().describe("elementId of the target node (from memory-search results)."),
      relationshipType: z.string().describe("Relationship type label — e.g. RELATES_TO, MENTIONS, WORKED_WITH. Uppercase-coerced. Underscore-prefixed types (_SOFT_DELETE, etc.) are reserved and rejected."),
      properties: z.record(z.string(), z.unknown()).optional().describe("Properties to set on the relationship on CREATE. Ignored on DELETE and on idempotent CREATE (MERGE-matched)."),
    },
    async ({ action, fromId, toId, relationshipType, properties }: {
      action: "create" | "delete";
      fromId: string;
      toId: string;
      relationshipType: string;
      properties?: Record<string, unknown>;
    }) => {
      if (!accountId) return refuseNoAccount("memory-edge");
      try {
        const result = await memoryEdge({
          action,
          fromId,
          toId,
          relationshipType,
          properties,
          accountId,
        });

        if ("error" in result) {
          return {
            content: [{ type: "text" as const, text: `memory-edge rejected: ${JSON.stringify(result)}` }],
            isError: true,
          };
        }

        const detail =
          "created" in result
            ? result.created
              ? "Edge created."
              : "Edge already exists (idempotent)."
            : result.deleted
              ? "Edge deleted."
              : "Edge was not present (no-op).";

        return { content: [{ type: "text" as const, text: detail }] };
      } catch (err) {
        return {
          content: [{ type: "text" as const, text: `memory-edge failed: ${err instanceof Error ? err.message : String(err)}` }],
          isError: true,
        };
      }
    }
  );

  eagerTool(server,
    "session-compact-status",
```

- [ ] **Step 3: TypeScript check**

```bash
cd maxy-code/platform/plugins/memory/mcp && npx tsc --noEmit 2>&1 | head -20
```

Expected: no new errors introduced by this change.

- [ ] **Step 4: Commit**

```bash
git add maxy-code/platform/plugins/memory/mcp/src/index.ts
git commit -m "$(cat <<'EOF'
feat: register memory-edge in memory MCP server index.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Session: 3104d4b4-d03e-4339-b65e-6014223c069e
EOF
)"
```

---

### Task 4: Update `PLUGIN.md` and `graph.md`

**Files:**
- Modify: `maxy-code/platform/plugins/memory/PLUGIN.md`
- Modify: `maxy-code/platform/plugins/docs/references/graph.md`

- [ ] **Step 1: Add tool entry to `PLUGIN.md`** (tool list, after the `memory-update` entry)

Find:
```yaml
  - name: memory-update
    publicAllowlist: false
    adminAllowlist: true
  - name: memory-update-by-name
```

Replace with:
```yaml
  - name: memory-update
    publicAllowlist: false
    adminAllowlist: true
  - name: memory-edge
    publicAllowlist: false
    adminAllowlist: true
  - name: memory-update-by-name
```

- [ ] **Step 2: Update `PLUGIN.md` description prose**

Find the `description:` field's first sentence fragment (around "memory-write, memory-update,"):
```
memory-write, memory-update, memory-update-by-name
```

Replace with:
```
memory-write, memory-update, memory-edge (create/delete typed directed edges between pre-existing nodes), memory-update-by-name
```

- [ ] **Step 3: Add edge-write section to `graph.md`** — append at end of file

```markdown

## Direct edge management

`memory-edge` creates or deletes a typed directed edge between two nodes that already exist in the graph. Both nodes must belong to the same account — mismatched or foreign nodes are rejected with a structured error before any mutation runs.

**Create:** MERGE is idempotent. First call returns `{created: true}`; a repeated call with the same endpoints and type returns `{created: false}`. Properties supplied on the call are stamped onto the relationship on CREATE only; a subsequent idempotent hit does not overwrite them.

**Delete:** If the edge is present it is deleted and `{deleted: true}` is returned. If absent, the call is a no-op and returns `{deleted: false}`.

`relationshipType` is uppercase-coerced. Types that start with an underscore (e.g. `_SOFT_DELETE`) are reserved for platform internals and are rejected.

Typical flow: call `memory-search` for each endpoint to retrieve their `elementId` values, then call `memory-edge action=create relationshipType=RELATES_TO fromId=<id> toId=<id>`. The new edge appears when you hop-expand either endpoint on the `/graph` canvas.
```

- [ ] **Step 4: Commit**

```bash
git add maxy-code/platform/plugins/memory/PLUGIN.md maxy-code/platform/plugins/docs/references/graph.md
git commit -m "$(cat <<'EOF'
docs: add memory-edge to PLUGIN.md tool list and graph.md edge-write section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Session: 3104d4b4-d03e-4339-b65e-6014223c069e
EOF
)"
```

---

### Task 5: Update specialist agent templates

**Files:**
- Modify: `maxy-code/platform/templates/specialists/agents/database-operator.md`
- Modify: `maxy-code/platform/templates/specialists/agents/archive-ingest-operator.md`
- Modify: `maxy-code/platform/templates/specialists/agents/librarian.md`

- [ ] **Step 1: Update `database-operator.md`**

Find:
```
tools: Read, mcp__plugin_memory_memory__memory-write, mcp__plugin_memory_memory__memory-update, mcp__plugin_memory_memory__memory-update-by-name,
```

Replace with:
```
tools: Read, mcp__plugin_memory_memory__memory-write, mcp__plugin_memory_memory__memory-update, mcp__plugin_memory_memory__memory-edge, mcp__plugin_memory_memory__memory-update-by-name,
```

(Add `mcp__plugin_memory_memory__memory-edge` after `memory-update` and before `memory-update-by-name`.)

- [ ] **Step 2: Update `archive-ingest-operator.md`**

Find:
```
tools: Read, mcp__plugin_memory_memory__memory-write, mcp__plugin_memory_memory__memory-update, mcp__plugin_memory_memory__memory-search,
```

Replace with:
```
tools: Read, mcp__plugin_memory_memory__memory-write, mcp__plugin_memory_memory__memory-update, mcp__plugin_memory_memory__memory-edge, mcp__plugin_memory_memory__memory-search,
```

- [ ] **Step 3: Update `librarian.md`**

Find:
```
tools: mcp__plugin_memory_memory__memory-ingest-extract, mcp__plugin_memory_memory__memory-ingest, mcp__plugin_memory_memory__memory-ingest-web, mcp__plugin_memory_memory__memory-archive-write, mcp__plugin_memory_memory__memory-write, mcp__plugin_memory_memory__memory-update, mcp__plugin_memory_memory__memory-search,
```

Replace with:
```
tools: mcp__plugin_memory_memory__memory-ingest-extract, mcp__plugin_memory_memory__memory-ingest, mcp__plugin_memory_memory__memory-ingest-web, mcp__plugin_memory_memory__memory-archive-write, mcp__plugin_memory_memory__memory-write, mcp__plugin_memory_memory__memory-update, mcp__plugin_memory_memory__memory-edge, mcp__plugin_memory_memory__memory-search,
```

- [ ] **Step 4: Commit**

```bash
git add \
  maxy-code/platform/templates/specialists/agents/database-operator.md \
  maxy-code/platform/templates/specialists/agents/archive-ingest-operator.md \
  maxy-code/platform/templates/specialists/agents/librarian.md
git commit -m "$(cat <<'EOF'
feat: add memory-edge to write-specialist agent templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Session: 3104d4b4-d03e-4339-b65e-6014223c069e
EOF
)"
```
