openclaw-cortex/docs/ARCHITECTURE.md
Claudia d41a13f914 feat: openclaw-cortex v0.1.0 — conversation intelligence plugin
Thread tracking, decision extraction, boot context generation,
pre-compaction snapshots, structured narratives.

- 10 source files, 1983 LOC TypeScript
- 9 test files, 270 tests passing
- Zero runtime dependencies
- Cerberus approved + all findings fixed
- EN/DE pattern matching, atomic file writes
- Graceful degradation (read-only workspace, corrupt JSON)
2026-02-17 12:16:49 +01:00

58 KiB
Raw Permalink Blame History

@vainplex/openclaw-cortex — Architecture Document

Conversation intelligence layer for OpenClaw. Adds automated thread tracking, decision extraction, boot context generation, and pre-compaction snapshots on top of memory-core.

Version: 0.1.0 Date: 2026-02-17 Status: Design — ready for implementation


Table of Contents

  1. Overview
  2. Module Diagram
  3. Hook Registration Table
  4. Data Flow Per Feature
  5. File & Directory Structure
  6. Config Schema
  7. TypeScript Interface Definitions
  8. Error Handling Strategy
  9. Testing Strategy

1. Overview

What This Plugin Does

openclaw-cortex is a read-and-derive plugin. It listens to message hooks, extracts structured intelligence (threads, decisions, mood), and persists state to {workspace}/memory/reboot/. At session start, it assembles a dense BOOTSTRAP.md that primes the agent with continuity context. Before compaction, it snapshots the hot zone so nothing is lost.

What This Plugin Does NOT Do

  • Does not replace memory-core (daily notes, facts, compaction are handled there)
  • Does not call external LLMs (v1 is structured-only)
  • Does not require NATS or any external service
  • Does not mutate conversation history or intercept messages

Design Principles

Principle Rationale
Zero runtime dependencies Node built-ins only. Keeps the plugin fast and portable.
Graceful degradation Read-only workspace? Skip writes, log warning, continue.
Workspace-relative paths No hardcoded paths. Everything derived from workspace root.
Complementary to memory-core Reads memory-core artifacts (daily notes), writes to its own memory/reboot/ directory.
Idempotent operations Running thread tracker twice on the same messages produces the same state.
Synchronous hooks, async I/O Hook handlers are synchronous decision-makers; file I/O is fire-and-forget with error catching.

Relationship to Python Reference

The Python reference at darkplex-core/cortex/memory/ (5 modules, ~950 LOC) is the source of truth for regex patterns, data shapes, and algorithmic behavior. This TypeScript port preserves:

  • All regex patterns (decision, closure, wait, topic, mood) verbatim
  • Thread scoring and prioritization logic
  • Boot context assembly structure and character budget
  • Pre-compaction pipeline ordering

Differences from Python reference:

  • No NATS CLI subprocess calls (plugin receives messages via hooks, not NATS stream queries)
  • No Ollama/LLM narrative generation (v1 structured-only)
  • No facts.jsonl / knowledge queries (deferred to v2 — depends on memory-core API)
  • No calendar/wellbeing integration (deferred to v2)

2. Module Diagram

┌─────────────────────────────────────────────────────────────┐
│                    OpenClaw Gateway                         │
│                                                             │
│  Hooks:                                                     │
│  ├─ message_received ──┐                                    │
│  ├─ message_sent ──────┤                                    │
│  ├─ session_start ─────┤                                    │
│  ├─ before_compaction ─┤                                    │
│  └─ after_compaction ──┘                                    │
│                        │                                    │
└────────────────────────┼────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│                 index.ts (Plugin Entry)                      │
│                                                             │
│  register(api) {                                            │
│    config = resolveConfig(api.pluginConfig)                 │
│    registerCortexHooks(api, config)                         │
│    api.registerCommand("cortexstatus", ...)                 │
│  }                                                          │
└────────────┬────────────────────────────────────────────────┘
             │
             ▼
┌─────────────────────────────────────────────────────────────┐
│                   src/hooks.ts                              │
│                                                             │
│  Dispatches hook events to feature modules:                 │
│                                                             │
│  message_received ──→ threadTracker.processMessage()        │
│  message_sent ──────→ threadTracker.processMessage()        │
│                  ──→ decisionTracker.processMessage()        │
│  session_start ─────→ bootContext.generate()                │
│  before_compaction ─→ preCompaction.run()                   │
│  after_compaction ──→ (log only)                            │
└────┬───────┬────────┬────────┬──────────────────────────────┘
     │       │        │        │
     ▼       ▼        ▼        ▼
┌────────┐┌────────┐┌────────┐┌──────────────┐┌──────────┐
│ Thread ││Decision││  Boot  ││    Pre-      ││Narrative │
│Tracker ││Tracker ││Context ││ Compaction   ││Generator │
│        ││        ││        ││              ││          │
│track() ││extract ││assem-  ││snapshot() +  ││generate()│
│prune() ││persist ││ble()   ││orchestrate   ││          │
│close() ││        ││write() ││all modules   ││          │
└───┬────┘└───┬────┘└───┬────┘└──────┬───────┘└────┬─────┘
    │         │         │            │              │
    ▼         ▼         ▼            ▼              ▼
┌─────────────────────────────────────────────────────────────┐
│                    src/patterns.ts                           │
│                                                             │
│  DECISION_PATTERNS, CLOSE_PATTERNS, WAIT_PATTERNS,          │
│  TOPIC_PATTERNS, MOOD_PATTERNS                              │
│  getPatterns(language: "en" | "de" | "both")                │
└─────────────────────────────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│                    src/storage.ts                            │
│                                                             │
│  loadJson(), saveJson() — atomic writes via .tmp rename     │
│  ensureRebootDir() — create memory/reboot/ if needed        │
│  isWritable() — check workspace write permission            │
└─────────────────────────────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│              {workspace}/memory/reboot/                      │
│                                                             │
│  threads.json      — Thread state                           │
│  decisions.json    — Decision log                           │
│  narrative.md      — 24h activity summary                   │
│  hot-snapshot.md   — Pre-compaction context snapshot         │
│                                                             │
│              {workspace}/BOOTSTRAP.md                        │
│              — Dense boot context for agent priming          │
└─────────────────────────────────────────────────────────────┘

