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)
This commit is contained in:
Claudia 2026-02-17 12:16:49 +01:00
commit d41a13f914
28 changed files with 8269 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
*.tgz

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Vainplex
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

151
README.md Normal file
View file

@ -0,0 +1,151 @@
# @vainplex/openclaw-cortex
> Conversation intelligence layer for [OpenClaw](https://github.com/openclaw/openclaw) — automated thread tracking, decision extraction, boot context generation, and pre-compaction snapshots.
[![npm](https://img.shields.io/npm/v/@vainplex/openclaw-cortex)](https://www.npmjs.com/package/@vainplex/openclaw-cortex)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
## What It Does
`openclaw-cortex` listens to OpenClaw message hooks and automatically:
- **📋 Tracks conversation threads** — detects topic shifts, closures, decisions, and blocking items
- **🎯 Extracts decisions** — recognizes when decisions are made (English + German) and logs them
- **🚀 Generates boot context** — assembles a dense `BOOTSTRAP.md` at session start so the agent has continuity
- **📸 Pre-compaction snapshots** — saves thread state + hot snapshot before memory compaction
- **📖 Structured narratives** — generates 24h activity summaries from threads + decisions
Works **alongside** `memory-core` (OpenClaw's built-in memory) — doesn't replace it.
## Install
```bash
# From npm
npm install @vainplex/openclaw-cortex
# Copy to OpenClaw extensions
cp -r node_modules/@vainplex/openclaw-cortex ~/.openclaw/extensions/openclaw-cortex
```
Or clone directly:
```bash
cd ~/.openclaw/extensions
git clone https://github.com/alberthild/openclaw-cortex.git
cd openclaw-cortex && npm install && npm run build
```
## Configure
Add to your OpenClaw config:
```json
{
"plugins": {
"openclaw-cortex": {
"enabled": true,
"patterns": {
"language": "both"
},
"threadTracker": {
"enabled": true,
"pruneDays": 7,
"maxThreads": 50
},
"decisionTracker": {
"enabled": true,
"maxDecisions": 100,
"dedupeWindowHours": 24
},
"bootContext": {
"enabled": true,
"maxChars": 16000,
"onSessionStart": true,
"maxThreadsInBoot": 7,
"maxDecisionsInBoot": 10,
"decisionRecencyDays": 14
},
"preCompaction": {
"enabled": true,
"maxSnapshotMessages": 15
},
"narrative": {
"enabled": true
}
}
}
}
```
Restart OpenClaw after configuring.
## How It Works
### Hooks
| Hook | Feature | Priority |
|---|---|---|
| `message_received` | Thread + Decision Tracking | 100 |
| `message_sent` | Thread + Decision Tracking | 100 |
| `session_start` | Boot Context Generation | 10 |
| `before_compaction` | Pre-Compaction Snapshot | 5 |
| `after_compaction` | Logging | 200 |
### Output Files
```
{workspace}/
├── BOOTSTRAP.md # Dense boot context (regenerated each session)
└── memory/
└── reboot/
├── threads.json # Thread state
├── decisions.json # Decision log
├── narrative.md # 24h activity summary
└── hot-snapshot.md # Pre-compaction snapshot
```
### Pattern Languages
Thread and decision detection supports English, German, or both:
- **Decision patterns**: "we decided", "let's do", "the plan is", "wir machen", "beschlossen"
- **Closure patterns**: "is done", "it works", "fixed ✅", "erledigt", "gefixt"
- **Wait patterns**: "waiting for", "blocked by", "warte auf"
- **Topic patterns**: "back to", "now about", "jetzt zu", "bzgl."
- **Mood detection**: frustrated, excited, tense, productive, exploratory
### Graceful Degradation
- Read-only workspace → runs in-memory, skips writes
- Corrupt JSON → starts fresh, next write recovers
- Missing directories → creates them automatically
- Hook errors → caught and logged, never crashes the gateway
## Development
```bash
npm install
npm test # 270 tests
npm run typecheck # TypeScript strict mode
npm run build # Compile to dist/
```
## Performance
- Zero runtime dependencies (Node built-ins only)
- All hook handlers are non-blocking (fire-and-forget)
- Atomic file writes via `.tmp` + rename
- Tested with 270 unit + integration tests
## Architecture
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full design document including module diagrams, data flows, type definitions, and testing strategy.
## License
MIT — see [LICENSE](LICENSE)
## Related
- [@vainplex/nats-eventstore](https://www.npmjs.com/package/@vainplex/nats-eventstore) — Publish OpenClaw events to NATS JetStream
- [OpenClaw](https://github.com/openclaw/openclaw) — Multi-channel AI gateway

1663
docs/ARCHITECTURE.md Normal file

File diff suppressed because it is too large Load diff

61
index.ts Normal file
View file

@ -0,0 +1,61 @@
import { registerCortexHooks } from "./src/hooks.js";
import { resolveConfig, resolveWorkspace } from "./src/config.js";
import { loadJson, rebootDir } from "./src/storage.js";
import type { OpenClawPluginApi, ThreadsData } 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: () => {
try {
const workspace = resolveWorkspace(config);
const data = loadJson<Partial<ThreadsData>>(
`${rebootDir(workspace)}/threads.json`,
);
const threads = data.threads ?? [];
const openCount = threads.filter(t => t.status === "open").length;
const closedCount = threads.filter(t => t.status === "closed").length;
const mood = data.session_mood ?? "neutral";
const updated = data.updated ?? "never";
return {
text: [
"**Cortex Status**",
`Threads: ${openCount} open, ${closedCount} closed`,
`Mood: ${mood}`,
`Updated: ${updated}`,
].join("\n"),
};
} catch {
return { text: "[cortex] Status: operational (no data yet)" };
}
},
});
api.logger.info("[cortex] Ready");
},
};
export default plugin;

154
openclaw.plugin.json Normal file
View file

@ -0,0 +1,154 @@
{
"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"
}
}
}
}
}
}

1516
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

48
package.json Normal file
View file

@ -0,0 +1,48 @@
{
"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>"
}

253
src/boot-context.ts Normal file
View file

@ -0,0 +1,253 @@
import { join } from "node:path";
import type {
Thread,
Decision,
ThreadsData,
DecisionsData,
ExecutionMode,
PluginLogger,
CortexConfig,
Mood,
} from "./types.js";
import { MOOD_EMOJI, PRIORITY_EMOJI, PRIORITY_ORDER } from "./types.js";
import { loadJson, loadText, rebootDir, isFileOlderThan, saveText, ensureRebootDir } from "./storage.js";
/**
* Determine execution mode from current hour.
*/
export function getExecutionMode(): ExecutionMode {
const hour = new Date().getHours();
if (hour >= 6 && hour < 12) return "Morning — brief, directive, efficient";
if (hour >= 12 && hour < 18) return "Afternoon — execution mode";
if (hour >= 18 && hour < 22) return "Evening — strategic, philosophical possible";
return "Night — emergencies only";
}
/**
* Load threads data from disk.
*/
function loadThreadsData(workspace: string): Partial<ThreadsData> {
const data = loadJson<Partial<ThreadsData>>(
join(rebootDir(workspace), "threads.json"),
);
// Handle legacy format where data is an array
if (Array.isArray(data)) {
return { threads: data as unknown as Thread[] };
}
return data;
}
/**
* Get sorted open threads by priority and recency.
*/
export function getOpenThreads(workspace: string, limit: number): Thread[] {
const data = loadThreadsData(workspace);
const threads = (data.threads ?? []).filter(t => t.status === "open");
threads.sort((a, b) => {
const priA = PRIORITY_ORDER[a.priority] ?? 3;
const priB = PRIORITY_ORDER[b.priority] ?? 3;
if (priA !== priB) return priA - priB;
// More recent first
return b.last_activity.localeCompare(a.last_activity);
});
return threads.slice(0, limit);
}
/**
* Generate staleness warning from integrity data.
*/
export function integrityWarning(workspace: string): string {
const data = loadThreadsData(workspace);
const integrity = data.integrity;
if (!integrity?.last_event_timestamp) {
return "⚠️ No integrity data — thread tracker may not have run yet.";
}
try {
const lastTs = integrity.last_event_timestamp;
const lastDt = new Date(lastTs.endsWith("Z") ? lastTs : lastTs + "Z");
const ageMin = (Date.now() - lastDt.getTime()) / 60000;
if (ageMin > 480) {
return `🚨 STALE DATA: Thread data is ${Math.round(ageMin / 60)}h old.`;
}
if (ageMin > 120) {
return `⚠️ Data staleness: Thread data is ${Math.round(ageMin / 60)}h old.`;
}
return "";
} catch {
return "⚠️ Could not parse integrity timestamp.";
}
}
/**
* Load hot snapshot if it's fresh (< 1 hour old).
*/
function loadHotSnapshot(workspace: string): string {
const filePath = join(rebootDir(workspace), "hot-snapshot.md");
if (isFileOlderThan(filePath, 1)) return "";
const content = loadText(filePath);
return content.trim().slice(0, 1000);
}
/**
* Load decisions from the last N days, return last `limit` entries.
*/
function loadRecentDecisions(workspace: string, days: number, limit: number): Decision[] {
const data = loadJson<Partial<DecisionsData>>(
join(rebootDir(workspace), "decisions.json"),
);
const decisions = Array.isArray(data.decisions) ? data.decisions : [];
const cutoff = new Date(
Date.now() - days * 24 * 60 * 60 * 1000,
).toISOString().slice(0, 10);
return decisions
.filter(d => d.date >= cutoff)
.slice(-limit);
}
/**
* Load narrative if it's fresh (< 36 hours old).
*/
function loadNarrative(workspace: string): string {
const filePath = join(rebootDir(workspace), "narrative.md");
if (isFileOlderThan(filePath, 36)) return "";
const content = loadText(filePath);
return content.trim().slice(0, 2000);
}
/**
* Boot Context Generator assembles BOOTSTRAP.md from persisted state.
*/
export class BootContextGenerator {
private readonly workspace: string;
private readonly config: CortexConfig["bootContext"];
private readonly logger: PluginLogger;
constructor(
workspace: string,
config: CortexConfig["bootContext"],
logger: PluginLogger,
) {
this.workspace = workspace;
this.config = config;
this.logger = logger;
}
/**
* Check if boot context should be generated.
*/
shouldGenerate(): boolean {
return this.config.enabled && this.config.onSessionStart;
}
/** Build header section. */
private buildHeader(): string {
const now = new Date();
return [
"# Context Briefing",
`Generated: ${now.toISOString().slice(0, 19)}Z | Local: ${now.toTimeString().slice(0, 5)}`,
"",
].join("\n");
}
/** Build state section (mode, mood, warnings). */
private buildState(): string {
const lines: string[] = ["## ⚡ State", `Mode: ${getExecutionMode()}`];
const threadsData = loadThreadsData(this.workspace);
const mood = (threadsData.session_mood ?? "neutral") as Mood;
if (mood !== "neutral") {
lines.push(`Last session mood: ${mood} ${MOOD_EMOJI[mood] ?? ""}`);
}
const warning = integrityWarning(this.workspace);
if (warning) {
lines.push("", warning);
}
lines.push("");
return lines.join("\n");
}
/** Build threads section. */
private buildThreads(threads: Thread[]): string {
if (threads.length === 0) return "";
const lines: string[] = ["## 🧵 Active Threads"];
for (const t of threads) {
const priEmoji = PRIORITY_EMOJI[t.priority] ?? "⚪";
const moodTag = t.mood && t.mood !== "neutral" ? ` [${t.mood}]` : "";
lines.push("", `### ${priEmoji} ${t.title}${moodTag}`);
lines.push(`Priority: ${t.priority} | Last: ${t.last_activity.slice(0, 16)}`);
lines.push(`Summary: ${t.summary || "no summary"}`);
if (t.waiting_for) lines.push(`⏳ Waiting for: ${t.waiting_for}`);
if (t.decisions.length > 0) lines.push(`Decisions: ${t.decisions.join(", ")}`);
}
lines.push("");
return lines.join("\n");
}
/** Build decisions section. */
private buildDecisions(decisions: Decision[]): string {
if (decisions.length === 0) return "";
const impactEmoji: Record<string, string> = { critical: "🔴", high: "🟠", medium: "🟡", low: "🔵" };
const lines: string[] = ["## 🎯 Recent Decisions"];
for (const d of decisions) {
lines.push(`- ${impactEmoji[d.impact] ?? "⚪"} **${d.what}** (${d.date})`);
if (d.why) lines.push(` Why: ${d.why.slice(0, 100)}`);
}
lines.push("");
return lines.join("\n");
}
/**
* Assemble and return BOOTSTRAP.md content.
*/
generate(): string {
ensureRebootDir(this.workspace, this.logger);
const threads = getOpenThreads(this.workspace, this.config.maxThreadsInBoot);
const decisions = loadRecentDecisions(
this.workspace, this.config.decisionRecencyDays, this.config.maxDecisionsInBoot,
);
const hot = loadHotSnapshot(this.workspace);
const narrative = loadNarrative(this.workspace);
const sections = [
this.buildHeader(),
this.buildState(),
hot ? `## 🔥 Last Session Snapshot\n${hot}\n` : "",
narrative ? `## 📖 Narrative (last 24h)\n${narrative}\n` : "",
this.buildThreads(threads),
this.buildDecisions(decisions),
"---",
`_Boot context | ${threads.length} active threads | ${decisions.length} recent decisions_`,
];
let result = sections.filter(Boolean).join("\n");
if (result.length > this.config.maxChars) {
result = result.slice(0, this.config.maxChars) + "\n\n_[truncated to token budget]_";
}
return result;
}
/**
* Generate and write BOOTSTRAP.md to the workspace root.
*/
write(): boolean {
try {
const content = this.generate();
const outputPath = join(this.workspace, "BOOTSTRAP.md");
return saveText(outputPath, content, this.logger);
} catch (err) {
this.logger.warn(`[cortex] Boot context generation failed: ${err}`);
return false;
}
}
}

107
src/config.ts Normal file
View file

