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

1663 lines
58 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# @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](#1-overview)
2. [Module Diagram](#2-module-diagram)
3. [Hook Registration Table](#3-hook-registration-table)
4. [Data Flow Per Feature](#4-data-flow-per-feature)
5. [File & Directory Structure](#5-file--directory-structure)
6. [Config Schema](#6-config-schema)
7. [TypeScript Interface Definitions](#7-typescript-interface-definitions)
8. [Error Handling Strategy](#8-error-handling-strategy)
9. [Testing 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:**
```markdown
# 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`
```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`
```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`
```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
```typescript
// ============================================================
// 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
```typescript
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
```typescript
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:
```typescript
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:
```typescript
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)
```typescript
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)
```typescript
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)
```typescript
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
```typescript
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:
```json
{
"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
```bash
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)
```typescript
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:
```typescript
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
```typescript
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.*