zouroboros-memory 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 marlandoj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Auto-capture integration
3
+ *
4
+ * Extracts facts, episodes, and decisions from conversation text
5
+ * and stores them in memory automatically.
6
+ */
7
+ import type { MemoryConfig, MemoryEntry, EpisodicMemory } from 'zouroboros-core';
8
+ export interface CaptureResult {
9
+ facts: MemoryEntry[];
10
+ episodes: EpisodicMemory[];
11
+ totalExtracted: number;
12
+ durationMs: number;
13
+ }
14
+ export interface CaptureOptions {
15
+ source?: string;
16
+ entity?: string;
17
+ conversationId?: string;
18
+ dryRun?: boolean;
19
+ }
20
+ /**
21
+ * Extract facts, decisions, and episodes from conversation text.
22
+ */
23
+ export declare function extractFromText(text: string): {
24
+ facts: {
25
+ entity: string;
26
+ key: string | undefined;
27
+ value: string;
28
+ category: string;
29
+ }[];
30
+ episodes: {
31
+ summary: string;
32
+ outcome: 'success' | 'failure' | 'ongoing';
33
+ entities: string[];
34
+ }[];
35
+ };
36
+ /**
37
+ * Auto-capture: extract and store facts/episodes from conversation text.
38
+ */
39
+ export declare function autoCapture(text: string, config: MemoryConfig, options?: CaptureOptions): Promise<CaptureResult>;
40
+ /**
41
+ * Add text to the capture buffer.
42
+ * Text is accumulated and periodically flushed.
43
+ */
44
+ export declare function bufferForCapture(text: string): void;
45
+ /**
46
+ * Start interval-based auto-capture.
47
+ * Flushes the capture buffer at the configured interval.
48
+ */
49
+ export declare function startAutoCapture(config: MemoryConfig, options?: CaptureOptions): void;
50
+ /**
51
+ * Stop interval-based auto-capture and flush remaining buffer.
52
+ */
53
+ export declare function stopAutoCapture(config: MemoryConfig, options?: CaptureOptions): Promise<CaptureResult | null>;
54
+ /**
55
+ * Get the current capture buffer size (for diagnostics).
56
+ */
57
+ export declare function getCaptureBufferSize(): number;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Auto-capture integration
3
+ *
4
+ * Extracts facts, episodes, and decisions from conversation text
5
+ * and stores them in memory automatically.
6
+ */
7
+ import { storeFact } from './facts.js';
8
+ import { createEpisode } from './episodes.js';
9
+ /**
10
+ * Pattern matchers for extracting structured information from text.
11
+ */
12
+ const FACT_PATTERNS = [
13
+ // "X is Y" / "X are Y"
14
+ /(?:^|\.\s+)([A-Z][a-zA-Z0-9_ -]+)\s+(?:is|are)\s+(.+?)(?:\.|$)/gm,
15
+ // "X uses Y" / "X requires Y"
16
+ /(?:^|\.\s+)([A-Z][a-zA-Z0-9_ -]+)\s+(?:uses?|requires?|depends?\s+on|runs?\s+on)\s+(.+?)(?:\.|$)/gm,
17
+ // Key-value from "set X to Y" / "changed X to Y"
18
+ /(?:set|changed?|updated?|configured?)\s+([a-zA-Z_][a-zA-Z0-9_.]+)\s+(?:to|=)\s+(.+?)(?:\.|,|$)/gim,
19
+ ];
20
+ const DECISION_PATTERNS = [
21
+ // "decided to X" / "chose to X" / "will X"
22
+ /(?:decided?|chose|choosing|will|going)\s+to\s+(.+?)(?:\.|$)/gim,
23
+ // "instead of X, Y"
24
+ /instead\s+of\s+(.+?),\s*(.+?)(?:\.|$)/gim,
25
+ ];
26
+ const EPISODE_PATTERNS = [
27
+ // "completed X" / "finished X" / "fixed X"
28
+ /(?:completed?|finished|fixed|resolved|deployed|implemented|shipped)\s+(.+?)(?:\.|$)/gim,
29
+ // "failed to X" / "error in X"
30
+ /(?:failed?\s+to|error\s+in|broke|crashed|bug\s+in)\s+(.+?)(?:\.|$)/gim,
31
+ ];
32
+ /**
33
+ * Extract facts, decisions, and episodes from conversation text.
34
+ */
35
+ export function extractFromText(text) {
36
+ const facts = [];
37
+ const episodes = [];
38
+ const seen = new Set();
39
+ // Extract facts
40
+ for (const pattern of FACT_PATTERNS) {
41
+ pattern.lastIndex = 0;
42
+ let match;
43
+ while ((match = pattern.exec(text)) !== null) {
44
+ const entity = match[1].trim();
45
+ const value = match[2].trim();
46
+ const key = entity + ':' + value;
47
+ if (!seen.has(key) && entity.length > 1 && value.length > 1) {
48
+ seen.add(key);
49
+ facts.push({ entity, key: undefined, value, category: 'fact' });
50
+ }
51
+ }
52
+ }
53
+ // Extract decisions
54
+ for (const pattern of DECISION_PATTERNS) {
55
+ pattern.lastIndex = 0;
56
+ let match;
57
+ while ((match = pattern.exec(text)) !== null) {
58
+ const value = match[1].trim();
59
+ if (value.length > 5 && !seen.has('decision:' + value)) {
60
+ seen.add('decision:' + value);
61
+ facts.push({
62
+ entity: 'system',
63
+ key: 'decision',
64
+ value,
65
+ category: 'decision',
66
+ });
67
+ }
68
+ }
69
+ }
70
+ // Extract episodes
71
+ for (const pattern of EPISODE_PATTERNS) {
72
+ pattern.lastIndex = 0;
73
+ let match;
74
+ while ((match = pattern.exec(text)) !== null) {
75
+ const summary = match[0].trim();
76
+ const isFailure = /fail|error|broke|crash|bug/i.test(summary);
77
+ if (summary.length > 5 && !seen.has('ep:' + summary)) {
78
+ seen.add('ep:' + summary);
79
+ // Extract capitalized words as entities
80
+ const entities = Array.from(new Set(summary.match(/[A-Z][a-zA-Z0-9_-]+/g)?.filter(w => w.length > 2) ?? ['system']));
81
+ episodes.push({
82
+ summary,
83
+ outcome: isFailure ? 'failure' : 'success',
84
+ entities: entities.length > 0 ? entities : ['system'],
85
+ });
86
+ }
87
+ }
88
+ }
89
+ return { facts, episodes };
90
+ }
91
+ /**
92
+ * Auto-capture: extract and store facts/episodes from conversation text.
93
+ */
94
+ export async function autoCapture(text, config, options = {}) {
95
+ const start = performance.now();
96
+ const { source = 'auto-capture', entity: defaultEntity, dryRun = false } = options;
97
+ const extracted = extractFromText(text);
98
+ const result = {
99
+ facts: [],
100
+ episodes: [],
101
+ totalExtracted: extracted.facts.length + extracted.episodes.length,
102
+ durationMs: 0,
103
+ };
104
+ if (dryRun) {
105
+ result.durationMs = performance.now() - start;
106
+ return result;
107
+ }
108
+ // Store facts
109
+ for (const fact of extracted.facts) {
110
+ const stored = await storeFact({
111
+ entity: defaultEntity ?? fact.entity,
112
+ key: fact.key,
113
+ value: fact.value,
114
+ category: fact.category,
115
+ source,
116
+ decay: 'medium',
117
+ importance: fact.category === 'decision' ? 1.5 : 1.0,
118
+ }, config);
119
+ result.facts.push(stored);
120
+ }
121
+ // Store episodes
122
+ for (const ep of extracted.episodes) {
123
+ const stored = createEpisode({
124
+ summary: ep.summary,
125
+ outcome: ep.outcome,
126
+ entities: ep.entities,
127
+ metadata: options.conversationId ? { conversationId: options.conversationId } : undefined,
128
+ });
129
+ result.episodes.push(stored);
130
+ }
131
+ result.durationMs = performance.now() - start;
132
+ return result;
133
+ }
134
+ /**
135
+ * Capture state for interval-based auto-capture.
136
+ */
137
+ let captureTimer = null;
138
+ let captureBuffer = [];
139
+ /**
140
+ * Add text to the capture buffer.
141
+ * Text is accumulated and periodically flushed.
142
+ */
143
+ export function bufferForCapture(text) {
144
+ captureBuffer.push(text);
145
+ }
146
+ /**
147
+ * Start interval-based auto-capture.
148
+ * Flushes the capture buffer at the configured interval.
149
+ */
150
+ export function startAutoCapture(config, options = {}) {
151
+ if (captureTimer)
152
+ return;
153
+ const intervalMs = (config.captureIntervalMinutes ?? 30) * 60 * 1000;
154
+ captureTimer = setInterval(async () => {
155
+ if (captureBuffer.length === 0)
156
+ return;
157
+ const text = captureBuffer.join('\n\n');
158
+ captureBuffer = [];
159
+ await autoCapture(text, config, options);
160
+ }, intervalMs);
161
+ }
162
+ /**
163
+ * Stop interval-based auto-capture and flush remaining buffer.
164
+ */
165
+ export async function stopAutoCapture(config, options = {}) {
166
+ if (captureTimer) {
167
+ clearInterval(captureTimer);
168
+ captureTimer = null;
169
+ }
170
+ if (captureBuffer.length === 0)
171
+ return null;
172
+ const text = captureBuffer.join('\n\n');
173
+ captureBuffer = [];
174
+ return autoCapture(text, config, options);
175
+ }
176
+ /**
177
+ * Get the current capture buffer size (for diagnostics).
178
+ */
179
+ export function getCaptureBufferSize() {
180
+ return captureBuffer.length;
181
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bun
2
+ import { parseArgs } from 'util';
3
+ import { join } from 'path';
4
+ import { VERSION } from './index.js';
5
+ const { values, positionals } = parseArgs({
6
+ args: process.argv.slice(2),
7
+ options: {
8
+ stats: { type: 'boolean', short: 's' },
9
+ version: { type: 'boolean', short: 'v' },
10
+ help: { type: 'boolean', short: 'h' },
11
+ },
12
+ strict: false,
13
+ allowPositionals: true,
14
+ });
15
+ function printHelp() {
16
+ console.log(`
17
+ zouroboros-memory v${VERSION} — Hybrid SQLite + Vector memory system
18
+
19
+ USAGE:
20
+ zouroboros-memory [options]
21
+ zouroboros-memory <command> [args]
22
+
23
+ OPTIONS:
24
+ --stats, -s Show memory database statistics
25
+ --version, -v Show version
26
+ --help, -h Show this help
27
+
28
+ COMMANDS (v4 enhancements):
29
+ metrics [report|record|clear] Memory system metrics dashboard (MEM-101)
30
+ import --source <type> --path <p> Import from external sources (MEM-102)
31
+ budget [init|status|track|reset] Context budget tracking (MEM-001)
32
+ summarize [args] Episode summarization (MEM-002)
33
+ multi-hop [retrieve|benchmark] Multi-hop retrieval (MEM-003)
34
+ conflicts [detect|resolve|stats] Conflict resolution (MEM-103)
35
+ cross-persona [args] Cross-persona memory (MEM-104)
36
+ graph-traversal [args] Graph traversal tools (MEM-105)
37
+ embed-bench [compare|benchmark] Embedding model benchmark (MEM-202)
38
+
39
+ PROGRAMMATIC USAGE:
40
+ import { init, storeFact, searchFacts } from 'zouroboros-memory';
41
+ `);
42
+ }
43
+ if (values.help) {
44
+ printHelp();
45
+ process.exit(0);
46
+ }
47
+ if (values.version) {
48
+ console.log(`zouroboros-memory v${VERSION}`);
49
+ process.exit(0);
50
+ }
51
+ if (values.stats) {
52
+ console.log('Memory system statistics:');
53
+ console.log(' Run "zouroboros doctor" for a full health check.');
54
+ console.log(' Or use the programmatic API: import { getStats } from "zouroboros-memory"');
55
+ process.exit(0);
56
+ }
57
+ // v4 subcommand routing — delegates to individual script CLIs
58
+ const command = positionals[0];
59
+ if (command) {
60
+ const { execSync } = require('child_process');
61
+ const srcDir = import.meta.dir;
62
+ const subArgs = process.argv.slice(3).join(' ');
63
+ const commandMap = {
64
+ 'metrics': 'metrics.ts',
65
+ 'import': 'import-pipeline.ts',
66
+ 'budget': 'context-budget.ts',
67
+ 'summarize': 'episode-summarizer.ts',
68
+ 'multi-hop': 'multi-hop.ts',
69
+ 'conflicts': 'conflict-resolver.ts',
70
+ 'cross-persona': 'cross-persona.ts',
71
+ 'graph-traversal': 'graph-traversal.ts',
72
+ 'embed-bench': 'embedding-benchmark.ts',
73
+ };
74
+ const scriptFile = commandMap[command];
75
+ if (scriptFile) {
76
+ const scriptPath = join(srcDir, scriptFile);
77
+ try {
78
+ execSync(`bun "${scriptPath}" ${subArgs}`, { stdio: 'inherit' });
79
+ }
80
+ catch (e) {
81
+ const msg = e instanceof Error ? e.message : String(e);
82
+ console.error(`Command "${command}" failed: ${msg}`);
83
+ process.exit(1);
84
+ }
85
+ process.exit(0);
86
+ }
87
+ console.error(`Unknown command: ${command}`);
88
+ printHelp();
89
+ process.exit(1);
90
+ }
91
+ printHelp();
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * conflict-resolver.ts — Memory Conflict Detection & Resolution
4
+ * MEM-103: Memory Conflict Resolution
5
+ *
6
+ * Usage:
7
+ * bun conflict-resolver.ts detect --entity <name>
8
+ * bun conflict-resolver.ts resolve --id <conflictId> --strategy supersede|flag
9
+ * bun conflict-resolver.ts history --entity <name> --key <key>
10
+ */
11
+ import { Database } from "bun:sqlite";
12
+ export type ConflictType = "semantic" | "temporal" | "exact";
13
+ export type ResolutionStrategy = "supersede" | "merge" | "flag" | "pending";
14
+ export interface ConflictRecord {
15
+ id: string;
16
+ factId: string;
17
+ conflictingFactId: string;
18
+ conflictType: ConflictType;
19
+ resolution: ResolutionStrategy | null;
20
+ fact1Value: string;
21
+ fact2Value: string;
22
+ createdAt: number;
23
+ }
24
+ export interface ProvenanceRecord {
25
+ id: string;
26
+ factId: string;
27
+ source: string;
28
+ capturedAt: number;
29
+ captureMethod?: string;
30
+ supersededBy?: string;
31
+ supersededAt?: number;
32
+ effectiveFrom: number;
33
+ effectiveUntil?: number;
34
+ }
35
+ export declare function isContradiction(fact1: {
36
+ value: string;
37
+ entity: string;
38
+ key: string | null;
39
+ }, fact2: {
40
+ value: string;
41
+ entity: string;
42
+ key: string | null;
43
+ }): Promise<boolean>;
44
+ export declare function findEntityConflicts(db: Database, entity: string): ConflictRecord[];
45
+ export declare function detectNewConflict(db: Database, entity: string, key: string | null, newValue: string): Promise<ConflictRecord | null>;
46
+ export declare function resolveConflict(db: Database, conflictId: string, strategy: ResolutionStrategy): void;
47
+ export declare function resolveAllPending(db: Database, strategy?: ResolutionStrategy): number;
48
+ export declare function trackProvenance(db: Database, factId: string, source: string, captureMethod?: string): void;
49
+ export declare function getProvenance(db: Database, factId: string): ProvenanceRecord[];
50
+ export declare function getFactHistory(db: Database, entity: string, key: string | null): Array<{
51
+ id: string;
52
+ value: string;
53
+ effectiveFrom: number;
54
+ superseded: boolean;
55
+ }>;
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * conflict-resolver.ts — Memory Conflict Detection & Resolution
4
+ * MEM-103: Memory Conflict Resolution
5
+ *
6
+ * Usage:
7
+ * bun conflict-resolver.ts detect --entity <name>
8
+ * bun conflict-resolver.ts resolve --id <conflictId> --strategy supersede|flag
9
+ * bun conflict-resolver.ts history --entity <name> --key <key>
10
+ */
11
+ import { Database } from "bun:sqlite";
12
+ import { randomUUID } from "crypto";
13
+ const DB_PATH = process.env.ZO_MEMORY_DB || "/home/workspace/.zo/memory/shared-facts.db";
14
+ const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
15
+ function getDb() {
16
+ const db = new Database(DB_PATH);
17
+ db.exec("PRAGMA journal_mode = WAL");
18
+ db.exec(`
19
+ CREATE TABLE IF NOT EXISTS fact_conflicts (
20
+ id TEXT PRIMARY KEY,
21
+ fact_id TEXT NOT NULL REFERENCES facts(id) ON DELETE CASCADE,
22
+ conflicting_fact_id TEXT NOT NULL REFERENCES facts(id) ON DELETE CASCADE,
23
+ conflict_type TEXT NOT NULL,
24
+ resolution TEXT CHECK(resolution IN ('superseded','merged','flagged','pending')),
25
+ resolved_at INTEGER,
26
+ created_at INTEGER DEFAULT (strftime('%s','now'))
27
+ );
28
+ CREATE TABLE IF NOT EXISTS fact_provenance (
29
+ id TEXT PRIMARY KEY,
30
+ fact_id TEXT NOT NULL REFERENCES facts(id) ON DELETE CASCADE,
31
+ source TEXT NOT NULL,
32
+ captured_at INTEGER NOT NULL,
33
+ capture_method TEXT,
34
+ superseded_by TEXT REFERENCES facts(id),
35
+ superseded_at INTEGER,
36
+ effective_from INTEGER DEFAULT (strftime('%s','now')),
37
+ effective_until INTEGER,
38
+ metadata TEXT,
39
+ UNIQUE(fact_id, source)
40
+ );
41
+ CREATE INDEX IF NOT EXISTS idx_conflicts_fact ON fact_conflicts(fact_id);
42
+ CREATE INDEX IF NOT EXISTS idx_provenance_fact ON fact_provenance(fact_id);
43
+ `);
44
+ return db;
45
+ }
46
+ async function ollamaCheck(prompt) {
47
+ try {
48
+ const resp = await fetch(`${OLLAMA_URL}/api/generate`, {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({ model: "qwen2.5:1.5b", prompt, stream: false, options: { temperature: 0, num_predict: 50 } }),
52
+ signal: AbortSignal.timeout(15000),
53
+ });
54
+ if (!resp.ok)
55
+ return false;
56
+ const data = await resp.json();
57
+ const r = (data.response || "").trim().toLowerCase();
58
+ return r === "yes" || r === "true";
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ export async function isContradiction(fact1, fact2) {
65
+ if (fact1.value.trim().toLowerCase() === fact2.value.trim().toLowerCase())
66
+ return false;
67
+ const n1 = parseFloat((fact1.value.match(/^[\d.,]+/)?.[0] || "").replace(/,/g, ""));
68
+ const n2 = parseFloat((fact2.value.match(/^[\d.,]+/)?.[0] || "").replace(/,/g, ""));
69
+ if (!isNaN(n1) && !isNaN(n2) && Math.abs(n1 - n2) > 0.001 * Math.max(n1, n2))
70
+ return true;
71
+ const prompt = `Do these two statements directly contradict each other? Reply "yes" or "no".
72
+ 1: "${fact1.value.slice(0, 300)}"
73
+ 2: "${fact2.value.slice(0, 300)}"`;
74
+ return ollamaCheck(prompt);
75
+ }
76
+ export function findEntityConflicts(db, entity) {
77
+ const rows = db.prepare(`
78
+ SELECT fc.id, fc.fact_id, fc.conflicting_fact_id, fc.conflict_type, fc.resolution, fc.created_at,
79
+ f1.value as fact1_value, f2.value as fact2_value
80
+ FROM fact_conflicts fc
81
+ JOIN facts f1 ON fc.fact_id = f1.id
82
+ JOIN facts f2 ON fc.conflicting_fact_id = f2.id
83
+ WHERE f1.entity = ? OR f2.entity = ?
84
+ ORDER BY fc.created_at DESC
85
+ `).all(entity, entity);
86
+ return rows.map(r => ({
87
+ id: r.id, factId: r.fact_id, conflictingFactId: r.conflicting_fact_id,
88
+ conflictType: r.conflict_type, resolution: r.resolution,
89
+ fact1Value: r.fact1_value, fact2Value: r.fact2_value, createdAt: r.created_at,
90
+ }));
91
+ }
92
+ export async function detectNewConflict(db, entity, key, newValue) {
93
+ const existing = db.prepare(`
94
+ SELECT id, entity, key, value, created_at FROM facts
95
+ WHERE entity = ? ${key ? "AND key = ?" : "AND key IS NULL"} AND (expires_at IS NULL OR expires_at > ?)
96
+ ORDER BY created_at DESC LIMIT 10
97
+ `).all(entity, ...(key ? [key] : []), Math.floor(Date.now() / 1000));
98
+ for (const ef of existing) {
99
+ if (ef.value.trim().toLowerCase() === newValue.trim().toLowerCase())
100
+ continue;
101
+ const isContradict = await isContradiction({ value: newValue, entity, key }, { value: ef.value, entity, key });
102
+ if (isContradict) {
103
+ const nowSec = Math.floor(Date.now() / 1000);
104
+ const cid = randomUUID();
105
+ db.prepare(`INSERT OR IGNORE INTO fact_conflicts (id, fact_id, conflicting_fact_id, conflict_type, resolution, created_at) VALUES (?, ?, ?, 'semantic', 'pending', ?)`).run(cid, ef.id, randomUUID(), nowSec);
106
+ return { id: cid, factId: ef.id, conflictingFactId: ef.id, conflictType: "semantic", resolution: "pending", fact1Value: ef.value, fact2Value: newValue, createdAt: nowSec };
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+ export function resolveConflict(db, conflictId, strategy) {
112
+ const nowSec = Math.floor(Date.now() / 1000);
113
+ if (strategy === "supersede") {
114
+ db.prepare("UPDATE fact_conflicts SET resolution = 'superseded', resolved_at = ? WHERE id = ?").run(nowSec, conflictId);
115
+ db.prepare("UPDATE facts SET expires_at = ? WHERE id = (SELECT fact_id FROM fact_conflicts WHERE id = ? AND resolution = 'superseded')").run(nowSec - 1, conflictId);
116
+ }
117
+ else {
118
+ db.prepare("UPDATE fact_conflicts SET resolution = ?, resolved_at = ? WHERE id = ?").run(strategy, nowSec, conflictId);
119
+ }
120
+ }
121
+ export function resolveAllPending(db, strategy = "flag") {
122
+ const result = db.prepare("UPDATE fact_conflicts SET resolution = ?, resolved_at = ? WHERE resolution = 'pending'").run(strategy, Math.floor(Date.now() / 1000));
123
+ return result.changes;
124
+ }
125
+ export function trackProvenance(db, factId, source, captureMethod) {
126
+ const nowSec = Math.floor(Date.now() / 1000);
127
+ db.prepare(`INSERT OR IGNORE INTO fact_provenance (id, fact_id, source, captured_at, capture_method, effective_from) VALUES (?, ?, ?, ?, ?, ?)`).run(randomUUID(), factId, source, nowSec, captureMethod || null, nowSec);
128
+ }
129
+ export function getProvenance(db, factId) {
130
+ return db.prepare("SELECT * FROM fact_provenance WHERE fact_id = ? ORDER BY effective_from DESC").all(factId).map(r => ({
131
+ id: r.id, factId: r.fact_id, source: r.source,
132
+ capturedAt: r.captured_at, captureMethod: r.capture_method,
133
+ supersededBy: r.superseded_by, supersededAt: r.superseded_at,
134
+ effectiveFrom: r.effective_from, effectiveUntil: r.effective_until,
135
+ }));
136
+ }
137
+ export function getFactHistory(db, entity, key) {
138
+ const rows = db.prepare(`
139
+ SELECT f.id, f.value, COALESCE(fp.effective_from, f.created_at) as eff, fp.superseded_by
140
+ FROM facts f LEFT JOIN fact_provenance fp ON f.id = fp.fact_id
141
+ WHERE f.entity = ? ${key ? "AND f.key = ?" : "AND f.key IS NULL"}
142
+ ORDER BY eff DESC
143
+ `).all(entity, ...(key ? [key] : []));
144
+ return rows.map(r => ({ id: r.id, value: r.value, effectiveFrom: r.eff, superseded: Boolean(r.superseded_by) }));
145
+ }
146
+ async function main() {
147
+ const args = process.argv.slice(2);
148
+ if (args.length === 0) {
149
+ console.log("Conflict Resolver CLI\n\nCommands:\n detect --entity <name> Detect conflicts for an entity\n resolve --id <conflictId> --strategy supersede|flag\n resolve-all --strategy <s> Resolve all pending\n provenance --id <factId> Show provenance chain\n history --entity <name> --key <key> Show value history\n stats Conflict stats");
150
+ process.exit(0);
151
+ }
152
+ const flags = {};
153
+ for (let i = 0; i < args.length; i++)
154
+ if (args[i].startsWith("--"))
155
+ flags[args[i].slice(2)] = args[i + 1] || "";
156
+ const command = args[0];
157
+ const db = getDb();
158
+ if (command === "detect") {
159
+ if (!flags.entity) {
160
+ console.error("--entity required");
161
+ process.exit(1);
162
+ }
163
+ const conflicts = findEntityConflicts(db, flags.entity);
164
+ if (conflicts.length === 0) {
165
+ console.log(`No conflicts for "${flags.entity}".`);
166
+ }
167
+ else {
168
+ console.log(`${conflicts.length} conflict(s):\n`);
169
+ for (const c of conflicts) {
170
+ console.log(` [${c.id.slice(0, 8)}] ${c.conflictType} | ${c.resolution || "pending"}\n F1: ${c.fact1Value.slice(0, 80)}\n F2: ${c.fact2Value.slice(0, 80)}\n`);
171
+ }
172
+ }
173
+ }
174
+ else if (command === "resolve") {
175
+ if (!flags.id || !flags.strategy) {
176
+ console.error("--id and --strategy required");
177
+ process.exit(1);
178
+ }
179
+ resolveConflict(db, flags.id, flags.strategy);
180
+ console.log(`Conflict ${flags.id.slice(0, 8)} resolved as ${flags.strategy}.`);
181
+ }
182
+ else if (command === "resolve-all") {
183
+ const n = resolveAllPending(db, flags.strategy || "flag");
184
+ console.log(`${n} pending conflict(s) resolved.`);
185
+ }
186
+ else if (command === "provenance") {
187
+ if (!flags.id) {
188
+ console.error("--id required");
189
+ process.exit(1);
190
+ }
191
+ const prov = getProvenance(db, flags.id);
192
+ if (prov.length === 0) {
193
+ console.log("No provenance records.");
194
+ }
195
+ else {
196
+ prov.forEach(p => console.log(` ${new Date(p.capturedAt * 1000).toISOString()} | ${p.source} | superseded=${p.supersededBy ? "yes (" + p.supersededBy.slice(0, 8) + ")" : "no"}`));
197
+ }
198
+ }
199
+ else if (command === "history") {
200
+ if (!flags.entity) {
201
+ console.error("--entity required");
202
+ process.exit(1);
203
+ }
204
+ const history = getFactHistory(db, flags.entity, flags.key || null);
205
+ if (history.length === 0) {
206
+ console.log("No history found.");
207
+ }
208
+ else {
209
+ history.forEach(h => console.log(` [${h.superseded ? "SUPERSEDED" : "active"}] ${h.value.slice(0, 80)}\n since ${new Date(h.effectiveFrom * 1000).toDateString()}`));
210
+ }
211
+ }
212
+ else if (command === "stats") {
213
+ const total = db.prepare("SELECT COUNT(*) as c FROM fact_conflicts").get();
214
+ const pending = db.prepare("SELECT COUNT(*) as c FROM fact_conflicts WHERE resolution = 'pending'").get();
215
+ const resolved = db.prepare("SELECT COUNT(*) as c FROM fact_conflicts WHERE resolution != 'pending'").get();
216
+ console.log(`Conflicts: ${total.c} total | ${pending.c} pending | ${resolved.c} resolved`);
217
+ }
218
+ db.close();
219
+ }
220
+ if (import.meta.main)
221
+ main();