@ -0,0 +1,107 @@
import type { CortexConfig } from "./types.js";
export const DEFAULTS: CortexConfig = {
enabled: true,
workspace: "",
threadTracker: {
enabled: true,
pruneDays: 7,
maxThreads: 50,
},
decisionTracker: {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
},
bootContext: {
enabled: true,
maxChars: 16000,
onSessionStart: true,
maxThreadsInBoot: 7,
maxDecisionsInBoot: 10,
decisionRecencyDays: 14,
},
preCompaction: {
enabled: true,
maxSnapshotMessages: 15,
},
narrative: {
enabled: true,
},
patterns: {
language: "both",
},
};
function bool(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function int(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) return Math.round(value);
return fallback;
}
function str(value: unknown, fallback: string): string {
return typeof value === "string" ? value : fallback;
}
function lang(value: unknown): "en" | "de" | "both" {
if (value === "en" || value === "de" || value === "both") return value;
return "both";
}
export function resolveConfig(pluginConfig?: Record<string, unknown>): CortexConfig {
const raw = pluginConfig ?? {};
const tt = (raw.threadTracker ?? {}) as Record<string, unknown>;
const dt = (raw.decisionTracker ?? {}) as Record<string, unknown>;
const bc = (raw.bootContext ?? {}) as Record<string, unknown>;
const pc = (raw.preCompaction ?? {}) as Record<string, unknown>;
const nr = (raw.narrative ?? {}) as Record<string, unknown>;
const pt = (raw.patterns ?? {}) as Record<string, unknown>;
return {
enabled: bool(raw.enabled, DEFAULTS.enabled),
workspace: str(raw.workspace, DEFAULTS.workspace),
threadTracker: {
enabled: bool(tt.enabled, DEFAULTS.threadTracker.enabled),
pruneDays: int(tt.pruneDays, DEFAULTS.threadTracker.pruneDays),
maxThreads: int(tt.maxThreads, DEFAULTS.threadTracker.maxThreads),
},
decisionTracker: {
enabled: bool(dt.enabled, DEFAULTS.decisionTracker.enabled),
maxDecisions: int(dt.maxDecisions, DEFAULTS.decisionTracker.maxDecisions),
dedupeWindowHours: int(dt.dedupeWindowHours, DEFAULTS.decisionTracker.dedupeWindowHours),
},
bootContext: {
enabled: bool(bc.enabled, DEFAULTS.bootContext.enabled),
maxChars: int(bc.maxChars, DEFAULTS.bootContext.maxChars),
onSessionStart: bool(bc.onSessionStart, DEFAULTS.bootContext.onSessionStart),
maxThreadsInBoot: int(bc.maxThreadsInBoot, DEFAULTS.bootContext.maxThreadsInBoot),
maxDecisionsInBoot: int(bc.maxDecisionsInBoot, DEFAULTS.bootContext.maxDecisionsInBoot),
decisionRecencyDays: int(bc.decisionRecencyDays, DEFAULTS.bootContext.decisionRecencyDays),
},
preCompaction: {
enabled: bool(pc.enabled, DEFAULTS.preCompaction.enabled),
maxSnapshotMessages: int(pc.maxSnapshotMessages, DEFAULTS.preCompaction.maxSnapshotMessages),
},
narrative: {
enabled: bool(nr.enabled, DEFAULTS.narrative.enabled),
},
patterns: {
language: lang(pt.language),
},
};
}
/**
* Resolve workspace directory from config, hook context, env, or cwd.
*/
export function resolveWorkspace(
config: CortexConfig,
ctx?: { workspaceDir?: string },
): string {
if (config.workspace) return config.workspace;
if (ctx?.workspaceDir) return ctx.workspaceDir;
return process.env.WORKSPACE_DIR ?? process.cwd();
}

178
src/decision-tracker.ts Normal file
View file

@ -0,0 +1,178 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type {
Decision,
DecisionsData,
ImpactLevel,
PluginLogger,
} from "./types.js";
import { getPatterns, HIGH_IMPACT_KEYWORDS } from "./patterns.js";
import type { PatternLanguage } from "./patterns.js";
import { loadJson, saveJson, rebootDir, ensureRebootDir } from "./storage.js";
export type DecisionTrackerConfig = {
enabled: boolean;
maxDecisions: number;
dedupeWindowHours: number;
};
/**
* Infer impact level from decision context text.
*/
export function inferImpact(text: string): ImpactLevel {
const lower = text.toLowerCase();
for (const kw of HIGH_IMPACT_KEYWORDS) {
if (lower.includes(kw)) return "high";
}
return "medium";
}
/**
* Extract context window around a match: 50 chars before, 100 chars after.
*/
function extractContext(text: string, matchIndex: number, matchLength: number): { what: string; why: string } {
const start = Math.max(0, matchIndex - 50);
const end = Math.min(text.length, matchIndex + matchLength + 100);
const what = text.slice(start, end).trim();
// Wider context for "why"
const whyStart = Math.max(0, matchIndex - 100);
const whyEnd = Math.min(text.length, matchIndex + matchLength + 200);
const why = text.slice(whyStart, whyEnd).trim();
return { what, why };
}
/**
* Decision Tracker extracts and persists decisions from messages.
*/
export class DecisionTracker {
private decisions: Decision[] = [];
private readonly filePath: string;
private readonly config: DecisionTrackerConfig;
private readonly language: PatternLanguage;
private readonly logger: PluginLogger;
private writeable = true;
constructor(
workspace: string,
config: DecisionTrackerConfig,
language: PatternLanguage,
logger: PluginLogger,
) {
this.config = config;
this.language = language;
this.logger = logger;
this.filePath = join(rebootDir(workspace), "decisions.json");
// Ensure directory exists
ensureRebootDir(workspace, logger);
// Load existing state
const data = loadJson<Partial<DecisionsData>>(this.filePath);
this.decisions = Array.isArray(data.decisions) ? data.decisions : [];
}
/**
* Process a message: scan for decision patterns, dedup, persist.
*/
processMessage(content: string, sender: string): void {
if (!content) return;
const patterns = getPatterns(this.language);
const now = new Date();
const dateStr = now.toISOString().slice(0, 10);
let changed = false;
for (const pattern of patterns.decision) {
const globalPattern = new RegExp(pattern.source, "gi");
let match: RegExpExecArray | null;
while ((match = globalPattern.exec(content)) !== null) {
const { what, why } = extractContext(content, match.index, match[0].length);
// Deduplication: skip if identical 'what' exists within dedupeWindow
if (this.isDuplicate(what, now)) continue;
const decision: Decision = {
id: randomUUID(),
what,
date: dateStr,
why,
impact: inferImpact(what + " " + why),
who: sender,
extracted_at: now.toISOString(),
};
this.decisions.push(decision);
changed = true;
}
}
if (changed) {
this.enforceMax();
this.persist();
}
}
/**
* Check if a decision with the same 'what' exists within the dedup window.
*/
private isDuplicate(what: string, now: Date): boolean {
const windowMs = this.config.dedupeWindowHours * 60 * 60 * 1000;
const cutoff = new Date(now.getTime() - windowMs).toISOString();
return this.decisions.some(
d => d.what === what && d.extracted_at >= cutoff,
);
}
/**
* Enforce maxDecisions cap remove oldest decisions first.
*/
private enforceMax(): void {
if (this.decisions.length > this.config.maxDecisions) {
this.decisions = this.decisions.slice(
this.decisions.length - this.config.maxDecisions,
);
}
}
/**
* Persist decisions to disk.
*/
private persist(): void {
if (!this.writeable) return;
const data: DecisionsData = {
version: 1,
updated: new Date().toISOString(),
decisions: this.decisions,
};
const ok = saveJson(this.filePath, data, this.logger);
if (!ok) {
this.writeable = false;
this.logger.warn("[cortex] Decision tracker: workspace not writable");
}
}
/**
* Get all decisions (in-memory).
*/
getDecisions(): Decision[] {
return [...this.decisions];
}
/**
* Get recent decisions within N days.
*/
getRecentDecisions(days: number, limit: number): Decision[] {
const cutoff = new Date(
Date.now() - days * 24 * 60 * 60 * 1000,
).toISOString().slice(0, 10);
return this.decisions
.filter(d => d.date >= cutoff)
.slice(-limit);
}
}

121
src/hooks.ts Normal file
View file

@ -0,0 +1,121 @@
import type {
OpenClawPluginApi,
CortexConfig,
HookEvent,
HookContext,
} from "./types.js";
import { resolveWorkspace } from "./config.js";
import { ThreadTracker } from "./thread-tracker.js";
import { DecisionTracker } from "./decision-tracker.js";
import { BootContextGenerator } from "./boot-context.js";
import { PreCompaction } from "./pre-compaction.js";
/**
* Extract message content from a hook event using the fallback chain.
*/
function extractContent(event: HookEvent): string {
return event.content ?? event.message ?? event.text ?? "";
}
/**
* Extract sender from a hook event.
*/
function extractSender(event: HookEvent): string {
return event.from ?? event.sender ?? event.role ?? "unknown";
}
/** Shared state across hooks, lazy-initialized on first call. */
type HookState = {
workspace: string | null;
threadTracker: ThreadTracker | null;
decisionTracker: DecisionTracker | null;
};
function ensureInit(state: HookState, config: CortexConfig, logger: OpenClawPluginApi["logger"], ctx?: HookContext): void {
if (!state.workspace) {
state.workspace = resolveWorkspace(config, ctx);
}
if (!state.threadTracker && config.threadTracker.enabled) {
state.threadTracker = new ThreadTracker(state.workspace, config.threadTracker, config.patterns.language, logger);
}
if (!state.decisionTracker && config.decisionTracker.enabled) {
state.decisionTracker = new DecisionTracker(state.workspace, config.decisionTracker, config.patterns.language, logger);
}
}
/** Register message hooks (message_received + message_sent). */
function registerMessageHooks(api: OpenClawPluginApi, config: CortexConfig, state: HookState): void {
if (!config.threadTracker.enabled && !config.decisionTracker.enabled) return;
const handler = (event: HookEvent, ctx: HookContext, senderOverride?: string) => {
try {
ensureInit(state, config, api.logger, ctx);
const content = extractContent(event);
const sender = senderOverride ?? extractSender(event);
if (!content) return;
if (config.threadTracker.enabled && state.threadTracker) state.threadTracker.processMessage(content, sender);
if (config.decisionTracker.enabled && state.decisionTracker) state.decisionTracker.processMessage(content, sender);
} catch (err) {
api.logger.warn(`[cortex] message hook error: ${err}`);
}
};
api.on("message_received", (event, ctx) => handler(event, ctx), { priority: 100 });
api.on("message_sent", (event, ctx) => handler(event, ctx, event.role ?? "assistant"), { priority: 100 });
}
/** Register session_start hook for boot context. */
function registerSessionHooks(api: OpenClawPluginApi, config: CortexConfig, state: HookState): void {
if (!config.bootContext.enabled || !config.bootContext.onSessionStart) return;
api.on("session_start", (_event, ctx) => {
try {
ensureInit(state, config, api.logger, ctx);
new BootContextGenerator(state.workspace!, config.bootContext, api.logger).write();
api.logger.info("[cortex] Boot context generated on session start");
} catch (err) {
api.logger.warn(`[cortex] session_start error: ${err}`);
}
}, { priority: 10 });
}
/** Register compaction hooks (before + after). */
function registerCompactionHooks(api: OpenClawPluginApi, config: CortexConfig, state: HookState): void {
if (config.preCompaction.enabled) {
api.on("before_compaction", (event, ctx) => {
try {
ensureInit(state, config, api.logger, ctx);
const tracker = state.threadTracker ?? new ThreadTracker(state.workspace!, config.threadTracker, config.patterns.language, api.logger);
const result = new PreCompaction(state.workspace!, config, api.logger, tracker).run(event.compactingMessages);
if (result.warnings.length > 0) api.logger.warn(`[cortex] Pre-compaction warnings: ${result.warnings.join("; ")}`);
api.logger.info(`[cortex] Pre-compaction complete: ${result.messagesSnapshotted} messages snapshotted`);
} catch (err) {
api.logger.warn(`[cortex] before_compaction error: ${err}`);
}
}, { priority: 5 });
}
api.on("after_compaction", () => {
try {
api.logger.info(`[cortex] Compaction completed at ${new Date().toISOString()}`);
} catch (err) {
api.logger.warn(`[cortex] after_compaction error: ${err}`);
}
}, { priority: 200 });
}
/**
* Register all cortex hook handlers on the plugin API.
* Each handler is wrapped in try/catch never throws.
*/
export function registerCortexHooks(api: OpenClawPluginApi, config: CortexConfig): void {
const state: HookState = { workspace: null, threadTracker: null, decisionTracker: null };
registerMessageHooks(api, config, state);
registerSessionHooks(api, config, state);
registerCompactionHooks(api, config, state);
api.logger.info(
`[cortex] Hooks registered — threads:${config.threadTracker.enabled} decisions:${config.decisionTracker.enabled} boot:${config.bootContext.enabled} compaction:${config.preCompaction.enabled}`,
);
}

196
src/narrative-generator.ts Normal file
View file