Module Dependency Graph

index.ts
  ├── src/config.ts         (resolveConfig, CortexConfig type)
  ├── src/hooks.ts          (registerCortexHooks — hook dispatcher)
  │     ├── src/thread-tracker.ts
  │     │     ├── src/patterns.ts
  │     │     └── src/storage.ts
  │     ├── src/decision-tracker.ts
  │     │     ├── src/patterns.ts
  │     │     └── src/storage.ts
  │     ├── src/boot-context.ts
  │     │     └── src/storage.ts
  │     ├── src/pre-compaction.ts
  │     │     ├── src/thread-tracker.ts
  │     │     ├── src/narrative-generator.ts
  │     │     ├── src/boot-context.ts
  │     │     └── src/storage.ts
  │     └── src/narrative-generator.ts
  │           └── src/storage.ts
  └── src/types.ts          (shared TypeScript interfaces)

No circular dependencies. patterns.ts, storage.ts, and types.ts are leaf modules.


3. Hook Registration Table

Hook Feature Priority Behavior Blocking
message_received Thread Tracker 100 (low) Extract signals from user message, update thread state No — fire-and-forget
message_sent Thread Tracker 100 (low) Extract signals from assistant message, update thread state No — fire-and-forget
message_received Decision Tracker 100 (low) Scan for decision patterns, append to decisions.json No — fire-and-forget
message_sent Decision Tracker 100 (low) Scan for decision patterns, append to decisions.json No — fire-and-forget
session_start Boot Context 10 (high) Assemble BOOTSTRAP.md from persisted state No — fire-and-forget
before_compaction Pre-Compaction 5 (highest) Run full pipeline: threads → snapshot → narrative → boot No — fire-and-forget
after_compaction Logging 200 (lowest) Log compaction completion timestamp No — fire-and-forget

Priority rationale:

  • Pre-compaction runs at priority 5 to execute before other before_compaction handlers (e.g., NATS event store might publish the compaction event — we want our snapshot saved first).
  • Boot context at priority 10 ensures BOOTSTRAP.md exists before other session_start handlers that might read it.
  • Message processing at priority 100 is non-critical and should not delay message delivery.

All handlers are non-blocking. File I/O failures are caught and logged, never thrown. The plugin must never interfere with message flow.


4. Data Flow Per Feature

4.1 Thread Tracker

message_received / message_sent
         │
         ▼
  ┌──────────────────┐
  │  Extract content  │  ← event.content || event.message || event.text
  │  from hook event  │
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │ extractSignals() │  ← Run all regex patterns against content
  │                  │  → { decisions[], closures[], waits[], topics[] }
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │ detectMood()     │  ← Last mood pattern match wins
  │                  │  → "frustrated" | "excited" | "tense" | etc.
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │ loadThreads()    │  ← Read threads.json
  │ updateThreads()  │  ← Match signals to existing threads via word overlap
  │ pruneThreads()   │  ← Remove closed threads older than pruneDays
  │ capThreads()     │  ← Enforce maxThreads limit (drop lowest-priority closed)
  │ saveThreads()    │  ← Atomic write to threads.json
  └──────────────────┘

Signal-to-thread matching: A signal (decision context, closure, wait) is matched to a thread when at least 2 words from the thread title appear in the signal text (case-insensitive). This is the same heuristic as the Python reference.

Thread lifecycle:

  1. Detection: New topics detected via TOPIC_PATTERNS create new threads with status open
  2. Update: Decisions and waits are appended to matching threads
  3. Closure: CLOSE_PATTERNS set matching open threads to status closed
  4. Pruning: Closed threads older than pruneDays are removed on every write
  5. Cap: If thread count exceeds maxThreads, oldest closed threads are removed first

4.2 Decision Tracker

message_received / message_sent
         │
         ▼
  ┌──────────────────┐
  │  Extract content  │
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────────────┐
  │ matchDecisionPatterns()  │  ← DECISION_PATTERNS regex scan
  │                          │  → context window: 50 chars before, 100 chars after match
  └────────┬─────────────────┘
           │ (if any matches)
           ▼
  ┌──────────────────────────┐
  │ buildDecision()          │  ← Construct Decision object
  │   what: context excerpt  │
  │   date: ISO date         │
  │   why: surrounding text  │
  │   impact: inferred       │
  │   who: from event sender │
  └────────┬─────────────────┘
           │
           ▼
  ┌──────────────────────────┐
  │ deduplicateDecisions()   │  ← Skip if identical 'what' exists within last 24h
  │ loadDecisions()          │
  │ appendDecision()         │
  │ saveDecisions()          │  ← Atomic write to decisions.json
  └──────────────────────────┘

Impact inference: Decisions matching critical keywords (e.g., "architecture", "security", "migration", "delete") get impact: "high". All others default to "medium".

4.3 Boot Context Generator

session_start (or before_agent_start, first call)
         │
         ▼
  ┌──────────────────────────┐
  │ shouldGenerate()         │  ← Check bootContext.enabled + onSessionStart
  └────────┬─────────────────┘
           │
           ▼
  ┌──────────────────────────┐    ┌─────────────────────┐
  │ loadOpenThreads()        │◄───│ memory/reboot/      │
  │ loadRecentDecisions()    │◄───│   threads.json      │
  │ loadNarrative()          │◄───│   decisions.json     │
  │ loadHotSnapshot()        │◄───│   narrative.md       │
  └────────┬─────────────────┘    │   hot-snapshot.md    │
           │                      └─────────────────────┘
           ▼
  ┌──────────────────────────┐
  │ assembleSections()       │
  │                          │
  │  1. Header + timestamp   │  ~100 chars
  │  2. State + mode + mood  │  ~200 chars
  │  3. Staleness warnings   │  ~100 chars (if applicable)
  │  4. Hot snapshot         │  ~1000 chars (if fresh, <1h old)
  │  5. Narrative            │  ~2000 chars (if fresh, <36h old)
  │  6. Active threads       │  ~4000 chars (top 7, by priority)
  │  7. Recent decisions     │  ~2000 chars (last 10, within 14d)
  │  8. Footer               │  ~100 chars
  │                          │
  │  Total budget: maxChars  │  default 16000 (~4000 tokens)
  └────────┬─────────────────┘
           │
           ▼
  ┌──────────────────────────┐
  │ truncateIfNeeded()       │  ← Hard cut at maxChars + "[truncated]" marker
  │ writeBootstrap()         │  ← Write to {workspace}/BOOTSTRAP.md
  └──────────────────────────┘

