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

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

398 lines
15 KiB
TypeScript

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);
});
});