@ -0,0 +1,196 @@
import { join } from "node:path";
import type {
Thread,
Decision,
ThreadsData,
DecisionsData,
NarrativeSections,
PluginLogger,
} from "./types.js";
import { loadJson, loadText, rebootDir, saveText, ensureRebootDir } from "./storage.js";
/**
* Load daily notes for today and yesterday.
*/
export function loadDailyNotes(workspace: string): string {
const parts: string[] = [];
const now = new Date();
const today = now.toISOString().slice(0, 10);
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
for (const date of [yesterday, today]) {
const filePath = join(workspace, "memory", `${date}.md`);
const content = loadText(filePath);
if (content) {
parts.push(`## ${date}\n${content.slice(0, 4000)}`);
}
}
return parts.join("\n\n");
}
/**
* Load threads from threads.json.
*/
function loadThreads(workspace: string): Thread[] {
const data = loadJson<Partial<ThreadsData>>(
join(rebootDir(workspace), "threads.json"),
);
return Array.isArray(data.threads) ? data.threads : [];
}
/**
* Load recent decisions (from last 24h).
*/
function loadRecentDecisions(workspace: string): Decision[] {
const data = loadJson<Partial<DecisionsData>>(
join(rebootDir(workspace), "decisions.json"),
);
const decisions = Array.isArray(data.decisions) ? data.decisions : [];
const yesterday = new Date(
Date.now() - 24 * 60 * 60 * 1000,
).toISOString().slice(0, 10);
return decisions.filter(d => d.date >= yesterday);
}
/**
* Extract timeline entries from daily notes.
*/
export function extractTimeline(notes: string): string[] {
const entries: string[] = [];
for (const line of notes.split("\n")) {
const trimmed = line.trim();
// Skip date headers (## 2026-02-17)
if (trimmed.startsWith("## ") && !trimmed.match(/^## \d{4}-\d{2}-\d{2}/)) {
entries.push(trimmed.slice(3));
} else if (trimmed.startsWith("### ")) {
entries.push(` ${trimmed.slice(4)}`);
}
}
return entries;
}
/**
* Build narrative sections from data.
*/
export function buildSections(
threads: Thread[],
decisions: Decision[],
notes: string,
): NarrativeSections {
const now = new Date();
const yesterday = new Date(
now.getTime() - 24 * 60 * 60 * 1000,
).toISOString().slice(0, 10);
const completed = threads.filter(
t => t.status === "closed" && t.last_activity.slice(0, 10) >= yesterday,
);
const open = threads.filter(t => t.status === "open");
const timelineEntries = extractTimeline(notes);
return { completed, open, decisions, timelineEntries };
}
/**
* Generate a structured narrative from sections.
*/
export function generateStructured(sections: NarrativeSections): string {
const now = new Date();
const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const monthNames = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const day = dayNames[now.getDay()];
const date = now.getDate();
const month = monthNames[now.getMonth()];
const year = now.getFullYear();
const parts: string[] = [
`*${day}, ${String(date).padStart(2, "0")}. ${month} ${year} — Narrative*\n`,
];
if (sections.completed.length > 0) {
parts.push("**Completed:**");
for (const t of sections.completed) {
parts.push(`- ✅ ${t.title}: ${(t.summary || "").slice(0, 100)}`);
}
parts.push("");
}
if (sections.open.length > 0) {
parts.push("**Open:**");
for (const t of sections.open) {
const emoji = t.priority === "critical" ? "🔴" : "🟡";
parts.push(`- ${emoji} ${t.title}: ${(t.summary || "").slice(0, 150)}`);
if (t.waiting_for) {
parts.push(`${t.waiting_for}`);
}
}
parts.push("");
}
if (sections.decisions.length > 0) {
parts.push("**Decisions:**");
for (const d of sections.decisions) {
parts.push(`- ${d.what}${(d.why || "").slice(0, 80)}`);
}
parts.push("");
}
if (sections.timelineEntries.length > 0) {
parts.push("**Timeline:**");
for (const entry of sections.timelineEntries) {
parts.push(`- ${entry}`);
}
parts.push("");
}
return parts.join("\n");
}
/**
* Narrative Generator creates a structured narrative from recent activity.
*/
export class NarrativeGenerator {
private readonly workspace: string;
private readonly logger: PluginLogger;
constructor(workspace: string, logger: PluginLogger) {
this.workspace = workspace;
this.logger = logger;
}
/**
* Generate and write narrative.md.
*/
generate(): string {
ensureRebootDir(this.workspace, this.logger);
const notes = loadDailyNotes(this.workspace);
const threads = loadThreads(this.workspace);
const decisions = loadRecentDecisions(this.workspace);
const sections = buildSections(threads, decisions, notes);
return generateStructured(sections);
}
/**
* Generate and write to disk.
*/
write(): boolean {
try {
const narrative = this.generate();
const filePath = join(rebootDir(this.workspace), "narrative.md");
return saveText(filePath, narrative, this.logger);
} catch (err) {
this.logger.warn(`[cortex] Narrative generation failed: ${err}`);
return false;
}
}
}

127
src/patterns.ts Normal file
View file

@ -0,0 +1,127 @@
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 = [
/(?:^|\s)(?:is |it's |that's |all )?(?:done|fixed|solved|closed)(?:\s|[.!]|$)/i,
/(?:^|\s)(?:it |that )works(?:\s|[.!]|$)/i,
/✅/,
];
const CLOSE_PATTERNS_DE = [
/(?:^|\s)(?:ist |schon )?(?:erledigt|gefixt|gelöst|fertig)(?:\s|[.!]|$)/i,
/(?:^|\s)(?:es |das )funktioniert(?:\s|[.!]|$)/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<Exclude<Mood, "neutral">, RegExp> = {
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 [Exclude<Mood, "neutral">, RegExp][]) {
// 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",
];
/** Export mood patterns for testing */
export { MOOD_PATTERNS };

144
src/pre-compaction.ts Normal file
View file

@ -0,0 +1,144 @@
import { join } from "node:path";
import type {
CompactingMessage,
PreCompactionResult,
PluginLogger,
CortexConfig,
} from "./types.js";
import { ThreadTracker } from "./thread-tracker.js";
import { NarrativeGenerator } from "./narrative-generator.js";
import { BootContextGenerator } from "./boot-context.js";
import { saveText, rebootDir, ensureRebootDir } from "./storage.js";
/**
* Build a hot snapshot markdown from compacting messages.
*/
export function buildHotSnapshot(
messages: CompactingMessage[],
maxMessages: number,
): string {
const now = new Date().toISOString().slice(0, 19) + "Z";
const parts: string[] = [
`# Hot Snapshot — ${now}`,
"## Last conversation before compaction",
"",
];
const recent = messages.slice(-maxMessages);
if (recent.length > 0) {
parts.push("**Recent messages:**");
for (const msg of recent) {
const content = msg.content.trim();
const short = content.length > 200 ? content.slice(0, 200) + "..." : content;
parts.push(`- [${msg.role}] ${short}`);
}
} else {
parts.push("(No recent messages captured)");
}
parts.push("");
return parts.join("\n");
}
/**
* Pre-Compaction Pipeline orchestrates all modules before memory compaction.
*/
export class PreCompaction {
private readonly workspace: string;
private readonly config: CortexConfig;
private readonly logger: PluginLogger;
private readonly threadTracker: ThreadTracker;
constructor(
workspace: string,
config: CortexConfig,
logger: PluginLogger,
threadTracker: ThreadTracker,
) {
this.workspace = workspace;
this.config = config;
this.logger = logger;
this.threadTracker = threadTracker;
}
/**
* Run the full pre-compaction pipeline.
*/
run(compactingMessages?: CompactingMessage[]): PreCompactionResult {
const warnings: string[] = [];
const now = new Date().toISOString();
let messagesSnapshotted = 0;
ensureRebootDir(this.workspace, this.logger);
// 1. Flush thread tracker state
try {
this.threadTracker.flush();
this.logger.info("[cortex] Pre-compaction: thread state flushed");
} catch (err) {
warnings.push(`Thread flush failed: ${err}`);
this.logger.warn(`[cortex] Pre-compaction: thread flush failed: ${err}`);
}
// 2. Build and write hot snapshot
try {
const messages = compactingMessages ?? [];
messagesSnapshotted = Math.min(
messages.length,
this.config.preCompaction.maxSnapshotMessages,
);
const snapshot = buildHotSnapshot(
messages,
this.config.preCompaction.maxSnapshotMessages,
);
const snapshotPath = join(rebootDir(this.workspace), "hot-snapshot.md");
const ok = saveText(snapshotPath, snapshot, this.logger);
if (!ok) warnings.push("Hot snapshot write failed");
this.logger.info(
`[cortex] Pre-compaction: hot snapshot (${messagesSnapshotted} messages)`,
);
} catch (err) {
warnings.push(`Hot snapshot failed: ${err}`);
this.logger.warn(`[cortex] Pre-compaction: hot snapshot failed: ${err}`);
}
// 3. Generate narrative
try {
if (this.config.narrative.enabled) {
const narrative = new NarrativeGenerator(this.workspace, this.logger);
narrative.write();
this.logger.info("[cortex] Pre-compaction: narrative generated");
}
} catch (err) {
warnings.push(`Narrative generation failed: ${err}`);
this.logger.warn(
`[cortex] Pre-compaction: narrative generation failed: ${err}`,
);
}
// 4. Generate boot context
try {
if (this.config.bootContext.enabled) {
const boot = new BootContextGenerator(
this.workspace,
this.config.bootContext,
this.logger,
);
boot.write();
this.logger.info("[cortex] Pre-compaction: boot context generated");
}
} catch (err) {
warnings.push(`Boot context generation failed: ${err}`);
this.logger.warn(
`[cortex] Pre-compaction: boot context generation failed: ${err}`,
);
}
return {
success: warnings.length === 0,
timestamp: now,
messagesSnapshotted,
warnings,
};
}
}

126
src/storage.ts Normal file
View file

@ -0,0 +1,126 @@
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;
}

303
src/thread-tracker.ts Normal file
View file

@ -0,0 +1,303 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type {
Thread,
ThreadsData,
ThreadSignals,
ThreadPriority,
PluginLogger,
} from "./types.js";
import { getPatterns, detectMood, HIGH_IMPACT_KEYWORDS } from "./patterns.js";
import type { PatternLanguage } from "./patterns.js";
import { loadJson, saveJson, rebootDir, ensureRebootDir } from "./storage.js";
export type ThreadTrackerConfig = {
enabled: boolean;
pruneDays: number;
maxThreads: number;
};
/**
* Check if text matches a thread via word overlap ( minOverlap words from title in text).
* Words shorter than 3 characters are excluded.
*/
export 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;
}
/**
* Extract thread-related signals from message text.
*/
export function extractSignals(text: string, language: PatternLanguage): ThreadSignals {
const patterns = getPatterns(language);
const signals: ThreadSignals = { decisions: [], closures: [], waits: [], topics: [] };
for (const pattern of patterns.decision) {
const globalPattern = new RegExp(pattern.source, "gi");
let match: RegExpExecArray | null;
while ((match = globalPattern.exec(text)) !== null) {
const start = Math.max(0, match.index - 50);
const end = Math.min(text.length, match.index + match[0].length + 100);
signals.decisions.push(text.slice(start, end).trim());
}
}
for (const pattern of patterns.close) {
if (pattern.test(text)) {
signals.closures.push(true);
}
}
for (const pattern of patterns.wait) {
const globalPattern = new RegExp(pattern.source, "gi");
let match: RegExpExecArray | null;
while ((match = globalPattern.exec(text)) !== null) {
const end = Math.min(text.length, match.index + match[0].length + 80);
signals.waits.push(text.slice(match.index, end).trim());
}
}
for (const pattern of patterns.topic) {
const globalPattern = new RegExp(pattern.source, "gi");
let match: RegExpExecArray | null;
while ((match = globalPattern.exec(text)) !== null) {
if (match[1]) {
signals.topics.push(match[1].trim());
}
}
}
return signals;
}
/**
* Infer thread priority from content.
*/
function inferPriority(text: string): ThreadPriority {
const lower = text.toLowerCase();
for (const kw of HIGH_IMPACT_KEYWORDS) {
if (lower.includes(kw)) return "high";
}
return "medium";
}
/**
* Thread Tracker manages conversation thread state.
*/
export class ThreadTracker {
private threads: Thread[] = [];
private dirty = false;
private writeable = true;
private eventsProcessed = 0;
private lastEventTimestamp = "";
private sessionMood = "neutral";
private readonly filePath: string;
private readonly config: ThreadTrackerConfig;
private readonly language: PatternLanguage;
private readonly logger: PluginLogger;
constructor(
workspace: string,
config: ThreadTrackerConfig,
language: PatternLanguage,
logger: PluginLogger,
) {
this.config = config;
this.language = language;
this.logger = logger;
this.filePath = join(rebootDir(workspace), "threads.json");
// Ensure directory exists
ensureRebootDir(workspace, logger);
// Load existing state
const data = loadJson<Partial<ThreadsData>>(this.filePath);
this.threads = Array.isArray(data.threads) ? data.threads : [];
this.sessionMood = data.session_mood ?? "neutral";
}
/** Create new threads from topic signals. */
private createFromTopics(topics: string[], sender: string, mood: string, now: string): void {
for (const topic of topics) {
const exists = this.threads.some(
t => t.title.toLowerCase() === topic.toLowerCase() || matchesThread(t, topic),
);
if (!exists) {
this.threads.push({
id: randomUUID(), title: topic, status: "open",
priority: inferPriority(topic), summary: `Topic detected from ${sender}`,
decisions: [], waiting_for: null, mood, last_activity: now, created: now,
});
}
}
}
/** Close threads matching closure signals. */
private closeMatching(content: string, closures: boolean[], now: string): void {
if (closures.length === 0) return;
for (const thread of this.threads) {
if (thread.status === "open" && matchesThread(thread, content)) {
thread.status = "closed";
thread.last_activity = now;
}
}
}
/** Append decisions to matching threads. */
private applyDecisions(decisions: string[], now: string): void {
for (const ctx of decisions) {
for (const thread of this.threads) {
if (thread.status === "open" && matchesThread(thread, ctx)) {
const short = ctx.slice(0, 100);
if (!thread.decisions.includes(short)) {
thread.decisions.push(short);
thread.last_activity = now;
}
}
}
}
}
/** Update waiting_for on matching threads. */
private applyWaits(waits: string[], content: string, now: string): void {
for (const waitCtx of waits) {
for (const thread of this.threads) {
if (thread.status === "open" && matchesThread(thread, content)) {
thread.waiting_for = waitCtx.slice(0, 100);
thread.last_activity = now;
}
}
}
}
/** Update mood on active threads matching content. */
private applyMood(mood: string, content: string): void {
if (mood === "neutral") return;
for (const thread of this.threads) {
if (thread.status === "open" && matchesThread(thread, content)) {
thread.mood = mood;
}
}
}
/**
* Process a message: extract signals, update threads, persist.
*/
processMessage(content: string, sender: string): void {
if (!content) return;
const signals = extractSignals(content, this.language);
const mood = detectMood(content);
const now = new Date().toISOString();
this.eventsProcessed++;
this.lastEventTimestamp = now;
if (mood !== "neutral") this.sessionMood = mood;
this.createFromTopics(signals.topics, sender, mood, now);
this.closeMatching(content, signals.closures, now);
this.applyDecisions(signals.decisions, now);
this.applyWaits(signals.waits, content, now);
this.applyMood(mood, content);
this.dirty = true;
this.pruneAndCap();
this.persist();
}
/**
* Prune closed threads older than pruneDays and enforce maxThreads cap.
*/
private pruneAndCap(): void {
const cutoff = new Date(
Date.now() - this.config.pruneDays * 24 * 60 * 60 * 1000,
).toISOString();
// Remove closed threads older than cutoff
this.threads = this.threads.filter(
t => !(t.status === "closed" && t.last_activity < cutoff),
);
// Enforce maxThreads cap — remove oldest closed threads first
if (this.threads.length > this.config.maxThreads) {
const open = this.threads.filter(t => t.status === "open");
const closed = this.threads
.filter(t => t.status === "closed")
.sort((a, b) => a.last_activity.localeCompare(b.last_activity));
const budget = this.config.maxThreads - open.length;
this.threads = [...open, ...closed.slice(Math.max(0, closed.length - budget))];
}
}
/**
* Attempt to persist current state to disk.
*/
private persist(): void {
if (!this.writeable) return;
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;
}
/**
* Build the ThreadsData object for serialization.
*/
private buildData(): ThreadsData {
return {
version: 2,
updated: new Date().toISOString(),
threads: this.threads,
integrity: {
last_event_timestamp: this.lastEventTimestamp || new Date().toISOString(),
events_processed: this.eventsProcessed,
source: "hooks",
},
session_mood: this.sessionMood,
};
}
/**
* Force-flush state to disk. Called by pre-compaction.
*/
flush(): boolean {
if (!this.dirty) return true;
return saveJson(this.filePath, this.buildData(), this.logger);
}
/**
* Get current thread list (in-memory).
*/
getThreads(): Thread[] {
return [...this.threads];
}
/**
* Get current session mood.
*/
getSessionMood(): string {
return this.sessionMood;
}
/**
* Get events processed count.
*/
getEventsProcessed(): number {
return this.eventsProcessed;
}
}

283
src/types.ts Normal file
View file

@ -0,0 +1,283 @@
// ============================================================
// 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,
};

508
test/boot-context.test.ts Normal file
View file

