omegon 0.6.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/.gitattributes +3 -0
- package/AGENTS.md +16 -0
- package/LICENSE +15 -0
- package/README.md +289 -0
- package/bin/pi.mjs +30 -0
- package/extensions/00-secrets/index.ts +1126 -0
- package/extensions/01-auth/auth.ts +401 -0
- package/extensions/01-auth/index.ts +289 -0
- package/extensions/auto-compact.ts +42 -0
- package/extensions/bootstrap/deps.ts +291 -0
- package/extensions/bootstrap/index.ts +811 -0
- package/extensions/chronos/chronos.sh +487 -0
- package/extensions/chronos/index.ts +148 -0
- package/extensions/cleave/assessment.ts +754 -0
- package/extensions/cleave/bridge.ts +31 -0
- package/extensions/cleave/conflicts.ts +250 -0
- package/extensions/cleave/dispatcher.ts +808 -0
- package/extensions/cleave/guardrails.ts +426 -0
- package/extensions/cleave/index.ts +3121 -0
- package/extensions/cleave/lifecycle-emitter.ts +20 -0
- package/extensions/cleave/openspec.ts +811 -0
- package/extensions/cleave/planner.ts +260 -0
- package/extensions/cleave/review.ts +579 -0
- package/extensions/cleave/skills.ts +355 -0
- package/extensions/cleave/types.ts +261 -0
- package/extensions/cleave/workspace.ts +861 -0
- package/extensions/cleave/worktree.ts +243 -0
- package/extensions/core-renderers.ts +253 -0
- package/extensions/dashboard/context-gauge.ts +58 -0
- package/extensions/dashboard/file-watch.ts +14 -0
- package/extensions/dashboard/footer.ts +1145 -0
- package/extensions/dashboard/git.ts +185 -0
- package/extensions/dashboard/index.ts +478 -0
- package/extensions/dashboard/memory-audit.ts +34 -0
- package/extensions/dashboard/overlay-data.ts +705 -0
- package/extensions/dashboard/overlay.ts +365 -0
- package/extensions/dashboard/render-utils.ts +54 -0
- package/extensions/dashboard/types.ts +191 -0
- package/extensions/dashboard/uri-helper.ts +45 -0
- package/extensions/debug.ts +69 -0
- package/extensions/defaults.ts +282 -0
- package/extensions/design-tree/dashboard-state.ts +161 -0
- package/extensions/design-tree/design-card.ts +362 -0
- package/extensions/design-tree/index.ts +2130 -0
- package/extensions/design-tree/lifecycle-emitter.ts +41 -0
- package/extensions/design-tree/tree.ts +1607 -0
- package/extensions/design-tree/types.ts +163 -0
- package/extensions/distill.ts +127 -0
- package/extensions/effort/index.ts +395 -0
- package/extensions/effort/tiers.ts +146 -0
- package/extensions/effort/types.ts +105 -0
- package/extensions/lib/git-state.ts +227 -0
- package/extensions/lib/local-models.ts +157 -0
- package/extensions/lib/model-preferences.ts +51 -0
- package/extensions/lib/model-routing.ts +720 -0
- package/extensions/lib/operator-fallback.ts +205 -0
- package/extensions/lib/operator-profile.ts +360 -0
- package/extensions/lib/slash-command-bridge.ts +253 -0
- package/extensions/lib/typebox-helpers.ts +16 -0
- package/extensions/local-inference/index.ts +727 -0
- package/extensions/mcp-bridge/README.md +220 -0
- package/extensions/mcp-bridge/index.ts +951 -0
- package/extensions/mcp-bridge/lib.ts +365 -0
- package/extensions/mcp-bridge/mcp.json +3 -0
- package/extensions/mcp-bridge/package.json +11 -0
- package/extensions/model-budget.ts +752 -0
- package/extensions/offline-driver.ts +403 -0
- package/extensions/openspec/archive-gate.ts +164 -0
- package/extensions/openspec/branch-cleanup.ts +64 -0
- package/extensions/openspec/dashboard-state.ts +50 -0
- package/extensions/openspec/index.ts +1917 -0
- package/extensions/openspec/lifecycle-emitter.ts +65 -0
- package/extensions/openspec/lifecycle-files.ts +70 -0
- package/extensions/openspec/lifecycle.ts +50 -0
- package/extensions/openspec/reconcile.ts +187 -0
- package/extensions/openspec/spec.ts +1385 -0
- package/extensions/openspec/types.ts +98 -0
- package/extensions/project-memory/DESIGN-global-mind.md +198 -0
- package/extensions/project-memory/README.md +202 -0
- package/extensions/project-memory/api-types.ts +382 -0
- package/extensions/project-memory/compaction-policy.ts +29 -0
- package/extensions/project-memory/core.ts +164 -0
- package/extensions/project-memory/embeddings.ts +230 -0
- package/extensions/project-memory/extraction-v2.ts +861 -0
- package/extensions/project-memory/factstore.ts +2177 -0
- package/extensions/project-memory/index.ts +3459 -0
- package/extensions/project-memory/injection-metrics.ts +91 -0
- package/extensions/project-memory/jsonl-io.ts +12 -0
- package/extensions/project-memory/lifecycle.ts +331 -0
- package/extensions/project-memory/migration.ts +293 -0
- package/extensions/project-memory/package.json +9 -0
- package/extensions/project-memory/sci-renderers.ts +7 -0
- package/extensions/project-memory/template.ts +103 -0
- package/extensions/project-memory/triggers.ts +52 -0
- package/extensions/project-memory/types.ts +102 -0
- package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
- package/extensions/render/composition/package-lock.json +534 -0
- package/extensions/render/composition/package.json +22 -0
- package/extensions/render/composition/render.mjs +246 -0
- package/extensions/render/composition/test-comp.tsx +87 -0
- package/extensions/render/composition/types.ts +24 -0
- package/extensions/render/excalidraw/UPSTREAM.md +81 -0
- package/extensions/render/excalidraw/elements.ts +764 -0
- package/extensions/render/excalidraw/index.ts +66 -0
- package/extensions/render/excalidraw/types.ts +223 -0
- package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
- package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
- package/extensions/render/excalidraw-renderer/render_template.html +59 -0
- package/extensions/render/index.ts +830 -0
- package/extensions/render/native-diagrams/index.ts +57 -0
- package/extensions/render/native-diagrams/motifs.ts +542 -0
- package/extensions/render/native-diagrams/raster.ts +8 -0
- package/extensions/render/native-diagrams/scene.ts +75 -0
- package/extensions/render/native-diagrams/spec.ts +204 -0
- package/extensions/render/native-diagrams/svg.ts +116 -0
- package/extensions/sci-ui.ts +304 -0
- package/extensions/session-log.ts +174 -0
- package/extensions/shared-state.ts +146 -0
- package/extensions/spinner-verbs.ts +91 -0
- package/extensions/style.ts +281 -0
- package/extensions/terminal-title.ts +191 -0
- package/extensions/tool-profile/index.ts +291 -0
- package/extensions/tool-profile/profiles.ts +290 -0
- package/extensions/types.d.ts +9 -0
- package/extensions/vault/index.ts +185 -0
- package/extensions/version-check.ts +90 -0
- package/extensions/view/index.ts +859 -0
- package/extensions/view/uri-resolver.ts +148 -0
- package/extensions/web-search/index.ts +182 -0
- package/extensions/web-search/providers.ts +121 -0
- package/extensions/web-ui/index.ts +110 -0
- package/extensions/web-ui/server.ts +265 -0
- package/extensions/web-ui/state.ts +462 -0
- package/extensions/web-ui/static/index.html +145 -0
- package/extensions/web-ui/types.ts +284 -0
- package/package.json +76 -0
- package/prompts/init.md +75 -0
- package/prompts/new-repo.md +54 -0
- package/prompts/oci-login.md +56 -0
- package/prompts/status.md +50 -0
- package/settings.json +4 -0
- package/skills/cleave/SKILL.md +218 -0
- package/skills/git/SKILL.md +209 -0
- package/skills/git/_reference/ci-validation.md +204 -0
- package/skills/oci/SKILL.md +338 -0
- package/skills/openspec/SKILL.md +346 -0
- package/skills/pi-extensions/SKILL.md +191 -0
- package/skills/pi-tui/SKILL.md +517 -0
- package/skills/python/SKILL.md +189 -0
- package/skills/rust/SKILL.md +268 -0
- package/skills/security/SKILL.md +206 -0
- package/skills/style/SKILL.md +264 -0
- package/skills/typescript/SKILL.md +225 -0
- package/skills/vault/SKILL.md +102 -0
- package/themes/alpharius-legacy.json +85 -0
- package/themes/alpharius.conf +59 -0
- package/themes/alpharius.json +88 -0
|
@@ -0,0 +1,2177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Memory — Fact Store
|
|
3
|
+
*
|
|
4
|
+
* SQLite-backed storage for memory facts with decay-based reinforcement.
|
|
5
|
+
* Replaces the markdown-based MemoryStorage for structured persistence.
|
|
6
|
+
*
|
|
7
|
+
* Schema:
|
|
8
|
+
* facts — individual knowledge atoms with confidence decay
|
|
9
|
+
* minds — named memory stores with lifecycle
|
|
10
|
+
* facts_fts — FTS5 virtual table for full-text search
|
|
11
|
+
*
|
|
12
|
+
* Rendering:
|
|
13
|
+
* Active facts are rendered to Markdown-KV for LLM context injection.
|
|
14
|
+
* The LLM never sees the database directly.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as crypto from "node:crypto";
|
|
20
|
+
import { SECTIONS, type SectionName } from "./template.ts";
|
|
21
|
+
import { cosineSimilarity, vectorToBlob, blobToVector } from "./embeddings.ts";
|
|
22
|
+
import {
|
|
23
|
+
computeConfidence as coreComputeConfidence,
|
|
24
|
+
contentHash as coreContentHash,
|
|
25
|
+
normalizeForHash as coreNormalizeForHash,
|
|
26
|
+
type DecayProfile as CoreDecayProfile,
|
|
27
|
+
type DecayProfileName,
|
|
28
|
+
resolveDecayProfile,
|
|
29
|
+
DECAY as CORE_DECAY,
|
|
30
|
+
GLOBAL_DECAY as CORE_GLOBAL_DECAY,
|
|
31
|
+
RECENT_WORK_DECAY as CORE_RECENT_WORK_DECAY,
|
|
32
|
+
} from "./core.ts";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the SQLite database constructor.
|
|
36
|
+
* Prefers better-sqlite3 (native, battle-tested), falls back to node:sqlite.
|
|
37
|
+
*/
|
|
38
|
+
function loadDatabase(): any {
|
|
39
|
+
try {
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
const BetterSqlite3 = require("better-sqlite3");
|
|
42
|
+
// Verify the native addon loads (ABI mismatch throws here, not at require time).
|
|
43
|
+
// Create a throwaway in-memory DB to exercise the native binding.
|
|
44
|
+
const test = new BetterSqlite3(":memory:");
|
|
45
|
+
test.close();
|
|
46
|
+
return BetterSqlite3;
|
|
47
|
+
} catch {
|
|
48
|
+
// Fallback: wrap node:sqlite DatabaseSync to match better-sqlite3 API subset.
|
|
49
|
+
// Triggers when better-sqlite3 is missing OR its native addon was compiled
|
|
50
|
+
// against a different Node.js version (ERR_DLOPEN_FAILED).
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
52
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
53
|
+
return class NodeSqliteWrapper {
|
|
54
|
+
private db: any;
|
|
55
|
+
constructor(filepath: string) {
|
|
56
|
+
this.db = new DatabaseSync(filepath);
|
|
57
|
+
}
|
|
58
|
+
pragma(stmt: string) {
|
|
59
|
+
return this.db.prepare(`PRAGMA ${stmt}`).get();
|
|
60
|
+
}
|
|
61
|
+
exec(sql: string) {
|
|
62
|
+
this.db.exec(sql);
|
|
63
|
+
}
|
|
64
|
+
prepare(sql: string) {
|
|
65
|
+
const s = this.db.prepare(sql);
|
|
66
|
+
return {
|
|
67
|
+
run: (...args: any[]) => s.run(...args),
|
|
68
|
+
get: (...args: any[]) => s.get(...args),
|
|
69
|
+
all: (...args: any[]) => s.all(...args),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
close() {
|
|
73
|
+
this.db.close();
|
|
74
|
+
}
|
|
75
|
+
transaction(fn: Function) {
|
|
76
|
+
return (...args: any[]) => {
|
|
77
|
+
this.db.exec("BEGIN");
|
|
78
|
+
try {
|
|
79
|
+
const result = fn(...args);
|
|
80
|
+
this.db.exec("COMMIT");
|
|
81
|
+
return result;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
this.db.exec("ROLLBACK");
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const Database = loadDatabase();
|
|
93
|
+
|
|
94
|
+
/** Generate a short unique ID */
|
|
95
|
+
function nanoid(size = 12): string {
|
|
96
|
+
const bytes = crypto.randomBytes(size);
|
|
97
|
+
return bytes.toString("base64url").slice(0, size);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Normalize content for dedup hashing — delegates to core.ts */
|
|
101
|
+
const normalizeForHash = coreNormalizeForHash;
|
|
102
|
+
|
|
103
|
+
/** Compute content hash for dedup — delegates to core.ts */
|
|
104
|
+
const contentHash = coreContentHash;
|
|
105
|
+
|
|
106
|
+
// --- Types ---
|
|
107
|
+
|
|
108
|
+
export interface Fact {
|
|
109
|
+
id: string;
|
|
110
|
+
mind: string;
|
|
111
|
+
section: string;
|
|
112
|
+
content: string;
|
|
113
|
+
status: "active" | "superseded" | "archived";
|
|
114
|
+
created_at: string;
|
|
115
|
+
created_session: string | null;
|
|
116
|
+
supersedes: string | null;
|
|
117
|
+
superseded_at: string | null;
|
|
118
|
+
archived_at: string | null;
|
|
119
|
+
source: "manual" | "extraction" | "ingest" | "migration" | "lifecycle" | "tool-call";
|
|
120
|
+
content_hash: string;
|
|
121
|
+
confidence: number;
|
|
122
|
+
last_reinforced: string;
|
|
123
|
+
reinforcement_count: number;
|
|
124
|
+
decay_rate: number;
|
|
125
|
+
/** Decay profile discriminant — stored per-fact for correct read-time decay. */
|
|
126
|
+
decay_profile: DecayProfileName;
|
|
127
|
+
/** Lamport logical timestamp — incremented on every mutation. Higher version wins on git-sync. */
|
|
128
|
+
version: number;
|
|
129
|
+
/** Last time this fact was returned by semanticSearch. Null if never accessed. */
|
|
130
|
+
last_accessed: string | null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface MindRecord {
|
|
134
|
+
name: string;
|
|
135
|
+
description: string;
|
|
136
|
+
status: "active" | "refined" | "retired";
|
|
137
|
+
origin_type: "local" | "link" | "remote";
|
|
138
|
+
origin_path: string | null;
|
|
139
|
+
origin_url: string | null;
|
|
140
|
+
readonly: number; // 0 or 1
|
|
141
|
+
parent: string | null;
|
|
142
|
+
created_at: string;
|
|
143
|
+
last_sync: string | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface StoreFactOptions {
|
|
147
|
+
mind?: string;
|
|
148
|
+
section: SectionName;
|
|
149
|
+
content: string;
|
|
150
|
+
source?: Fact["source"];
|
|
151
|
+
session?: string | null;
|
|
152
|
+
supersedes?: string | null;
|
|
153
|
+
confidence?: number;
|
|
154
|
+
reinforcement_count?: number;
|
|
155
|
+
decay_rate?: number;
|
|
156
|
+
/** Decay profile discriminant — stored per-fact so read-time decay uses the correct profile. */
|
|
157
|
+
decayProfile?: DecayProfileName;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface ReinforcementResult {
|
|
161
|
+
reinforced: number;
|
|
162
|
+
added: number;
|
|
163
|
+
newFactIds: string[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface Episode {
|
|
167
|
+
id: string;
|
|
168
|
+
mind: string;
|
|
169
|
+
title: string;
|
|
170
|
+
narrative: string;
|
|
171
|
+
date: string;
|
|
172
|
+
session_id: string | null;
|
|
173
|
+
created_at: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface Edge {
|
|
177
|
+
id: string;
|
|
178
|
+
source_fact_id: string;
|
|
179
|
+
target_fact_id: string;
|
|
180
|
+
relation: string;
|
|
181
|
+
description: string;
|
|
182
|
+
confidence: number;
|
|
183
|
+
last_reinforced: string;
|
|
184
|
+
reinforcement_count: number;
|
|
185
|
+
decay_rate: number;
|
|
186
|
+
status: "active" | "archived";
|
|
187
|
+
created_at: string;
|
|
188
|
+
created_session: string | null;
|
|
189
|
+
source_mind: string | null;
|
|
190
|
+
target_mind: string | null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface EdgeResult {
|
|
194
|
+
added: number;
|
|
195
|
+
reinforced: number;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- Decay math (delegated to core.ts — the Rust port target) ---
|
|
199
|
+
|
|
200
|
+
/** Re-export canonical decay profiles from core.ts so existing importers are unaffected. */
|
|
201
|
+
export const DECAY = CORE_DECAY;
|
|
202
|
+
export const GLOBAL_DECAY = CORE_GLOBAL_DECAY;
|
|
203
|
+
export const RECENT_WORK_DECAY = CORE_RECENT_WORK_DECAY;
|
|
204
|
+
export type DecayProfile = CoreDecayProfile;
|
|
205
|
+
export { DecayProfileName, resolveDecayProfile };
|
|
206
|
+
|
|
207
|
+
/** Section-specific decay overrides — keyed by section name */
|
|
208
|
+
const SECTION_DECAY_OVERRIDES: Partial<Record<string, typeof RECENT_WORK_DECAY>> = {
|
|
209
|
+
"Recent Work": RECENT_WORK_DECAY,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** Delegates to core.ts::computeConfidence — single source of truth. */
|
|
213
|
+
export const computeConfidence = coreComputeConfidence;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Determine the decay profile name for a section.
|
|
217
|
+
* Recent Work → "recent_work" (halfLife=2d). All others → undefined (uses storeFact's default "standard").
|
|
218
|
+
* Centralizes the mapping so it's not duplicated across processExtraction call sites.
|
|
219
|
+
*/
|
|
220
|
+
function decayProfileForSection(section: string): DecayProfileName | undefined {
|
|
221
|
+
return section === "Recent Work" ? "recent_work" : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- FactStore ---
|
|
225
|
+
|
|
226
|
+
export class FactStore {
|
|
227
|
+
private db: any;
|
|
228
|
+
private dbPath: string;
|
|
229
|
+
private decayProfile: DecayProfile;
|
|
230
|
+
|
|
231
|
+
constructor(memoryDir: string, opts?: { decay?: DecayProfile; dbName?: string }) {
|
|
232
|
+
this.decayProfile = opts?.decay ?? DECAY;
|
|
233
|
+
this.dbPath = path.join(memoryDir, opts?.dbName ?? "facts.db");
|
|
234
|
+
fs.mkdirSync(memoryDir, { recursive: true });
|
|
235
|
+
this.db = new Database(this.dbPath);
|
|
236
|
+
this.db.pragma("journal_mode = WAL");
|
|
237
|
+
this.db.pragma("foreign_keys = ON");
|
|
238
|
+
this.initSchema();
|
|
239
|
+
this.runMigrations();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Current schema version — bump when adding migrations */
|
|
243
|
+
static readonly SCHEMA_VERSION = 4;
|
|
244
|
+
|
|
245
|
+
private getSchemaVersion(): number {
|
|
246
|
+
try {
|
|
247
|
+
const row = this.db.prepare(`SELECT version FROM schema_version ORDER BY version DESC LIMIT 1`).get();
|
|
248
|
+
return row?.version ?? 0;
|
|
249
|
+
} catch {
|
|
250
|
+
return 0; // Table doesn't exist yet
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private setSchemaVersion(version: number): void {
|
|
255
|
+
this.db.prepare(
|
|
256
|
+
`INSERT INTO schema_version (version, applied_at) VALUES (?, ?)`
|
|
257
|
+
).run(version, new Date().toISOString());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Run schema migrations incrementally.
|
|
262
|
+
* Each migration is idempotent and tagged with a version number.
|
|
263
|
+
* Version 1 = initial schema (CREATE TABLE IF NOT EXISTS in initSchema).
|
|
264
|
+
* Version 2+ = incremental ALTER/CREATE statements.
|
|
265
|
+
*/
|
|
266
|
+
private runMigrations(): void {
|
|
267
|
+
const current = this.getSchemaVersion();
|
|
268
|
+
const target = FactStore.SCHEMA_VERSION;
|
|
269
|
+
|
|
270
|
+
if (current >= target) return;
|
|
271
|
+
|
|
272
|
+
// Migration 1→2: Add vector and episode tables (v3 Hippocampus)
|
|
273
|
+
// These use CREATE TABLE IF NOT EXISTS so they're idempotent even for
|
|
274
|
+
// databases that already have them from the original non-versioned code.
|
|
275
|
+
if (current < 2) {
|
|
276
|
+
this.db.exec(`
|
|
277
|
+
CREATE TABLE IF NOT EXISTS facts_vec (
|
|
278
|
+
fact_id TEXT PRIMARY KEY,
|
|
279
|
+
embedding BLOB NOT NULL,
|
|
280
|
+
model TEXT NOT NULL,
|
|
281
|
+
dims INTEGER NOT NULL,
|
|
282
|
+
created_at TEXT NOT NULL,
|
|
283
|
+
FOREIGN KEY (fact_id) REFERENCES facts(id) ON DELETE CASCADE
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
CREATE TABLE IF NOT EXISTS episodes (
|
|
287
|
+
id TEXT PRIMARY KEY,
|
|
288
|
+
mind TEXT NOT NULL DEFAULT 'default',
|
|
289
|
+
title TEXT NOT NULL,
|
|
290
|
+
narrative TEXT NOT NULL,
|
|
291
|
+
date TEXT NOT NULL,
|
|
292
|
+
session_id TEXT,
|
|
293
|
+
created_at TEXT NOT NULL,
|
|
294
|
+
FOREIGN KEY (mind) REFERENCES minds(name) ON DELETE CASCADE
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
CREATE TABLE IF NOT EXISTS episode_facts (
|
|
298
|
+
episode_id TEXT NOT NULL,
|
|
299
|
+
fact_id TEXT NOT NULL,
|
|
300
|
+
PRIMARY KEY (episode_id, fact_id),
|
|
301
|
+
FOREIGN KEY (episode_id) REFERENCES episodes(id) ON DELETE CASCADE,
|
|
302
|
+
FOREIGN KEY (fact_id) REFERENCES facts(id) ON DELETE CASCADE
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
CREATE INDEX IF NOT EXISTS idx_episodes_mind
|
|
306
|
+
ON episodes(mind, date DESC);
|
|
307
|
+
CREATE INDEX IF NOT EXISTS idx_episodes_date
|
|
308
|
+
ON episodes(date DESC);
|
|
309
|
+
|
|
310
|
+
CREATE TABLE IF NOT EXISTS episodes_vec (
|
|
311
|
+
episode_id TEXT PRIMARY KEY,
|
|
312
|
+
embedding BLOB NOT NULL,
|
|
313
|
+
model TEXT NOT NULL,
|
|
314
|
+
dims INTEGER NOT NULL,
|
|
315
|
+
created_at TEXT NOT NULL,
|
|
316
|
+
FOREIGN KEY (episode_id) REFERENCES episodes(id) ON DELETE CASCADE
|
|
317
|
+
);
|
|
318
|
+
`);
|
|
319
|
+
this.setSchemaVersion(2);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Migration 2→3: Correctness and Rust-migration prerequisites
|
|
323
|
+
//
|
|
324
|
+
// decay_profile: fixes the wrong-profile decay bug — stores which decay
|
|
325
|
+
// profile was used at write time so read-time computeConfidence uses the
|
|
326
|
+
// correct profile. Existing facts get 'standard' (matches the effective
|
|
327
|
+
// behaviour since DECAY was always the default).
|
|
328
|
+
//
|
|
329
|
+
// version (Lamport timestamp): fixes git-sync conflict resolution bug where
|
|
330
|
+
// archived facts could be resurrected by concurrent reinforcement on
|
|
331
|
+
// another machine. Higher version always wins on import. Existing facts
|
|
332
|
+
// get version=0; new mutations start at MAX(version)+1.
|
|
333
|
+
//
|
|
334
|
+
// last_accessed: enables access-pattern reinforcement — decay timer resets
|
|
335
|
+
// when a fact is retrieved by memory_recall, independent of explicit
|
|
336
|
+
// memory_store reinforcement. Nullable; null means "never accessed".
|
|
337
|
+
//
|
|
338
|
+
// embedding_metadata: versions the embedding model + dimension in the DB.
|
|
339
|
+
// Dimension mismatch is now a detectable error rather than a silent skip.
|
|
340
|
+
// facts_vec gains model_name FK so multi-model coexistence is tracked.
|
|
341
|
+
if (current < 3) {
|
|
342
|
+
this.db.exec(`
|
|
343
|
+
ALTER TABLE facts ADD COLUMN decay_profile TEXT NOT NULL DEFAULT 'standard';
|
|
344
|
+
ALTER TABLE facts ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
|
|
345
|
+
ALTER TABLE facts ADD COLUMN last_accessed TEXT;
|
|
346
|
+
|
|
347
|
+
CREATE INDEX IF NOT EXISTS idx_facts_version
|
|
348
|
+
ON facts(version DESC);
|
|
349
|
+
|
|
350
|
+
CREATE TABLE IF NOT EXISTS embedding_metadata (
|
|
351
|
+
model_name TEXT PRIMARY KEY,
|
|
352
|
+
dims INTEGER NOT NULL,
|
|
353
|
+
inserted_at TEXT NOT NULL
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
ALTER TABLE facts_vec ADD COLUMN model_name TEXT NOT NULL DEFAULT '';
|
|
357
|
+
ALTER TABLE episodes_vec ADD COLUMN model_name TEXT NOT NULL DEFAULT '';
|
|
358
|
+
`);
|
|
359
|
+
this.setSchemaVersion(3);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- Schema v4: Fix existing Recent Work facts with wrong decay_profile ---
|
|
363
|
+
// Before this fix, all Recent Work facts were stored with decay_profile='standard'
|
|
364
|
+
// (halfLife=14d) instead of 'recent_work' (halfLife=2d). This made Recent Work
|
|
365
|
+
// facts persist 7× longer than intended and disagree with SECTION_DECAY_OVERRIDES
|
|
366
|
+
// (which correctly applied RECENT_WORK_DECAY at read time by section name).
|
|
367
|
+
// This migration aligns the stored column with the read-time behaviour.
|
|
368
|
+
if (current < 4) {
|
|
369
|
+
this.db.prepare(
|
|
370
|
+
`UPDATE facts SET decay_profile = 'recent_work' WHERE section = 'Recent Work' AND decay_profile = 'standard'`
|
|
371
|
+
).run();
|
|
372
|
+
this.setSchemaVersion(4);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private initSchema(): void {
|
|
377
|
+
// Schema version tracking — must be first so migrations can read it
|
|
378
|
+
this.db.exec(`
|
|
379
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
380
|
+
version INTEGER PRIMARY KEY,
|
|
381
|
+
applied_at TEXT NOT NULL
|
|
382
|
+
);
|
|
383
|
+
`);
|
|
384
|
+
|
|
385
|
+
// Version 1 tables — core schema
|
|
386
|
+
this.db.exec(`
|
|
387
|
+
CREATE TABLE IF NOT EXISTS minds (
|
|
388
|
+
name TEXT PRIMARY KEY,
|
|
389
|
+
description TEXT NOT NULL DEFAULT '',
|
|
390
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
391
|
+
origin_type TEXT NOT NULL DEFAULT 'local',
|
|
392
|
+
origin_path TEXT,
|
|
393
|
+
origin_url TEXT,
|
|
394
|
+
readonly INTEGER NOT NULL DEFAULT 0,
|
|
395
|
+
parent TEXT,
|
|
396
|
+
created_at TEXT NOT NULL,
|
|
397
|
+
last_sync TEXT
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
CREATE TABLE IF NOT EXISTS facts (
|
|
401
|
+
id TEXT PRIMARY KEY,
|
|
402
|
+
mind TEXT NOT NULL DEFAULT 'default',
|
|
403
|
+
section TEXT NOT NULL,
|
|
404
|
+
content TEXT NOT NULL,
|
|
405
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
406
|
+
created_at TEXT NOT NULL,
|
|
407
|
+
created_session TEXT,
|
|
408
|
+
supersedes TEXT,
|
|
409
|
+
superseded_at TEXT,
|
|
410
|
+
archived_at TEXT,
|
|
411
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
412
|
+
content_hash TEXT NOT NULL,
|
|
413
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
414
|
+
last_reinforced TEXT NOT NULL,
|
|
415
|
+
reinforcement_count INTEGER NOT NULL DEFAULT 1,
|
|
416
|
+
decay_rate REAL NOT NULL DEFAULT ${DECAY.baseRate},
|
|
417
|
+
FOREIGN KEY (mind) REFERENCES minds(name) ON DELETE CASCADE
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
CREATE INDEX IF NOT EXISTS idx_facts_active
|
|
421
|
+
ON facts(mind, status) WHERE status = 'active';
|
|
422
|
+
CREATE INDEX IF NOT EXISTS idx_facts_hash
|
|
423
|
+
ON facts(mind, content_hash);
|
|
424
|
+
CREATE INDEX IF NOT EXISTS idx_facts_section
|
|
425
|
+
ON facts(mind, section) WHERE status = 'active';
|
|
426
|
+
CREATE INDEX IF NOT EXISTS idx_facts_supersedes
|
|
427
|
+
ON facts(supersedes);
|
|
428
|
+
CREATE INDEX IF NOT EXISTS idx_facts_temporal
|
|
429
|
+
ON facts(created_at);
|
|
430
|
+
CREATE INDEX IF NOT EXISTS idx_facts_confidence
|
|
431
|
+
ON facts(mind, confidence) WHERE status = 'active';
|
|
432
|
+
CREATE INDEX IF NOT EXISTS idx_facts_session
|
|
433
|
+
ON facts(created_session);
|
|
434
|
+
|
|
435
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
436
|
+
id TEXT PRIMARY KEY,
|
|
437
|
+
source_fact_id TEXT NOT NULL,
|
|
438
|
+
target_fact_id TEXT NOT NULL,
|
|
439
|
+
relation TEXT NOT NULL,
|
|
440
|
+
description TEXT NOT NULL,
|
|
441
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
442
|
+
last_reinforced TEXT NOT NULL,
|
|
443
|
+
reinforcement_count INTEGER NOT NULL DEFAULT 1,
|
|
444
|
+
decay_rate REAL NOT NULL DEFAULT ${DECAY.baseRate},
|
|
445
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
446
|
+
created_at TEXT NOT NULL,
|
|
447
|
+
created_session TEXT,
|
|
448
|
+
source_mind TEXT,
|
|
449
|
+
target_mind TEXT,
|
|
450
|
+
FOREIGN KEY (source_fact_id) REFERENCES facts(id) ON DELETE CASCADE,
|
|
451
|
+
FOREIGN KEY (target_fact_id) REFERENCES facts(id) ON DELETE CASCADE
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
CREATE INDEX IF NOT EXISTS idx_edges_source
|
|
455
|
+
ON edges(source_fact_id) WHERE status = 'active';
|
|
456
|
+
CREATE INDEX IF NOT EXISTS idx_edges_target
|
|
457
|
+
ON edges(target_fact_id) WHERE status = 'active';
|
|
458
|
+
CREATE INDEX IF NOT EXISTS idx_edges_relation
|
|
459
|
+
ON edges(relation) WHERE status = 'active';
|
|
460
|
+
`);
|
|
461
|
+
|
|
462
|
+
// FTS5 virtual table for full-text search
|
|
463
|
+
this.db.exec(`
|
|
464
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
|
|
465
|
+
id UNINDEXED,
|
|
466
|
+
mind UNINDEXED,
|
|
467
|
+
section UNINDEXED,
|
|
468
|
+
content,
|
|
469
|
+
content='facts',
|
|
470
|
+
content_rowid='rowid'
|
|
471
|
+
);
|
|
472
|
+
`);
|
|
473
|
+
|
|
474
|
+
// Triggers to keep FTS in sync
|
|
475
|
+
// Check if triggers exist before creating (CREATE TRIGGER IF NOT EXISTS not universally supported)
|
|
476
|
+
const triggerExists = this.db.prepare(
|
|
477
|
+
`SELECT 1 FROM sqlite_master WHERE type='trigger' AND name='facts_fts_insert'`
|
|
478
|
+
).get();
|
|
479
|
+
|
|
480
|
+
if (!triggerExists) {
|
|
481
|
+
this.db.exec(`
|
|
482
|
+
CREATE TRIGGER facts_fts_insert AFTER INSERT ON facts BEGIN
|
|
483
|
+
INSERT INTO facts_fts(rowid, id, mind, section, content)
|
|
484
|
+
VALUES (NEW.rowid, NEW.id, NEW.mind, NEW.section, NEW.content);
|
|
485
|
+
END;
|
|
486
|
+
|
|
487
|
+
CREATE TRIGGER facts_fts_delete AFTER DELETE ON facts BEGIN
|
|
488
|
+
INSERT INTO facts_fts(facts_fts, rowid, id, mind, section, content)
|
|
489
|
+
VALUES ('delete', OLD.rowid, OLD.id, OLD.mind, OLD.section, OLD.content);
|
|
490
|
+
END;
|
|
491
|
+
|
|
492
|
+
CREATE TRIGGER facts_fts_update AFTER UPDATE ON facts BEGIN
|
|
493
|
+
INSERT INTO facts_fts(facts_fts, rowid, id, mind, section, content)
|
|
494
|
+
VALUES ('delete', OLD.rowid, OLD.id, OLD.mind, OLD.section, OLD.content);
|
|
495
|
+
INSERT INTO facts_fts(rowid, id, mind, section, content)
|
|
496
|
+
VALUES (NEW.rowid, NEW.id, NEW.mind, NEW.section, NEW.content);
|
|
497
|
+
END;
|
|
498
|
+
`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Vector and episode tables are created in runMigrations() (version 2+).
|
|
502
|
+
|
|
503
|
+
// Mark version 1 if this is a fresh database
|
|
504
|
+
if (this.getSchemaVersion() === 0) {
|
|
505
|
+
this.setSchemaVersion(1);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Ensure 'default' mind exists
|
|
509
|
+
const defaultMind = this.db.prepare(`SELECT 1 FROM minds WHERE name = 'default'`).get();
|
|
510
|
+
if (!defaultMind) {
|
|
511
|
+
this.db.prepare(`
|
|
512
|
+
INSERT INTO minds (name, description, status, origin_type, created_at)
|
|
513
|
+
VALUES ('default', 'Project default memory', 'active', 'local', ?)
|
|
514
|
+
`).run(new Date().toISOString());
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// Fact CRUD
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Store a fact. Returns the fact ID if stored, or null if duplicate.
|
|
524
|
+
* Handles dedup via content_hash and optional explicit supersession.
|
|
525
|
+
*/
|
|
526
|
+
storeFact(opts: StoreFactOptions): { id: string; duplicate: boolean } {
|
|
527
|
+
const mind = opts.mind ?? "default";
|
|
528
|
+
const now = new Date().toISOString();
|
|
529
|
+
const hash = contentHash(opts.content);
|
|
530
|
+
const source = opts.source ?? "manual";
|
|
531
|
+
const content = opts.content.replace(/^-\s*/, "").trim();
|
|
532
|
+
|
|
533
|
+
// Dedup check — same mind, same hash, still active
|
|
534
|
+
const existing = this.db.prepare(
|
|
535
|
+
`SELECT id FROM facts WHERE mind = ? AND content_hash = ? AND status = 'active'`
|
|
536
|
+
).get(mind, hash);
|
|
537
|
+
|
|
538
|
+
if (existing) {
|
|
539
|
+
// Reinforce the existing fact instead of duplicating
|
|
540
|
+
this.reinforceFact(existing.id);
|
|
541
|
+
return { id: existing.id, duplicate: true };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const id = nanoid();
|
|
545
|
+
|
|
546
|
+
// Lamport timestamp: MAX(version)+1 ensures this mutation is always "newer"
|
|
547
|
+
// than any existing fact, even on import from another machine.
|
|
548
|
+
const versionRow = this.db.prepare(`SELECT COALESCE(MAX(version), 0) + 1 AS v FROM facts`).get();
|
|
549
|
+
const version = versionRow?.v ?? 1;
|
|
550
|
+
|
|
551
|
+
// Decay profile discriminant — stored so read-time computeConfidence
|
|
552
|
+
// uses the correct profile regardless of which profile is currently active.
|
|
553
|
+
const decayProfileName: DecayProfileName = opts.decayProfile ?? "standard";
|
|
554
|
+
|
|
555
|
+
// If superseding, mark old fact and record its version for conflict detection
|
|
556
|
+
if (opts.supersedes) {
|
|
557
|
+
this.db.prepare(
|
|
558
|
+
`UPDATE facts SET status = 'superseded', superseded_at = ?, version = ? WHERE id = ?`
|
|
559
|
+
).run(now, version, opts.supersedes);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
this.db.prepare(`
|
|
563
|
+
INSERT INTO facts (id, mind, section, content, status, created_at, created_session,
|
|
564
|
+
supersedes, source, content_hash, confidence, last_reinforced,
|
|
565
|
+
reinforcement_count, decay_rate, decay_profile, version)
|
|
566
|
+
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
567
|
+
`).run(
|
|
568
|
+
id, mind, opts.section, content, now,
|
|
569
|
+
opts.session ?? null,
|
|
570
|
+
opts.supersedes ?? null,
|
|
571
|
+
source, hash,
|
|
572
|
+
opts.confidence ?? 1.0,
|
|
573
|
+
now,
|
|
574
|
+
opts.reinforcement_count ?? 1,
|
|
575
|
+
opts.decay_rate ?? this.decayProfile.baseRate,
|
|
576
|
+
decayProfileName,
|
|
577
|
+
version,
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
return { id, duplicate: false };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Reinforce a fact — bump confidence, extend half-life.
|
|
585
|
+
* Updates last_reinforced and increments version (Lamport clock).
|
|
586
|
+
*/
|
|
587
|
+
reinforceFact(id: string): void {
|
|
588
|
+
const now = new Date().toISOString();
|
|
589
|
+
const versionRow = this.db.prepare(`SELECT COALESCE(MAX(version), 0) + 1 AS v FROM facts`).get();
|
|
590
|
+
const version = versionRow?.v ?? 1;
|
|
591
|
+
this.db.prepare(`
|
|
592
|
+
UPDATE facts
|
|
593
|
+
SET confidence = 1.0,
|
|
594
|
+
last_reinforced = ?,
|
|
595
|
+
reinforcement_count = reinforcement_count + 1,
|
|
596
|
+
version = ?
|
|
597
|
+
WHERE id = ?
|
|
598
|
+
`).run(now, version, id);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Update last_accessed for access-pattern reinforcement.
|
|
603
|
+
* Resets the effective decay timer without incrementing reinforcement_count.
|
|
604
|
+
* Called by memory_recall after returning a fact to the agent.
|
|
605
|
+
*/
|
|
606
|
+
touchFact(id: string): void {
|
|
607
|
+
const now = new Date().toISOString();
|
|
608
|
+
this.db.prepare(`UPDATE facts SET last_accessed = ? WHERE id = ?`).run(now, id);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Process extraction output: a list of observed facts and directives.
|
|
613
|
+
* Returns counts of what happened.
|
|
614
|
+
*/
|
|
615
|
+
processExtraction(
|
|
616
|
+
mind: string,
|
|
617
|
+
actions: ExtractionAction[],
|
|
618
|
+
session?: string,
|
|
619
|
+
): ReinforcementResult {
|
|
620
|
+
let reinforced = 0;
|
|
621
|
+
let added = 0;
|
|
622
|
+
const newFactIds: string[] = [];
|
|
623
|
+
|
|
624
|
+
const tx = this.db.transaction(() => {
|
|
625
|
+
for (const action of actions) {
|
|
626
|
+
switch (action.type) {
|
|
627
|
+
case "observe": {
|
|
628
|
+
// Fact observed in session — reinforce if exists, add if new
|
|
629
|
+
const hash = contentHash(action.content ?? "");
|
|
630
|
+
const existing = this.db.prepare(
|
|
631
|
+
`SELECT id FROM facts WHERE mind = ? AND content_hash = ? AND status = 'active'`
|
|
632
|
+
).get(mind, hash);
|
|
633
|
+
|
|
634
|
+
if (existing) {
|
|
635
|
+
this.reinforceFact((existing as { id: string }).id);
|
|
636
|
+
reinforced++;
|
|
637
|
+
} else if (action.section && action.content) {
|
|
638
|
+
const result = this.storeFact({
|
|
639
|
+
mind,
|
|
640
|
+
section: action.section,
|
|
641
|
+
content: action.content,
|
|
642
|
+
source: "extraction",
|
|
643
|
+
session,
|
|
644
|
+
decayProfile: decayProfileForSection(action.section),
|
|
645
|
+
});
|
|
646
|
+
if (!result.duplicate) newFactIds.push(result.id);
|
|
647
|
+
added++;
|
|
648
|
+
}
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
case "reinforce": {
|
|
652
|
+
// Explicit reinforcement by ID
|
|
653
|
+
if (action.id) {
|
|
654
|
+
this.reinforceFact(action.id);
|
|
655
|
+
reinforced++;
|
|
656
|
+
}
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
case "supersede": {
|
|
660
|
+
// Explicit replacement
|
|
661
|
+
if (action.id && action.content && action.section) {
|
|
662
|
+
const result = this.storeFact({
|
|
663
|
+
mind,
|
|
664
|
+
section: action.section,
|
|
665
|
+
content: action.content,
|
|
666
|
+
source: "extraction",
|
|
667
|
+
session,
|
|
668
|
+
decayProfile: decayProfileForSection(action.section),
|
|
669
|
+
supersedes: action.id,
|
|
670
|
+
});
|
|
671
|
+
if (!result.duplicate) newFactIds.push(result.id);
|
|
672
|
+
added++;
|
|
673
|
+
}
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
case "archive": {
|
|
677
|
+
// Explicit archival
|
|
678
|
+
if (action.id) {
|
|
679
|
+
this.archiveFact(action.id);
|
|
680
|
+
}
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
tx();
|
|
688
|
+
return { reinforced, added, newFactIds };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Archive a fact and clean up its vector embedding */
|
|
692
|
+
archiveFact(id: string): void {
|
|
693
|
+
const now = new Date().toISOString();
|
|
694
|
+
const versionRow = this.db.prepare(`SELECT COALESCE(MAX(version), 0) + 1 AS v FROM facts`).get();
|
|
695
|
+
const version = versionRow?.v ?? 1;
|
|
696
|
+
this.db.prepare(
|
|
697
|
+
`UPDATE facts SET status = 'archived', archived_at = ?, version = ? WHERE id = ?`
|
|
698
|
+
).run(now, version, id);
|
|
699
|
+
// Clean up orphaned vector (CASCADE only fires on DELETE, not status change)
|
|
700
|
+
this.db.prepare(`DELETE FROM facts_vec WHERE fact_id = ?`).run(id);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Sweep facts that have decayed below their profile's minimum confidence.
|
|
705
|
+
* Archives them in bulk. Returns the number of facts archived.
|
|
706
|
+
*
|
|
707
|
+
* This converts passive decay (lower confidence on read) into active archival.
|
|
708
|
+
* Without this sweep, decayed facts remain status='active' forever — inflating
|
|
709
|
+
* fact counts, consuming vector storage, and cluttering section ceiling checks.
|
|
710
|
+
*
|
|
711
|
+
* Uses getActiveFacts() which already:
|
|
712
|
+
* 1. Computes confidence via computeConfidence (canonical formula from core.ts)
|
|
713
|
+
* 2. Exempts NO_DECAY_SECTIONS (Specs → confidence = 1.0, never archived)
|
|
714
|
+
* 3. Applies SECTION_DECAY_OVERRIDES (Recent Work → RECENT_WORK_DECAY)
|
|
715
|
+
* So fact.confidence is the correct, already-computed value — no re-derivation.
|
|
716
|
+
*
|
|
717
|
+
* Profile resolution uses SECTION_DECAY_OVERRIDES (same as getActiveFacts) so
|
|
718
|
+
* the minimumConfidence threshold is consistent with the confidence computation.
|
|
719
|
+
*/
|
|
720
|
+
sweepDecayedFacts(mind: string): number {
|
|
721
|
+
const facts = this.getActiveFacts(mind);
|
|
722
|
+
let swept = 0;
|
|
723
|
+
|
|
724
|
+
for (const fact of facts) {
|
|
725
|
+
// Resolve profile the same way getActiveFacts does — by section override
|
|
726
|
+
// first, then per-fact decay_profile column. This ensures the minimum
|
|
727
|
+
// confidence threshold matches the formula that computed fact.confidence.
|
|
728
|
+
const profile = SECTION_DECAY_OVERRIDES[fact.section]
|
|
729
|
+
?? resolveDecayProfile(fact.decay_profile);
|
|
730
|
+
|
|
731
|
+
if (fact.confidence <= profile.minimumConfidence) {
|
|
732
|
+
this.archiveFact(fact.id);
|
|
733
|
+
swept++;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return swept;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Archive all facts from a specific session */
|
|
741
|
+
archiveSession(session: string): number {
|
|
742
|
+
const now = new Date().toISOString();
|
|
743
|
+
const result = this.db.prepare(
|
|
744
|
+
`UPDATE facts SET status = 'archived', archived_at = ?
|
|
745
|
+
WHERE created_session = ? AND status = 'active'`
|
|
746
|
+
).run(now, session);
|
|
747
|
+
return result.changes;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
// Edge CRUD
|
|
752
|
+
// ---------------------------------------------------------------------------
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Store an edge between two facts. Deduplicates by source+target+relation.
|
|
756
|
+
* If the same edge exists, reinforces it instead.
|
|
757
|
+
*/
|
|
758
|
+
storeEdge(opts: {
|
|
759
|
+
sourceFact: string;
|
|
760
|
+
targetFact: string;
|
|
761
|
+
relation: string;
|
|
762
|
+
description: string;
|
|
763
|
+
session?: string;
|
|
764
|
+
sourceMind?: string;
|
|
765
|
+
targetMind?: string;
|
|
766
|
+
}): { id: string; duplicate: boolean } {
|
|
767
|
+
const now = new Date().toISOString();
|
|
768
|
+
|
|
769
|
+
// Dedup: same source, target, and relation
|
|
770
|
+
const existing = this.db.prepare(
|
|
771
|
+
`SELECT id FROM edges
|
|
772
|
+
WHERE source_fact_id = ? AND target_fact_id = ? AND relation = ? AND status = 'active'`
|
|
773
|
+
).get(opts.sourceFact, opts.targetFact, opts.relation);
|
|
774
|
+
|
|
775
|
+
if (existing) {
|
|
776
|
+
this.reinforceEdge(existing.id);
|
|
777
|
+
return { id: existing.id, duplicate: true };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const id = nanoid();
|
|
781
|
+
this.db.prepare(`
|
|
782
|
+
INSERT INTO edges (id, source_fact_id, target_fact_id, relation, description,
|
|
783
|
+
confidence, last_reinforced, reinforcement_count, decay_rate,
|
|
784
|
+
status, created_at, created_session, source_mind, target_mind)
|
|
785
|
+
VALUES (?, ?, ?, ?, ?, 1.0, ?, 1, ?, 'active', ?, ?, ?, ?)
|
|
786
|
+
`).run(
|
|
787
|
+
id, opts.sourceFact, opts.targetFact, opts.relation, opts.description,
|
|
788
|
+
now, this.decayProfile.baseRate, now, opts.session ?? null,
|
|
789
|
+
opts.sourceMind ?? null, opts.targetMind ?? null,
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
return { id, duplicate: false };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Reinforce an edge */
|
|
796
|
+
reinforceEdge(id: string): void {
|
|
797
|
+
const now = new Date().toISOString();
|
|
798
|
+
this.db.prepare(`
|
|
799
|
+
UPDATE edges
|
|
800
|
+
SET confidence = 1.0, last_reinforced = ?, reinforcement_count = reinforcement_count + 1
|
|
801
|
+
WHERE id = ?
|
|
802
|
+
`).run(now, id);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Archive an edge */
|
|
806
|
+
archiveEdge(id: string): void {
|
|
807
|
+
this.db.prepare(
|
|
808
|
+
`UPDATE edges SET status = 'archived' WHERE id = ?`
|
|
809
|
+
).run(id);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Get active edges for a fact (both directions) */
|
|
813
|
+
getEdgesForFact(factId: string): Edge[] {
|
|
814
|
+
const edges = this.db.prepare(`
|
|
815
|
+
SELECT * FROM edges
|
|
816
|
+
WHERE (source_fact_id = ? OR target_fact_id = ?) AND status = 'active'
|
|
817
|
+
`).all(factId, factId) as Edge[];
|
|
818
|
+
|
|
819
|
+
return this.applyEdgeDecay(edges);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Get all active edges, optionally filtered by mind */
|
|
823
|
+
getActiveEdges(mind?: string): Edge[] {
|
|
824
|
+
let edges: Edge[];
|
|
825
|
+
if (mind) {
|
|
826
|
+
edges = this.db.prepare(`
|
|
827
|
+
SELECT * FROM edges
|
|
828
|
+
WHERE (source_mind = ? OR target_mind = ?) AND status = 'active'
|
|
829
|
+
`).all(mind, mind) as Edge[];
|
|
830
|
+
} else {
|
|
831
|
+
edges = this.db.prepare(
|
|
832
|
+
`SELECT * FROM edges WHERE status = 'active'`
|
|
833
|
+
).all() as Edge[];
|
|
834
|
+
}
|
|
835
|
+
return this.applyEdgeDecay(edges);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Get a single edge by ID */
|
|
839
|
+
getEdge(id: string): Edge | null {
|
|
840
|
+
const edge = this.db.prepare(`SELECT * FROM edges WHERE id = ?`).get(id) as Edge | null;
|
|
841
|
+
if (edge) {
|
|
842
|
+
const [decayed] = this.applyEdgeDecay([edge]);
|
|
843
|
+
return decayed;
|
|
844
|
+
}
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Get active edges connected to any of the given fact IDs.
|
|
850
|
+
* Returns top N by reinforcement count, filtered by min confidence after decay.
|
|
851
|
+
*/
|
|
852
|
+
getEdgesForFacts(factIds: string[], limit: number = 20, minConfidence: number = DECAY.minimumConfidence): Edge[] {
|
|
853
|
+
if (factIds.length === 0) return [];
|
|
854
|
+
|
|
855
|
+
const placeholders = factIds.map(() => "?").join(",");
|
|
856
|
+
const edges = this.db.prepare(`
|
|
857
|
+
SELECT * FROM edges
|
|
858
|
+
WHERE status = 'active'
|
|
859
|
+
AND (source_fact_id IN (${placeholders}) OR target_fact_id IN (${placeholders}))
|
|
860
|
+
ORDER BY reinforcement_count DESC
|
|
861
|
+
LIMIT ?
|
|
862
|
+
`).all(...factIds, ...factIds, limit * 2) as Edge[]; // fetch extra to account for decay filtering
|
|
863
|
+
|
|
864
|
+
const decayed = this.applyEdgeDecay(edges);
|
|
865
|
+
return decayed
|
|
866
|
+
.filter(e => e.confidence >= minConfidence)
|
|
867
|
+
.slice(0, limit);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Apply confidence decay to edges (same decay profile as this store's facts) */
|
|
871
|
+
private applyEdgeDecay(edges: Edge[]): Edge[] {
|
|
872
|
+
const now = Date.now();
|
|
873
|
+
for (const edge of edges) {
|
|
874
|
+
const lastReinforced = new Date(edge.last_reinforced).getTime();
|
|
875
|
+
const daysSince = (now - lastReinforced) / (1000 * 60 * 60 * 24);
|
|
876
|
+
edge.confidence = computeConfidence(daysSince, edge.reinforcement_count, this.decayProfile);
|
|
877
|
+
}
|
|
878
|
+
return edges;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Process edge actions from global extraction.
|
|
883
|
+
* Handles connect and reinforce_edge action types.
|
|
884
|
+
*/
|
|
885
|
+
processEdges(
|
|
886
|
+
actions: ExtractionAction[],
|
|
887
|
+
session?: string,
|
|
888
|
+
): EdgeResult {
|
|
889
|
+
let added = 0;
|
|
890
|
+
let reinforced = 0;
|
|
891
|
+
|
|
892
|
+
const tx = this.db.transaction(() => {
|
|
893
|
+
for (const action of actions) {
|
|
894
|
+
if (action.type !== "connect") continue;
|
|
895
|
+
if (!action.source || !action.target || !action.relation) continue;
|
|
896
|
+
|
|
897
|
+
// Verify both facts exist
|
|
898
|
+
const sourceFact = this.getFact(action.source);
|
|
899
|
+
const targetFact = this.getFact(action.target);
|
|
900
|
+
if (!sourceFact || !targetFact) continue;
|
|
901
|
+
|
|
902
|
+
const result = this.storeEdge({
|
|
903
|
+
sourceFact: action.source,
|
|
904
|
+
targetFact: action.target,
|
|
905
|
+
relation: action.relation,
|
|
906
|
+
description: action.description ?? `${action.relation}: ${sourceFact.content.slice(0, 50)} → ${targetFact.content.slice(0, 50)}`,
|
|
907
|
+
session,
|
|
908
|
+
sourceMind: sourceFact.mind,
|
|
909
|
+
targetMind: targetFact.mind,
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
if (result.duplicate) reinforced++;
|
|
913
|
+
else added++;
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
tx();
|
|
918
|
+
return { added, reinforced };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
// Queries
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Get active facts for a mind, with confidence decay applied.
|
|
927
|
+
* Optionally limit to top N by confidence.
|
|
928
|
+
*/
|
|
929
|
+
getActiveFacts(mind: string, limit?: number): Fact[] {
|
|
930
|
+
const facts = this.db.prepare(
|
|
931
|
+
`SELECT * FROM facts WHERE mind = ? AND status = 'active'
|
|
932
|
+
ORDER BY section, created_at`
|
|
933
|
+
).all(mind) as Fact[];
|
|
934
|
+
|
|
935
|
+
// Apply time-based confidence decay.
|
|
936
|
+
// Specs are exempt (binary exist/not-exist).
|
|
937
|
+
// "Recent Work" uses a fast-decay profile (half-life 2d, no reinforcement extension).
|
|
938
|
+
// All other sections use the store's default decay profile.
|
|
939
|
+
const NO_DECAY_SECTIONS: readonly string[] = ["Specs"];
|
|
940
|
+
const now = Date.now();
|
|
941
|
+
for (const fact of facts) {
|
|
942
|
+
if (NO_DECAY_SECTIONS.includes(fact.section)) {
|
|
943
|
+
fact.confidence = 1.0;
|
|
944
|
+
} else {
|
|
945
|
+
const lastReinforced = new Date(fact.last_reinforced).getTime();
|
|
946
|
+
const daysSince = (now - lastReinforced) / (1000 * 60 * 60 * 24);
|
|
947
|
+
const profile = SECTION_DECAY_OVERRIDES[fact.section] ?? this.decayProfile;
|
|
948
|
+
fact.confidence = computeConfidence(daysSince, fact.reinforcement_count, profile);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Sort by confidence descending within each section
|
|
953
|
+
facts.sort((a, b) => {
|
|
954
|
+
if (a.section !== b.section) {
|
|
955
|
+
const idxA = SECTIONS.indexOf(a.section as SectionName);
|
|
956
|
+
const idxB = SECTIONS.indexOf(b.section as SectionName);
|
|
957
|
+
return idxA - idxB;
|
|
958
|
+
}
|
|
959
|
+
return b.confidence - a.confidence;
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
if (limit) {
|
|
963
|
+
return facts.slice(0, limit);
|
|
964
|
+
}
|
|
965
|
+
return facts;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Get active facts for a specific section, sorted by confidence descending. */
|
|
969
|
+
getFactsBySection(mind: string, section: string): Fact[] {
|
|
970
|
+
const facts = this.db.prepare(
|
|
971
|
+
`SELECT * FROM facts WHERE mind = ? AND section = ? AND status = 'active' ORDER BY created_at`
|
|
972
|
+
).all(mind, section) as Fact[];
|
|
973
|
+
|
|
974
|
+
const NO_DECAY_SECTIONS: readonly string[] = ["Specs"];
|
|
975
|
+
const now = Date.now();
|
|
976
|
+
for (const fact of facts) {
|
|
977
|
+
if (NO_DECAY_SECTIONS.includes(fact.section)) {
|
|
978
|
+
fact.confidence = 1.0;
|
|
979
|
+
} else {
|
|
980
|
+
const lastReinforced = new Date(fact.last_reinforced).getTime();
|
|
981
|
+
const daysSince = (now - lastReinforced) / (1000 * 60 * 60 * 24);
|
|
982
|
+
const profile = SECTION_DECAY_OVERRIDES[fact.section] ?? this.decayProfile;
|
|
983
|
+
fact.confidence = computeConfidence(daysSince, fact.reinforcement_count, profile);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
facts.sort((a, b) => b.confidence - a.confidence);
|
|
988
|
+
return facts;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** Get the count of active facts per section for a mind. */
|
|
992
|
+
getSectionCounts(mind: string): Map<string, number> {
|
|
993
|
+
const rows = this.db.prepare(
|
|
994
|
+
`SELECT section, COUNT(*) as count FROM facts WHERE mind = ? AND status = 'active' GROUP BY section`
|
|
995
|
+
).all(mind) as { section: string; count: number }[];
|
|
996
|
+
return new Map(rows.map(r => [r.section, r.count]));
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** Count active facts for a mind */
|
|
1000
|
+
countActiveFacts(mind: string): number {
|
|
1001
|
+
const row = this.db.prepare(
|
|
1002
|
+
`SELECT COUNT(*) as count FROM facts WHERE mind = ? AND status = 'active'`
|
|
1003
|
+
).get(mind);
|
|
1004
|
+
return row?.count ?? 0;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** Find facts whose content starts with a given prefix using a LIKE query (no FTS5, safe for special chars) */
|
|
1008
|
+
findFactsByContentPrefix(prefix: string, mind?: string): Fact[] {
|
|
1009
|
+
// Use LIKE with escaped pattern — only % and _ need escaping in LIKE
|
|
1010
|
+
const escaped = prefix.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
1011
|
+
const pattern = `${escaped}%`;
|
|
1012
|
+
|
|
1013
|
+
if (mind) {
|
|
1014
|
+
return this.db.prepare(`
|
|
1015
|
+
SELECT * FROM facts
|
|
1016
|
+
WHERE content LIKE ? ESCAPE '\\' AND mind = ? AND status = 'active'
|
|
1017
|
+
ORDER BY created_at DESC
|
|
1018
|
+
`).all(pattern, mind) as Fact[];
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return this.db.prepare(`
|
|
1022
|
+
SELECT * FROM facts
|
|
1023
|
+
WHERE content LIKE ? ESCAPE '\\' AND status = 'active'
|
|
1024
|
+
ORDER BY created_at DESC
|
|
1025
|
+
`).all(pattern) as Fact[];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** Full-text search across all facts (all minds, all statuses) */
|
|
1029
|
+
searchFacts(query: string, mind?: string): Fact[] {
|
|
1030
|
+
// FTS5 match syntax
|
|
1031
|
+
const ftsQuery = query.split(/\s+/).filter(t => t.length > 0).join(" AND ");
|
|
1032
|
+
if (!ftsQuery) return [];
|
|
1033
|
+
|
|
1034
|
+
if (mind) {
|
|
1035
|
+
return this.db.prepare(`
|
|
1036
|
+
SELECT f.* FROM facts f
|
|
1037
|
+
JOIN facts_fts fts ON f.rowid = fts.rowid
|
|
1038
|
+
WHERE facts_fts MATCH ? AND f.mind = ?
|
|
1039
|
+
ORDER BY rank
|
|
1040
|
+
`).all(ftsQuery, mind) as Fact[];
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return this.db.prepare(`
|
|
1044
|
+
SELECT f.* FROM facts f
|
|
1045
|
+
JOIN facts_fts fts ON f.rowid = fts.rowid
|
|
1046
|
+
WHERE facts_fts MATCH ?
|
|
1047
|
+
ORDER BY rank
|
|
1048
|
+
`).all(ftsQuery) as Fact[];
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Search archived/superseded facts (replaces searchArchive) */
|
|
1052
|
+
searchArchive(query: string, mind?: string): Fact[] {
|
|
1053
|
+
const ftsQuery = query.split(/\s+/).filter(t => t.length > 0).join(" AND ");
|
|
1054
|
+
if (!ftsQuery) return [];
|
|
1055
|
+
|
|
1056
|
+
if (mind) {
|
|
1057
|
+
return this.db.prepare(`
|
|
1058
|
+
SELECT f.* FROM facts f
|
|
1059
|
+
JOIN facts_fts fts ON f.rowid = fts.rowid
|
|
1060
|
+
WHERE facts_fts MATCH ? AND f.mind = ? AND f.status IN ('archived', 'superseded')
|
|
1061
|
+
ORDER BY f.created_at DESC
|
|
1062
|
+
`).all(ftsQuery, mind) as Fact[];
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return this.db.prepare(`
|
|
1066
|
+
SELECT f.* FROM facts f
|
|
1067
|
+
JOIN facts_fts fts ON f.rowid = fts.rowid
|
|
1068
|
+
WHERE facts_fts MATCH ? AND f.status IN ('archived', 'superseded')
|
|
1069
|
+
ORDER BY f.created_at DESC
|
|
1070
|
+
`).all(ftsQuery) as Fact[];
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** Get a single fact by ID */
|
|
1074
|
+
getFact(id: string): Fact | null {
|
|
1075
|
+
return this.db.prepare(`SELECT * FROM facts WHERE id = ?`).get(id) as Fact | null;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Get supersession chain for a fact */
|
|
1079
|
+
getSupersessionChain(id: string): Fact[] {
|
|
1080
|
+
const chain: Fact[] = [];
|
|
1081
|
+
let current = this.getFact(id);
|
|
1082
|
+
while (current) {
|
|
1083
|
+
chain.push(current);
|
|
1084
|
+
if (current.supersedes) {
|
|
1085
|
+
current = this.getFact(current.supersedes);
|
|
1086
|
+
} else {
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return chain;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ---------------------------------------------------------------------------
|
|
1094
|
+
// Rendering — Markdown-KV for LLM injection
|
|
1095
|
+
// ---------------------------------------------------------------------------
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Render active facts as Markdown-KV for LLM context injection.
|
|
1099
|
+
* Filters by confidence threshold and respects a line budget.
|
|
1100
|
+
*/
|
|
1101
|
+
renderForInjection(mind: string, opts?: { maxFacts?: number; minConfidence?: number; maxEdges?: number; showIds?: boolean }): string {
|
|
1102
|
+
const maxFacts = opts?.maxFacts ?? 50;
|
|
1103
|
+
const maxEdges = opts?.maxEdges ?? 20;
|
|
1104
|
+
const minConfidence = opts?.minConfidence ?? this.decayProfile.minimumConfidence;
|
|
1105
|
+
const showIds = opts?.showIds ?? false;
|
|
1106
|
+
|
|
1107
|
+
// Per-section caps: Architecture is the largest section by volume.
|
|
1108
|
+
// Cap it aggressively so it can't crowd out other sections or blow context.
|
|
1109
|
+
// Remaining sections are capped at reasonable defaults.
|
|
1110
|
+
const SECTION_CAPS: Partial<Record<SectionName, number>> = {
|
|
1111
|
+
Architecture: 12,
|
|
1112
|
+
Decisions: 10,
|
|
1113
|
+
Constraints: 6,
|
|
1114
|
+
"Known Issues": 6,
|
|
1115
|
+
"Patterns & Conventions": 6,
|
|
1116
|
+
Specs: 10,
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
let facts = this.getActiveFacts(mind);
|
|
1120
|
+
|
|
1121
|
+
// Filter by confidence
|
|
1122
|
+
facts = facts.filter(f => f.confidence >= minConfidence);
|
|
1123
|
+
|
|
1124
|
+
// Apply per-section caps (top N by confidence within each section)
|
|
1125
|
+
const cappedFacts: typeof facts = [];
|
|
1126
|
+
for (const section of SECTIONS) {
|
|
1127
|
+
const sectionFacts = facts
|
|
1128
|
+
.filter(f => f.section === section)
|
|
1129
|
+
.sort((a, b) => b.confidence - a.confidence)
|
|
1130
|
+
.slice(0, SECTION_CAPS[section as SectionName] ?? 10);
|
|
1131
|
+
cappedFacts.push(...sectionFacts);
|
|
1132
|
+
}
|
|
1133
|
+
facts = cappedFacts;
|
|
1134
|
+
|
|
1135
|
+
// Apply global cap as a final safety net
|
|
1136
|
+
if (facts.length > maxFacts) {
|
|
1137
|
+
facts.sort((a, b) => b.confidence - a.confidence);
|
|
1138
|
+
facts = facts.slice(0, maxFacts);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Re-sort by section order for display
|
|
1142
|
+
facts.sort((a, b) => {
|
|
1143
|
+
const idxA = SECTIONS.indexOf(a.section as SectionName);
|
|
1144
|
+
const idxB = SECTIONS.indexOf(b.section as SectionName);
|
|
1145
|
+
if (idxA !== idxB) return idxA - idxB;
|
|
1146
|
+
return b.confidence - a.confidence;
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
const lines: string[] = [
|
|
1150
|
+
"<!-- Project Memory — managed by project-memory extension -->",
|
|
1151
|
+
"",
|
|
1152
|
+
];
|
|
1153
|
+
|
|
1154
|
+
const sectionDescriptions: Record<string, string> = {
|
|
1155
|
+
Architecture: "_System structure, component relationships, key abstractions_",
|
|
1156
|
+
Decisions: "_Choices made and their rationale_",
|
|
1157
|
+
Constraints: "_Requirements, limitations, environment details_",
|
|
1158
|
+
"Known Issues": "_Bugs, flaky tests, workarounds_",
|
|
1159
|
+
"Patterns & Conventions": "_Code style, project conventions, common approaches_",
|
|
1160
|
+
Specs: "_Active specifications, acceptance criteria, and design contracts driving current work_",
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
// Build a set of rendered fact IDs for edge lookup
|
|
1164
|
+
const renderedFactIds = new Set<string>();
|
|
1165
|
+
|
|
1166
|
+
for (const section of SECTIONS) {
|
|
1167
|
+
const sectionFacts = facts.filter(f => f.section === section);
|
|
1168
|
+
lines.push(`## ${section}`);
|
|
1169
|
+
lines.push(sectionDescriptions[section] ?? "");
|
|
1170
|
+
lines.push("");
|
|
1171
|
+
if (sectionFacts.length > 0) {
|
|
1172
|
+
for (const f of sectionFacts) {
|
|
1173
|
+
const date = f.created_at.split("T")[0];
|
|
1174
|
+
lines.push(showIds ? `- [${f.id}] ${f.content} [${date}]` : `- ${f.content} [${date}]`);
|
|
1175
|
+
renderedFactIds.add(f.id);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
lines.push("");
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Render edges between rendered facts (capped)
|
|
1182
|
+
const relevantEdges = renderedFactIds.size > 0
|
|
1183
|
+
? this.getEdgesForFacts([...renderedFactIds], maxEdges, minConfidence)
|
|
1184
|
+
: [];
|
|
1185
|
+
|
|
1186
|
+
if (relevantEdges.length > 0) {
|
|
1187
|
+
lines.push("## Connections");
|
|
1188
|
+
lines.push("_Relationships between facts across domains_");
|
|
1189
|
+
lines.push("");
|
|
1190
|
+
for (const edge of relevantEdges) {
|
|
1191
|
+
const sourceFact = this.getFact(edge.source_fact_id);
|
|
1192
|
+
const targetFact = this.getFact(edge.target_fact_id);
|
|
1193
|
+
if (!sourceFact || !targetFact) continue;
|
|
1194
|
+
const srcLabel = sourceFact.content.length > 60
|
|
1195
|
+
? sourceFact.content.slice(0, 57) + "..."
|
|
1196
|
+
: sourceFact.content;
|
|
1197
|
+
const tgtLabel = targetFact.content.length > 60
|
|
1198
|
+
? targetFact.content.slice(0, 57) + "..."
|
|
1199
|
+
: targetFact.content;
|
|
1200
|
+
lines.push(`- ${srcLabel} **—${edge.relation}→** ${tgtLabel}`);
|
|
1201
|
+
}
|
|
1202
|
+
lines.push("");
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return lines.join("\n");
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Render an arbitrary list of facts as Markdown-KV.
|
|
1210
|
+
* Unlike renderForInjection, this doesn't query the DB — it formats what you give it.
|
|
1211
|
+
*/
|
|
1212
|
+
renderFactList(facts: Fact[], opts?: { showIds?: boolean }): string {
|
|
1213
|
+
const showIds = opts?.showIds ?? false;
|
|
1214
|
+
|
|
1215
|
+
const lines: string[] = [
|
|
1216
|
+
"<!-- Project Memory — managed by project-memory extension -->",
|
|
1217
|
+
"",
|
|
1218
|
+
];
|
|
1219
|
+
|
|
1220
|
+
const sectionDescriptions: Record<string, string> = {
|
|
1221
|
+
Architecture: "_System structure, component relationships, key abstractions_",
|
|
1222
|
+
Decisions: "_Choices made and their rationale_",
|
|
1223
|
+
Constraints: "_Requirements, limitations, environment details_",
|
|
1224
|
+
"Known Issues": "_Bugs, flaky tests, workarounds_",
|
|
1225
|
+
"Patterns & Conventions": "_Code style, project conventions, common approaches_",
|
|
1226
|
+
Specs: "_Active specifications, acceptance criteria, and design contracts driving current work_",
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
// Group by section, maintaining SECTIONS order
|
|
1230
|
+
for (const section of SECTIONS) {
|
|
1231
|
+
const sectionFacts = facts.filter(f => f.section === section);
|
|
1232
|
+
if (sectionFacts.length === 0) continue;
|
|
1233
|
+
lines.push(`## ${section}`);
|
|
1234
|
+
lines.push(sectionDescriptions[section] ?? "");
|
|
1235
|
+
lines.push("");
|
|
1236
|
+
for (const f of sectionFacts) {
|
|
1237
|
+
const date = f.created_at.split("T")[0];
|
|
1238
|
+
lines.push(showIds ? `- [${f.id}] ${f.content} [${date}]` : `- ${f.content} [${date}]`);
|
|
1239
|
+
}
|
|
1240
|
+
lines.push("");
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
return lines.join("\n");
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// ---------------------------------------------------------------------------
|
|
1247
|
+
// Mind management
|
|
1248
|
+
// ---------------------------------------------------------------------------
|
|
1249
|
+
|
|
1250
|
+
/** Create a mind */
|
|
1251
|
+
createMind(name: string, description: string, opts?: { parent?: string; origin_type?: string; origin_path?: string; readonly?: boolean }): void {
|
|
1252
|
+
this.db.prepare(`
|
|
1253
|
+
INSERT INTO minds (name, description, status, origin_type, origin_path, readonly, parent, created_at)
|
|
1254
|
+
VALUES (?, ?, 'active', ?, ?, ?, ?, ?)
|
|
1255
|
+
`).run(
|
|
1256
|
+
name, description,
|
|
1257
|
+
opts?.origin_type ?? "local",
|
|
1258
|
+
opts?.origin_path ?? null,
|
|
1259
|
+
opts?.readonly ? 1 : 0,
|
|
1260
|
+
opts?.parent ?? null,
|
|
1261
|
+
new Date().toISOString(),
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** Get a mind record */
|
|
1266
|
+
getMind(name: string): MindRecord | null {
|
|
1267
|
+
return this.db.prepare(`SELECT * FROM minds WHERE name = ?`).get(name) as MindRecord | null;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/** List all minds */
|
|
1271
|
+
listMinds(): (MindRecord & { factCount: number })[] {
|
|
1272
|
+
return this.db.prepare(`
|
|
1273
|
+
SELECT m.*, COALESCE(fc.count, 0) as factCount
|
|
1274
|
+
FROM minds m
|
|
1275
|
+
LEFT JOIN (
|
|
1276
|
+
SELECT mind, COUNT(*) as count FROM facts WHERE status = 'active' GROUP BY mind
|
|
1277
|
+
) fc ON m.name = fc.mind
|
|
1278
|
+
ORDER BY CASE m.status WHEN 'active' THEN 0 WHEN 'refined' THEN 1 WHEN 'retired' THEN 2 END
|
|
1279
|
+
`).all() as (MindRecord & { factCount: number })[];
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/** Update mind status */
|
|
1283
|
+
setMindStatus(name: string, status: MindRecord["status"]): void {
|
|
1284
|
+
this.db.prepare(`UPDATE minds SET status = ? WHERE name = ?`).run(status, name);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** Delete a mind and all its facts */
|
|
1288
|
+
deleteMind(name: string): void {
|
|
1289
|
+
if (name === "default") throw new Error("Cannot delete the default mind");
|
|
1290
|
+
const tx = this.db.transaction(() => {
|
|
1291
|
+
this.db.prepare(`DELETE FROM facts WHERE mind = ?`).run(name);
|
|
1292
|
+
this.db.prepare(`DELETE FROM minds WHERE name = ?`).run(name);
|
|
1293
|
+
});
|
|
1294
|
+
tx();
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** Check if a mind exists */
|
|
1298
|
+
mindExists(name: string): boolean {
|
|
1299
|
+
return !!this.db.prepare(`SELECT 1 FROM minds WHERE name = ?`).get(name);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** Check if a mind is readonly */
|
|
1303
|
+
isMindReadonly(name: string): boolean {
|
|
1304
|
+
const mind = this.getMind(name);
|
|
1305
|
+
return mind?.readonly === 1;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/** Fork a mind — copy all active facts to a new mind */
|
|
1309
|
+
forkMind(sourceName: string, newName: string, description: string): void {
|
|
1310
|
+
const tx = this.db.transaction(() => {
|
|
1311
|
+
this.createMind(newName, description, { parent: sourceName });
|
|
1312
|
+
|
|
1313
|
+
const facts = this.getActiveFacts(sourceName);
|
|
1314
|
+
const now = new Date().toISOString();
|
|
1315
|
+
|
|
1316
|
+
for (const fact of facts) {
|
|
1317
|
+
this.db.prepare(`
|
|
1318
|
+
INSERT INTO facts (id, mind, section, content, status, created_at, created_session,
|
|
1319
|
+
source, content_hash, confidence, last_reinforced,
|
|
1320
|
+
reinforcement_count, decay_rate)
|
|
1321
|
+
VALUES (?, ?, ?, ?, 'active', ?, NULL, 'ingest', ?, 1.0, ?, ?, ?)
|
|
1322
|
+
`).run(
|
|
1323
|
+
nanoid(), newName, fact.section, fact.content, now,
|
|
1324
|
+
fact.content_hash, now, fact.reinforcement_count, fact.decay_rate,
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
tx();
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/** Ingest facts from one mind into another */
|
|
1332
|
+
ingestMind(sourceName: string, targetName: string): { factsIngested: number; duplicatesSkipped: number } {
|
|
1333
|
+
const sourceFacts = this.getActiveFacts(sourceName);
|
|
1334
|
+
let ingested = 0;
|
|
1335
|
+
let skipped = 0;
|
|
1336
|
+
|
|
1337
|
+
const tx = this.db.transaction(() => {
|
|
1338
|
+
for (const fact of sourceFacts) {
|
|
1339
|
+
const result = this.storeFact({
|
|
1340
|
+
mind: targetName,
|
|
1341
|
+
section: fact.section as SectionName,
|
|
1342
|
+
content: fact.content,
|
|
1343
|
+
source: "ingest",
|
|
1344
|
+
reinforcement_count: fact.reinforcement_count,
|
|
1345
|
+
});
|
|
1346
|
+
if (result.duplicate) {
|
|
1347
|
+
skipped++;
|
|
1348
|
+
} else {
|
|
1349
|
+
ingested++;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Retire source if writable
|
|
1354
|
+
if (!this.isMindReadonly(sourceName)) {
|
|
1355
|
+
this.setMindStatus(sourceName, "retired");
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
tx();
|
|
1359
|
+
|
|
1360
|
+
return { factsIngested: ingested, duplicatesSkipped: skipped };
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// ---------------------------------------------------------------------------
|
|
1364
|
+
// Active mind state (persisted in DB via a settings table or pragma)
|
|
1365
|
+
// ---------------------------------------------------------------------------
|
|
1366
|
+
|
|
1367
|
+
/** Get/set active mind using a simple key-value in the DB */
|
|
1368
|
+
getActiveMind(): string | null {
|
|
1369
|
+
// Use a lightweight approach — store in a settings row
|
|
1370
|
+
this.db.exec(`
|
|
1371
|
+
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)
|
|
1372
|
+
`);
|
|
1373
|
+
const row = this.db.prepare(`SELECT value FROM settings WHERE key = 'active_mind'`).get();
|
|
1374
|
+
if (!row) return null;
|
|
1375
|
+
const name = row.value;
|
|
1376
|
+
if (name && this.mindExists(name)) return name;
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
setActiveMind(name: string | null): void {
|
|
1381
|
+
this.db.exec(`
|
|
1382
|
+
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)
|
|
1383
|
+
`);
|
|
1384
|
+
this.db.prepare(`
|
|
1385
|
+
INSERT OR REPLACE INTO settings (key, value) VALUES ('active_mind', ?)
|
|
1386
|
+
`).run(name);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// ---------------------------------------------------------------------------
|
|
1390
|
+
// JSONL Export/Import — portable fact sync across machines
|
|
1391
|
+
// ---------------------------------------------------------------------------
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Export all facts and edges to JSONL format.
|
|
1395
|
+
* Each line is a self-contained JSON object with type prefix.
|
|
1396
|
+
* Includes all statuses so the full history is portable.
|
|
1397
|
+
*/
|
|
1398
|
+
exportToJsonl(): string {
|
|
1399
|
+
const lines: string[] = [];
|
|
1400
|
+
|
|
1401
|
+
// Export minds (except default which is auto-created)
|
|
1402
|
+
const minds = this.listMinds();
|
|
1403
|
+
for (const mind of minds) {
|
|
1404
|
+
if (mind.name === "default") continue;
|
|
1405
|
+
lines.push(JSON.stringify({
|
|
1406
|
+
_type: "mind",
|
|
1407
|
+
name: mind.name,
|
|
1408
|
+
description: mind.description,
|
|
1409
|
+
status: mind.status,
|
|
1410
|
+
origin_type: mind.origin_type,
|
|
1411
|
+
created_at: mind.created_at,
|
|
1412
|
+
}));
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// Export all active facts — deterministic: chronological with id tie-break
|
|
1416
|
+
const allFacts = this.db.prepare(
|
|
1417
|
+
`SELECT * FROM facts WHERE status = 'active' ORDER BY mind, section, created_at, id`
|
|
1418
|
+
).all() as Fact[];
|
|
1419
|
+
|
|
1420
|
+
for (const fact of allFacts) {
|
|
1421
|
+
lines.push(JSON.stringify({
|
|
1422
|
+
_type: "fact",
|
|
1423
|
+
id: fact.id,
|
|
1424
|
+
mind: fact.mind,
|
|
1425
|
+
section: fact.section,
|
|
1426
|
+
content: fact.content,
|
|
1427
|
+
status: fact.status,
|
|
1428
|
+
created_at: fact.created_at,
|
|
1429
|
+
source: fact.source,
|
|
1430
|
+
content_hash: fact.content_hash,
|
|
1431
|
+
confidence: fact.confidence,
|
|
1432
|
+
last_reinforced: fact.last_reinforced,
|
|
1433
|
+
reinforcement_count: fact.reinforcement_count,
|
|
1434
|
+
decay_rate: fact.decay_rate,
|
|
1435
|
+
supersedes: fact.supersedes,
|
|
1436
|
+
}));
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Export active edges — deterministic: chronological with id tie-break
|
|
1440
|
+
const allEdges = this.db.prepare(
|
|
1441
|
+
`SELECT * FROM edges WHERE status = 'active' ORDER BY created_at, id`
|
|
1442
|
+
).all() as Edge[];
|
|
1443
|
+
|
|
1444
|
+
for (const edge of allEdges) {
|
|
1445
|
+
lines.push(JSON.stringify({
|
|
1446
|
+
_type: "edge",
|
|
1447
|
+
id: edge.id,
|
|
1448
|
+
source_fact_id: edge.source_fact_id,
|
|
1449
|
+
target_fact_id: edge.target_fact_id,
|
|
1450
|
+
relation: edge.relation,
|
|
1451
|
+
description: edge.description,
|
|
1452
|
+
confidence: edge.confidence,
|
|
1453
|
+
last_reinforced: edge.last_reinforced,
|
|
1454
|
+
reinforcement_count: edge.reinforcement_count,
|
|
1455
|
+
decay_rate: edge.decay_rate,
|
|
1456
|
+
source_mind: edge.source_mind,
|
|
1457
|
+
target_mind: edge.target_mind,
|
|
1458
|
+
}));
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Export episodes — deterministic: chronological with id tie-break
|
|
1462
|
+
const allEpisodes = this.db.prepare(
|
|
1463
|
+
`SELECT * FROM episodes ORDER BY date, created_at, id`
|
|
1464
|
+
).all() as Episode[];
|
|
1465
|
+
|
|
1466
|
+
for (const ep of allEpisodes) {
|
|
1467
|
+
const factIds = this.getEpisodeFactIds(ep.id);
|
|
1468
|
+
lines.push(JSON.stringify({
|
|
1469
|
+
_type: "episode",
|
|
1470
|
+
id: ep.id,
|
|
1471
|
+
mind: ep.mind,
|
|
1472
|
+
title: ep.title,
|
|
1473
|
+
narrative: ep.narrative,
|
|
1474
|
+
date: ep.date,
|
|
1475
|
+
session_id: ep.session_id,
|
|
1476
|
+
created_at: ep.created_at,
|
|
1477
|
+
fact_ids: factIds,
|
|
1478
|
+
}));
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
return lines.join("\n") + "\n";
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Import from JSONL, merging with existing data.
|
|
1486
|
+
* Uses content_hash dedup for facts — existing facts get reinforced,
|
|
1487
|
+
* new facts get inserted. Edges dedup by source+target+relation.
|
|
1488
|
+
* Returns counts of what happened.
|
|
1489
|
+
*/
|
|
1490
|
+
importFromJsonl(jsonl: string): { factsAdded: number; factsReinforced: number; edgesAdded: number; edgesReinforced: number; mindsCreated: number } {
|
|
1491
|
+
let factsAdded = 0;
|
|
1492
|
+
let factsReinforced = 0;
|
|
1493
|
+
let edgesAdded = 0;
|
|
1494
|
+
let edgesReinforced = 0;
|
|
1495
|
+
let mindsCreated = 0;
|
|
1496
|
+
|
|
1497
|
+
// Map from imported fact ID → local fact ID (for edge remapping)
|
|
1498
|
+
const factIdMap = new Map<string, string>();
|
|
1499
|
+
|
|
1500
|
+
// Pre-dedup: merge=union in git can produce multiple lines with the same id
|
|
1501
|
+
// but different metadata (reinforcement_count, last_reinforced). Keep only the
|
|
1502
|
+
// line with the highest reinforcement_count per id to prevent churn.
|
|
1503
|
+
const dedupedRecords: any[] = [];
|
|
1504
|
+
const seenById = new Map<string, number>(); // id → index in dedupedRecords
|
|
1505
|
+
for (const line of jsonl.split("\n")) {
|
|
1506
|
+
const trimmed = line.trim();
|
|
1507
|
+
if (!trimmed) continue;
|
|
1508
|
+
let record: any;
|
|
1509
|
+
try {
|
|
1510
|
+
record = JSON.parse(trimmed);
|
|
1511
|
+
} catch {
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
const id = record.id;
|
|
1515
|
+
if (id && seenById.has(id)) {
|
|
1516
|
+
const idx = seenById.get(id)!;
|
|
1517
|
+
const existing = dedupedRecords[idx];
|
|
1518
|
+
// Keep higher reinforcement_count; tie-break on last_reinforced
|
|
1519
|
+
if ((record.reinforcement_count ?? 0) > (existing.reinforcement_count ?? 0) ||
|
|
1520
|
+
((record.reinforcement_count ?? 0) === (existing.reinforcement_count ?? 0) &&
|
|
1521
|
+
(record.last_reinforced ?? "") > (existing.last_reinforced ?? ""))) {
|
|
1522
|
+
dedupedRecords[idx] = record;
|
|
1523
|
+
}
|
|
1524
|
+
} else {
|
|
1525
|
+
if (id) seenById.set(id, dedupedRecords.length);
|
|
1526
|
+
dedupedRecords.push(record);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const tx = this.db.transaction(() => {
|
|
1531
|
+
for (const record of dedupedRecords) {
|
|
1532
|
+
|
|
1533
|
+
switch (record._type) {
|
|
1534
|
+
case "mind": {
|
|
1535
|
+
if (!this.mindExists(record.name)) {
|
|
1536
|
+
this.createMind(record.name, record.description ?? "", {
|
|
1537
|
+
origin_type: record.origin_type ?? "local",
|
|
1538
|
+
});
|
|
1539
|
+
mindsCreated++;
|
|
1540
|
+
}
|
|
1541
|
+
break;
|
|
1542
|
+
}
|
|
1543
|
+
case "fact": {
|
|
1544
|
+
const mind = record.mind ?? "default";
|
|
1545
|
+
if (!this.mindExists(mind)) {
|
|
1546
|
+
this.createMind(mind, "", { origin_type: "local" });
|
|
1547
|
+
mindsCreated++;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Dedup by content hash — check ALL statuses to avoid resurrecting
|
|
1551
|
+
// archived or superseded facts from stale JSONL snapshots.
|
|
1552
|
+
const hash = record.content_hash ?? contentHash(record.content);
|
|
1553
|
+
const existingAny = this.db.prepare(
|
|
1554
|
+
`SELECT id, status FROM facts WHERE mind = ? AND content_hash = ?`
|
|
1555
|
+
).get(mind, hash) as { id: string; status: string } | undefined;
|
|
1556
|
+
|
|
1557
|
+
if (existingAny) {
|
|
1558
|
+
if (existingAny.status === "active") {
|
|
1559
|
+
// Reinforce, take higher reinforcement count
|
|
1560
|
+
const existingFact = this.getFact(existingAny.id);
|
|
1561
|
+
if (existingFact && record.reinforcement_count > existingFact.reinforcement_count) {
|
|
1562
|
+
this.db.prepare(`
|
|
1563
|
+
UPDATE facts SET reinforcement_count = ?, last_reinforced = ?, confidence = 1.0
|
|
1564
|
+
WHERE id = ?
|
|
1565
|
+
`).run(record.reinforcement_count, record.last_reinforced ?? new Date().toISOString(), existingAny.id);
|
|
1566
|
+
} else {
|
|
1567
|
+
this.reinforceFact(existingAny.id);
|
|
1568
|
+
}
|
|
1569
|
+
factsReinforced++;
|
|
1570
|
+
}
|
|
1571
|
+
// Archived/superseded facts: skip silently (don't resurrect)
|
|
1572
|
+
factIdMap.set(record.id, existingAny.id);
|
|
1573
|
+
} else {
|
|
1574
|
+
const id = nanoid();
|
|
1575
|
+
const now = new Date().toISOString();
|
|
1576
|
+
this.db.prepare(`
|
|
1577
|
+
INSERT INTO facts (id, mind, section, content, status, created_at, created_session,
|
|
1578
|
+
supersedes, source, content_hash, confidence, last_reinforced,
|
|
1579
|
+
reinforcement_count, decay_rate)
|
|
1580
|
+
VALUES (?, ?, ?, ?, 'active', ?, NULL, ?, ?, ?, ?, ?, ?, ?)
|
|
1581
|
+
`).run(
|
|
1582
|
+
id, mind, record.section, record.content,
|
|
1583
|
+
record.created_at ?? now,
|
|
1584
|
+
record.supersedes ?? null,
|
|
1585
|
+
record.source ?? "ingest",
|
|
1586
|
+
hash,
|
|
1587
|
+
record.confidence ?? 1.0,
|
|
1588
|
+
record.last_reinforced ?? now,
|
|
1589
|
+
record.reinforcement_count ?? 1,
|
|
1590
|
+
record.decay_rate ?? this.decayProfile.baseRate,
|
|
1591
|
+
);
|
|
1592
|
+
factIdMap.set(record.id, id);
|
|
1593
|
+
factsAdded++;
|
|
1594
|
+
}
|
|
1595
|
+
break;
|
|
1596
|
+
}
|
|
1597
|
+
case "edge": {
|
|
1598
|
+
// Remap fact IDs
|
|
1599
|
+
const sourceId = factIdMap.get(record.source_fact_id) ?? record.source_fact_id;
|
|
1600
|
+
const targetId = factIdMap.get(record.target_fact_id) ?? record.target_fact_id;
|
|
1601
|
+
|
|
1602
|
+
// Verify both facts exist locally
|
|
1603
|
+
if (!this.getFact(sourceId) || !this.getFact(targetId)) continue;
|
|
1604
|
+
|
|
1605
|
+
const result = this.storeEdge({
|
|
1606
|
+
sourceFact: sourceId,
|
|
1607
|
+
targetFact: targetId,
|
|
1608
|
+
relation: record.relation,
|
|
1609
|
+
description: record.description,
|
|
1610
|
+
sourceMind: record.source_mind,
|
|
1611
|
+
targetMind: record.target_mind,
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
if (result.duplicate) {
|
|
1615
|
+
edgesReinforced++;
|
|
1616
|
+
} else {
|
|
1617
|
+
edgesAdded++;
|
|
1618
|
+
}
|
|
1619
|
+
break;
|
|
1620
|
+
}
|
|
1621
|
+
case "episode": {
|
|
1622
|
+
// Import episode — preserve original ID for cross-machine dedup.
|
|
1623
|
+
// getEpisode checks by ID, so using record.id ensures re-import is idempotent.
|
|
1624
|
+
const existing = this.getEpisode(record.id);
|
|
1625
|
+
if (!existing) {
|
|
1626
|
+
const mind = record.mind ?? "default";
|
|
1627
|
+
if (!this.mindExists(mind)) {
|
|
1628
|
+
this.createMind(mind, "", { origin_type: "local" });
|
|
1629
|
+
mindsCreated++;
|
|
1630
|
+
}
|
|
1631
|
+
// Remap fact IDs
|
|
1632
|
+
const factIds = (record.fact_ids as string[] ?? [])
|
|
1633
|
+
.map((id: string) => factIdMap.get(id) ?? id)
|
|
1634
|
+
.filter((id: string) => !!this.getFact(id));
|
|
1635
|
+
|
|
1636
|
+
this._storeEpisodeInner(record.id, {
|
|
1637
|
+
mind,
|
|
1638
|
+
title: record.title,
|
|
1639
|
+
narrative: record.narrative,
|
|
1640
|
+
date: record.date,
|
|
1641
|
+
sessionId: record.session_id ?? null,
|
|
1642
|
+
factIds,
|
|
1643
|
+
createdAt: record.created_at,
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
break;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
tx();
|
|
1653
|
+
return { factsAdded, factsReinforced, edgesAdded, edgesReinforced, mindsCreated };
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* Get the mtime of the database file, or null if it doesn't exist.
|
|
1658
|
+
*/
|
|
1659
|
+
getDbMtime(): Date | null {
|
|
1660
|
+
try {
|
|
1661
|
+
const stat = fs.statSync(this.dbPath);
|
|
1662
|
+
return stat.mtime;
|
|
1663
|
+
} catch {
|
|
1664
|
+
return null;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// ---------------------------------------------------------------------------
|
|
1669
|
+
// Vector Embeddings — Semantic Retrieval
|
|
1670
|
+
// ---------------------------------------------------------------------------
|
|
1671
|
+
|
|
1672
|
+
/** Register an embedding model in the metadata table (idempotent). */
|
|
1673
|
+
registerEmbeddingModel(model: string, dims: number): void {
|
|
1674
|
+
const now = new Date().toISOString();
|
|
1675
|
+
this.db.prepare(`
|
|
1676
|
+
INSERT OR IGNORE INTO embedding_metadata (model_name, dims, inserted_at)
|
|
1677
|
+
VALUES (?, ?, ?)
|
|
1678
|
+
`).run(model, dims, now);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/** Return the active embedding model metadata, or null if no vectors stored. */
|
|
1682
|
+
getActiveEmbeddingModel(): { model_name: string; dims: number } | null {
|
|
1683
|
+
return this.db.prepare(
|
|
1684
|
+
`SELECT model_name, dims FROM embedding_metadata ORDER BY inserted_at DESC LIMIT 1`
|
|
1685
|
+
).get() ?? null;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/** Store an embedding for a fact — also registers the model. */
|
|
1689
|
+
storeFactVector(factId: string, embedding: Float32Array, model: string): void {
|
|
1690
|
+
this.registerEmbeddingModel(model, embedding.length);
|
|
1691
|
+
const blob = vectorToBlob(embedding);
|
|
1692
|
+
const now = new Date().toISOString();
|
|
1693
|
+
this.db.prepare(`
|
|
1694
|
+
INSERT OR REPLACE INTO facts_vec (fact_id, embedding, model, dims, created_at, model_name)
|
|
1695
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1696
|
+
`).run(factId, blob, model, embedding.length, now, model);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/** Get embedding for a fact */
|
|
1700
|
+
getFactVector(factId: string): Float32Array | null {
|
|
1701
|
+
const row = this.db.prepare(
|
|
1702
|
+
`SELECT embedding FROM facts_vec WHERE fact_id = ?`
|
|
1703
|
+
).get(factId);
|
|
1704
|
+
if (!row?.embedding) return null;
|
|
1705
|
+
return blobToVector(row.embedding as Buffer);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/** Check if a fact has a stored vector */
|
|
1709
|
+
hasFactVector(factId: string): boolean {
|
|
1710
|
+
return !!this.db.prepare(
|
|
1711
|
+
`SELECT 1 FROM facts_vec WHERE fact_id = ?`
|
|
1712
|
+
).get(factId);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/** Get all fact IDs that are missing vectors */
|
|
1716
|
+
getFactsMissingVectors(mind: string): string[] {
|
|
1717
|
+
const rows = this.db.prepare(`
|
|
1718
|
+
SELECT f.id FROM facts f
|
|
1719
|
+
LEFT JOIN facts_vec v ON f.id = v.fact_id
|
|
1720
|
+
WHERE f.mind = ? AND f.status = 'active' AND v.fact_id IS NULL
|
|
1721
|
+
`).all(mind) as { id: string }[];
|
|
1722
|
+
return rows.map(r => r.id);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/** Count facts with vectors for a mind */
|
|
1726
|
+
countFactVectors(mind: string): number {
|
|
1727
|
+
const row = this.db.prepare(`
|
|
1728
|
+
SELECT COUNT(*) as count FROM facts_vec v
|
|
1729
|
+
JOIN facts f ON v.fact_id = f.id
|
|
1730
|
+
WHERE f.mind = ? AND f.status = 'active'
|
|
1731
|
+
`).get(mind);
|
|
1732
|
+
return row?.count ?? 0;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/**
|
|
1736
|
+
* Semantic search: find top-k active facts most similar to a query vector.
|
|
1737
|
+
* Returns facts with similarity scores, filtered by mind and min confidence.
|
|
1738
|
+
* Applies confidence decay and computes final score as similarity × confidence.
|
|
1739
|
+
*
|
|
1740
|
+
* Skips vectors with mismatched dimensions (e.g., from a different embedding model).
|
|
1741
|
+
*/
|
|
1742
|
+
semanticSearch(
|
|
1743
|
+
queryVec: Float32Array,
|
|
1744
|
+
mind: string,
|
|
1745
|
+
opts?: { k?: number; minSimilarity?: number; section?: string },
|
|
1746
|
+
): (Fact & { similarity: number; score: number })[] {
|
|
1747
|
+
const k = opts?.k ?? 10;
|
|
1748
|
+
const minSim = opts?.minSimilarity ?? 0.3;
|
|
1749
|
+
const queryDims = queryVec.length;
|
|
1750
|
+
|
|
1751
|
+
// Get all active facts with vectors for this mind
|
|
1752
|
+
let query = `
|
|
1753
|
+
SELECT f.*, v.embedding, v.dims FROM facts f
|
|
1754
|
+
JOIN facts_vec v ON f.id = v.fact_id
|
|
1755
|
+
WHERE f.mind = ? AND f.status = 'active'
|
|
1756
|
+
`;
|
|
1757
|
+
const params: any[] = [mind];
|
|
1758
|
+
|
|
1759
|
+
if (opts?.section) {
|
|
1760
|
+
query += ` AND f.section = ?`;
|
|
1761
|
+
params.push(opts.section);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
const rows = this.db.prepare(query).all(...params) as (Fact & { embedding: Buffer; dims: number })[];
|
|
1765
|
+
|
|
1766
|
+
// Compute similarities
|
|
1767
|
+
const NO_DECAY_SECTIONS: readonly string[] = ["Specs"];
|
|
1768
|
+
const now = Date.now();
|
|
1769
|
+
const scored: (Fact & { similarity: number; score: number })[] = [];
|
|
1770
|
+
|
|
1771
|
+
let dimMismatchCount = 0;
|
|
1772
|
+
|
|
1773
|
+
for (const row of rows) {
|
|
1774
|
+
// Dimension mismatch: log warning instead of silently skipping.
|
|
1775
|
+
// This happens when the embedding model changes (e.g., 384-dim → 1024-dim).
|
|
1776
|
+
if (row.dims !== queryDims) {
|
|
1777
|
+
dimMismatchCount++;
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
const factVec = blobToVector(row.embedding);
|
|
1782
|
+
const similarity = cosineSimilarity(queryVec, factVec);
|
|
1783
|
+
|
|
1784
|
+
if (similarity < minSim) continue;
|
|
1785
|
+
|
|
1786
|
+
// Apply confidence decay using the fact's stored decay profile (not the
|
|
1787
|
+
// store-wide default). This fixes the wrong-profile decay bug where a
|
|
1788
|
+
// "recent_work" fact was decayed with the "standard" profile.
|
|
1789
|
+
let confidence: number;
|
|
1790
|
+
if (NO_DECAY_SECTIONS.includes(row.section)) {
|
|
1791
|
+
confidence = 1.0;
|
|
1792
|
+
} else {
|
|
1793
|
+
// Use access reinforcement: effective last-active is max(last_reinforced, last_accessed)
|
|
1794
|
+
const lastReinforced = new Date(row.last_reinforced).getTime();
|
|
1795
|
+
const lastAccessed = row.last_accessed ? new Date(row.last_accessed).getTime() : 0;
|
|
1796
|
+
const effectiveLastActive = Math.max(lastReinforced, lastAccessed);
|
|
1797
|
+
const daysSince = (now - effectiveLastActive) / (1000 * 60 * 60 * 24);
|
|
1798
|
+
const profile = resolveDecayProfile(row.decay_profile);
|
|
1799
|
+
confidence = computeConfidence(daysSince, row.reinforcement_count, profile);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// Remove embedding from returned object
|
|
1803
|
+
const { embedding: _, dims: _d, ...fact } = row;
|
|
1804
|
+
scored.push({
|
|
1805
|
+
...fact,
|
|
1806
|
+
confidence,
|
|
1807
|
+
similarity,
|
|
1808
|
+
score: similarity * confidence,
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
if (dimMismatchCount > 0) {
|
|
1813
|
+
console.warn(
|
|
1814
|
+
`[project-memory] semanticSearch: ${dimMismatchCount} vectors skipped due to dimension mismatch ` +
|
|
1815
|
+
`(query=${queryDims}d, stored vectors have different dims). ` +
|
|
1816
|
+
`Re-embed with the current model to fix.`
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Sort by combined score descending, return top-k
|
|
1821
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1822
|
+
const results = scored.slice(0, k);
|
|
1823
|
+
|
|
1824
|
+
// Access reinforcement: touch returned facts so their effective decay timer
|
|
1825
|
+
// resets. Fire-and-forget — don't block the search response.
|
|
1826
|
+
for (const fact of results) {
|
|
1827
|
+
try { this.touchFact(fact.id); } catch { /* non-critical */ }
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
return results;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/**
|
|
1834
|
+
* Hybrid search: combines FTS5 keyword search with embedding-based semantic search
|
|
1835
|
+
* via Reciprocal Rank Fusion (RRF). Produces better recall than either method alone:
|
|
1836
|
+
* - FTS5 catches exact keyword matches (file paths, function names, identifiers)
|
|
1837
|
+
* - Embeddings catch semantic matches (synonyms, paraphrases, conceptual similarity)
|
|
1838
|
+
*
|
|
1839
|
+
* When queryVec is null (embeddings unavailable), degrades to FTS5-only.
|
|
1840
|
+
* RRF formula: score(d) = Σ 1/(k + rank_in_list), where k=60 (standard constant).
|
|
1841
|
+
*
|
|
1842
|
+
* Returns facts scored by RRF rank, with similarity and confidence fields populated.
|
|
1843
|
+
*/
|
|
1844
|
+
hybridSearch(
|
|
1845
|
+
queryText: string,
|
|
1846
|
+
queryVec: Float32Array | null,
|
|
1847
|
+
mind: string,
|
|
1848
|
+
opts?: { k?: number; minSimilarity?: number; section?: string; ftsK?: number; semanticK?: number },
|
|
1849
|
+
): (Fact & { similarity: number; score: number })[] {
|
|
1850
|
+
const k = opts?.k ?? 15;
|
|
1851
|
+
const ftsK = opts?.ftsK ?? 20;
|
|
1852
|
+
const semanticK = opts?.semanticK ?? 20;
|
|
1853
|
+
const RRF_K = 60; // Standard RRF constant
|
|
1854
|
+
|
|
1855
|
+
// --- FTS5 leg ---
|
|
1856
|
+
const ftsRanked: Map<string, number> = new Map(); // fact.id → rank (0-indexed)
|
|
1857
|
+
if (queryText.length > 2) {
|
|
1858
|
+
// Use OR mode for broader recall — AND is too restrictive for injection
|
|
1859
|
+
const tokens = queryText.split(/\s+/).filter(t => t.length > 1);
|
|
1860
|
+
if (tokens.length > 0) {
|
|
1861
|
+
const ftsQuery = tokens.join(" OR ");
|
|
1862
|
+
try {
|
|
1863
|
+
let query = `
|
|
1864
|
+
SELECT f.* FROM facts f
|
|
1865
|
+
JOIN facts_fts fts ON f.rowid = fts.rowid
|
|
1866
|
+
WHERE facts_fts MATCH ? AND f.mind = ? AND f.status = 'active'
|
|
1867
|
+
`;
|
|
1868
|
+
const params: any[] = [ftsQuery, mind];
|
|
1869
|
+
if (opts?.section) {
|
|
1870
|
+
query += ` AND f.section = ?`;
|
|
1871
|
+
params.push(opts.section);
|
|
1872
|
+
}
|
|
1873
|
+
query += ` ORDER BY rank LIMIT ?`;
|
|
1874
|
+
params.push(ftsK);
|
|
1875
|
+
const rows = this.db.prepare(query).all(...params) as Fact[];
|
|
1876
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1877
|
+
ftsRanked.set(rows[i].id, i);
|
|
1878
|
+
}
|
|
1879
|
+
} catch {
|
|
1880
|
+
// FTS5 query syntax error (e.g., special characters) — skip FTS leg
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// --- Embedding leg ---
|
|
1886
|
+
const semanticRanked: Map<string, { rank: number; similarity: number }> = new Map();
|
|
1887
|
+
if (queryVec) {
|
|
1888
|
+
const hits = this.semanticSearch(queryVec, mind, {
|
|
1889
|
+
k: semanticK,
|
|
1890
|
+
minSimilarity: opts?.minSimilarity ?? 0.3,
|
|
1891
|
+
section: opts?.section,
|
|
1892
|
+
});
|
|
1893
|
+
for (let i = 0; i < hits.length; i++) {
|
|
1894
|
+
semanticRanked.set(hits[i].id, { rank: i, similarity: hits[i].similarity });
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// --- RRF merge ---
|
|
1899
|
+
const allIds = new Set([...ftsRanked.keys(), ...semanticRanked.keys()]);
|
|
1900
|
+
const scored: { id: string; rrfScore: number; similarity: number }[] = [];
|
|
1901
|
+
|
|
1902
|
+
for (const id of allIds) {
|
|
1903
|
+
let rrfScore = 0;
|
|
1904
|
+
let similarity = 0;
|
|
1905
|
+
|
|
1906
|
+
const ftsRank = ftsRanked.get(id);
|
|
1907
|
+
if (ftsRank !== undefined) {
|
|
1908
|
+
rrfScore += 1 / (RRF_K + ftsRank);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
const semHit = semanticRanked.get(id);
|
|
1912
|
+
if (semHit !== undefined) {
|
|
1913
|
+
rrfScore += 1 / (RRF_K + semHit.rank);
|
|
1914
|
+
similarity = semHit.similarity;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
scored.push({ id, rrfScore, similarity });
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
scored.sort((a, b) => b.rrfScore - a.rrfScore);
|
|
1921
|
+
const topIds = scored.slice(0, k);
|
|
1922
|
+
|
|
1923
|
+
// Hydrate facts with scores
|
|
1924
|
+
const results: (Fact & { similarity: number; score: number })[] = [];
|
|
1925
|
+
for (const { id, rrfScore, similarity } of topIds) {
|
|
1926
|
+
const fact = this.getFact(id);
|
|
1927
|
+
if (!fact || fact.status !== "active") continue;
|
|
1928
|
+
results.push({ ...fact, similarity, score: rrfScore });
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// Access reinforcement on returned results
|
|
1932
|
+
for (const fact of results) {
|
|
1933
|
+
try { this.touchFact(fact.id); } catch { /* non-critical */ }
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
return results;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/**
|
|
1940
|
+
* Find facts similar to a given fact (for conflict detection).
|
|
1941
|
+
* Returns facts in the same section with high similarity but different content hash.
|
|
1942
|
+
* Skips vectors with mismatched dimensions.
|
|
1943
|
+
*/
|
|
1944
|
+
findSimilarFacts(
|
|
1945
|
+
factContent: string,
|
|
1946
|
+
queryVec: Float32Array,
|
|
1947
|
+
mind: string,
|
|
1948
|
+
section: string,
|
|
1949
|
+
opts?: { threshold?: number; limit?: number },
|
|
1950
|
+
): (Fact & { similarity: number })[] {
|
|
1951
|
+
const threshold = opts?.threshold ?? 0.8;
|
|
1952
|
+
const limit = opts?.limit ?? 5;
|
|
1953
|
+
const queryDims = queryVec.length;
|
|
1954
|
+
const contentHashVal = contentHash(factContent);
|
|
1955
|
+
|
|
1956
|
+
const rows = this.db.prepare(`
|
|
1957
|
+
SELECT f.*, v.embedding, v.dims FROM facts f
|
|
1958
|
+
JOIN facts_vec v ON f.id = v.fact_id
|
|
1959
|
+
WHERE f.mind = ? AND f.section = ? AND f.status = 'active'
|
|
1960
|
+
AND f.content_hash != ?
|
|
1961
|
+
`).all(mind, section, contentHashVal) as (Fact & { embedding: Buffer; dims: number })[];
|
|
1962
|
+
|
|
1963
|
+
const results: (Fact & { similarity: number })[] = [];
|
|
1964
|
+
|
|
1965
|
+
for (const row of rows) {
|
|
1966
|
+
if (row.dims !== queryDims) continue;
|
|
1967
|
+
|
|
1968
|
+
const factVec = blobToVector(row.embedding);
|
|
1969
|
+
const similarity = cosineSimilarity(queryVec, factVec);
|
|
1970
|
+
|
|
1971
|
+
if (similarity >= threshold) {
|
|
1972
|
+
const { embedding: _, dims: _d, ...fact } = row;
|
|
1973
|
+
results.push({ ...fact, similarity });
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
results.sort((a, b) => b.similarity - a.similarity);
|
|
1978
|
+
return results.slice(0, limit);
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* Purge vectors with mismatched dimensions. Called when embedding model changes.
|
|
1983
|
+
* Returns number of vectors purged.
|
|
1984
|
+
*/
|
|
1985
|
+
purgeStaleVectors(expectedDims: number): number {
|
|
1986
|
+
const result = this.db.prepare(
|
|
1987
|
+
`DELETE FROM facts_vec WHERE dims != ?`
|
|
1988
|
+
).run(expectedDims);
|
|
1989
|
+
const episodeResult = this.db.prepare(
|
|
1990
|
+
`DELETE FROM episodes_vec WHERE dims != ?`
|
|
1991
|
+
).run(expectedDims);
|
|
1992
|
+
return result.changes + episodeResult.changes;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
// ---------------------------------------------------------------------------
|
|
1996
|
+
// Episodes — Session Narratives
|
|
1997
|
+
// ---------------------------------------------------------------------------
|
|
1998
|
+
|
|
1999
|
+
/** Store an episode */
|
|
2000
|
+
storeEpisode(opts: {
|
|
2001
|
+
mind: string;
|
|
2002
|
+
title: string;
|
|
2003
|
+
narrative: string;
|
|
2004
|
+
date: string;
|
|
2005
|
+
sessionId?: string;
|
|
2006
|
+
factIds?: string[];
|
|
2007
|
+
}): string {
|
|
2008
|
+
const id = nanoid();
|
|
2009
|
+
const tx = this.db.transaction(() => {
|
|
2010
|
+
this._storeEpisodeInner(id, opts);
|
|
2011
|
+
});
|
|
2012
|
+
tx();
|
|
2013
|
+
return id;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/**
|
|
2017
|
+
* Inner episode insert — no transaction wrapper.
|
|
2018
|
+
* Safe to call inside an existing transaction (e.g. importFromJsonl).
|
|
2019
|
+
*/
|
|
2020
|
+
private _storeEpisodeInner(id: string, opts: {
|
|
2021
|
+
mind: string;
|
|
2022
|
+
title: string;
|
|
2023
|
+
narrative: string;
|
|
2024
|
+
date: string;
|
|
2025
|
+
sessionId?: string | null;
|
|
2026
|
+
factIds?: string[];
|
|
2027
|
+
createdAt?: string;
|
|
2028
|
+
}): void {
|
|
2029
|
+
const now = opts.createdAt ?? new Date().toISOString();
|
|
2030
|
+
this.db.prepare(`
|
|
2031
|
+
INSERT INTO episodes (id, mind, title, narrative, date, session_id, created_at)
|
|
2032
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2033
|
+
`).run(id, opts.mind, opts.title, opts.narrative, opts.date, opts.sessionId ?? null, now);
|
|
2034
|
+
|
|
2035
|
+
if (opts.factIds?.length) {
|
|
2036
|
+
const stmt = this.db.prepare(
|
|
2037
|
+
`INSERT OR IGNORE INTO episode_facts (episode_id, fact_id) VALUES (?, ?)`
|
|
2038
|
+
);
|
|
2039
|
+
for (const factId of opts.factIds) {
|
|
2040
|
+
stmt.run(id, factId);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/** Get episodes for a mind, ordered by date descending */
|
|
2046
|
+
getEpisodes(mind: string, limit?: number): Episode[] {
|
|
2047
|
+
const sql = `SELECT * FROM episodes WHERE mind = ? ORDER BY date DESC` +
|
|
2048
|
+
(limit ? ` LIMIT ${limit}` : "");
|
|
2049
|
+
return this.db.prepare(sql).all(mind) as Episode[];
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/** Get a single episode by ID */
|
|
2053
|
+
getEpisode(id: string): Episode | null {
|
|
2054
|
+
return this.db.prepare(`SELECT * FROM episodes WHERE id = ?`).get(id) as Episode | null;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
/** Get fact IDs linked to an episode */
|
|
2058
|
+
getEpisodeFactIds(episodeId: string): string[] {
|
|
2059
|
+
const rows = this.db.prepare(
|
|
2060
|
+
`SELECT fact_id FROM episode_facts WHERE episode_id = ?`
|
|
2061
|
+
).all(episodeId) as { fact_id: string }[];
|
|
2062
|
+
return rows.map(r => r.fact_id);
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
/** Get episodes that reference a specific fact */
|
|
2066
|
+
getEpisodesForFact(factId: string): Episode[] {
|
|
2067
|
+
return this.db.prepare(`
|
|
2068
|
+
SELECT e.* FROM episodes e
|
|
2069
|
+
JOIN episode_facts ef ON e.id = ef.episode_id
|
|
2070
|
+
WHERE ef.fact_id = ?
|
|
2071
|
+
ORDER BY e.date DESC
|
|
2072
|
+
`).all(factId) as Episode[];
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
/** Store an episode embedding */
|
|
2076
|
+
storeEpisodeVector(episodeId: string, embedding: Float32Array, model: string): void {
|
|
2077
|
+
const blob = vectorToBlob(embedding);
|
|
2078
|
+
const now = new Date().toISOString();
|
|
2079
|
+
this.db.prepare(`
|
|
2080
|
+
INSERT OR REPLACE INTO episodes_vec (episode_id, embedding, model, dims, created_at)
|
|
2081
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2082
|
+
`).run(episodeId, blob, model, embedding.length, now);
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
/** Semantic search over episodes. Skips vectors with mismatched dimensions. */
|
|
2086
|
+
semanticSearchEpisodes(
|
|
2087
|
+
queryVec: Float32Array,
|
|
2088
|
+
mind: string,
|
|
2089
|
+
opts?: { k?: number; minSimilarity?: number },
|
|
2090
|
+
): (Episode & { similarity: number })[] {
|
|
2091
|
+
const k = opts?.k ?? 5;
|
|
2092
|
+
const minSim = opts?.minSimilarity ?? 0.3;
|
|
2093
|
+
const queryDims = queryVec.length;
|
|
2094
|
+
|
|
2095
|
+
const rows = this.db.prepare(`
|
|
2096
|
+
SELECT e.*, v.embedding, v.dims FROM episodes e
|
|
2097
|
+
JOIN episodes_vec v ON e.id = v.episode_id
|
|
2098
|
+
WHERE e.mind = ?
|
|
2099
|
+
`).all(mind) as (Episode & { embedding: Buffer; dims: number })[];
|
|
2100
|
+
|
|
2101
|
+
const results: (Episode & { similarity: number })[] = [];
|
|
2102
|
+
|
|
2103
|
+
for (const row of rows) {
|
|
2104
|
+
if (row.dims !== queryDims) continue;
|
|
2105
|
+
|
|
2106
|
+
const vec = blobToVector(row.embedding);
|
|
2107
|
+
const similarity = cosineSimilarity(queryVec, vec);
|
|
2108
|
+
|
|
2109
|
+
if (similarity >= minSim) {
|
|
2110
|
+
const { embedding: _, dims: _d, ...episode } = row;
|
|
2111
|
+
results.push({ ...episode, similarity });
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
results.sort((a, b) => b.similarity - a.similarity);
|
|
2116
|
+
return results.slice(0, k);
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/** Count episodes for a mind */
|
|
2120
|
+
countEpisodes(mind: string): number {
|
|
2121
|
+
const row = this.db.prepare(
|
|
2122
|
+
`SELECT COUNT(*) as count FROM episodes WHERE mind = ?`
|
|
2123
|
+
).get(mind);
|
|
2124
|
+
return row?.count ?? 0;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// ---------------------------------------------------------------------------
|
|
2128
|
+
// Lifecycle
|
|
2129
|
+
// ---------------------------------------------------------------------------
|
|
2130
|
+
|
|
2131
|
+
close(): void {
|
|
2132
|
+
this.db.close();
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
getDbPath(): string {
|
|
2136
|
+
return this.dbPath;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// --- Extraction action types ---
|
|
2141
|
+
|
|
2142
|
+
export interface ExtractionAction {
|
|
2143
|
+
type: "observe" | "reinforce" | "supersede" | "archive" | "connect";
|
|
2144
|
+
id?: string;
|
|
2145
|
+
section?: SectionName;
|
|
2146
|
+
content?: string;
|
|
2147
|
+
// connect-specific fields
|
|
2148
|
+
source?: string;
|
|
2149
|
+
target?: string;
|
|
2150
|
+
relation?: string;
|
|
2151
|
+
description?: string;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* Parse extraction agent output (JSONL) into actions.
|
|
2156
|
+
* Tolerant — skips malformed lines.
|
|
2157
|
+
*/
|
|
2158
|
+
export function parseExtractionOutput(output: string): ExtractionAction[] {
|
|
2159
|
+
const actions: ExtractionAction[] = [];
|
|
2160
|
+
for (const line of output.split("\n")) {
|
|
2161
|
+
const trimmed = line.trim();
|
|
2162
|
+
if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("#")) continue;
|
|
2163
|
+
try {
|
|
2164
|
+
const parsed = JSON.parse(trimmed);
|
|
2165
|
+
if (parsed.type && typeof parsed.type === "string") {
|
|
2166
|
+
actions.push(parsed as ExtractionAction);
|
|
2167
|
+
} else if (parsed.action) {
|
|
2168
|
+
// Accept {action: "observe"} as alias for {type: "observe"}
|
|
2169
|
+
actions.push({ ...parsed, type: parsed.action } as ExtractionAction);
|
|
2170
|
+
}
|
|
2171
|
+
} catch {
|
|
2172
|
+
// Skip malformed lines — best effort
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
return actions;
|
|
2177
|
+
}
|