Section budget allocation (within 16000 char default):

Section Max Chars Notes
Header + State 500 Always included
Hot Snapshot 1000 Only if <1h old
Narrative 2000 Only if <36h old
Threads (×7) 8000 Sorted by priority, then recency
Decisions (×10) 3000 Last 14 days
Footer 500 Stats line

Thread prioritization (same as Python reference):

  1. Sort by priority: critical → high → medium → low
  2. Within same priority: sort by last_activity descending (most recent first)
  3. Take top 7

4.4 Pre-Compaction Snapshot

before_compaction
         │
         ▼
  ┌──────────────────────────┐
  │ 1. threadTracker.flush() │  ← Force-write current thread state
  └────────┬─────────────────┘
           │
           ▼
  ┌──────────────────────────┐
  │ 2. buildHotSnapshot()    │  ← Summarize event.compactingMessages (from hook payload)
  │    writeSnapshot()       │  ← Write to memory/reboot/hot-snapshot.md
  └────────┬─────────────────┘
           │
           ▼
  ┌──────────────────────────┐
  │ 3. narrative.generate()  │  ← Structured narrative from threads + decisions
  └────────┬─────────────────┘
           │
           ▼
  ┌──────────────────────────┐
  │ 4. bootContext.generate() │  ← Full BOOTSTRAP.md assembly
  └──────────────────────────┘

Hot snapshot content format:

# Hot Snapshot — 2026-02-17T10:30:00Z
## Last conversation before compaction

**Recent messages:**
- [user] Can you fix the auth bug in...
- [assistant] I'll look at the JWT validation...
- [user] Also check the rate limiter
- [assistant] Done — both issues fixed...

**Thread state at compaction:**
- 3 open threads, 1 decision pending

Important: The before_compaction hook receives the messages that are about to be compacted. The plugin extracts a summary from these messages for the hot snapshot, rather than querying NATS (unlike the Python reference which calls nats stream get).

4.5 Narrative Generator

Called by: pre-compaction pipeline OR standalone via command
         │
         ▼
  ┌──────────────────────────┐
  │ loadDailyNotes()         │  ← Read {workspace}/memory/{date}.md for today + yesterday
  │ loadThreads()            │  ← Read threads.json
  │ loadDecisions()          │  ← Read decisions.json (last 24h)
  └────────┬─────────────────┘
           │
           ▼
  ┌──────────────────────────┐
  │ generateStructured()     │  ← Pure template-based generation
  │                          │
  │  Sections:               │
  │  1. Date header          │
  │  2. Completed threads    │  ← closed in last 24h
  │  3. Open threads         │  ← with priority emoji
  │  4. Decisions            │  ← from last 24h
  │  5. Timeline             │  ← extracted from daily note headers
  └────────┬─────────────────┘
           │
           ▼
  ┌──────────────────────────┐
  │ writeNarrative()         │  ← Write to memory/reboot/narrative.md
  └──────────────────────────┘

v1 is structured-only. No LLM dependency. The Python reference's generate_llm() with Ollama is intentionally excluded. A future v2 could use llm_input/llm_output hooks or a dedicated API to enrich the narrative.


5. File & Directory Structure

Plugin Repository Layout

openclaw-cortex/
├── package.json
├── tsconfig.json
├── openclaw.plugin.json
├── index.ts                          # Plugin entry point
├── LICENSE                           # MIT
├── README.md
├── docs/
│   └── ARCHITECTURE.md               # This document
├── src/
│   ├── types.ts                      # All shared TypeScript interfaces
│   ├── config.ts                     # Config resolution + defaults
│   ├── storage.ts                    # JSON I/O, path helpers, atomic writes
│   ├── patterns.ts                   # Regex patterns (EN/DE/both) + mood detection
│   ├── hooks.ts                      # Hook registration dispatcher
│   ├── thread-tracker.ts             # Thread detection, matching, pruning
│   ├── decision-tracker.ts           # Decision extraction + deduplication
│   ├── boot-context.ts               # BOOTSTRAP.md assembly
│   ├── pre-compaction.ts             # Pre-compaction pipeline orchestration
│   └── narrative-generator.ts        # Structured narrative generation
└── test/
    ├── patterns.test.ts              # Pattern matching unit tests
    ├── thread-tracker.test.ts        # Thread lifecycle tests
    ├── decision-tracker.test.ts      # Decision extraction tests
    ├── boot-context.test.ts          # Boot context assembly tests
    ├── pre-compaction.test.ts        # Pipeline orchestration tests
    ├── narrative-generator.test.ts   # Narrative generation tests
    ├── storage.test.ts               # Atomic I/O tests
    ├── config.test.ts                # Config resolution tests
    ├── hooks.test.ts                 # Hook dispatch integration tests
    └── fixtures/                     # Test data
        ├── threads.json
        ├── decisions.json
        ├── narrative.md
        ├── daily-note-sample.md
        └── messages/                 # Sample message payloads
            ├── decision-de.json
            ├── decision-en.json
            ├── closure.json
            └── topic-shift.json

Runtime File Layout (workspace)

{workspace}/
├── BOOTSTRAP.md                      # Generated boot context (overwritten each session)
└── memory/
    ├── 2026-02-17.md                 # Daily notes (read by narrative generator)
    ├── 2026-02-16.md
    └── reboot/
        ├── threads.json              # Thread state
        ├── decisions.json            # Decision log
        ├── narrative.md              # 24h activity summary
        └── hot-snapshot.md           # Pre-compaction snapshot

6. Config Schema

openclaw.plugin.json