@ -0,0 +1,508 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, utimesSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
BootContextGenerator,
getExecutionMode,
getOpenThreads,
integrityWarning,
} from "../src/boot-context.js";
import type { CortexConfig } from "../src/types.js";
const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
function makeWorkspace(): string {
const ws = mkdtempSync(join(tmpdir(), "cortex-bc-"));
mkdirSync(join(ws, "memory", "reboot"), { recursive: true });
return ws;
}
const defaultBootConfig: CortexConfig["bootContext"] = {
enabled: true,
maxChars: 16000,
onSessionStart: true,
maxThreadsInBoot: 7,
maxDecisionsInBoot: 10,
decisionRecencyDays: 14,
};
function seedThreads(ws: string, threads: Record<string, unknown>[] = []) {
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2,
updated: new Date().toISOString(),
threads,
integrity: {
last_event_timestamp: new Date().toISOString(),
events_processed: 5,
source: "hooks",
},
session_mood: "productive",
}),
);
}
function seedDecisions(ws: string, decisions: Record<string, unknown>[] = []) {
writeFileSync(
join(ws, "memory", "reboot", "decisions.json"),
JSON.stringify({
version: 1,
updated: new Date().toISOString(),
decisions,
}),
);
}
function seedNarrative(ws: string, content: string, hoursOld = 0) {
const filePath = join(ws, "memory", "reboot", "narrative.md");
writeFileSync(filePath, content);
if (hoursOld > 0) {
const mtime = new Date(Date.now() - hoursOld * 60 * 60 * 1000);
utimesSync(filePath, mtime, mtime);
}
}
function seedHotSnapshot(ws: string, content: string, hoursOld = 0) {
const filePath = join(ws, "memory", "reboot", "hot-snapshot.md");
writeFileSync(filePath, content);
if (hoursOld > 0) {
const mtime = new Date(Date.now() - hoursOld * 60 * 60 * 1000);
utimesSync(filePath, mtime, mtime);
}
}
// ════════════════════════════════════════════════════════════
// getExecutionMode
// ════════════════════════════════════════════════════════════
describe("getExecutionMode", () => {
it("returns a string containing a mode description", () => {
const mode = getExecutionMode();
expect(typeof mode).toBe("string");
expect(mode.length).toBeGreaterThan(0);
// Should contain one of the known modes
const validModes = ["Morning", "Afternoon", "Evening", "Night"];
expect(validModes.some(m => mode.includes(m))).toBe(true);
});
});
// ════════════════════════════════════════════════════════════
// getOpenThreads
// ════════════════════════════════════════════════════════════
describe("getOpenThreads", () => {
it("returns only open threads", () => {
const ws = makeWorkspace();
seedThreads(ws, [
{ id: "1", title: "open one", status: "open", priority: "medium", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: new Date().toISOString(), created: new Date().toISOString() },
{ id: "2", title: "closed one", status: "closed", priority: "medium", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: new Date().toISOString(), created: new Date().toISOString() },
]);
const threads = getOpenThreads(ws, 7);
expect(threads).toHaveLength(1);
expect(threads[0].id).toBe("1");
});
it("sorts by priority (critical first)", () => {
const ws = makeWorkspace();
seedThreads(ws, [
{ id: "low", title: "low", status: "open", priority: "low", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: new Date().toISOString(), created: new Date().toISOString() },
{ id: "critical", title: "crit", status: "open", priority: "critical", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: new Date().toISOString(), created: new Date().toISOString() },
{ id: "high", title: "high", status: "open", priority: "high", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: new Date().toISOString(), created: new Date().toISOString() },
]);
const threads = getOpenThreads(ws, 7);
expect(threads[0].id).toBe("critical");
expect(threads[1].id).toBe("high");
expect(threads[2].id).toBe("low");
});
it("within same priority, sorts by recency (newest first)", () => {
const ws = makeWorkspace();
const older = new Date(Date.now() - 60000).toISOString();
const newer = new Date().toISOString();
seedThreads(ws, [
{ id: "old", title: "old", status: "open", priority: "medium", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: older, created: older },
{ id: "new", title: "new", status: "open", priority: "medium", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: newer, created: newer },
]);
const threads = getOpenThreads(ws, 7);
expect(threads[0].id).toBe("new");
});
it("respects limit parameter", () => {
const ws = makeWorkspace();
const threads = Array.from({ length: 10 }, (_, i) => ({
id: `t-${i}`, title: `thread ${i}`, status: "open", priority: "medium",
summary: "", decisions: [], waiting_for: null, mood: "neutral",
last_activity: new Date().toISOString(), created: new Date().toISOString(),
}));
seedThreads(ws, threads);
const result = getOpenThreads(ws, 3);
expect(result).toHaveLength(3);
});
it("handles missing threads.json", () => {
const ws = makeWorkspace();
const threads = getOpenThreads(ws, 7);
expect(threads).toHaveLength(0);
});
});
// ════════════════════════════════════════════════════════════
// integrityWarning
// ════════════════════════════════════════════════════════════
describe("integrityWarning", () => {
it("returns warning when no integrity data", () => {
const ws = makeWorkspace();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({ version: 2, threads: [], integrity: {}, session_mood: "neutral" }),
);
const warning = integrityWarning(ws);
expect(warning).toContain("⚠️");
});
it("returns empty string for fresh data", () => {
const ws = makeWorkspace();
seedThreads(ws);
const warning = integrityWarning(ws);
expect(warning).toBe("");
});
it("returns staleness warning for data > 2h old", () => {
const ws = makeWorkspace();
const old = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "neutral",
integrity: { last_event_timestamp: old, events_processed: 1, source: "hooks" },
}),
);
const warning = integrityWarning(ws);
expect(warning).toContain("⚠️");
expect(warning).toContain("staleness");
});
it("returns STALE DATA for data > 8h old", () => {
const ws = makeWorkspace();
const old = new Date(Date.now() - 10 * 60 * 60 * 1000).toISOString();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "neutral",
integrity: { last_event_timestamp: old, events_processed: 1, source: "hooks" },
}),
);
const warning = integrityWarning(ws);
expect(warning).toContain("🚨");
expect(warning).toContain("STALE DATA");
});
it("handles missing file gracefully", () => {
const ws = makeWorkspace();
const warning = integrityWarning(ws);
expect(warning).toContain("⚠️");
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — generate
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — generate", () => {
it("produces valid markdown with header", () => {
const ws = makeWorkspace();
seedThreads(ws);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("# Context Briefing");
expect(md).toContain("Generated:");
});
it("includes execution mode", () => {
const ws = makeWorkspace();
seedThreads(ws);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("Mode:");
});
it("includes session mood if not neutral", () => {
const ws = makeWorkspace();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "excited",
integrity: { last_event_timestamp: new Date().toISOString(), events_processed: 1, source: "hooks" },
}),
);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("excited");
expect(md).toContain("🔥");
});
it("includes active threads section", () => {
const ws = makeWorkspace();
seedThreads(ws, [
{
id: "1", title: "auth migration", status: "open", priority: "high",
summary: "Migrating to OAuth2", decisions: ["use PKCE"], waiting_for: "code review",
mood: "productive", last_activity: new Date().toISOString(), created: new Date().toISOString(),
},
]);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("🧵 Active Threads");
expect(md).toContain("auth migration");
expect(md).toContain("🟠"); // high priority
expect(md).toContain("Migrating to OAuth2");
expect(md).toContain("⏳ Waiting for: code review");
expect(md).toContain("use PKCE");
});
it("includes recent decisions section", () => {
const ws = makeWorkspace();
seedThreads(ws);
seedDecisions(ws, [
{
id: "d1", what: "decided to use TypeScript",
date: new Date().toISOString().slice(0, 10),
why: "Type safety", impact: "high", who: "albert",
extracted_at: new Date().toISOString(),
},
]);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("🎯 Recent Decisions");
expect(md).toContain("decided to use TypeScript");
});
it("includes narrative when fresh", () => {
const ws = makeWorkspace();
seedThreads(ws);
seedNarrative(ws, "Today was productive. Built the cortex plugin.", 0);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("📖 Narrative");
expect(md).toContain("Today was productive");
});
it("excludes narrative when stale (>36h)", () => {
const ws = makeWorkspace();
seedThreads(ws);
seedNarrative(ws, "Old narrative content here", 48);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).not.toContain("Old narrative content");
});
it("includes hot snapshot when fresh", () => {
const ws = makeWorkspace();
seedThreads(ws);
seedHotSnapshot(ws, "# Hot Snapshot\nRecent conversation...", 0);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("🔥 Last Session Snapshot");
});
it("excludes hot snapshot when stale (>1h)", () => {
const ws = makeWorkspace();
seedThreads(ws);
seedHotSnapshot(ws, "# Old Snapshot\nOld conversation...", 2);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).not.toContain("Old Snapshot");
});
it("includes footer with stats", () => {
const ws = makeWorkspace();
seedThreads(ws);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("_Boot context |");
});
it("handles empty state gracefully", () => {
const ws = makeWorkspace();
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("# Context Briefing");
expect(md).toContain("_Boot context |");
// Should still be valid markdown
expect(md.length).toBeGreaterThan(50);
});
it("excludes decisions older than decisionRecencyDays", () => {
const ws = makeWorkspace();
seedThreads(ws);
const oldDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
seedDecisions(ws, [
{
id: "old", what: "old decision about fonts",
date: oldDate, why: "legacy", impact: "medium", who: "user",
extracted_at: new Date().toISOString(),
},
]);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).not.toContain("old decision about fonts");
});
it("limits threads to maxThreadsInBoot", () => {
const ws = makeWorkspace();
const threads = Array.from({ length: 15 }, (_, i) => ({
id: `t-${i}`, title: `thread ${i}`, status: "open", priority: "medium",
summary: `summary ${i}`, decisions: [], waiting_for: null, mood: "neutral",
last_activity: new Date().toISOString(), created: new Date().toISOString(),
}));
seedThreads(ws, threads);
const config = { ...defaultBootConfig, maxThreadsInBoot: 3 };
const gen = new BootContextGenerator(ws, config, logger);
const md = gen.generate();
// Count thread section headers
const threadHeaders = (md.match(/^### /gm) || []).length;
expect(threadHeaders).toBe(3);
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — truncation
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — truncation", () => {
it("truncates output exceeding maxChars", () => {
const ws = makeWorkspace();
// Seed many threads to exceed budget
const threads = Array.from({ length: 20 }, (_, i) => ({
id: `t-${i}`, title: `very long thread title number ${i}`,
status: "open", priority: "medium",
summary: "A".repeat(500), decisions: ["X".repeat(100)],
waiting_for: "Y".repeat(100), mood: "neutral",
last_activity: new Date().toISOString(), created: new Date().toISOString(),
}));
seedThreads(ws, threads);
const config = { ...defaultBootConfig, maxChars: 2000 };
const gen = new BootContextGenerator(ws, config, logger);
const md = gen.generate();
expect(md.length).toBeLessThanOrEqual(2100); // 2000 + truncation marker
expect(md).toContain("[truncated");
});
it("does not truncate within budget", () => {
const ws = makeWorkspace();
seedThreads(ws);
const config = { ...defaultBootConfig, maxChars: 64000 };
const gen = new BootContextGenerator(ws, config, logger);
const md = gen.generate();
expect(md).not.toContain("[truncated");
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — write
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — write", () => {
it("writes BOOTSTRAP.md to workspace root", () => {
const ws = makeWorkspace();
seedThreads(ws);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const result = gen.write();
expect(result).toBe(true);
const content = readFileSync(join(ws, "BOOTSTRAP.md"), "utf-8");
expect(content).toContain("# Context Briefing");
});
it("overwrites existing BOOTSTRAP.md", () => {
const ws = makeWorkspace();
writeFileSync(join(ws, "BOOTSTRAP.md"), "old content");
seedThreads(ws);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
gen.write();
const content = readFileSync(join(ws, "BOOTSTRAP.md"), "utf-8");
expect(content).toContain("# Context Briefing");
expect(content).not.toContain("old content");
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — shouldGenerate
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — shouldGenerate", () => {
it("returns true when enabled and onSessionStart", () => {
const ws = makeWorkspace();
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
expect(gen.shouldGenerate()).toBe(true);
});
it("returns false when disabled", () => {
const ws = makeWorkspace();
const config = { ...defaultBootConfig, enabled: false };
const gen = new BootContextGenerator(ws, config, logger);
expect(gen.shouldGenerate()).toBe(false);
});
it("returns false when onSessionStart is false", () => {
const ws = makeWorkspace();
const config = { ...defaultBootConfig, onSessionStart: false };
const gen = new BootContextGenerator(ws, config, logger);
expect(gen.shouldGenerate()).toBe(false);
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — mood display
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — mood display", () => {
it("shows frustrated mood with emoji", () => {
const ws = makeWorkspace();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "frustrated",
integrity: { last_event_timestamp: new Date().toISOString(), events_processed: 1, source: "hooks" },
}),
);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("frustrated");
expect(md).toContain("😤");
});
it("does not show mood line for neutral", () => {
const ws = makeWorkspace();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "neutral",
integrity: { last_event_timestamp: new Date().toISOString(), events_processed: 1, source: "hooks" },
}),
);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).not.toContain("Last session mood:");
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — staleness warnings in output
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — staleness in output", () => {
it("includes staleness warning for old data", () => {
const ws = makeWorkspace();
const old = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "neutral",
integrity: { last_event_timestamp: old, events_processed: 1, source: "hooks" },
}),
);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("⚠️");
});
});

92
test/config.test.ts Normal file
View file

@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { resolveConfig, DEFAULTS as DEFAULT_CONFIG } from "../src/config.js";
describe("resolveConfig", () => {
it("returns defaults when no config provided", () => {
const config = resolveConfig(undefined);
expect(config.enabled).toBe(true);
expect(config.threadTracker.enabled).toBe(true);
expect(config.threadTracker.pruneDays).toBe(7);
expect(config.threadTracker.maxThreads).toBe(50);
expect(config.decisionTracker.enabled).toBe(true);
expect(config.decisionTracker.maxDecisions).toBe(100);
expect(config.decisionTracker.dedupeWindowHours).toBe(24);
expect(config.bootContext.enabled).toBe(true);
expect(config.bootContext.maxChars).toBe(16000);
expect(config.bootContext.onSessionStart).toBe(true);
expect(config.bootContext.maxThreadsInBoot).toBe(7);
expect(config.bootContext.maxDecisionsInBoot).toBe(10);
expect(config.bootContext.decisionRecencyDays).toBe(14);
expect(config.preCompaction.enabled).toBe(true);
expect(config.preCompaction.maxSnapshotMessages).toBe(15);
expect(config.narrative.enabled).toBe(true);
expect(config.patterns.language).toBe("both");
});
it("returns defaults for empty object", () => {
const config = resolveConfig({});
expect(config).toEqual(DEFAULT_CONFIG);
});
it("merges partial top-level config", () => {
const config = resolveConfig({ enabled: false });
expect(config.enabled).toBe(false);
expect(config.threadTracker.enabled).toBe(true); // unchanged
});
it("merges partial nested config", () => {
const config = resolveConfig({
threadTracker: { pruneDays: 30 },
});
expect(config.threadTracker.pruneDays).toBe(30);
expect(config.threadTracker.enabled).toBe(true); // default preserved
expect(config.threadTracker.maxThreads).toBe(50); // default preserved
});
it("merges multiple nested sections", () => {
const config = resolveConfig({
bootContext: { maxChars: 8000 },
patterns: { language: "de" },
});
expect(config.bootContext.maxChars).toBe(8000);
expect(config.bootContext.onSessionStart).toBe(true);
expect(config.patterns.language).toBe("de");
});
it("handles workspace override", () => {
const config = resolveConfig({ workspace: "/custom/path" });
expect(config.workspace).toBe("/custom/path");
});
it("ignores unknown keys", () => {
const config = resolveConfig({ unknownKey: "value" } as any);
expect(config.enabled).toBe(true);
expect((config as any).unknownKey).toBeUndefined();
});
it("handles null config", () => {
const config = resolveConfig(null as any);
expect(config).toEqual(DEFAULT_CONFIG);
});
it("preserves all feature disabled states", () => {
const config = resolveConfig({
threadTracker: { enabled: false },
decisionTracker: { enabled: false },
bootContext: { enabled: false },
preCompaction: { enabled: false },
narrative: { enabled: false },
});
expect(config.threadTracker.enabled).toBe(false);
expect(config.decisionTracker.enabled).toBe(false);
expect(config.bootContext.enabled).toBe(false);
expect(config.preCompaction.enabled).toBe(false);
expect(config.narrative.enabled).toBe(false);
});
it("respects language enum values", () => {
expect(resolveConfig({ patterns: { language: "en" } }).patterns.language).toBe("en");
expect(resolveConfig({ patterns: { language: "de" } }).patterns.language).toBe("de");
expect(resolveConfig({ patterns: { language: "both" } }).patterns.language).toBe("both");
});
});

