# v8.2 PR 18: Multi-Graph Memory (MAGMA/SYNAPSE) Implementation Plan

> Status note (2026-03-08): Active roadmap order, blockers, and contributor priority live in the [Engram Feature Roadmap](https://github.com/users/joshuaswarren/projects/1). This file is historical design context and may describe scope that is still unshipped.

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add three typed memory graphs (entity, time, causal) stored as JSONL files and a spreading-activation BFS traversal that can expand QMD seed results to discover related memories not surfaced by cosine similarity alone.

**Architecture:** New `src/graph.ts` module with a `GraphIndex` class. Edges are appended to `memory/state/graphs/{entity,time,causal}.jsonl` during write/extraction. The orchestrator calls graph-building hooks during `persistExtraction` and exposes `spreadingActivation()` for use by the graph recall planner (PR 19). All behavior is behind `multiGraphMemoryEnabled` (default `false`). Fail-open: any graph error is caught and logged, never rethrown; memory writes succeed regardless.

**Tech Stack:** TypeScript, Node.js `fs/promises`, `readline` (for streaming JSONL reads), existing `StorageManager` + `MemoryFrontmatter`, `node:test` + `node:assert/strict` for tests.

---

## Context for the implementer

- **Test runner:** `npm run test` (runs `tsx --test tests/*.test.ts`)
- **All tests:** `npm run preflight:quick`
- **Storage pattern:** look at `src/temporal-index.ts` for how a module reads/writes state JSON files under `<baseDir>/state/`. Mirror that pattern for JSONL.
- **Frontmatter parsing:** `src/storage.ts` exports `parseFrontmatter()` — use it when reading memory files for edge building.
- **MemoryFrontmatter type:** defined in `src/types.ts` line ~358; key fields: `entityRef?: string`, `created: string`. Chunks also have `parentId?: string`, `chunkIndex?: number`.
- **Config:** add to `src/config.ts` in `PluginConfig` interface AND in `parseConfig()` defaults AND in `openclaw.plugin.json` configSchema.
- **Orchestrator integration points:**
  - `persistExtraction()` at ~line 1760 — add entity + time + causal graph-building after memories are written
  - Constructor at ~line 187 — add `graphIndex` field
  - `runConsolidation()` is NOT required for graph (graphs build incrementally on each write)
- **Thread ID access:** `this.threading.processTurn()` returns `threadId` at ~line 1590. The thread ID is passed as context during extraction.
- **`indexPersistedMemory` pattern** (~line 2007): fires after each persisted memory — graph building should follow the same call-site pattern.

---

## Task 1: Create `src/graph.ts` with types, JSONL helpers, and edge-building

**Files:**
- Create: `src/graph.ts`

**Step 1: Create the file with types, path helpers, and low-level JSONL I/O**

```typescript
/**
 * Multi-Graph Memory (MAGMA/SYNAPSE-inspired, v8.2)
 *
 * Maintains three typed edge graphs:
 *   entity.jsonl  — memories sharing a named entity (entityRef)
 *   time.jsonl    — consecutive memories in the same thread/session
 *   causal.jsonl  — memories linked by causal language heuristics
 *
 * Stored under `<memoryDir>/state/graphs/`.
 * All writes are fail-open: errors are caught/logged, never thrown.
 */

import { mkdir, appendFile, readFile, open } from "node:fs/promises";
import * as path from "path";
import * as readline from "readline";
import * as fs from "fs";

export type GraphType = "entity" | "time" | "causal";

export interface GraphEdge {
  from: string;   // relative memory path (e.g. "facts/2026-02-22/abc.md")
  to: string;     // relative memory path
  type: GraphType;
  weight: number; // 1.0 default, decay applied during traversal
  label: string;  // entity name, threadId, or matched causal phrase
  ts: string;     // ISO timestamp of edge creation
}

export interface GraphConfig {
  multiGraphMemoryEnabled: boolean;
  entityGraphEnabled: boolean;
  timeGraphEnabled: boolean;
  causalGraphEnabled: boolean;
  maxGraphTraversalSteps: number;
  graphActivationDecay: number;
  maxEntityGraphEdgesPerMemory: number;
}

// Causal signal phrases — order matters (most specific first)
export const CAUSAL_PHRASES = [
  "as a result",
  "led to",
  "because of",
  "therefore",
  "caused",
  "because",
];

export function graphsDir(memoryDir: string): string {
  return path.join(memoryDir, "state", "graphs");
}

export function graphFilePath(memoryDir: string, type: GraphType): string {
  return path.join(graphsDir(memoryDir), `${type}.jsonl`);
}

export async function ensureGraphsDir(memoryDir: string): Promise<void> {
  await mkdir(graphsDir(memoryDir), { recursive: true });
}

export async function appendEdge(memoryDir: string, edge: GraphEdge): Promise<void> {
  await ensureGraphsDir(memoryDir);
  const line = JSON.stringify(edge) + "\n";
  await appendFile(graphFilePath(memoryDir, edge.type), line, "utf8");
}

/**
 * Read all edges of a given type from the JSONL file.
 * Returns [] if the file doesn't exist or is corrupt (fail-open).
 */
export async function readEdges(memoryDir: string, type: GraphType): Promise<GraphEdge[]> {
  const filePath = graphFilePath(memoryDir, type);
  try {
    const raw = await readFile(filePath, "utf8");
    const edges: GraphEdge[] = [];
    for (const line of raw.split("\n")) {
      const trimmed = line.trim();
      if (!trimmed) continue;
      try {
        edges.push(JSON.parse(trimmed) as GraphEdge);
      } catch {
        // skip corrupt lines
      }
    }
    return edges;
  } catch {
    return [];
  }
}

/**
 * Read edges from all enabled graph types.
 */
export async function readAllEdges(
  memoryDir: string,
  config: Pick<GraphConfig, "entityGraphEnabled" | "timeGraphEnabled" | "causalGraphEnabled">,
): Promise<GraphEdge[]> {
  const parts: GraphEdge[][] = await Promise.all([
    config.entityGraphEnabled ? readEdges(memoryDir, "entity") : Promise.resolve([]),
    config.timeGraphEnabled ? readEdges(memoryDir, "time") : Promise.resolve([]),
    config.causalGraphEnabled ? readEdges(memoryDir, "causal") : Promise.resolve([]),
  ]);
  return parts.flat();
}

/**
 * Detect causal signal phrases in text. Returns the first matched phrase, or null.
 */
export function detectCausalPhrase(text: string): string | null {
  const lower = text.toLowerCase();
  for (const phrase of CAUSAL_PHRASES) {
    if (lower.includes(phrase)) return phrase;
  }
  return null;
}
```

**Step 2: Verify the file compiles**

```bash
cd <worktree-root>
npx tsx --check src/graph.ts
```
Expected: no errors.

**Step 3: Commit**

```bash
git add src/graph.ts
git commit -m "feat(graph): add graph types, JSONL helpers, causal phrase detector"
```

---

## Task 2: Add `GraphIndex` class for incremental edge building

**Files:**
- Modify: `src/graph.ts` (append below the helpers)

**Step 1: Append the `GraphIndex` class to `src/graph.ts`**

```typescript
/**
 * GraphIndex — builds and updates the three memory graphs.
 *
 * Usage (orchestrator):
 *   this.graphIndex = new GraphIndex(config.memoryDir, config);
 *
 *   // After each memory write:
 *   await this.graphIndex.onMemoryWritten(memoryPath, frontmatter, threadId, recentInThread);
 */
export class GraphIndex {
  private readonly memoryDir: string;
  private readonly cfg: GraphConfig;

  constructor(memoryDir: string, cfg: GraphConfig) {
    this.memoryDir = memoryDir;
    this.cfg = cfg;
  }

  /**
   * Called after a memory is written to disk.
   *
   * @param memoryPath - relative path from memoryDir (e.g. "facts/2026-02-22/abc.md")
   * @param entityRef  - entityRef frontmatter field (if any)
   * @param content    - full memory text (for causal detection)
   * @param created    - ISO timestamp of this memory
   * @param threadId   - current thread ID (for time graph)
   * @param recentInThread - paths of the N most-recent memories in this thread (for time graph)
   * @param entitySiblings - paths of other memories that share the same entityRef (for entity graph)
   */
  async onMemoryWritten(opts: {
    memoryPath: string;
    entityRef?: string;
    content: string;
    created: string;
    threadId?: string;
    recentInThread?: string[];
    entitySiblings?: string[];
    causalPredecessor?: string;
  }): Promise<void> {
    if (!this.cfg.multiGraphMemoryEnabled) return;
    const ts = new Date().toISOString();

    try {
      // Entity graph
      if (this.cfg.entityGraphEnabled && opts.entityRef && opts.entitySiblings?.length) {
        const siblings = opts.entitySiblings.slice(0, this.cfg.maxEntityGraphEdgesPerMemory);
        for (const sibling of siblings) {
          await appendEdge(this.memoryDir, {
            from: opts.memoryPath,
            to: sibling,
            type: "entity",
            weight: 1.0,
            label: opts.entityRef,
            ts,
          });
        }
      }

      // Time graph — link to most recent memory in same thread
      if (this.cfg.timeGraphEnabled && opts.threadId && opts.recentInThread?.length) {
        const predecessor = opts.recentInThread[opts.recentInThread.length - 1];
        if (predecessor && predecessor !== opts.memoryPath) {
          await appendEdge(this.memoryDir, {
            from: predecessor,
            to: opts.memoryPath,
            type: "time",
            weight: 1.0,
            label: opts.threadId,
            ts,
          });
        }
      }

      // Causal graph
      if (this.cfg.causalGraphEnabled && opts.causalPredecessor) {
        const phrase = detectCausalPhrase(opts.content);
        if (phrase) {
          await appendEdge(this.memoryDir, {
            from: opts.causalPredecessor,
            to: opts.memoryPath,
            type: "causal",
            weight: 1.0,
            label: phrase,
            ts,
          });
        }
      }
    } catch (err) {
      // Fail-open: graph write errors must never surface to caller
      const log = await import("./logger.js").then((m) => m.default);
      log.warn(`[graph] onMemoryWritten error: ${err}`);
    }
  }
}
```

**Step 2: Verify compile**

```bash
npx tsx --check src/graph.ts
```
Expected: no errors.

**Step 3: Commit**

```bash
git add src/graph.ts
git commit -m "feat(graph): add GraphIndex class for incremental entity/time/causal edge building"
```

---

## Task 3: Write failing unit tests for graph helpers

**Files:**
- Create: `tests/graph.test.ts`

**Step 1: Create the test file**

```typescript
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "path";
import {
  graphsDir,
  graphFilePath,
  appendEdge,
  readEdges,
  readAllEdges,
  detectCausalPhrase,
  CAUSAL_PHRASES,
  GraphIndex,
  type GraphConfig,
} from "../src/graph.js";

let tmpDir: string;

before(async () => {
  tmpDir = await mkdtemp(path.join(tmpdir(), "engram-graph-test-"));
});

after(async () => {
  await rm(tmpDir, { recursive: true, force: true });
});

describe("detectCausalPhrase", () => {
  it("detects 'because'", () => {
    assert.equal(detectCausalPhrase("I did it because you asked"), "because");
  });

  it("detects 'as a result' before 'because'", () => {
    assert.equal(detectCausalPhrase("as a result of the change, because yes"), "as a result");
  });

  it("returns null when no phrase present", () => {
    assert.equal(detectCausalPhrase("Just a normal sentence"), null);
  });

  it("is case-insensitive", () => {
    assert.equal(detectCausalPhrase("Therefore we proceeded"), "therefore");
  });
});

describe("appendEdge + readEdges", () => {
  it("appends and reads back an edge", async () => {
    const edge = {
      from: "facts/a.md",
      to: "facts/b.md",
      type: "entity" as const,
      weight: 1.0,
      label: "project-x",
      ts: new Date().toISOString(),
    };
    await appendEdge(tmpDir, edge);
    const edges = await readEdges(tmpDir, "entity");
    assert.equal(edges.length, 1);
    assert.equal(edges[0].from, "facts/a.md");
    assert.equal(edges[0].label, "project-x");
  });

  it("returns [] for missing file (fail-open)", async () => {
    const fresh = await mkdtemp(path.join(tmpdir(), "engram-graph-empty-"));
    try {
      const edges = await readEdges(fresh, "causal");
      assert.deepEqual(edges, []);
    } finally {
      await rm(fresh, { recursive: true, force: true });
    }
  });
});

describe("readAllEdges", () => {
  it("merges all enabled graph types", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "engram-graph-all-"));
    try {
      await appendEdge(dir, { from: "a.md", to: "b.md", type: "entity", weight: 1.0, label: "e", ts: new Date().toISOString() });
      await appendEdge(dir, { from: "c.md", to: "d.md", type: "time", weight: 1.0, label: "t1", ts: new Date().toISOString() });
      const edges = await readAllEdges(dir, { entityGraphEnabled: true, timeGraphEnabled: true, causalGraphEnabled: false });
      assert.equal(edges.length, 2);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });
});

describe("GraphIndex.onMemoryWritten", () => {
  const cfg: GraphConfig = {
    multiGraphMemoryEnabled: true,
    entityGraphEnabled: true,
    timeGraphEnabled: true,
    causalGraphEnabled: true,
    maxGraphTraversalSteps: 3,
    graphActivationDecay: 0.7,
    maxEntityGraphEdgesPerMemory: 10,
  };

  it("writes entity edges for entityRef siblings", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "engram-gi-entity-"));
    try {
      const gi = new GraphIndex(dir, cfg);
      await gi.onMemoryWritten({
        memoryPath: "facts/new.md",
        entityRef: "project-alpha",
        content: "We finalized the design",
        created: new Date().toISOString(),
        entitySiblings: ["facts/old.md"],
      });
      const edges = await readEdges(dir, "entity");
      assert.equal(edges.length, 1);
      assert.equal(edges[0].label, "project-alpha");
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  it("writes time edge to predecessor in thread", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "engram-gi-time-"));
    try {
      const gi = new GraphIndex(dir, cfg);
      await gi.onMemoryWritten({
        memoryPath: "facts/new.md",
        content: "Next step in the plan",
        created: new Date().toISOString(),
        threadId: "thread-123",
        recentInThread: ["facts/prev.md"],
      });
      const edges = await readEdges(dir, "time");
      assert.equal(edges.length, 1);
      assert.equal(edges[0].from, "facts/prev.md");
      assert.equal(edges[0].to, "facts/new.md");
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  it("writes causal edge when content contains causal phrase", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "engram-gi-causal-"));
    try {
      const gi = new GraphIndex(dir, cfg);
      await gi.onMemoryWritten({
        memoryPath: "facts/new.md",
        content: "We pivoted because the original plan failed",
        created: new Date().toISOString(),
        causalPredecessor: "facts/root.md",
      });
      const edges = await readEdges(dir, "causal");
      assert.equal(edges.length, 1);
      assert.equal(edges[0].label, "because");
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  it("does nothing when multiGraphMemoryEnabled is false", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "engram-gi-disabled-"));
    try {
      const gi = new GraphIndex(dir, { ...cfg, multiGraphMemoryEnabled: false });
      await gi.onMemoryWritten({
        memoryPath: "facts/new.md",
        content: "because something",
        created: new Date().toISOString(),
        entityRef: "proj",
        entitySiblings: ["facts/old.md"],
        causalPredecessor: "facts/prev.md",
      });
      const edges = await readAllEdges(dir, { entityGraphEnabled: true, timeGraphEnabled: true, causalGraphEnabled: true });
      assert.deepEqual(edges, []);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });
});
```

**Step 2: Run the tests — verify they FAIL (module not fully wired yet is acceptable; these test helpers that exist)**

```bash
npm run test 2>&1 | grep -A3 "graph"
```

If `src/graph.ts` exports are correct the tests should **pass** already (pure unit tests). Expected: all graph tests pass.

**Step 3: Commit**

```bash
git add tests/graph.test.ts
git commit -m "test(graph): add unit tests for graph helpers and GraphIndex"
```

---

## Task 4: Add `spreadingActivation` to `GraphIndex`

**Files:**
- Modify: `src/graph.ts` (append method to `GraphIndex` class, before the closing `}`)

**Step 1: Add the method inside `GraphIndex`**

```typescript
  /**
   * Spreading activation BFS (SYNAPSE-inspired).
   *
   * Starting from `seeds`, traverse the combined graph for up to `maxSteps` hops.
   * Each candidate gets an activation score = edge.weight × decay^hop.
   * Returns top-N candidate paths sorted by descending activation score.
   *
   * @param seeds   - initial memory paths to expand from (e.g. QMD top results)
   * @param maxSteps - max BFS hops (from config: maxGraphTraversalSteps)
   * @returns Array of {path, score} sorted descending, not including seed paths
   */
  async spreadingActivation(
    seeds: string[],
    maxSteps?: number,
  ): Promise<Array<{ path: string; score: number }>> {
    if (!this.cfg.multiGraphMemoryEnabled) return [];
    const steps = maxSteps ?? this.cfg.maxGraphTraversalSteps;
    const decay = this.cfg.graphActivationDecay;

    try {
      const allEdges = await readAllEdges(this.memoryDir, {
        entityGraphEnabled: this.cfg.entityGraphEnabled,
        timeGraphEnabled: this.cfg.timeGraphEnabled,
        causalGraphEnabled: this.cfg.causalGraphEnabled,
      });

      // Build adjacency index: from → edges, to → edges (bidirectional for entity/time, directional for causal)
      const adj = new Map<string, GraphEdge[]>();
      for (const edge of allEdges) {
        if (!adj.has(edge.from)) adj.set(edge.from, []);
        adj.get(edge.from)!.push(edge);
        // Entity and time edges are bidirectional
        if (edge.type !== "causal") {
          if (!adj.has(edge.to)) adj.set(edge.to, []);
          adj.get(edge.to)!.push({ ...edge, from: edge.to, to: edge.from });
        }
      }

      const seedSet = new Set(seeds);
      const scores = new Map<string, number>(); // candidate path → best activation score
      const visited = new Set<string>(seeds);

      // BFS queue: [nodePath, hop]
      const queue: Array<[string, number]> = seeds.map((s) => [s, 0]);

      while (queue.length > 0) {
        const [node, hop] = queue.shift()!;
        if (hop >= steps) continue;

        const edges = adj.get(node) ?? [];
        for (const edge of edges) {
          const neighbor = edge.to === node ? edge.from : edge.to;
          const score = edge.weight * Math.pow(decay, hop + 1);

          if (!seedSet.has(neighbor)) {
            const existing = scores.get(neighbor) ?? 0;
            if (score > existing) scores.set(neighbor, score);
          }

          if (!visited.has(neighbor)) {
            visited.add(neighbor);
            queue.push([neighbor, hop + 1]);
          }
        }
      }

      return Array.from(scores.entries())
        .map(([path, score]) => ({ path, score }))
        .sort((a, b) => b.score - a.score);
    } catch (err) {
      const log = await import("./logger.js").then((m) => m.default);
      log.warn(`[graph] spreadingActivation error: ${err}`);
      return [];
    }
  }
```

**Step 2: Add spreading activation tests to `tests/graph.test.ts`**

Append this test block at the end of the file:

```typescript
describe("GraphIndex.spreadingActivation", () => {
  const cfg: GraphConfig = {
    multiGraphMemoryEnabled: true,
    entityGraphEnabled: true,
    timeGraphEnabled: true,
    causalGraphEnabled: false,
    maxGraphTraversalSteps: 3,
    graphActivationDecay: 0.7,
    maxEntityGraphEdgesPerMemory: 10,
  };

  it("returns hop-1 neighbors with decay applied", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "engram-sa-basic-"));
    try {
      const gi = new GraphIndex(dir, cfg);
      // seed=A, A→B (entity)
      await appendEdge(dir, { from: "A.md", to: "B.md", type: "entity", weight: 1.0, label: "e", ts: new Date().toISOString() });
      const results = await gi.spreadingActivation(["A.md"], 1);
      assert.equal(results.length, 1);
      assert.equal(results[0].path, "B.md");
      assert.ok(Math.abs(results[0].score - 0.7) < 0.001, `expected 0.7 got ${results[0].score}`);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  it("does not include seed paths in results", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "engram-sa-noseed-"));
    try {
      const gi = new GraphIndex(dir, cfg);
      await appendEdge(dir, { from: "A.md", to: "A.md", type: "entity", weight: 1.0, label: "e", ts: new Date().toISOString() });
      const results = await gi.spreadingActivation(["A.md"]);
      assert.equal(results.filter((r) => r.path === "A.md").length, 0);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  it("returns [] when feature disabled", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "engram-sa-off-"));
    try {
      const gi = new GraphIndex(dir, { ...cfg, multiGraphMemoryEnabled: false });
      await appendEdge(dir, { from: "A.md", to: "B.md", type: "entity", weight: 1.0, label: "e", ts: new Date().toISOString() });
      const results = await gi.spreadingActivation(["A.md"]);
      assert.deepEqual(results, []);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  it("returns [] on missing graph files (fail-open)", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "engram-sa-empty-"));
    try {
      const gi = new GraphIndex(dir, cfg);
      const results = await gi.spreadingActivation(["A.md"]);
      assert.deepEqual(results, []);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });
});
```

**Step 3: Run all graph tests**

```bash
npm run test 2>&1 | grep -E "(graph|pass|fail)" | head -30
```
Expected: all graph tests pass.

**Step 4: Commit**

```bash
git add src/graph.ts tests/graph.test.ts
git commit -m "feat(graph): add spreadingActivation BFS with decay + tests"
```

---

## Task 5: Add config flags to types, config, and plugin manifest

**Files:**
- Modify: `src/types.ts`
- Modify: `src/config.ts`
- Modify: `openclaw.plugin.json`

**Step 1: Add to `src/types.ts` — find the `PluginConfig` interface and add after existing flags**

Search for `queryAwareIndexingEnabled` in `src/types.ts`; insert after it:

```typescript
  // v8.2 multi-graph memory (PR 18)
  multiGraphMemoryEnabled: boolean;
  entityGraphEnabled: boolean;
  timeGraphEnabled: boolean;
  causalGraphEnabled: boolean;
  maxGraphTraversalSteps: number;
  graphActivationDecay: number;
  maxEntityGraphEdgesPerMemory: number;
```

**Step 2: Add defaults to `src/config.ts` — find `parseConfig()` defaults object**

Search for `queryAwareIndexingEnabled` in `src/config.ts`; insert after it:

```typescript
  multiGraphMemoryEnabled: false,
  entityGraphEnabled: true,
  timeGraphEnabled: true,
  causalGraphEnabled: true,
  maxGraphTraversalSteps: 3,
  graphActivationDecay: 0.7,
  maxEntityGraphEdgesPerMemory: 10,
```

**Step 3: Add to `openclaw.plugin.json` configSchema**

Find `queryAwareIndexingEnabled` in `openclaw.plugin.json`; insert after its block:

```json
"multiGraphMemoryEnabled": {
  "type": "boolean",
  "default": false,
  "description": "Enable multi-graph memory (entity, time, causal edges). Default false."
},
"entityGraphEnabled": {
  "type": "boolean",
  "default": true,
  "description": "Build entity co-reference graph (requires multiGraphMemoryEnabled). Default true."
},
"timeGraphEnabled": {
  "type": "boolean",
  "default": true,
  "description": "Build temporal sequence graph within threads (requires multiGraphMemoryEnabled). Default true."
},
"causalGraphEnabled": {
  "type": "boolean",
  "default": true,
  "description": "Build causal inference graph from heuristic phrases (requires multiGraphMemoryEnabled). Default true."
},
"maxGraphTraversalSteps": {
  "type": "number",
  "default": 3,
  "description": "Maximum BFS hops during spreading activation. Default 3."
},
"graphActivationDecay": {
  "type": "number",
  "default": 0.7,
  "description": "Per-hop activation decay factor (0-1). Default 0.7."
},
"maxEntityGraphEdgesPerMemory": {
  "type": "number",
  "default": 10,
  "description": "Maximum entity graph edges written per memory write. Default 10."
}
```

**Step 4: Verify compile**

```bash
npm run build 2>&1 | tail -20
```
Expected: no type errors.

**Step 5: Commit**

```bash
git add src/types.ts src/config.ts openclaw.plugin.json
git commit -m "feat(graph): add config flags for multi-graph memory"
```

---

## Task 6: Wire `GraphIndex` into `orchestrator.ts`

**Files:**
- Modify: `src/orchestrator.ts`

**Step 1: Add import at the top of `src/orchestrator.ts`**

Find the block of imports near the top. After the `temporal-index` import, add:

```typescript
import { GraphIndex } from "./graph.js";
```

**Step 2: Add `graphIndex` field to the `Orchestrator` class**

Near the `tmtBuilder` or `boxBuilders` field (~line 187), add:

```typescript
private graphIndex: GraphIndex;
```

**Step 3: Initialize in constructor**

In the constructor body, after the `tmtBuilder` initialization (or near other service initializations), add:

```typescript
this.graphIndex = new GraphIndex(this.config.memoryDir, this.config);
```

**Step 4: Wire into `persistExtraction()` — entity siblings lookup + write call**

In `persistExtraction()`, find the loop where each `fact` is persisted (~line 1760). After `await this.indexPersistedMemory(storage, memoryId);` for a successfully-persisted fact, add:

```typescript
// v8.2: graph edge building (fail-open — errors logged inside GraphIndex)
if (this.config.multiGraphMemoryEnabled) {
  // Entity siblings: find other memories that share the same entityRef
  const entitySiblings: string[] = [];
  if (fact.entityRef) {
    try {
      const allMems = await storage.getAllMemories(this.config.memoryDir);
      for (const m of allMems) {
        if (m.frontmatter.entityRef === fact.entityRef && m.path !== storage.memoryPath(this.config.memoryDir, memoryId)) {
          entitySiblings.push(storage.relativeMemoryPath(this.config.memoryDir, m.path));
        }
      }
    } catch { /* fail-open */ }
  }
  // Recent thread memories for time graph
  const recentInThread: string[] = [];
  if (threadId) {
    try {
      const thread = await this.threading.getThread(threadId);
      if (thread?.memoryIds?.length) {
        recentInThread.push(
          ...thread.memoryIds
            .filter((id: string) => id !== memoryId)
            .slice(-3)
            .map((id: string) => storage.relativeMemoryPath(this.config.memoryDir, storage.memoryPath(this.config.memoryDir, id)))
        );
      }
    } catch { /* fail-open */ }
  }
  await this.graphIndex.onMemoryWritten({
    memoryPath: storage.relativeMemoryPath(this.config.memoryDir, storage.memoryPath(this.config.memoryDir, memoryId)),
    entityRef: fact.entityRef,
    content: fact.content ?? "",
    created: new Date().toISOString(),
    threadId: threadId ?? undefined,
    recentInThread,
    entitySiblings,
    causalPredecessor: recentInThread[recentInThread.length - 1],
  });
}
```

> **Note for implementer:** `storage.getAllMemories`, `storage.memoryPath`, and `storage.relativeMemoryPath` may not exist by those exact names — look at `src/storage.ts` for the actual method names that list/read memories and resolve paths, and use those. Adjust accordingly. The key contract is: pass the relative memory path from `memoryDir`, not an absolute path.

**Step 5: Verify build**

```bash
npm run build 2>&1 | tail -20
```
Expected: clean build, no type errors.

**Step 6: Run preflight**

```bash
npm run preflight:quick 2>&1 | tail -30
```
Expected: all tests pass.

**Step 7: Commit**

```bash
git add src/orchestrator.ts
git commit -m "feat(graph): wire GraphIndex into orchestrator persistExtraction"
```

---

## Task 7: Integration tests — feature flag behavior

**Files:**
- Modify: `tests/graph.test.ts` (append integration test block)

**Step 1: Append integration tests**

```typescript
describe("Integration: multiGraphMemoryEnabled=false baseline", () => {
  it("graph files are NOT written when flag is false", async () => {
    const { readEdges } = await import("../src/graph.js");
    const dir = await mkdtemp(path.join(tmpdir(), "engram-integration-off-"));
    try {
      const cfg: GraphConfig = {
        multiGraphMemoryEnabled: false,
        entityGraphEnabled: true,
        timeGraphEnabled: true,
        causalGraphEnabled: true,
        maxGraphTraversalSteps: 3,
        graphActivationDecay: 0.7,
        maxEntityGraphEdgesPerMemory: 10,
      };
      const gi = new GraphIndex(dir, cfg);
      await gi.onMemoryWritten({
        memoryPath: "facts/new.md",
        entityRef: "thing",
        content: "because of something",
        created: new Date().toISOString(),
        entitySiblings: ["facts/old.md"],
        causalPredecessor: "facts/old.md",
        threadId: "t1",
        recentInThread: ["facts/old.md"],
      });
      const entityEdges = await readEdges(dir, "entity");
      const timeEdges = await readEdges(dir, "time");
      const causalEdges = await readEdges(dir, "causal");
      assert.equal(entityEdges.length, 0);
      assert.equal(timeEdges.length, 0);
      assert.equal(causalEdges.length, 0);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });
});

describe("Integration: corrupt JSONL fail-open", () => {
  it("readEdges returns [] on corrupt file", async () => {
    const { writeFile, mkdir } = await import("node:fs/promises");
    const dir = await mkdtemp(path.join(tmpdir(), "engram-corrupt-"));
    try {
      await mkdir(path.join(dir, "state", "graphs"), { recursive: true });
      await writeFile(path.join(dir, "state", "graphs", "entity.jsonl"), "NOT JSON\n{\"broken\": true}\n");
      const edges = await readEdges(dir, "entity");
      // The "{\"broken\": true}" line parses successfully (1 edge); "NOT JSON" is skipped
      // This verifies fail-open skips corrupt lines, not that ALL lines fail
      assert.ok(edges.length <= 1);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });
});
```

**Step 2: Run full test suite**

```bash
npm run test 2>&1 | tail -20
```
Expected: all tests pass.

**Step 3: Commit**

```bash
git add tests/graph.test.ts
git commit -m "test(graph): add integration tests for flag-off baseline and corrupt JSONL fail-open"
```

---

## Task 8: Full preflight and open PR

**Step 1: Run full preflight**

```bash
npm run preflight:quick 2>&1
```
Expected: typecheck passes, all tests pass, build succeeds.

**Step 2: Push branch**

```bash
git push -u origin HEAD
```

**Step 3: Open PR**

```bash
gh pr create \
  --title "feat: v8.2 PR 18 — Multi-Graph Memory (entity/time/causal graphs + spreading activation)" \
  --body "## Summary

Implements multi-graph memory for v8.2 (MAGMA/SYNAPSE-inspired).

### What ships
- New \`src/graph.ts\`: \`GraphIndex\` class, typed edge JSONL I/O, \`spreadingActivation\` BFS
- Three graph types behind sub-flags: entity co-reference, time sequence, causal inference
- Incremental graph building: edges written after each memory write in \`persistExtraction()\`
- Spreading activation: BFS from seed nodes with configurable decay, ready for PR 19 (graph recall)
- All behavior behind \`multiGraphMemoryEnabled: false\` (default)
- Fail-open: every graph write/read error is caught + logged; memory writes succeed regardless

### Config flags added (all default off/conservative)
- \`multiGraphMemoryEnabled\` (default \`false\`)
- \`entityGraphEnabled\` (default \`true\` when master on)
- \`timeGraphEnabled\` (default \`true\` when master on)
- \`causalGraphEnabled\` (default \`true\` when master on)
- \`maxGraphTraversalSteps\` (default \`3\`)
- \`graphActivationDecay\` (default \`0.7\`)
- \`maxEntityGraphEdgesPerMemory\` (default \`10\`)

### Test coverage
- Unit tests for all graph helpers, \`GraphIndex\` edge building, \`spreadingActivation\`
- Integration test: flag-off → no graph files written
- Integration test: corrupt JSONL → fail-open, no crash

### Depends on
None (parallel with PR 17). PR 19 (graph recall) depends on both PR 17 and PR 18.

## Test plan
- [x] \`npm run preflight:quick\` passes
- [x] Feature disabled: no \`state/graphs/\` directory created
- [x] Feature enabled: edges appear in JSONL after test extraction
- [x] Corrupt JSONL: \`spreadingActivation\` returns \`[]\`, no exception
" \
  --label "enhancement"
```

**Step 4: Record PR number and report back**

```bash
gh pr view --json number,url | cat
```

---

## Notes for PR 19 (Graph Recall — depends on this PR)

The `GraphIndex.spreadingActivation(seeds, maxSteps)` method is the integration point. PR 19 will:
1. Import `GraphIndex` from `src/graph.ts`
2. Call `this.graphIndex.spreadingActivation(qmdTopResults, maxSteps)` in the recall planner when mode is `graph_mode`
3. Merge and re-rank the graph-expanded candidates with QMD results
4. Record the expansion path to `state/last_graph_recall.json`