{
  "id": "openclaw-cortex",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "enabled": {
        "type": "boolean",
        "default": true,
        "description": "Enable/disable the cortex plugin entirely"
      },
      "workspace": {
        "type": "string",
        "default": "",
        "description": "Workspace directory override. Empty = auto-detect from OpenClaw context."
      },
      "threadTracker": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "enabled": {
            "type": "boolean",
            "default": true,
            "description": "Enable thread detection and tracking"
          },
          "pruneDays": {
            "type": "integer",
            "minimum": 1,
            "maximum": 90,
            "default": 7,
            "description": "Auto-prune closed threads older than N days"
          },
          "maxThreads": {
            "type": "integer",
            "minimum": 5,
            "maximum": 200,
            "default": 50,
            "description": "Maximum number of threads to retain"
          }
        }
      },
      "decisionTracker": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "enabled": {
            "type": "boolean",
            "default": true,
            "description": "Enable decision extraction from messages"
          },
          "maxDecisions": {
            "type": "integer",
            "minimum": 10,
            "maximum": 500,
            "default": 100,
            "description": "Maximum number of decisions to retain"
          },
          "dedupeWindowHours": {
            "type": "integer",
            "minimum": 1,
            "maximum": 168,
            "default": 24,
            "description": "Skip decisions with identical 'what' within this window"
          }
        }
      },
      "bootContext": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "enabled": {
            "type": "boolean",
            "default": true,
            "description": "Enable BOOTSTRAP.md generation"
          },
          "maxChars": {
            "type": "integer",
            "minimum": 2000,
            "maximum": 64000,
            "default": 16000,
            "description": "Maximum character budget for BOOTSTRAP.md (~4 chars per token)"
          },
          "onSessionStart": {
            "type": "boolean",
            "default": true,
            "description": "Generate BOOTSTRAP.md on session_start hook"
          },
          "maxThreadsInBoot": {
            "type": "integer",
            "minimum": 1,
            "maximum": 20,
            "default": 7,
            "description": "Maximum number of threads to include in boot context"
          },
          "maxDecisionsInBoot": {
            "type": "integer",
            "minimum": 1,
            "maximum": 30,
            "default": 10,
            "description": "Maximum number of recent decisions in boot context"
          },
          "decisionRecencyDays": {
            "type": "integer",
            "minimum": 1,
            "maximum": 90,
            "default": 14,
            "description": "Include decisions from the last N days"
          }
        }
      },
      "preCompaction": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "enabled": {
            "type": "boolean",
            "default": true,
            "description": "Enable pre-compaction snapshot pipeline"
          },
          "maxSnapshotMessages": {
            "type": "integer",
            "minimum": 5,
            "maximum": 50,
            "default": 15,
            "description": "Maximum messages to include in hot snapshot"
          }
        }
      },
      "narrative": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "enabled": {
            "type": "boolean",
            "default": true,
            "description": "Enable structured narrative generation"
          }
        }
      },
      "patterns": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "language": {
            "type": "string",
            "enum": ["en", "de", "both"],
            "default": "both",
            "description": "Language for regex pattern matching: English, German, or both"
          }
        }
      }
    }
  }
}

package.json

{
  "name": "@vainplex/openclaw-cortex",
  "version": "0.1.0",
  "description": "OpenClaw plugin: conversation intelligence — thread tracking, decision extraction, boot context, pre-compaction snapshots",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist",
    "openclaw.plugin.json",
    "README.md",
    "LICENSE"
  ],
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build",
    "test": "vitest run",
    "test:watch": "vitest",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {},
  "devDependencies": {
    "vitest": "^3.0.0",
    "@types/node": "^22.0.0",
    "typescript": "^5.7.0"
  },
  "openclaw": {
    "extensions": [
      "./dist/index.js"
    ]
  },
  "keywords": [
    "openclaw",
    "plugin",
    "cortex",
    "memory",
    "thread-tracking",
    "boot-context",
    "conversation-intelligence"
  ],
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/alberthild/openclaw-cortex.git"
  },
  "homepage": "https://github.com/alberthild/openclaw-cortex#readme",
  "author": "Vainplex <hildalbert@gmail.com>"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "."
  },
  "include": ["index.ts", "src/**/*.ts"],
  "exclude": ["node_modules", "dist", "test"]
}

7. TypeScript Interface Definitions

src/types.ts — All Shared Interfaces

// ============================================================
// Plugin API Types (OpenClaw contract)
// ============================================================

export type PluginLogger = {
  info: (msg: string) => void;
  warn: (msg: string) => void;
  error: (msg: string) => void;
  debug: (msg: string) => void;
};

export type OpenClawPluginApi = {
  id: string;
  pluginConfig?: Record<string, unknown>;
  logger: PluginLogger;
  config: Record<string, unknown>;
  registerService: (service: PluginService) => void;
  registerCommand: (command: PluginCommand) => void;
  on: (
    hookName: string,
    handler: (event: HookEvent, ctx: HookContext) => void,
    opts?: { priority?: number },
  ) => void;
};

export type PluginService = {
  id: string;
  start: (ctx: ServiceContext) => Promise<void>;
  stop: (ctx: ServiceContext) => Promise<void>;
};

export type ServiceContext = {
  logger: PluginLogger;
  config: Record<string, unknown>;
};

export type PluginCommand = {
  name: string;
  description: string;
  requireAuth?: boolean;
  handler: (args?: Record<string, unknown>) => { text: string } | Promise<{ text: string }>;
};

export type HookEvent = {
  content?: string;
  message?: string;
  text?: string;
  from?: string;
  to?: string;
  sender?: string;
  role?: string;
  timestamp?: string;
  sessionId?: string;
  messageCount?: number;
  compactingCount?: number;
  compactingMessages?: CompactingMessage[];
  [key: string]: unknown;
};

export type HookContext = {
  agentId?: string;
  sessionKey?: string;
  sessionId?: string;
  channelId?: string;
  workspaceDir?: string;
};

export type CompactingMessage = {
  role: string;
  content: string;
  timestamp?: string;
};

// ============================================================
// Thread Tracker Types
// ============================================================

export type ThreadStatus = "open" | "closed";

export type ThreadPriority = "critical" | "high" | "medium" | "low";