View file

@ -0,0 +1,398 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { DecisionTracker, inferImpact } from "../src/decision-tracker.js";
const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
function makeWorkspace(): string {
const ws = mkdtempSync(join(tmpdir(), "cortex-dt-"));
mkdirSync(join(ws, "memory", "reboot"), { recursive: true });
return ws;
}
function readDecisions(ws: string) {
const raw = readFileSync(join(ws, "memory", "reboot", "decisions.json"), "utf-8");
return JSON.parse(raw);
}
// ════════════════════════════════════════════════════════════
// inferImpact
// ════════════════════════════════════════════════════════════
describe("inferImpact", () => {
it("returns 'high' for 'architecture'", () => {
expect(inferImpact("changed the architecture")).toBe("high");
});
it("returns 'high' for 'security'", () => {
expect(inferImpact("security vulnerability found")).toBe("high");
});
it("returns 'high' for 'migration'", () => {
expect(inferImpact("database migration plan")).toBe("high");
});
it("returns 'high' for 'delete'", () => {
expect(inferImpact("delete the old repo")).toBe("high");
});
it("returns 'high' for 'production'", () => {
expect(inferImpact("deployed to production")).toBe("high");
});
it("returns 'high' for 'deploy'", () => {
expect(inferImpact("deploy to staging")).toBe("high");
});
it("returns 'high' for 'critical'", () => {
expect(inferImpact("critical bug found")).toBe("high");
});
it("returns 'high' for German 'architektur'", () => {
expect(inferImpact("Die Architektur muss geändert werden")).toBe("high");
});
it("returns 'high' for German 'löschen'", () => {
expect(inferImpact("Repo löschen")).toBe("high");
});
it("returns 'high' for 'strategy'", () => {
expect(inferImpact("new business strategy")).toBe("high");
});
it("returns 'medium' for generic text", () => {
expect(inferImpact("changed the color scheme")).toBe("medium");
});
it("returns 'medium' for empty text", () => {
expect(inferImpact("")).toBe("medium");
});
});
// ════════════════════════════════════════════════════════════
// DecisionTracker — basic extraction
// ════════════════════════════════════════════════════════════
describe("DecisionTracker", () => {
let workspace: string;
let tracker: DecisionTracker;
beforeEach(() => {
workspace = makeWorkspace();
tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
}, "both", logger);
});
it("starts with empty decisions", () => {
expect(tracker.getDecisions()).toHaveLength(0);
});
it("extracts a decision from English text", () => {
tracker.processMessage("We decided to use TypeScript for all plugins", "albert");
const decisions = tracker.getDecisions();
expect(decisions.length).toBe(1);
expect(decisions[0].what).toContain("decided");
expect(decisions[0].who).toBe("albert");
});
it("extracts a decision from German text", () => {
tracker.processMessage("Wir haben beschlossen, TS zu verwenden", "albert");
const decisions = tracker.getDecisions();
expect(decisions.length).toBe(1);
expect(decisions[0].what).toContain("beschlossen");
});
it("sets correct date format (YYYY-MM-DD)", () => {
tracker.processMessage("We decided to go with plan A", "user");
const d = tracker.getDecisions()[0];
expect(d.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("sets extracted_at as ISO timestamp", () => {
tracker.processMessage("The decision was to use Vitest", "user");
const d = tracker.getDecisions()[0];
expect(d.extracted_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("generates unique IDs", () => {
tracker.processMessage("decided to use A", "user");
tracker.processMessage("decided to use B as well", "user");
const ids = tracker.getDecisions().map(d => d.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("does not extract from unrelated text", () => {
tracker.processMessage("The weather is nice and sunny today", "user");
expect(tracker.getDecisions()).toHaveLength(0);
});
it("skips empty content", () => {
tracker.processMessage("", "user");
expect(tracker.getDecisions()).toHaveLength(0);
});
it("extracts context window for 'why'", () => {
tracker.processMessage("After much debate and long discussions about the tech stack and the future of the company, we finally decided to use Rust for performance and safety reasons going forward", "user");
const d = tracker.getDecisions()[0];
// 'why' has a wider window (100 before + 200 after) vs 'what' (50 before + 100 after)
expect(d.why.length).toBeGreaterThanOrEqual(d.what.length);
});
it("persists decisions to disk", () => {
tracker.processMessage("We agreed on MIT license for all plugins", "albert");
const data = readDecisions(workspace);
expect(data.version).toBe(1);
expect(data.decisions.length).toBe(1);
});
});
// ════════════════════════════════════════════════════════════
// DecisionTracker — deduplication
// ════════════════════════════════════════════════════════════
describe("DecisionTracker — deduplication", () => {
it("deduplicates identical decisions within window", () => {
const workspace = makeWorkspace();
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
}, "both", logger);
tracker.processMessage("We decided to use TypeScript", "user");
tracker.processMessage("We decided to use TypeScript", "user");
expect(tracker.getDecisions()).toHaveLength(1);
});
it("allows different decisions", () => {
const workspace = makeWorkspace();
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
}, "both", logger);
tracker.processMessage("We decided to use TypeScript", "user");
tracker.processMessage("We decided to use ESM modules", "user");
expect(tracker.getDecisions()).toHaveLength(2);
});
});
// ════════════════════════════════════════════════════════════
// DecisionTracker — impact inference
// ════════════════════════════════════════════════════════════
describe("DecisionTracker — impact inference", () => {
it("assigns high impact for architecture decisions", () => {
const workspace = makeWorkspace();
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
}, "both", logger);
tracker.processMessage("We decided to change the architecture completely", "user");
expect(tracker.getDecisions()[0].impact).toBe("high");
});
it("assigns medium impact for generic decisions", () => {
const workspace = makeWorkspace();
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
}, "both", logger);
tracker.processMessage("We decided to change the color scheme to blue", "user");
expect(tracker.getDecisions()[0].impact).toBe("medium");
});
it("assigns high impact for security decisions", () => {
const workspace = makeWorkspace();
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
}, "both", logger);
tracker.processMessage("The decision was to prioritize the security audit immediately", "user");
expect(tracker.getDecisions()[0].impact).toBe("high");
});
});
// ════════════════════════════════════════════════════════════
// DecisionTracker — maxDecisions cap
// ════════════════════════════════════════════════════════════
describe("DecisionTracker — maxDecisions cap", () => {
it("enforces maxDecisions by removing oldest", () => {
const workspace = makeWorkspace();
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 3,
dedupeWindowHours: 0, // disable dedup for this test
}, "both", logger);
tracker.processMessage("decided to do alpha first", "user");
tracker.processMessage("decided to do bravo second", "user");
tracker.processMessage("decided to do charlie third", "user");
tracker.processMessage("decided to do delta fourth", "user");
const decisions = tracker.getDecisions();
expect(decisions.length).toBe(3);
// Oldest should be gone
expect(decisions.some(d => d.what.includes("alpha"))).toBe(false);
// Newest should be present
expect(decisions.some(d => d.what.includes("delta"))).toBe(true);
});
});
// ════════════════════════════════════════════════════════════
// DecisionTracker — loading existing state
// ════════════════════════════════════════════════════════════
describe("DecisionTracker — loading existing state", () => {
it("loads decisions from existing file", () => {
const workspace = makeWorkspace();
const existingData = {
version: 1,
updated: new Date().toISOString(),
decisions: [
{
id: "existing-1",
what: "decided to use TypeScript",
date: "2026-02-17",
why: "Type safety",
impact: "high",
who: "albert",
extracted_at: new Date().toISOString(),
},
],
};
writeFileSync(
join(workspace, "memory", "reboot", "decisions.json"),
JSON.stringify(existingData),
);
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
}, "both", logger);
expect(tracker.getDecisions()).toHaveLength(1);
expect(tracker.getDecisions()[0].id).toBe("existing-1");
});
it("handles missing decisions.json gracefully", () => {
const workspace = makeWorkspace();
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
}, "both", logger);
expect(tracker.getDecisions()).toHaveLength(0);
});
it("handles corrupt decisions.json gracefully", () => {
const workspace = makeWorkspace();
writeFileSync(
join(workspace, "memory", "reboot", "decisions.json"),
"invalid json {{{",
);
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
}, "both", logger);
expect(tracker.getDecisions()).toHaveLength(0);
});
});
// ════════════════════════════════════════════════════════════
// DecisionTracker — getRecentDecisions
// ════════════════════════════════════════════════════════════
describe("DecisionTracker — getRecentDecisions", () => {
it("filters by recency days", () => {
const workspace = makeWorkspace();
const oldDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const existingData = {
version: 1,
updated: new Date().toISOString(),
decisions: [
{
id: "old",
what: "old decision",
date: oldDate,
why: "old",
impact: "medium" as const,
who: "user",
extracted_at: new Date().toISOString(),
},
{
id: "recent",
what: "recent decision",
date: new Date().toISOString().slice(0, 10),
why: "recent",
impact: "medium" as const,
who: "user",
extracted_at: new Date().toISOString(),
},
],
};
writeFileSync(
join(workspace, "memory", "reboot", "decisions.json"),
JSON.stringify(existingData),
);
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
}, "both", logger);
const recent = tracker.getRecentDecisions(14, 10);
expect(recent).toHaveLength(1);
expect(recent[0].id).toBe("recent");
});
it("respects limit parameter", () => {
const workspace = makeWorkspace();
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 0,
}, "both", logger);
for (let i = 0; i < 5; i++) {
tracker.processMessage(`decided to do item number ${i} now`, "user");
}
const recent = tracker.getRecentDecisions(14, 2);
expect(recent).toHaveLength(2);
});
});
// ════════════════════════════════════════════════════════════
// DecisionTracker — multiple patterns in one message
// ════════════════════════════════════════════════════════════
describe("DecisionTracker — multiple patterns", () => {
it("extracts multiple decisions from one message", () => {
const workspace = makeWorkspace();
const tracker = new DecisionTracker(workspace, {
enabled: true,
maxDecisions: 100,
dedupeWindowHours: 24,
}, "both", logger);
// "decided" and "the plan is" are separate patterns in distinct sentences
// Use enough spacing so context windows don't produce identical 'what' values
tracker.processMessage(
"After reviewing all options, we decided to use TypeScript for the new plugin system. Meanwhile in a completely separate topic, the plan is to migrate the database to PostgreSQL next quarter.",
"user",
);
expect(tracker.getDecisions().length).toBeGreaterThanOrEqual(2);
});
});

193
test/hooks.test.ts Normal file
View file

@ -0,0 +1,193 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { registerCortexHooks } from "../src/hooks.js";
import { resolveConfig } from "../src/config.js";
import type { CortexConfig } from "../src/types.js";
const logger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
};
type HookRegistration = {
name: string;
handler: (...args: any[]) => void;
opts?: { priority?: number };
};
function makeMockApi(workspace: string, pluginConfig?: Record<string, unknown>) {
const hooks: HookRegistration[] = [];
const commands: Array<{ name: string }> = [];
return {
api: {
id: "openclaw-cortex",
logger,
pluginConfig: pluginConfig ?? {},
config: {},
on: (name: string, handler: (...args: any[]) => void, opts?: { priority?: number }) => {
hooks.push({ name, handler, opts });
},
registerCommand: (cmd: { name: string }) => {
commands.push(cmd);
},
registerService: () => {},
},
hooks,
commands,
workspace,
};
}
function makeWorkspace(): string {
const ws = mkdtempSync(join(tmpdir(), "cortex-hooks-"));
mkdirSync(join(ws, "memory", "reboot"), { recursive: true });
return ws;
}
describe("registerCortexHooks", () => {
it("registers hooks for all enabled features", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const { api, hooks } = makeMockApi(ws);
registerCortexHooks(api as any, config);
const hookNames = hooks.map(h => h.name);
expect(hookNames).toContain("message_received");
expect(hookNames).toContain("message_sent");
expect(hookNames).toContain("session_start");
expect(hookNames).toContain("before_compaction");
});
it("registers hooks with correct priorities", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const { api, hooks } = makeMockApi(ws);
registerCortexHooks(api as any, config);
const beforeCompaction = hooks.find(h => h.name === "before_compaction");
expect(beforeCompaction?.opts?.priority).toBeLessThanOrEqual(10);
const sessionStart = hooks.find(h => h.name === "session_start");
expect(sessionStart?.opts?.priority).toBeLessThanOrEqual(20);
const messageHooks = hooks.filter(h => h.name === "message_received" || h.name === "message_sent");
for (const mh of messageHooks) {
expect(mh.opts?.priority ?? 100).toBeGreaterThanOrEqual(50);
}
});
it("skips thread tracker hooks when disabled", () => {
const ws = makeWorkspace();
const config = resolveConfig({
workspace: ws,
threadTracker: { enabled: false },
decisionTracker: { enabled: false },
});
const { api, hooks } = makeMockApi(ws);
registerCortexHooks(api as any, config);
// Should still have session_start and before_compaction
const hookNames = hooks.map(h => h.name);
expect(hookNames).toContain("session_start");
expect(hookNames).toContain("before_compaction");
});
it("skips boot context hooks when disabled", () => {
const ws = makeWorkspace();
const config = resolveConfig({
workspace: ws,
bootContext: { enabled: false },
});
const { api, hooks } = makeMockApi(ws);
registerCortexHooks(api as any, config);
const hookNames = hooks.map(h => h.name);
// session_start should not be registered if bootContext is disabled
// (unless pre-compaction also uses it)
expect(hookNames).toContain("before_compaction");
});
it("message hooks don't throw on empty content", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const { api, hooks } = makeMockApi(ws);
registerCortexHooks(api as any, config);
const msgReceived = hooks.find(h => h.name === "message_received");
expect(() => {
msgReceived?.handler({}, { workspaceDir: ws });
}).not.toThrow();
});
it("message hooks don't throw on valid content", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const { api, hooks } = makeMockApi(ws);
registerCortexHooks(api as any, config);
const msgReceived = hooks.find(h => h.name === "message_received");
expect(() => {
msgReceived?.handler(
{ content: "We decided to use TypeScript", from: "albert" },
{ workspaceDir: ws },
);
}).not.toThrow();
});
it("session_start hook doesn't throw", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const { api, hooks } = makeMockApi(ws);
registerCortexHooks(api as any, config);
const sessionStart = hooks.find(h => h.name === "session_start");
expect(() => {
sessionStart?.handler({}, { workspaceDir: ws });
}).not.toThrow();
});
it("before_compaction hook doesn't throw", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const { api, hooks } = makeMockApi(ws);
registerCortexHooks(api as any, config);
const beforeCompaction = hooks.find(h => h.name === "before_compaction");
expect(() => {
beforeCompaction?.handler(
{ messageCount: 100, compactingCount: 50 },
{ workspaceDir: ws },
);
}).not.toThrow();
});
it("registers no hooks when all features disabled", () => {
const ws = makeWorkspace();
const config = resolveConfig({
workspace: ws,
threadTracker: { enabled: false },
decisionTracker: { enabled: false },
bootContext: { enabled: false },
preCompaction: { enabled: false },
narrative: { enabled: false },
});
const { api, hooks } = makeMockApi(ws);
registerCortexHooks(api as any, config);
// May still have after_compaction logging, but core hooks should be minimal
expect(hooks.length).toBeLessThanOrEqual(1);
});
});

