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)
58 KiB
@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
- Overview
- Module Diagram
- Hook Registration Table
- Data Flow Per Feature
- File & Directory Structure
- Config Schema
- TypeScript Interface Definitions
- Error Handling Strategy
- 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_compactionhandlers (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_starthandlers 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:
- Detection: New topics detected via
TOPIC_PATTERNScreate new threads with statusopen - Update: Decisions and waits are appended to matching threads
- Closure:
CLOSE_PATTERNSset matching open threads to statusclosed - Pruning: Closed threads older than
pruneDaysare removed on every write - 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):
- Sort by priority: critical → high → medium → low
- Within same priority: sort by
last_activitydescending (most recent first) - 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:
config.workspace(explicit plugin config override)ctx.workspaceDir(from hook context, provided by OpenClaw gateway)process.env.WORKSPACE_DIR(environment variable)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.