export type Thread = {
  /** Unique thread ID (UUIDv4) */
  id: string;
  /** Human-readable thread title (extracted from topic patterns or first message) */
  title: string;
  /** Thread lifecycle status */
  status: ThreadStatus;
  /** Priority level — inferred from content or manually set */
  priority: ThreadPriority;
  /** Brief summary of the thread topic */
  summary: string;
  /** Decisions made within this thread context */
  decisions: string[];
  /** What the thread is blocked on, if anything */
  waiting_for: string | null;
  /** Detected mood of conversation within this thread */
  mood: string;
  /** ISO 8601 timestamp of last activity */
  last_activity: string;
  /** ISO 8601 timestamp of thread creation */
  created: string;
};

export type ThreadsData = {
  /** Schema version (current: 2) */
  version: number;
  /** ISO 8601 timestamp of last update */
  updated: string;
  /** All tracked threads */
  threads: Thread[];
  /** Integrity tracking for staleness detection */
  integrity: ThreadIntegrity;
  /** Overall session mood from latest processing */
  session_mood: string;
};

export type ThreadIntegrity = {
  /** Timestamp of last processed event */
  last_event_timestamp: string;
  /** Number of events processed in last run */
  events_processed: number;
  /** Source of events */
  source: "hooks" | "daily_notes" | "unknown";
};

export type ThreadSignals = {
  decisions: string[];
  closures: boolean[];
  waits: string[];
  topics: string[];
};

// ============================================================
// Decision Tracker Types
// ============================================================

export type ImpactLevel = "critical" | "high" | "medium" | "low";

export type Decision = {
  /** Unique decision ID (UUIDv4) */
  id: string;
  /** What was decided — extracted context window around decision pattern match */
  what: string;
  /** ISO 8601 date (YYYY-MM-DD) when the decision was detected */
  date: string;
  /** Surrounding context explaining why / rationale */
  why: string;
  /** Inferred impact level */
  impact: ImpactLevel;
  /** Who made/announced the decision (from message sender) */
  who: string;
  /** ISO 8601 timestamp of extraction */
  extracted_at: string;
};

export type DecisionsData = {
  /** Schema version (current: 1) */
  version: number;
  /** ISO 8601 timestamp of last update */
  updated: string;
  /** All tracked decisions */
  decisions: Decision[];
};

// ============================================================
// Boot Context Types
// ============================================================

export type ExecutionMode =
  | "Morning — brief, directive, efficient"
  | "Afternoon — execution mode"
  | "Evening — strategic, philosophical possible"
  | "Night — emergencies only";

export type BootContextSections = {
  header: string;
  state: string;
  warnings: string;
  hotSnapshot: string;
  narrative: string;
  threads: string;
  decisions: string;
  footer: string;
};

// ============================================================
// Pre-Compaction Types
// ============================================================

export type PreCompactionResult = {
  /** Whether the pipeline completed successfully */
  success: boolean;
  /** Timestamp of snapshot */
  timestamp: string;
  /** Number of messages in hot snapshot */
  messagesSnapshotted: number;
  /** Errors encountered (non-fatal) */
  warnings: string[];
};

// ============================================================
// Narrative Types
// ============================================================

export type NarrativeSections = {
  completed: Thread[];
  open: Thread[];
  decisions: Decision[];
  timelineEntries: string[];
};

// ============================================================
// Config Types
// ============================================================

export type CortexConfig = {
  enabled: boolean;
  workspace: string;
  threadTracker: {
    enabled: boolean;
    pruneDays: number;
    maxThreads: number;
  };
  decisionTracker: {
    enabled: boolean;
    maxDecisions: number;
    dedupeWindowHours: number;
  };
  bootContext: {
    enabled: boolean;
    maxChars: number;
    onSessionStart: boolean;
    maxThreadsInBoot: number;
    maxDecisionsInBoot: number;
    decisionRecencyDays: number;
  };
  preCompaction: {
    enabled: boolean;
    maxSnapshotMessages: number;
  };
  narrative: {
    enabled: boolean;
  };
  patterns: {
    language: "en" | "de" | "both";
  };
};

// ============================================================
// Mood Types
// ============================================================

export type Mood =
  | "neutral"
  | "frustrated"
  | "excited"
  | "tense"
  | "productive"
  | "exploratory";

export const MOOD_EMOJI: Record<Mood, string> = {
  neutral: "",
  frustrated: "😤",
  excited: "🔥",
  tense: "⚡",
  productive: "🔧",
  exploratory: "🔬",
};

export const PRIORITY_EMOJI: Record<ThreadPriority, string> = {
  critical: "🔴",
  high: "🟠",
  medium: "🟡",
  low: "🔵",
};

export const PRIORITY_ORDER: Record<ThreadPriority, number> = {
  critical: 0,
  high: 1,
  medium: 2,
  low: 3,
};

src/patterns.ts — Pattern Definitions

import type { Mood } from "./types.js";

// ============================================================
// Pattern sets by language
// ============================================================