View file

@ -0,0 +1,177 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { NarrativeGenerator, loadDailyNotes, extractTimeline, buildSections, generateStructured } from "../src/narrative-generator.js";
const logger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
};
function makeWorkspace(): string {
const ws = mkdtempSync(join(tmpdir(), "cortex-narrative-"));
mkdirSync(join(ws, "memory", "reboot"), { recursive: true });
return ws;
}
function writeDailyNote(workspace: string, date: string, content: string) {
writeFileSync(join(workspace, "memory", `${date}.md`), content);
}
function writeThreads(workspace: string, threads: any[]) {
writeFileSync(
join(workspace, "memory", "reboot", "threads.json"),
JSON.stringify({ version: 2, updated: new Date().toISOString(), threads }, null, 2),
);
}
function writeDecisions(workspace: string, decisions: any[]) {
writeFileSync(
join(workspace, "memory", "reboot", "decisions.json"),
JSON.stringify({ version: 1, updated: new Date().toISOString(), decisions }, null, 2),
);
}
describe("NarrativeGenerator", () => {
it("creates instance without errors", () => {
const ws = makeWorkspace();
const gen = new NarrativeGenerator(ws, logger);
expect(gen).toBeTruthy();
});
it("generates empty narrative for empty workspace", () => {
const ws = makeWorkspace();
const gen = new NarrativeGenerator(ws, logger);
const result = gen.generate();
expect(result).toBeTruthy();
expect(typeof result).toBe("string");
});
it("includes date header", () => {
const ws = makeWorkspace();
const gen = new NarrativeGenerator(ws, logger);
const result = gen.generate();
expect(result).toMatch(/\d{4}/); // contains year
});
it("includes open threads", () => {
const ws = makeWorkspace();
writeThreads(ws, [
{
id: "t1",
title: "Auth Migration",
status: "open",
priority: "high",
summary: "Migrating auth system",
decisions: [],
waiting_for: null,
mood: "productive",
last_activity: new Date().toISOString(),
created: new Date().toISOString(),
},
]);
const gen = new NarrativeGenerator(ws, logger);
const result = gen.generate();
expect(result).toContain("Auth Migration");
});
it("includes closed threads as completed", () => {
const ws = makeWorkspace();
const now = new Date();
writeThreads(ws, [
{
id: "t2",
title: "Bug Fix Deploy",
status: "closed",
priority: "medium",
summary: "Fixed critical bug",
decisions: [],
waiting_for: null,
mood: "productive",
last_activity: now.toISOString(),
created: new Date(now.getTime() - 3600000).toISOString(),
},
]);
const gen = new NarrativeGenerator(ws, logger);
const result = gen.generate();
expect(result).toContain("Bug Fix Deploy");
});
it("includes recent decisions", () => {
const ws = makeWorkspace();
writeDecisions(ws, [
{
id: "d1",
what: "Use TypeScript for the plugin",
date: new Date().toISOString().slice(0, 10),
why: "Consistency with OpenClaw",
impact: "high",
who: "albert",
extracted_at: new Date().toISOString(),
},
]);
const gen = new NarrativeGenerator(ws, logger);
const result = gen.generate();
expect(result).toContain("TypeScript");
});
it("includes daily note content when available", () => {
const ws = makeWorkspace();
const today = new Date().toISOString().slice(0, 10);
writeDailyNote(ws, today, "## 10:00\nWorked on plugin architecture\n## 14:00\nCode review");
const gen = new NarrativeGenerator(ws, logger);
const result = gen.generate();
expect(result.length).toBeGreaterThan(0);
});
it("persists narrative to file", () => {
const ws = makeWorkspace();
writeThreads(ws, [
{
id: "t3",
title: "Test Thread",
status: "open",
priority: "medium",
summary: "Testing",
decisions: [],
waiting_for: null,
mood: "neutral",
last_activity: new Date().toISOString(),
created: new Date().toISOString(),
},
]);
const gen = new NarrativeGenerator(ws, logger);
gen.write();
const filePath = join(ws, "memory", "reboot", "narrative.md");
const content = readFileSync(filePath, "utf-8");
expect(content).toContain("Test Thread");
});
it("handles missing threads.json gracefully", () => {
const ws = makeWorkspace();
const gen = new NarrativeGenerator(ws, logger);
expect(() => gen.generate()).not.toThrow();
});
it("handles missing decisions.json gracefully", () => {
const ws = makeWorkspace();
const gen = new NarrativeGenerator(ws, logger);
expect(() => gen.generate()).not.toThrow();
});
it("handles corrupt threads.json", () => {
const ws = makeWorkspace();
writeFileSync(join(ws, "memory", "reboot", "threads.json"), "not json");
const gen = new NarrativeGenerator(ws, logger);
expect(() => gen.generate()).not.toThrow();
});
});

543
test/patterns.test.ts Normal file
View file

@ -0,0 +1,543 @@
import { describe, it, expect } from "vitest";
import { getPatterns, detectMood, HIGH_IMPACT_KEYWORDS, MOOD_PATTERNS } from "../src/patterns.js";
import type { PatternSet } from "../src/patterns.js";
// ── Helper: test if any pattern matches ──
function anyMatch(patterns: RegExp[], text: string): boolean {
return patterns.some(p => p.test(text));
}
function captureTopics(patterns: RegExp[], text: string): string[] {
const topics: string[] = [];
for (const p of patterns) {
const g = new RegExp(p.source, "gi");
let m: RegExpExecArray | null;
while ((m = g.exec(text)) !== null) {
if (m[1]) topics.push(m[1].trim());
}
}
return topics;
}
// ════════════════════════════════════════════════════════════
// Decision patterns
// ════════════════════════════════════════════════════════════
describe("decision patterns", () => {
describe("English", () => {
const { decision } = getPatterns("en");
it("matches 'decided'", () => {
expect(anyMatch(decision, "We decided to use TypeScript")).toBe(true);
});
it("matches 'decision'", () => {
expect(anyMatch(decision, "The decision was to go with plan B")).toBe(true);
});
it("matches 'agreed'", () => {
expect(anyMatch(decision, "We agreed on MIT license")).toBe(true);
});
it("matches 'let's do'", () => {
expect(anyMatch(decision, "let's do it this way")).toBe(true);
});
it("matches 'lets do' without apostrophe", () => {
expect(anyMatch(decision, "lets do it this way")).toBe(true);
});
it("matches 'the plan is'", () => {
expect(anyMatch(decision, "the plan is to deploy Friday")).toBe(true);
});
it("matches 'approach:'", () => {
expect(anyMatch(decision, "approach: use atomic writes")).toBe(true);
});
it("does not match unrelated text", () => {
expect(anyMatch(decision, "The weather is nice today")).toBe(false);
});
it("does not match partial words like 'undecided'", () => {
// 'undecided' contains 'decided' — pattern should still match due to regex
expect(anyMatch(decision, "I am undecided")).toBe(true);
});
it("is case-insensitive", () => {
expect(anyMatch(decision, "DECIDED to use ESM")).toBe(true);
});
});
describe("German", () => {
const { decision } = getPatterns("de");
it("matches 'entschieden'", () => {
expect(anyMatch(decision, "Wir haben uns entschieden")).toBe(true);
});
it("matches 'beschlossen'", () => {
expect(anyMatch(decision, "Wir haben beschlossen, TS zu nehmen")).toBe(true);
});
it("matches 'machen wir'", () => {
expect(anyMatch(decision, "Das machen wir so")).toBe(true);
});
it("matches 'wir machen'", () => {
expect(anyMatch(decision, "Dann wir machen das anders")).toBe(true);
});
it("matches 'der plan ist'", () => {
expect(anyMatch(decision, "Der plan ist, morgen zu deployen")).toBe(true);
});
it("matches 'ansatz:'", () => {
expect(anyMatch(decision, "Ansatz: atomare Schreibvorgänge")).toBe(true);
});
it("does not match English-only text", () => {
expect(anyMatch(decision, "We decided to use TypeScript")).toBe(false);
});
});
describe("both", () => {
const { decision } = getPatterns("both");
it("matches English patterns", () => {
expect(anyMatch(decision, "We decided to go")).toBe(true);
});
it("matches German patterns", () => {
expect(anyMatch(decision, "Wir haben beschlossen")).toBe(true);
});
it("has combined patterns", () => {
expect(decision.length).toBeGreaterThanOrEqual(2);
});
});
});
// ════════════════════════════════════════════════════════════
// Close patterns
// ════════════════════════════════════════════════════════════
describe("close patterns", () => {
describe("English", () => {
const { close } = getPatterns("en");
it("matches 'done'", () => {
expect(anyMatch(close, "That's done now")).toBe(true);
});
it("matches 'fixed'", () => {
expect(anyMatch(close, "Bug is fixed")).toBe(true);
});
it("matches 'solved'", () => {
expect(anyMatch(close, "Problem solved!")).toBe(true);
});
it("matches 'closed'", () => {
expect(anyMatch(close, "Issue closed")).toBe(true);
});
it("matches 'works'", () => {
expect(anyMatch(close, "It works perfectly")).toBe(true);
});
it("matches '✅'", () => {
expect(anyMatch(close, "Task complete ✅")).toBe(true);
});
it("does not match unrelated text", () => {
expect(anyMatch(close, "Still working on it")).toBe(false);
});
});
describe("German", () => {
const { close } = getPatterns("de");
it("matches 'erledigt'", () => {
expect(anyMatch(close, "Das ist erledigt")).toBe(true);
});
it("matches 'gefixt'", () => {
expect(anyMatch(close, "Bug ist gefixt")).toBe(true);
});
it("matches 'gelöst'", () => {
expect(anyMatch(close, "Problem gelöst")).toBe(true);
});
it("matches 'fertig'", () => {
expect(anyMatch(close, "Bin fertig damit")).toBe(true);
});
it("matches 'funktioniert'", () => {
expect(anyMatch(close, "Es funktioniert jetzt")).toBe(true);
});
});
describe("both", () => {
const { close } = getPatterns("both");
it("matches English 'done'", () => {
expect(anyMatch(close, "It's done")).toBe(true);
});
it("matches German 'erledigt'", () => {
expect(anyMatch(close, "Ist erledigt")).toBe(true);
});
});
});
// ════════════════════════════════════════════════════════════
// Wait patterns
// ════════════════════════════════════════════════════════════
describe("wait patterns", () => {
describe("English", () => {
const { wait } = getPatterns("en");
it("matches 'waiting for'", () => {
expect(anyMatch(wait, "We are waiting for the review")).toBe(true);
});
it("matches 'blocked by'", () => {
expect(anyMatch(wait, "This is blocked by the API change")).toBe(true);
});
it("matches 'need...first'", () => {
expect(anyMatch(wait, "We need the auth module first")).toBe(true);
});
it("does not match unrelated text", () => {
expect(anyMatch(wait, "Let's continue with the work")).toBe(false);
});
});
describe("German", () => {
const { wait } = getPatterns("de");
it("matches 'warte auf'", () => {
expect(anyMatch(wait, "Ich warte auf das Review")).toBe(true);
});
it("matches 'blockiert durch'", () => {
expect(anyMatch(wait, "Blockiert durch API-Änderung")).toBe(true);
});
it("matches 'brauche...erst'", () => {
expect(anyMatch(wait, "Brauche das Auth-Modul erst")).toBe(true);
});
});
});
// ════════════════════════════════════════════════════════════
// Topic patterns
// ════════════════════════════════════════════════════════════
describe("topic patterns", () => {
describe("English", () => {
const { topic } = getPatterns("en");
it("captures topic after 'back to'", () => {
const topics = captureTopics(topic, "Let's get back to the auth migration");
expect(topics.length).toBeGreaterThan(0);
expect(topics[0]).toContain("auth migration");
});
it("captures topic after 'now about'", () => {
const topics = captureTopics(topic, "now about the deployment pipeline");
expect(topics.length).toBeGreaterThan(0);
expect(topics[0]).toContain("deployment pipeline");
});
it("captures topic after 'regarding'", () => {
const topics = captureTopics(topic, "regarding the security audit");
expect(topics.length).toBeGreaterThan(0);
expect(topics[0]).toContain("security audit");
});
it("does not match without topic text", () => {
expect(anyMatch(topic, "just a random sentence")).toBe(false);
});
it("limits captured topic to 30 chars", () => {
const topics = captureTopics(topic, "back to the very long topic name that exceeds thirty characters limit here");
if (topics.length > 0) {
expect(topics[0].length).toBeLessThanOrEqual(31);
}
});
});
describe("German", () => {
const { topic } = getPatterns("de");
it("captures topic after 'zurück zu'", () => {
const topics = captureTopics(topic, "Zurück zu der Auth-Migration");
expect(topics.length).toBeGreaterThan(0);
expect(topics[0]).toContain("der Auth-Migration");
});
it("captures topic after 'jetzt zu'", () => {
const topics = captureTopics(topic, "Jetzt zu dem Deployment");
expect(topics.length).toBeGreaterThan(0);
});
it("captures topic after 'bzgl.'", () => {
const topics = captureTopics(topic, "Bzgl. dem Security Audit");
expect(topics.length).toBeGreaterThan(0);
});
it("captures topic after 'bzgl' without dot", () => {
const topics = captureTopics(topic, "bzgl dem Security Review");
expect(topics.length).toBeGreaterThan(0);
});
it("captures topic after 'wegen'", () => {
const topics = captureTopics(topic, "wegen der API-Änderung");
expect(topics.length).toBeGreaterThan(0);
});
});
describe("both", () => {
const { topic } = getPatterns("both");
it("captures English topics", () => {
const topics = captureTopics(topic, "back to the auth flow");
expect(topics.length).toBeGreaterThan(0);
});
it("captures German topics", () => {
const topics = captureTopics(topic, "zurück zu dem Plugin");
expect(topics.length).toBeGreaterThan(0);
});
});
});
// ════════════════════════════════════════════════════════════
// Mood detection
// ════════════════════════════════════════════════════════════
describe("detectMood", () => {
it("returns 'neutral' for empty string", () => {
expect(detectMood("")).toBe("neutral");
});
it("returns 'neutral' for unrelated text", () => {
expect(detectMood("The sky is blue")).toBe("neutral");
});
// Frustrated
it("detects 'frustrated' for 'fuck'", () => {
expect(detectMood("oh fuck, that's broken")).toBe("frustrated");
});
it("detects 'frustrated' for 'shit'", () => {
expect(detectMood("shit, it broke again")).toBe("frustrated");
});
it("detects 'frustrated' for 'mist'", () => {
expect(detectMood("So ein Mist")).toBe("frustrated");
});
it("detects 'frustrated' for 'nervig'", () => {
expect(detectMood("Das ist so nervig")).toBe("frustrated");
});
it("detects 'frustrated' for 'damn'", () => {
expect(detectMood("damn, not again")).toBe("frustrated");
});
it("detects 'frustrated' for 'wtf'", () => {
expect(detectMood("wtf is happening")).toBe("frustrated");
});
it("detects 'frustrated' for 'schon wieder'", () => {
expect(detectMood("Schon wieder kaputt")).toBe("frustrated");
});
it("detects 'frustrated' for 'sucks'", () => {
expect(detectMood("this sucks")).toBe("frustrated");
});
// Excited
it("detects 'excited' for 'geil'", () => {
expect(detectMood("Das ist geil!")).toBe("excited");
});
it("detects 'excited' for 'awesome'", () => {
expect(detectMood("That's awesome!")).toBe("excited");
});
it("detects 'excited' for 'nice'", () => {
expect(detectMood("nice work!")).toBe("excited");
});
it("detects 'excited' for '🚀'", () => {
expect(detectMood("Deployed! 🚀")).toBe("excited");
});
it("detects 'excited' for 'perfekt'", () => {
expect(detectMood("Das ist perfekt")).toBe("excited");
});
// Tense
it("detects 'tense' for 'careful'", () => {
expect(detectMood("be careful with that")).toBe("tense");
});
it("detects 'tense' for 'risky'", () => {
expect(detectMood("that's risky")).toBe("tense");
});
it("detects 'tense' for 'urgent'", () => {
expect(detectMood("this is urgent")).toBe("tense");
});
it("detects 'tense' for 'vorsicht'", () => {
expect(detectMood("Vorsicht damit")).toBe("tense");
});
it("detects 'tense' for 'dringend'", () => {
expect(detectMood("Dringend fixen")).toBe("tense");
});
// Productive
it("detects 'productive' for 'done'", () => {
expect(detectMood("All done!")).toBe("productive");
});
it("detects 'productive' for 'fixed'", () => {
expect(detectMood("Bug fixed")).toBe("productive");
});
it("detects 'productive' for 'deployed'", () => {
expect(detectMood("deployed to staging")).toBe("productive");
});
it("detects 'productive' for '✅'", () => {
expect(detectMood("Task ✅")).toBe("productive");
});
it("detects 'productive' for 'shipped'", () => {
expect(detectMood("shipped to prod")).toBe("productive");
});
// Exploratory
it("detects 'exploratory' for 'what if'", () => {
expect(detectMood("what if we used Rust?")).toBe("exploratory");
});
it("detects 'exploratory' for 'was wäre wenn'", () => {
expect(detectMood("Was wäre wenn wir Rust nehmen?")).toBe("exploratory");
});
it("detects 'exploratory' for 'idea'", () => {
expect(detectMood("I have an idea")).toBe("exploratory");
});
it("detects 'exploratory' for 'experiment'", () => {
expect(detectMood("let's experiment with this")).toBe("exploratory");
});
it("detects 'exploratory' for 'maybe'", () => {
expect(detectMood("maybe we should try")).toBe("exploratory");
});
// Last match wins
it("last match wins: frustrated then productive → productive", () => {
expect(detectMood("this sucks but then it works!")).toBe("productive");
});
it("last match wins: excited then tense → tense", () => {
expect(detectMood("Awesome but be careful")).toBe("tense");
});
it("case-insensitive mood detection", () => {
expect(detectMood("THIS IS AWESOME")).toBe("excited");
});
});
// ════════════════════════════════════════════════════════════
// Language switching
// ════════════════════════════════════════════════════════════
describe("getPatterns", () => {
it("returns only English patterns for 'en'", () => {
const p = getPatterns("en");
expect(anyMatch(p.decision, "decided")).toBe(true);
expect(anyMatch(p.decision, "beschlossen")).toBe(false);
});
it("returns only German patterns for 'de'", () => {
const p = getPatterns("de");
expect(anyMatch(p.decision, "beschlossen")).toBe(true);
expect(anyMatch(p.decision, "decided")).toBe(false);
});
it("returns merged patterns for 'both'", () => {
const p = getPatterns("both");
expect(anyMatch(p.decision, "decided")).toBe(true);
expect(anyMatch(p.decision, "beschlossen")).toBe(true);
});
it("each language has all pattern types", () => {
for (const lang of ["en", "de", "both"] as const) {
const p = getPatterns(lang);
expect(p.decision.length).toBeGreaterThan(0);
expect(p.close.length).toBeGreaterThan(0);
expect(p.wait.length).toBeGreaterThan(0);
expect(p.topic.length).toBeGreaterThan(0);
}
});
});
// ════════════════════════════════════════════════════════════
// High-impact keywords
// ════════════════════════════════════════════════════════════
describe("HIGH_IMPACT_KEYWORDS", () => {
it("contains architecture keywords", () => {
expect(HIGH_IMPACT_KEYWORDS).toContain("architecture");
expect(HIGH_IMPACT_KEYWORDS).toContain("architektur");
});
it("contains security keywords", () => {
expect(HIGH_IMPACT_KEYWORDS).toContain("security");
expect(HIGH_IMPACT_KEYWORDS).toContain("sicherheit");
});
it("contains deletion keywords", () => {
expect(HIGH_IMPACT_KEYWORDS).toContain("delete");
expect(HIGH_IMPACT_KEYWORDS).toContain("löschen");
});
it("contains production keywords", () => {
expect(HIGH_IMPACT_KEYWORDS).toContain("production");
expect(HIGH_IMPACT_KEYWORDS).toContain("deploy");
});
it("contains strategy keywords", () => {
expect(HIGH_IMPACT_KEYWORDS).toContain("strategy");
expect(HIGH_IMPACT_KEYWORDS).toContain("strategie");
});
it("is a non-empty array", () => {
expect(HIGH_IMPACT_KEYWORDS.length).toBeGreaterThan(10);
});
});
// ════════════════════════════════════════════════════════════
// Mood patterns export
// ════════════════════════════════════════════════════════════
describe("MOOD_PATTERNS", () => {
it("contains all mood types except neutral", () => {
expect(MOOD_PATTERNS).toHaveProperty("frustrated");
expect(MOOD_PATTERNS).toHaveProperty("excited");
expect(MOOD_PATTERNS).toHaveProperty("tense");
expect(MOOD_PATTERNS).toHaveProperty("productive");
expect(MOOD_PATTERNS).toHaveProperty("exploratory");
});
it("each mood pattern is a RegExp", () => {
for (const pattern of Object.values(MOOD_PATTERNS)) {
expect(pattern).toBeInstanceOf(RegExp);
}
});
});

167
test/pre-compaction.test.ts Normal file
View file

@ -0,0 +1,167 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { PreCompaction, buildHotSnapshot } from "../src/pre-compaction.js";
import { ThreadTracker } from "../src/thread-tracker.js";
import { resolveConfig } from "../src/config.js";
const logger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
};
function makeWorkspace(): string {
const ws = mkdtempSync(join(tmpdir(), "cortex-precompact-"));
mkdirSync(join(ws, "memory", "reboot"), { recursive: true });
return ws;
}
describe("buildHotSnapshot", () => {
it("builds markdown from messages", () => {
const result = buildHotSnapshot([
{ role: "user", content: "Fix the auth bug" },
{ role: "assistant", content: "Done, JWT validation is fixed" },
], 15);
expect(result).toContain("Hot Snapshot");
expect(result).toContain("auth bug");
expect(result).toContain("[user]");
expect(result).toContain("[assistant]");
});
it("handles empty messages", () => {
const result = buildHotSnapshot([], 15);
expect(result).toContain("Hot Snapshot");
expect(result).toContain("No recent messages");
});
it("truncates long messages", () => {
const longMsg = "A".repeat(500);
const result = buildHotSnapshot([{ role: "user", content: longMsg }], 15);
expect(result).toContain("...");
expect(result.length).toBeLessThan(500);
});
it("limits to maxMessages (takes last N)", () => {
const messages = Array.from({ length: 20 }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: `Message ${i}`,
}));
const result = buildHotSnapshot(messages, 5);
expect(result).toContain("Message 19");
expect(result).toContain("Message 15");
expect(result).not.toContain("Message 0");
});
});
describe("PreCompaction", () => {
it("creates instance without errors", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const tracker = new ThreadTracker(ws, config.threadTracker, "both", logger);
const pipeline = new PreCompaction(ws, config, logger, tracker);
expect(pipeline).toBeTruthy();
});
it("runs without errors on empty workspace", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const tracker = new ThreadTracker(ws, config.threadTracker, "both", logger);
const pipeline = new PreCompaction(ws, config, logger, tracker);
const result = pipeline.run([]);
expect(result.success).toBe(true);
expect(result.warnings).toHaveLength(0);
});
it("creates hot-snapshot.md", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const tracker = new ThreadTracker(ws, config.threadTracker, "both", logger);
const pipeline = new PreCompaction(ws, config, logger, tracker);
pipeline.run([
{ role: "user", content: "Fix the auth bug" },
{ role: "assistant", content: "Done, the JWT validation is fixed" },
]);
const snapshotPath = join(ws, "memory", "reboot", "hot-snapshot.md");
expect(existsSync(snapshotPath)).toBe(true);
const content = readFileSync(snapshotPath, "utf-8");
expect(content).toContain("auth bug");
});
it("creates narrative.md", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const tracker = new ThreadTracker(ws, config.threadTracker, "both", logger);
const pipeline = new PreCompaction(ws, config, logger, tracker);
pipeline.run([]);
const narrativePath = join(ws, "memory", "reboot", "narrative.md");
expect(existsSync(narrativePath)).toBe(true);
});
it("creates BOOTSTRAP.md", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const tracker = new ThreadTracker(ws, config.threadTracker, "both", logger);
const pipeline = new PreCompaction(ws, config, logger, tracker);
pipeline.run([]);
const bootstrapPath = join(ws, "BOOTSTRAP.md");
expect(existsSync(bootstrapPath)).toBe(true);
});
it("reports correct messagesSnapshotted count", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws });
const tracker = new ThreadTracker(ws, config.threadTracker, "both", logger);
const pipeline = new PreCompaction(ws, config, logger, tracker);
const messages = Array.from({ length: 30 }, (_, i) => ({
role: "user" as const,
content: `Msg ${i}`,
}));
const result = pipeline.run(messages);
expect(result.messagesSnapshotted).toBe(config.preCompaction.maxSnapshotMessages);
});
it("handles errors gracefully — never throws", () => {
const ws = makeWorkspace();
writeFileSync(join(ws, "memory", "reboot", "threads.json"), "corrupt");
const config = resolveConfig({ workspace: ws });
const tracker = new ThreadTracker(ws, config.threadTracker, "both", logger);
const pipeline = new PreCompaction(ws, config, logger, tracker);
expect(() => pipeline.run([])).not.toThrow();
});
it("skips narrative when disabled", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws, narrative: { enabled: false } });
const tracker = new ThreadTracker(ws, config.threadTracker, "both", logger);
const pipeline = new PreCompaction(ws, config, logger, tracker);
pipeline.run([]);
// narrative.md should not be created (or at least pipeline won't error)
// The key assertion is it doesn't throw
});
it("skips boot context when disabled", () => {
const ws = makeWorkspace();
const config = resolveConfig({ workspace: ws, bootContext: { enabled: false } });
const tracker = new ThreadTracker(ws, config.threadTracker, "both", logger);
const pipeline = new PreCompaction(ws, config, logger, tracker);
pipeline.run([]);
const bootstrapPath = join(ws, "BOOTSTRAP.md");
expect(existsSync(bootstrapPath)).toBe(false);
});
});

184
test/storage.test.ts Normal file
View file

@ -0,0 +1,184 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
loadJson,
saveJson,
loadText,
saveText,
rebootDir,
ensureRebootDir,
isWritable,
getFileMtime,
isFileOlderThan,
} from "../src/storage.js";
const logger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
};
function makeTmp(): string {
return mkdtempSync(join(tmpdir(), "cortex-storage-"));
}
describe("rebootDir", () => {
it("returns memory/reboot path", () => {
expect(rebootDir("/workspace")).toBe(join("/workspace", "memory", "reboot"));
});
});
describe("ensureRebootDir", () => {
it("creates the directory", () => {
const ws = makeTmp();
const ok = ensureRebootDir(ws, logger);
expect(ok).toBe(true);
const stat = require("node:fs").statSync(rebootDir(ws));
expect(stat.isDirectory()).toBe(true);
});
it("returns true if directory already exists", () => {
const ws = makeTmp();
mkdirSync(join(ws, "memory", "reboot"), { recursive: true });
expect(ensureRebootDir(ws, logger)).toBe(true);
});
});
describe("isWritable", () => {
it("returns true for writable workspace", () => {
const ws = makeTmp();
expect(isWritable(ws)).toBe(true);
});
it("returns true when memory/ dir exists and is writable", () => {
const ws = makeTmp();
mkdirSync(join(ws, "memory"), { recursive: true });
expect(isWritable(ws)).toBe(true);
});
});
describe("loadJson", () => {
it("loads valid JSON", () => {
const ws = makeTmp();
const f = join(ws, "test.json");
writeFileSync(f, '{"a":1}');
const result = loadJson<{ a: number }>(f);
expect(result.a).toBe(1);
});
it("returns empty object for missing file", () => {
const result = loadJson("/nonexistent/path.json");
expect(result).toEqual({});
});
it("returns empty object for corrupt JSON", () => {
const ws = makeTmp();
const f = join(ws, "bad.json");
writeFileSync(f, "not json {{{");
expect(loadJson(f)).toEqual({});
});
it("returns empty object for empty file", () => {
const ws = makeTmp();
const f = join(ws, "empty.json");
writeFileSync(f, "");
expect(loadJson(f)).toEqual({});
});
});
describe("saveJson", () => {
it("writes valid JSON atomically", () => {
const ws = makeTmp();
const f = join(ws, "out.json");
const ok = saveJson(f, { hello: "world" }, logger);
expect(ok).toBe(true);
const content = JSON.parse(readFileSync(f, "utf-8"));
expect(content.hello).toBe("world");
});
it("creates parent directories", () => {
const ws = makeTmp();
const f = join(ws, "sub", "deep", "out.json");
const ok = saveJson(f, { nested: true }, logger);
expect(ok).toBe(true);
expect(JSON.parse(readFileSync(f, "utf-8")).nested).toBe(true);
});
it("no .tmp file left after successful write", () => {
const ws = makeTmp();
const f = join(ws, "clean.json");
saveJson(f, { clean: true }, logger);
const fs = require("node:fs");
expect(fs.existsSync(f + ".tmp")).toBe(false);
});
it("pretty-prints with 2-space indent", () => {
const ws = makeTmp();
const f = join(ws, "pretty.json");
saveJson(f, { a: 1 }, logger);
const raw = readFileSync(f, "utf-8");
expect(raw).toContain(" ");
expect(raw.endsWith("\n")).toBe(true);
});
});
describe("loadText", () => {
it("loads text file content", () => {
const ws = makeTmp();
const f = join(ws, "note.md");
writeFileSync(f, "# Hello\nWorld");
expect(loadText(f)).toBe("# Hello\nWorld");
});
it("returns empty string for missing file", () => {
expect(loadText("/nonexistent/file.md")).toBe("");
});
});
describe("saveText", () => {
it("writes text file atomically", () => {
const ws = makeTmp();
const f = join(ws, "out.md");
const ok = saveText(f, "# Test", logger);
expect(ok).toBe(true);
expect(readFileSync(f, "utf-8")).toBe("# Test");
});
it("creates parent directories", () => {
const ws = makeTmp();
const f = join(ws, "a", "b", "out.md");
saveText(f, "deep", logger);
expect(readFileSync(f, "utf-8")).toBe("deep");
});
});
describe("getFileMtime", () => {
it("returns ISO string for existing file", () => {
const ws = makeTmp();
const f = join(ws, "file.txt");
writeFileSync(f, "x");
const mtime = getFileMtime(f);
expect(mtime).toBeTruthy();
expect(new Date(mtime!).getTime()).toBeGreaterThan(0);
});
it("returns null for missing file", () => {
expect(getFileMtime("/nonexistent")).toBeNull();
});
});
describe("isFileOlderThan", () => {
it("returns true for missing file", () => {
expect(isFileOlderThan("/nonexistent", 1)).toBe(true);
});
it("returns false for fresh file", () => {
const ws = makeTmp();
const f = join(ws, "fresh.txt");
writeFileSync(f, "new");
expect(isFileOlderThan(f, 1)).toBe(false);
});
});