const DECISION_PATTERNS_EN = [
  /(?:decided|decision|agreed|let'?s do|the plan is|approach:)/i,
];

const DECISION_PATTERNS_DE = [
  /(?:entschieden|beschlossen|machen wir|wir machen|der plan ist|ansatz:)/i,
];

const CLOSE_PATTERNS_EN = [
  /(?:done|fixed|solved|closed|works|✅)/i,
];

const CLOSE_PATTERNS_DE = [
  /(?:erledigt|gefixt|gelöst|fertig|funktioniert)/i,
];

const WAIT_PATTERNS_EN = [
  /(?:waiting for|blocked by|need.*first)/i,
];

const WAIT_PATTERNS_DE = [
  /(?:warte auf|blockiert durch|brauche.*erst)/i,
];

const TOPIC_PATTERNS_EN = [
  /(?:back to|now about|regarding)\s+(\w[\w\s-]{2,30})/i,
];

const TOPIC_PATTERNS_DE = [
  /(?:zurück zu|jetzt zu|bzgl\.?|wegen)\s+(\w[\w\s-]{2,30})/i,
];

const MOOD_PATTERNS: Record<Mood, RegExp> = {
  neutral: /(?!)/,  // never matches — neutral is the default
  frustrated: /(?:fuck|shit|mist|nervig|genervt|damn|wtf|argh|schon wieder|zum kotzen|sucks)/i,
  excited: /(?:geil|nice|awesome|krass|boom|läuft|yes!|🎯|🚀|perfekt|brilliant|mega|sick)/i,
  tense: /(?:vorsicht|careful|risky|heikel|kritisch|dringend|urgent|achtung|gefährlich)/i,
  productive: /(?:erledigt|done|fixed|works|fertig|deployed|✅|gebaut|shipped|läuft)/i,
  exploratory: /(?:was wäre wenn|what if|könnte man|idea|idee|maybe|vielleicht|experiment)/i,
};

// ============================================================
// Public API
// ============================================================

export type PatternLanguage = "en" | "de" | "both";

export type PatternSet = {
  decision: RegExp[];
  close: RegExp[];
  wait: RegExp[];
  topic: RegExp[];
};

/**
 * Get pattern set for the configured language.
 * "both" merges EN + DE patterns.
 */
export function getPatterns(language: PatternLanguage): PatternSet {
  switch (language) {
    case "en":
      return {
        decision: DECISION_PATTERNS_EN,
        close: CLOSE_PATTERNS_EN,
        wait: WAIT_PATTERNS_EN,
        topic: TOPIC_PATTERNS_EN,
      };
    case "de":
      return {
        decision: DECISION_PATTERNS_DE,
        close: CLOSE_PATTERNS_DE,
        wait: WAIT_PATTERNS_DE,
        topic: TOPIC_PATTERNS_DE,
      };
    case "both":
      return {
        decision: [...DECISION_PATTERNS_EN, ...DECISION_PATTERNS_DE],
        close: [...CLOSE_PATTERNS_EN, ...CLOSE_PATTERNS_DE],
        wait: [...WAIT_PATTERNS_EN, ...WAIT_PATTERNS_DE],
        topic: [...TOPIC_PATTERNS_EN, ...TOPIC_PATTERNS_DE],
      };
  }
}

/**
 * Detect mood from text. Scans for all mood patterns; last match position wins.
 * Returns "neutral" if no mood pattern matches.
 */
export function detectMood(text: string): Mood {
  if (!text) return "neutral";

  let lastMood: Mood = "neutral";
  let lastPos = -1;

  for (const [mood, pattern] of Object.entries(MOOD_PATTERNS) as [Mood, RegExp][]) {
    if (mood === "neutral") continue;
    // Use global flag for position scanning
    const globalPattern = new RegExp(pattern.source, "gi");
    let match: RegExpExecArray | null;
    while ((match = globalPattern.exec(text)) !== null) {
      if (match.index > lastPos) {
        lastPos = match.index;
        lastMood = mood;
      }
    }
  }

  return lastMood;
}

/** High-impact keywords for decision impact inference */
export const HIGH_IMPACT_KEYWORDS = [
  "architecture", "architektur", "security", "sicherheit",
  "migration", "delete", "löschen", "production", "produktion",
  "deploy", "breaking", "major", "critical", "kritisch",
  "strategy", "strategie", "budget", "contract", "vertrag",
];

src/storage.ts — File I/O

import { readFileSync, writeFileSync, renameSync, mkdirSync, accessSync, statSync } from "node:fs";
import { constants } from "node:fs";
import { join, dirname } from "node:path";
import type { PluginLogger } from "./types.js";

/**
 * Resolve the reboot directory path.
 * Does NOT create it — use ensureRebootDir() for that.
 */
export function rebootDir(workspace: string): string {
  return join(workspace, "memory", "reboot");
}

/**
 * Ensure the memory/reboot/ directory exists.
 * Returns false if creation fails (read-only workspace).
 */
export function ensureRebootDir(workspace: string, logger: PluginLogger): boolean {
  const dir = rebootDir(workspace);
  try {
    mkdirSync(dir, { recursive: true });
    return true;
  } catch (err) {
    logger.warn(`[cortex] Cannot create ${dir}: ${err}`);
    return false;
  }
}

/**
 * Check if the workspace is writable.
 */
export function isWritable(workspace: string): boolean {
  try {
    accessSync(join(workspace, "memory"), constants.W_OK);
    return true;
  } catch {
    // memory/ might not exist yet — check workspace itself
    try {
      accessSync(workspace, constants.W_OK);
      return true;
    } catch {
      return false;
    }
  }
}

/**
 * Load a JSON file. Returns empty object on any failure.
 */
export function loadJson<T = Record<string, unknown>>(filePath: string): T {
  try {
    const content = readFileSync(filePath, "utf-8");
    return JSON.parse(content) as T;
  } catch {
    return {} as T;
  }
}

/**
 * Atomically write JSON to a file.
 * Writes to .tmp first, then renames. This prevents partial writes on crash.
 * Returns false on failure (read-only filesystem).
 */
export function saveJson(filePath: string, data: unknown, logger: PluginLogger): boolean {
  try {
    mkdirSync(dirname(filePath), { recursive: true });
    const tmpPath = filePath + ".tmp";
    writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
    renameSync(tmpPath, filePath);
    return true;
  } catch (err) {
    logger.warn(`[cortex] Failed to write ${filePath}: ${err}`);
    return false;
  }
}

/**
 * Load a text file. Returns empty string on failure.
 */
export function loadText(filePath: string): string {
  try {
    return readFileSync(filePath, "utf-8");
  } catch {
    return "";
  }
}

/**
 * Write a text file atomically.
 * Returns false on failure.
 */
export function saveText(filePath: string, content: string, logger: PluginLogger): boolean {
  try {
    mkdirSync(dirname(filePath), { recursive: true });
    const tmpPath = filePath + ".tmp";
    writeFileSync(tmpPath, content, "utf-8");
    renameSync(tmpPath, filePath);
    return true;
  } catch (err) {
    logger.warn(`[cortex] Failed to write ${filePath}: ${err}`);
    return false;
  }
}

/**
 * Get file modification time as ISO string. Returns null if file doesn't exist.
 */
export function getFileMtime(filePath: string): string | null {
  try {
    const stat = statSync(filePath);
    return stat.mtime.toISOString();
  } catch {
    return null;
  }
}

/**
 * Check if a file is older than the given number of hours.
 * Returns true if the file doesn't exist.
 */
export function isFileOlderThan(filePath: string, hours: number): boolean {
  const mtime = getFileMtime(filePath);
  if (!mtime) return true;
  const ageMs = Date.now() - new Date(mtime).getTime();
  return ageMs > hours * 60 * 60 * 1000;
}

8. Error Handling Strategy

Core Principle: Never Crash the Gateway

The cortex plugin processes intelligence in the background. A failure in thread detection must never prevent a message from being delivered. Every hook handler follows this pattern:

api.on("message_received", (event, ctx) => {
  try {
    // ... feature logic ...
  } catch (err) {
    logger.warn(`[cortex] thread-tracker error: ${err}`);
    // Swallow — do not re-throw
  }
});

Error Categories

Category Handling Example
File read failure Return default (empty object/string) threads.json missing → empty thread list
File write failure Log warning, skip write, continue Read-only workspace → no state persistence
Regex error Should never happen (compile-time patterns), but caught at outer level
Malformed JSON Return empty object, log debug Corrupt threads.json → treated as fresh state
Missing hook fields Fallback chain: event.content ?? event.message ?? event.text ?? "" Older gateway version missing content
Workspace not found Disable all write operations, log error once at registration
Config type mismatch Use defaults for any misconfigured value maxChars: "big" → use 16000

Graceful Degradation Matrix

Condition Behavior
Workspace is read-only All features run but skip file writes. In-memory state is maintained for the session. Log warning once.
threads.json corrupt Start with empty thread list. Next successful write recovers.
memory/ dir missing Create it. If creation fails → read-only mode.
No daily notes exist Narrative generator produces minimal output (threads + decisions only).
All features disabled Plugin registers but does nothing. No hooks registered.
Hook event missing content Skip processing for that event. No error logged (high frequency).

In-Memory Fallback

When writes fail, the thread tracker and decision tracker maintain state in memory for the current session:

class ThreadTracker {
  private threads: Thread[] = [];
  private dirty = false;
  private writeable = true;

  processMessage(content: string, sender: string): void {
    // Always process in memory
    this.updateThreads(content, sender);
    this.dirty = true;

    // Attempt persist
    if (this.writeable) {
      const ok = saveJson(this.filePath, this.buildData(), this.logger);
      if (!ok) {
        this.writeable = false;
        this.logger.warn("[cortex] Workspace not writable — running in-memory only");
      }
      if (ok) this.dirty = false;
    }
  }

  /** Force persist — called by pre-compaction */
  flush(): boolean {
    if (!this.dirty) return true;
    return saveJson(this.filePath, this.buildData(), this.logger);
  }
}

9. Testing Strategy

Framework & Configuration

  • Vitest (consistent with NATS Event Store plugin)
  • No mocking libraries — test doubles are hand-written (zero-dep constraint)
  • Tests run against in-memory or tmp/ directories (never real workspace)

Test Categories

Category Count (est.) What's tested
Pattern matching ~80 Every regex pattern × multiple inputs + edge cases
Thread tracker ~60 Signal extraction, thread matching, closure, pruning, cap, mood
Decision tracker ~40 Extraction, deduplication, impact inference, edge cases
Boot context ~50 Section assembly, truncation, staleness warnings, empty states
Narrative generator ~30 Structured output, daily note parsing, thread/decision inclusion
Pre-compaction ~20 Pipeline ordering, hot snapshot building, error resilience
Storage ~25 Atomic writes, JSON loading, corruption recovery, read-only handling
Config ~15 Default resolution, type coercion, nested config merging
Hooks integration ~15 Hook dispatch, feature disable, priority ordering
Total ~335

Test Patterns

1. Pattern Tests (unit)

import { describe, it, expect } from "vitest";
import { getPatterns, detectMood } from "../src/patterns.js";

describe("decision patterns (both)", () => {
  const { decision } = getPatterns("both");

  it("matches English 'decided'", () => {
    expect(decision.some(p => p.test("We decided to use TypeScript"))).toBe(true);
  });

  it("matches German 'beschlossen'", () => {
    expect(decision.some(p => p.test("Wir haben beschlossen, TS zu nehmen"))).toBe(true);
  });

  it("does not match unrelated text", () => {
    expect(decision.some(p => p.test("The weather is nice today"))).toBe(false);
  });
});

describe("detectMood", () => {
  it("returns 'frustrated' for frustration keywords", () => {
    expect(detectMood("This is damn annoying")).toBe("frustrated");
  });

  it("last match wins", () => {
    expect(detectMood("This sucks but then it works!")).toBe("productive");
  });

  it("returns 'neutral' for empty string", () => {
    expect(detectMood("")).toBe("neutral");
  });
});

2. Thread Tracker Tests (unit, with temp filesystem)

import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ThreadTracker } from "../src/thread-tracker.js";

describe("ThreadTracker", () => {
  let workspace: string;
  let tracker: ThreadTracker;
  const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };

  beforeEach(() => {
    workspace = mkdtempSync(join(tmpdir(), "cortex-test-"));
    mkdirSync(join(workspace, "memory", "reboot"), { recursive: true });
    tracker = new ThreadTracker(workspace, {
      enabled: true, pruneDays: 7, maxThreads: 50
    }, "both", logger);
  });

  it("detects a new topic from a topic pattern", () => {
    tracker.processMessage("Let's get back to the auth migration", "user");
    const threads = tracker.getThreads();
    expect(threads.some(t => t.title.includes("auth migration"))).toBe(true);
  });

  it("closes a thread when closure pattern detected", () => {
    tracker.processMessage("back to the login bug", "user");
    tracker.processMessage("login bug is fixed ✅", "assistant");
    const threads = tracker.getThreads();
    const loginThread = threads.find(t => t.title.includes("login bug"));
    expect(loginThread?.status).toBe("closed");
  });

  it("prunes closed threads older than pruneDays", () => {
    // ... inject a thread with old last_activity, run prune, verify removal
  });

  it("enforces maxThreads cap", () => {
    // ... create 55 threads, verify only 50 remain after processing
  });
});

3. Boot Context Tests (unit)