533
test/thread-tracker.test.ts Normal file
View file

@ -0,0 +1,533 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ThreadTracker, extractSignals, matchesThread } from "../src/thread-tracker.js";
import type { Thread } from "../src/types.js";
const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
function makeWorkspace(): string {
const ws = mkdtempSync(join(tmpdir(), "cortex-tt-"));
mkdirSync(join(ws, "memory", "reboot"), { recursive: true });
return ws;
}
function readThreads(ws: string) {
const raw = readFileSync(join(ws, "memory", "reboot", "threads.json"), "utf-8");
return JSON.parse(raw);
}
function makeThread(overrides: Partial<Thread> = {}): Thread {
return {
id: "test-id",
title: "auth migration OAuth2",
status: "open",
priority: "medium",
summary: "test thread",
decisions: [],
waiting_for: null,
mood: "neutral",
last_activity: new Date().toISOString(),
created: new Date().toISOString(),
...overrides,
};
}
// ════════════════════════════════════════════════════════════
// matchesThread
// ════════════════════════════════════════════════════════════
describe("matchesThread", () => {
it("matches when 2+ title words appear in text", () => {
const thread = makeThread({ title: "auth migration OAuth2" });
expect(matchesThread(thread, "the auth migration is progressing")).toBe(true);
});
it("does not match with only 1 overlapping word", () => {
const thread = makeThread({ title: "auth migration OAuth2" });
expect(matchesThread(thread, "auth is broken")).toBe(false);
});
it("does not match with zero overlapping words", () => {
const thread = makeThread({ title: "auth migration OAuth2" });
expect(matchesThread(thread, "the weather is nice")).toBe(false);
});
it("is case-insensitive", () => {
const thread = makeThread({ title: "Auth Migration" });
expect(matchesThread(thread, "the AUTH MIGRATION works")).toBe(true);
});
it("ignores words shorter than 3 characters", () => {
const thread = makeThread({ title: "a b c migration" });
// Only "migration" is > 2 chars, need 2 matches → false
expect(matchesThread(thread, "a b c something")).toBe(false);
});
it("respects custom minOverlap", () => {
const thread = makeThread({ title: "auth migration OAuth2" });
expect(matchesThread(thread, "auth migration OAuth2 is great", 3)).toBe(true);
expect(matchesThread(thread, "the auth migration is progressing", 3)).toBe(false);
});
it("handles empty title", () => {
const thread = makeThread({ title: "" });
expect(matchesThread(thread, "some text")).toBe(false);
});
it("handles empty text", () => {
const thread = makeThread({ title: "auth migration" });
expect(matchesThread(thread, "")).toBe(false);
});
});
// ════════════════════════════════════════════════════════════
// extractSignals
// ════════════════════════════════════════════════════════════
describe("extractSignals", () => {
it("extracts decision signals", () => {
const signals = extractSignals("We decided to use TypeScript for all plugins", "both");
expect(signals.decisions.length).toBeGreaterThan(0);
expect(signals.decisions[0]).toContain("decided");
});
it("extracts closure signals", () => {
const signals = extractSignals("The bug is fixed and working now", "both");
expect(signals.closures.length).toBeGreaterThan(0);
});
it("extracts wait signals", () => {
const signals = extractSignals("We are waiting for the code review", "both");
expect(signals.waits.length).toBeGreaterThan(0);
expect(signals.waits[0]).toContain("waiting for");
});
it("extracts topic signals", () => {
const signals = extractSignals("Let's get back to the auth migration", "both");
expect(signals.topics.length).toBeGreaterThan(0);
expect(signals.topics[0]).toContain("auth migration");
});
it("extracts multiple signal types from same text", () => {
const signals = extractSignals(
"Back to the auth module. We decided to fix it. It's done!",
"both",
);
expect(signals.topics.length).toBeGreaterThan(0);
expect(signals.decisions.length).toBeGreaterThan(0);
expect(signals.closures.length).toBeGreaterThan(0);
});
it("extracts German signals with 'both'", () => {
const signals = extractSignals("Wir haben beschlossen, das zu machen", "both");
expect(signals.decisions.length).toBeGreaterThan(0);
});
it("returns empty signals for unrelated text", () => {
const signals = extractSignals("The sky is blue and the grass is green", "both");
expect(signals.decisions).toHaveLength(0);
expect(signals.closures).toHaveLength(0);
expect(signals.waits).toHaveLength(0);
expect(signals.topics).toHaveLength(0);
});
it("extracts context window around decisions (50 before, 100 after)", () => {
const padding = "x".repeat(60);
const after = "y".repeat(120);
const text = `${padding}decided to use TypeScript${after}`;
const signals = extractSignals(text, "en");
expect(signals.decisions.length).toBeGreaterThan(0);
// Context window should be trimmed
const ctx = signals.decisions[0];
expect(ctx.length).toBeLessThan(text.length);
});
it("handles empty text", () => {
const signals = extractSignals("", "both");
expect(signals.decisions).toHaveLength(0);
expect(signals.closures).toHaveLength(0);
expect(signals.waits).toHaveLength(0);
expect(signals.topics).toHaveLength(0);
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — basic operations
// ════════════════════════════════════════════════════════════
describe("ThreadTracker", () => {
let workspace: string;
let tracker: ThreadTracker;
beforeEach(() => {
workspace = makeWorkspace();
tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
});
it("starts with empty threads", () => {
expect(tracker.getThreads()).toHaveLength(0);
});
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.length).toBeGreaterThanOrEqual(1);
// Should contain something related to "auth migration"
const found = threads.some(t =>
t.title.toLowerCase().includes("auth migration"),
);
expect(found).toBe(true);
});
it("creates thread with correct defaults", () => {
tracker.processMessage("back to the deployment pipeline", "user");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("deployment pipeline"),
);
expect(thread).toBeDefined();
expect(thread!.status).toBe("open");
expect(thread!.decisions).toHaveLength(0);
expect(thread!.waiting_for).toBeNull();
expect(thread!.id).toBeTruthy();
expect(thread!.created).toBeTruthy();
expect(thread!.last_activity).toBeTruthy();
});
it("does not create duplicate threads for same topic", () => {
tracker.processMessage("back to the deployment pipeline", "user");
tracker.processMessage("back to the deployment pipeline", "user");
const threads = tracker.getThreads().filter(t =>
t.title.toLowerCase().includes("deployment pipeline"),
);
expect(threads.length).toBe(1);
});
it("closes a thread when closure pattern detected", () => {
tracker.processMessage("back to the login bug fix", "user");
tracker.processMessage("the login bug fix is done ✅", "assistant");
const threads = tracker.getThreads();
const loginThread = threads.find(t =>
t.title.toLowerCase().includes("login bug"),
);
expect(loginThread?.status).toBe("closed");
});
it("appends decisions to matching threads", () => {
tracker.processMessage("back to the auth migration plan", "user");
tracker.processMessage("For the auth migration plan, we decided to use OAuth2 with PKCE", "assistant");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("auth migration"),
);
expect(thread?.decisions.length).toBeGreaterThan(0);
});
it("updates waiting_for on matching threads", () => {
tracker.processMessage("back to the deployment pipeline work", "user");
tracker.processMessage("The deployment pipeline is waiting for the staging environment fix", "user");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("deployment pipeline"),
);
expect(thread?.waiting_for).toBeTruthy();
});
it("updates mood on threads when mood detected", () => {
tracker.processMessage("back to the auth migration work", "user");
tracker.processMessage("this auth migration is awesome! auth migration rocks 🚀", "user");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("auth migration"),
);
expect(thread?.mood).not.toBe("neutral");
});
it("persists threads to disk", () => {
tracker.processMessage("back to the config refactor", "user");
const data = readThreads(workspace);
expect(data.version).toBe(2);
expect(data.threads.length).toBeGreaterThan(0);
});
it("tracks session mood", () => {
tracker.processMessage("This is awesome! 🚀", "user");
expect(tracker.getSessionMood()).not.toBe("neutral");
});
it("increments events processed", () => {
tracker.processMessage("hello", "user");
tracker.processMessage("world", "user");
expect(tracker.getEventsProcessed()).toBe(2);
});
it("skips empty content", () => {
tracker.processMessage("", "user");
expect(tracker.getEventsProcessed()).toBe(0);
});
it("persists integrity data", () => {
tracker.processMessage("back to something here now", "user");
const data = readThreads(workspace);
expect(data.integrity).toBeDefined();
expect(data.integrity.source).toBe("hooks");
expect(data.integrity.events_processed).toBe(1);
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — pruning
// ════════════════════════════════════════════════════════════
describe("ThreadTracker — pruning", () => {
it("prunes closed threads older than pruneDays", () => {
const workspace = makeWorkspace();
// Seed with an old closed thread
const oldDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
const threadsData = {
version: 2,
updated: oldDate,
threads: [
makeThread({
id: "old-closed",
title: "old deployment pipeline issue",
status: "closed",
last_activity: oldDate,
created: oldDate,
}),
makeThread({
id: "recent-open",
title: "recent auth migration work",
status: "open",
last_activity: new Date().toISOString(),
}),
],
integrity: { last_event_timestamp: oldDate, events_processed: 1, source: "hooks" as const },
session_mood: "neutral",
};
writeFileSync(
join(workspace, "memory", "reboot", "threads.json"),
JSON.stringify(threadsData),
);
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
// Trigger processing + prune
tracker.processMessage("back to the recent auth migration work update", "user");
const threads = tracker.getThreads();
expect(threads.find(t => t.id === "old-closed")).toBeUndefined();
expect(threads.find(t => t.id === "recent-open")).toBeDefined();
});
it("keeps closed threads within pruneDays", () => {
const workspace = makeWorkspace();
const recentDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
const threadsData = {
version: 2,
updated: recentDate,
threads: [
makeThread({
id: "recent-closed",
title: "recent fix completed done",
status: "closed",
last_activity: recentDate,
}),
],
integrity: { last_event_timestamp: recentDate, events_processed: 1, source: "hooks" as const },
session_mood: "neutral",
};
writeFileSync(
join(workspace, "memory", "reboot", "threads.json"),
JSON.stringify(threadsData),
);
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
tracker.processMessage("back to the something else here", "user");
expect(tracker.getThreads().find(t => t.id === "recent-closed")).toBeDefined();
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — maxThreads cap
// ════════════════════════════════════════════════════════════
describe("ThreadTracker — maxThreads cap", () => {
it("enforces maxThreads cap by removing oldest closed threads", () => {
const workspace = makeWorkspace();
const threads: Thread[] = [];
// Create 8 threads: 5 open + 3 closed
for (let i = 0; i < 5; i++) {
threads.push(makeThread({
id: `open-${i}`,
title: `open thread number ${i} task`,
status: "open",
last_activity: new Date(Date.now() - i * 60000).toISOString(),
}));
}
for (let i = 0; i < 3; i++) {
threads.push(makeThread({
id: `closed-${i}`,
title: `closed thread number ${i} done`,
status: "closed",
last_activity: new Date(Date.now() - i * 60000).toISOString(),
}));
}
const threadsData = {
version: 2,
updated: new Date().toISOString(),
threads,
integrity: { last_event_timestamp: new Date().toISOString(), events_processed: 1, source: "hooks" as const },
session_mood: "neutral",
};
writeFileSync(
join(workspace, "memory", "reboot", "threads.json"),
JSON.stringify(threadsData),
);
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 6, // 8 threads → cap at 6
}, "both", logger);
// Trigger processing which runs cap
tracker.processMessage("back to some topic here now", "user");
const result = tracker.getThreads();
expect(result.length).toBeLessThanOrEqual(7); // 6 + possible 1 new
// All open threads should be preserved
const openCount = result.filter(t => t.status === "open").length;
expect(openCount).toBeGreaterThanOrEqual(5);
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — loading existing state
// ════════════════════════════════════════════════════════════
describe("ThreadTracker — loading existing state", () => {
it("loads threads from existing threads.json", () => {
const workspace = makeWorkspace();
const threadsData = {
version: 2,
updated: new Date().toISOString(),
threads: [
makeThread({ id: "existing-1", title: "existing auth migration thread" }),
],
integrity: { last_event_timestamp: new Date().toISOString(), events_processed: 5, source: "hooks" as const },
session_mood: "excited",
};
writeFileSync(
join(workspace, "memory", "reboot", "threads.json"),
JSON.stringify(threadsData),
);
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
expect(tracker.getThreads()).toHaveLength(1);
expect(tracker.getThreads()[0].id).toBe("existing-1");
});
it("handles missing threads.json gracefully", () => {
const workspace = makeWorkspace();
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
expect(tracker.getThreads()).toHaveLength(0);
});
it("handles corrupt threads.json gracefully", () => {
const workspace = makeWorkspace();
writeFileSync(
join(workspace, "memory", "reboot", "threads.json"),
"not valid json{{{",
);
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
expect(tracker.getThreads()).toHaveLength(0);
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — flush
// ════════════════════════════════════════════════════════════
describe("ThreadTracker — flush", () => {
it("flush() persists dirty state", () => {
const workspace = makeWorkspace();
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
tracker.processMessage("back to the pipeline review", "user");
const result = tracker.flush();
expect(result).toBe(true);
});
it("flush() returns true when no dirty state", () => {
const workspace = makeWorkspace();
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
expect(tracker.flush()).toBe(true);
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — priority inference
// ════════════════════════════════════════════════════════════
describe("ThreadTracker — priority inference", () => {
it("assigns high priority for topics with impact keywords", () => {
const workspace = makeWorkspace();
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
tracker.processMessage("back to the security audit review", "user");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("security"),
);
expect(thread?.priority).toBe("high");
});
it("assigns medium priority for generic topics", () => {
const workspace = makeWorkspace();
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
tracker.processMessage("back to the feature flag setup", "user");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("feature flag"),
);
expect(thread?.priority).toBe("medium");
});
});

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"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"]
}