describe("BootContextGenerator", () => {
  it("produces valid markdown with all sections", () => {
    // Seed threads.json, decisions.json, narrative.md in temp workspace
    // Generate BOOTSTRAP.md
    // Verify each section header exists
  });

  it("respects maxChars budget", () => {
    // Seed many threads + decisions
    // Set maxChars to 2000
    // Verify output length <= 2000 + truncation marker
  });

  it("includes staleness warning for old data", () => {
    // Set integrity.last_event_timestamp to 12h ago
    // Verify "⚠️ Data staleness" appears in output
  });

  it("excludes hot snapshot if older than 1 hour", () => {
    // Create hot-snapshot.md with old mtime
    // Verify it's not in output
  });

  it("handles empty state gracefully", () => {
    // No threads, no decisions, no narrative
    // Verify minimal valid output with header + footer
  });
});

4. Hooks Integration Tests

describe("registerCortexHooks", () => {
  it("registers hooks for all enabled features", () => {
    const registeredHooks: string[] = [];
    const mockApi = {
      logger,
      on: (name: string) => registeredHooks.push(name),
      registerCommand: () => {},
      registerService: () => {},
      pluginConfig: {},
      config: {},
      id: "test",
    };
    // Call register(mockApi)
    expect(registeredHooks).toContain("message_received");
    expect(registeredHooks).toContain("session_start");
    expect(registeredHooks).toContain("before_compaction");
  });

  it("skips hooks for disabled features", () => {
    // Set threadTracker.enabled = false, decisionTracker.enabled = false
    // Verify message_received is NOT registered
  });

  it("uses correct hook priorities", () => {
    const hookPriorities: Record<string, number[]> = {};
    const mockApi = {
      logger,
      on: (name: string, _handler: any, opts?: { priority?: number }) => {
        hookPriorities[name] ??= [];
        hookPriorities[name].push(opts?.priority ?? 100);
      },
      registerCommand: () => {},
      registerService: () => {},
      pluginConfig: {},
      config: {},
      id: "test",
    };
    // Verify before_compaction priority is 5
    // Verify session_start priority is 10
  });
});

Test Fixtures

Test fixtures in test/fixtures/ provide realistic data for deterministic tests:

test/fixtures/threads.json — 5 threads (3 open, 2 closed) with varied priorities and ages.

test/fixtures/decisions.json — 8 decisions spanning 3 weeks with mixed impact levels.

test/fixtures/messages/decision-de.json — Hook event payload for a German decision message:

{
  "content": "Wir haben beschlossen, die Auth-Migration auf nächste Woche zu verschieben",
  "from": "albert",
  "timestamp": "2026-02-17T10:30:00Z"
}

Coverage Target

  • Line coverage: ≥90%
  • Branch coverage: ≥85%
  • Uncovered: only the plugin register() function itself (integration-level, tested via hooks tests)

Running Tests

npm test                    # Single run
npm run test:watch          # Watch mode
npx vitest --coverage       # With coverage report

Appendix A: Workspace Resolution

The workspace directory is resolved in this order:

  1. config.workspace (explicit plugin config override)
  2. ctx.workspaceDir (from hook context, provided by OpenClaw gateway)
  3. process.env.WORKSPACE_DIR (environment variable)
  4. process.cwd() (last resort fallback)
export function resolveWorkspace(config: CortexConfig, ctx?: HookContext): string {
  if (config.workspace) return config.workspace;
  if (ctx?.workspaceDir) return ctx.workspaceDir;
  return process.env.WORKSPACE_DIR ?? process.cwd();
}

Appendix B: Thread Matching Algorithm

The word-overlap algorithm for matching signals to threads:

function matchesThread(thread: Thread, text: string, minOverlap = 2): boolean {
  const threadWords = new Set(
    thread.title.toLowerCase().split(/\s+/).filter(w => w.length > 2)
  );
  const textWords = new Set(
    text.toLowerCase().split(/\s+/).filter(w => w.length > 2)
  );

  let overlap = 0;
  for (const word of threadWords) {
    if (textWords.has(word)) overlap++;
  }
  return overlap >= minOverlap;
}

This is intentionally simple and matches the Python reference behavior. It requires at least 2 words from the thread title to appear in the signal text. Words shorter than 3 characters are excluded to avoid false positives from articles/prepositions.

Appendix C: Full index.ts Blueprint

import { registerCortexHooks } from "./src/hooks.js";
import { resolveConfig } from "./src/config.js";
import type { OpenClawPluginApi, CortexConfig } from "./src/types.js";

const plugin = {
  id: "openclaw-cortex",
  name: "OpenClaw Cortex",
  description: "Conversation intelligence — thread tracking, decision extraction, boot context, pre-compaction snapshots",
  version: "0.1.0",

  register(api: OpenClawPluginApi) {
    const config = resolveConfig(api.pluginConfig);

    if (!config.enabled) {
      api.logger.info("[cortex] Disabled via config");
      return;
    }

    api.logger.info("[cortex] Registering conversation intelligence hooks...");

    // Register all hook handlers
    registerCortexHooks(api, config);

    // Register /cortexstatus command
    api.registerCommand({
      name: "cortexstatus",
      description: "Show cortex plugin status: thread count, last update, mood",
      requireAuth: true,
      handler: () => {
        // Read current state from files and return summary
        return {
          text: "[cortex] Status: operational",
        };
      },
    });

    api.logger.info("[cortex] Ready");
  },
};

export default plugin;

Appendix D: Migration Notes from Python Reference

Python module TypeScript module Key changes
common.py storage.ts + config.ts Split: file I/O → storage.ts, config → config.ts. No NATS credentials (not needed).
thread_tracker.py thread-tracker.ts No NATS subprocess calls. Messages arrive via hooks, not stream queries. No CLI (main()).
boot_assembler.py boot-context.ts No facts.jsonl knowledge queries (v2). No calendar/wellbeing integration (v2). No Ollama.
narrative_generator.py narrative-generator.ts Structured-only. No generate_llm(). No Ollama dependency.
pre_compaction.py pre-compaction.ts No NATS subprocess for recent messages. Messages come from before_compaction hook payload.

End of architecture document.