omegon-pi 0.14.3 → 0.15.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.
@@ -156,6 +156,12 @@ export interface Fact {
156
156
  /** jj change ID at fact creation — permanent provenance anchor.
157
157
  * Survives rebase/squash. Null for facts created outside jj repos. */
158
158
  jj_change_id: string | null;
159
+ /** Persona that owns this fact. Null = project-level fact. */
160
+ persona_id: string | null;
161
+ /** Memory layer: 'project' | 'persona' | 'working'. */
162
+ layer: string;
163
+ /** JSON array of domain tags (e.g. ["pcb","thermal"]). Null = untagged. */
164
+ tags: string | null;
159
165
  }
160
166
 
161
167
  export interface MindRecord {
@@ -320,7 +326,7 @@ export class FactStore {
320
326
  }
321
327
 
322
328
  /** Current schema version — bump when adding migrations */
323
- static readonly SCHEMA_VERSION = 5;
329
+ static readonly SCHEMA_VERSION = 6;
324
330
 
325
331
  private getSchemaVersion(): number {
326
332
  try {
@@ -452,29 +458,49 @@ export class FactStore {
452
458
  this.setSchemaVersion(4);
453
459
  }
454
460
 
455
- // Migration 4→5: schema alignment + jj change ID provenance tracking
461
+ // Migration 4→5: full Rust↔TS schema alignment
456
462
  //
457
- // Two concerns:
458
- // 1. DBs created by the Rust omegon binary at schema v4 are missing columns
459
- // that the TS schema has always had: created_session, superseded_at,
460
- // archived_at (facts), session_id (episodes), and related tables
461
- // (episode_facts, episodes_vec). These must be added idempotently.
462
- // 2. jj_change_id column for provenance tracking (new in v5).
463
+ // The Rust omegon-memory crate owns the canonical schema but may produce
464
+ // DBs missing columns/tables that TS requires. This migration idempotently
465
+ // brings ANY DB (Rust v4, Rust v5, or TS-created) to a state where all
466
+ // TS code paths work. Every ALTER TABLE is wrapped in try/catch because
467
+ // the column may already exist.
463
468
  if (current < 5) {
464
- // Add columns that may be missing from Rust-created DBs.
465
- // Each ALTER TABLE is wrapped in try/catch "duplicate column" is expected
466
- // for DBs created by TS (which always had these columns).
467
- const addColumnIfMissing = (table: string, col: string, type: string = "TEXT") => {
468
- try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`).run(); } catch { /* exists */ }
469
+ const addCol = (table: string, col: string, typedef: string = "TEXT") => {
470
+ try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${typedef}`).run(); } catch { /* exists */ }
469
471
  };
470
- addColumnIfMissing("facts", "created_session");
471
- addColumnIfMissing("facts", "superseded_at");
472
- addColumnIfMissing("facts", "archived_at");
473
- addColumnIfMissing("facts", "jj_change_id");
474
- addColumnIfMissing("episodes", "session_id");
475
- addColumnIfMissing("episodes", "jj_change_id");
476
-
477
- // Create tables that may not exist in Rust-created DBs
472
+
473
+ // --- facts table ---
474
+ addCol("facts", "created_session");
475
+ addCol("facts", "superseded_at");
476
+ addCol("facts", "archived_at");
477
+ addCol("facts", "jj_change_id");
478
+
479
+ // --- edges table ---
480
+ // Rust edges is minimal: id, source_fact_id, target_fact_id, relation,
481
+ // description, weight, created_at. TS needs these additional columns:
482
+ addCol("edges", "confidence", "REAL NOT NULL DEFAULT 1.0");
483
+ addCol("edges", "last_reinforced", "TEXT NOT NULL DEFAULT ''");
484
+ addCol("edges", "reinforcement_count", "INTEGER NOT NULL DEFAULT 1");
485
+ addCol("edges", "decay_rate", `REAL NOT NULL DEFAULT ${DECAY.baseRate}`);
486
+ addCol("edges", "status", "TEXT NOT NULL DEFAULT 'active'");
487
+ addCol("edges", "created_session");
488
+ addCol("edges", "source_mind");
489
+ addCol("edges", "target_mind");
490
+
491
+ // --- minds table ---
492
+ // Rust minds is minimal: name, description, status, origin_type, created_at.
493
+ addCol("minds", "origin_path");
494
+ addCol("minds", "origin_url");
495
+ addCol("minds", "readonly", "INTEGER NOT NULL DEFAULT 0");
496
+ addCol("minds", "parent");
497
+ addCol("minds", "last_sync");
498
+
499
+ // --- episodes table ---
500
+ addCol("episodes", "session_id");
501
+ addCol("episodes", "jj_change_id");
502
+
503
+ // --- tables that may not exist in Rust-created DBs ---
478
504
  this.db.exec(`
479
505
  CREATE TABLE IF NOT EXISTS episode_facts (
480
506
  episode_id TEXT NOT NULL,
@@ -493,9 +519,27 @@ export class FactStore {
493
519
  );
494
520
  `);
495
521
 
496
- // Indexes on these columns are created by ensureIndexes() after all migrations.
522
+ // Indexes on migration-added columns are created by ensureIndexes().
497
523
  this.setSchemaVersion(5);
498
524
  }
525
+
526
+ // Migration 5→6: Persona system
527
+ //
528
+ // Adds persona ownership and memory layering to facts:
529
+ // persona_id — which persona owns the fact (NULL = project-level)
530
+ // layer — memory layer: 'project' | 'persona' | 'working'
531
+ // tags — JSON array of domain tags (e.g. ["pcb","thermal"])
532
+ // All additive. Existing data reads cleanly with defaults.
533
+ if (current < 6) {
534
+ const addCol = (table: string, col: string, typedef: string = "TEXT") => {
535
+ try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${typedef}`).run(); } catch { /* exists */ }
536
+ };
537
+ addCol("facts", "persona_id");
538
+ addCol("facts", "layer", "TEXT NOT NULL DEFAULT 'project'");
539
+ addCol("facts", "tags");
540
+ // Indexes created by ensureIndexes().
541
+ this.setSchemaVersion(6);
542
+ }
499
543
  }
500
544
 
501
545
  private initSchema(): void {
@@ -578,12 +622,8 @@ export class FactStore {
578
622
  FOREIGN KEY (target_fact_id) REFERENCES facts(id) ON DELETE CASCADE
579
623
  );
580
624
 
581
- CREATE INDEX IF NOT EXISTS idx_edges_source
582
- ON edges(source_fact_id) WHERE status = 'active';
583
- CREATE INDEX IF NOT EXISTS idx_edges_target
584
- ON edges(target_fact_id) WHERE status = 'active';
585
- CREATE INDEX IF NOT EXISTS idx_edges_relation
586
- ON edges(relation) WHERE status = 'active';
625
+ -- Edge indexes that reference 'status' are created in ensureIndexes()
626
+ -- after migrations add the status column (missing in Rust-created DBs).
587
627
  `);
588
628
 
589
629
  // FTS5 virtual table for full-text search
@@ -642,17 +682,30 @@ export class FactStore {
642
682
  }
643
683
  }
644
684
 
685
+ /**
686
+ * Create indexes on columns that may not exist until after migrations.
687
+ * Called AFTER runMigrations() so Rust-created DBs have had their
688
+ * missing columns added before we try to index them.
689
+ */
645
690
  /**
646
691
  * Create indexes on columns that may not exist until after migrations.
647
692
  * Called AFTER runMigrations() so Rust-created DBs have had their
648
693
  * missing columns added before we try to index them.
649
694
  */
650
695
  private ensureIndexes(): void {
651
- const safeIndex = (sql: string) => {
652
- try { this.db.prepare(sql).run(); } catch { /* column or index issue non-fatal */ }
696
+ const idx = (sql: string) => {
697
+ try { this.db.prepare(sql).run(); } catch { /* column or index already exists */ }
653
698
  };
654
- safeIndex(`CREATE INDEX IF NOT EXISTS idx_facts_session ON facts(created_session)`);
655
- safeIndex(`CREATE INDEX IF NOT EXISTS idx_facts_version ON facts(version DESC)`);
699
+ // facts indexes on migration-added columns
700
+ idx(`CREATE INDEX IF NOT EXISTS idx_facts_session ON facts(created_session)`);
701
+ idx(`CREATE INDEX IF NOT EXISTS idx_facts_version ON facts(version DESC)`);
702
+ // edges indexes that reference 'status' (missing in Rust-created DBs before migration)
703
+ idx(`CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_fact_id) WHERE status = 'active'`);
704
+ idx(`CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_fact_id) WHERE status = 'active'`);
705
+ idx(`CREATE INDEX IF NOT EXISTS idx_edges_relation ON edges(relation) WHERE status = 'active'`);
706
+ // v6: persona system indexes
707
+ idx(`CREATE INDEX IF NOT EXISTS idx_facts_persona ON facts(persona_id) WHERE persona_id IS NOT NULL`);
708
+ idx(`CREATE INDEX IF NOT EXISTS idx_facts_layer ON facts(mind, layer) WHERE status = 'active'`);
656
709
  }
657
710
 
658
711
  // ---------------------------------------------------------------------------
@@ -0,0 +1,15 @@
1
+ {
2
+ "description": "Canonical memory DB schema. Generated from Rust omegon-memory. Do not edit — regenerate with: cargo test -p omegon-memory schema_contract_generate -- --ignored",
3
+ "schema_version": 6,
4
+ "tables": {
5
+ "edges": ["id", "source_fact_id", "target_fact_id", "relation", "description", "confidence", "last_reinforced", "reinforcement_count", "decay_rate", "status", "created_at", "created_session", "source_mind", "target_mind"],
6
+ "embedding_metadata": ["model_name", "dims", "inserted_at"],
7
+ "episode_facts": ["episode_id", "fact_id"],
8
+ "episodes": ["id", "mind", "title", "narrative", "date", "session_id", "created_at", "jj_change_id"],
9
+ "episodes_vec": ["episode_id", "embedding", "model_name", "dims", "created_at"],
10
+ "facts": ["id", "mind", "section", "content", "status", "created_at", "created_session", "supersedes", "superseded_at", "archived_at", "source", "content_hash", "confidence", "last_reinforced", "reinforcement_count", "decay_rate", "decay_profile", "version", "last_accessed", "jj_change_id", "persona_id", "layer", "tags"],
11
+ "facts_vec": ["fact_id", "embedding", "model_name", "dims", "created_at"],
12
+ "minds": ["name", "description", "status", "origin_type", "origin_path", "origin_url", "readonly", "parent", "created_at", "last_sync"],
13
+ "schema_version": ["version", "applied_at"]
14
+ }
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omegon-pi",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "description": "Omegon — an opinionated distribution of pi (by Mario Zechner) with extensions for lifecycle management, memory, orchestration, and visualization",
5
5
  "bin": {
6
6
  "omegon-pi": "bin/omegon-pi.mjs",
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * validate-schema-contract.mjs
4
+ *
5
+ * Validates that the TS factstore migration produces a schema that is a
6
+ * superset of the Rust omegon-memory schema contract.
7
+ *
8
+ * Usage: node scripts/validate-schema-contract.mjs <path-to-schema-contract.json>
9
+ *
10
+ * The script:
11
+ * 1. Creates a minimal Rust-shaped DB at the contract's stated schema_version
12
+ * (simulating what the Rust binary would create)
13
+ * 2. Opens it with FactStore (triggering TS migrations)
14
+ * 3. Verifies every table and column from the contract exists in the result
15
+ *
16
+ * Exit 0 = TS is compatible. Exit 1 = drift detected.
17
+ */
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { tmpdir } from "node:os";
23
+ import Database from "better-sqlite3";
24
+
25
+ const contractPath = process.argv[2];
26
+ if (!contractPath) {
27
+ console.error("Usage: node validate-schema-contract.mjs <schema-contract.json>");
28
+ process.exit(1);
29
+ }
30
+
31
+ const contract = JSON.parse(readFileSync(contractPath, "utf-8"));
32
+ console.log(`Schema contract: version ${contract.schema_version}, ${Object.keys(contract.tables).length} tables`);
33
+
34
+ // Step 1: Create a DB with ONLY the Rust contract's tables and columns.
35
+ // This simulates what the latest Rust binary would produce.
36
+ const dir = mkdtempSync(join(tmpdir(), "schema-validate-"));
37
+ const dbPath = join(dir, "facts.db");
38
+ const db = new Database(dbPath);
39
+ db.pragma("journal_mode = WAL");
40
+
41
+ for (const [table, columns] of Object.entries(contract.tables)) {
42
+ // Skip FTS virtual tables — they're created by TS initSchema
43
+ if (table.endsWith("_fts")) continue;
44
+
45
+ const colDefs = columns.map((col, i) => {
46
+ // First column is PRIMARY KEY
47
+ if (i === 0) return `${col} TEXT PRIMARY KEY`;
48
+ // embedding columns are BLOB
49
+ if (col === "embedding") return `${col} BLOB`;
50
+ // integer columns
51
+ if (["dims", "reinforcement_count", "readonly"].includes(col)) return `${col} INTEGER NOT NULL DEFAULT 0`;
52
+ // real columns
53
+ if (["confidence", "decay_rate"].includes(col)) return `${col} REAL NOT NULL DEFAULT 0`;
54
+ // version column
55
+ if (col === "version" && table === "facts") return `${col} INTEGER NOT NULL DEFAULT 0`;
56
+ if (col === "version" && table === "schema_version") return `${col} INTEGER PRIMARY KEY`;
57
+ // everything else is TEXT
58
+ return `${col} TEXT`;
59
+ });
60
+
61
+ // episode_facts has composite PK
62
+ if (table === "episode_facts") {
63
+ const cols = columns.map(c => `${c} TEXT NOT NULL`).join(", ");
64
+ db.exec(`CREATE TABLE IF NOT EXISTS ${table} (${cols}, PRIMARY KEY (${columns.join(", ")}))`);
65
+ } else {
66
+ db.exec(`CREATE TABLE IF NOT EXISTS ${table} (${colDefs.join(", ")})`);
67
+ }
68
+ }
69
+
70
+ // Insert schema version
71
+ db.exec(`INSERT OR REPLACE INTO schema_version (version, applied_at) VALUES (${contract.schema_version}, datetime('now'))`);
72
+
73
+ // Insert default mind (required by TS)
74
+ try {
75
+ db.exec(`INSERT INTO minds (name, created_at) VALUES ('default', datetime('now'))`);
76
+ } catch { /* might already exist */ }
77
+
78
+ db.close();
79
+ console.log(`Created Rust-shaped DB at ${dbPath}`);
80
+
81
+ // Step 2: Open with TS FactStore — this runs migrations
82
+ const { FactStore } = await import("../extensions/project-memory/factstore.ts");
83
+ let store;
84
+ try {
85
+ store = new FactStore(dir);
86
+ console.log("✓ FactStore opened Rust-shaped DB successfully");
87
+ } catch (e) {
88
+ console.error(`✗ FactStore FAILED to open Rust-shaped DB: ${e.message}`);
89
+ rmSync(dir, { recursive: true, force: true });
90
+ process.exit(1);
91
+ }
92
+
93
+ // Step 3: Verify every contract table+column exists after migration
94
+ const verifyDb = store.db ?? (store)._db ?? (() => { throw new Error("Cannot access DB handle"); })();
95
+ let errors = 0;
96
+
97
+ for (const [table, expectedCols] of Object.entries(contract.tables)) {
98
+ if (table.endsWith("_fts")) continue;
99
+
100
+ let actualCols;
101
+ try {
102
+ actualCols = verifyDb.prepare(`PRAGMA table_info(${table})`).all().map(r => r.name);
103
+ } catch {
104
+ console.error(`✗ Table '${table}' does not exist after TS migration`);
105
+ errors++;
106
+ continue;
107
+ }
108
+
109
+ for (const col of expectedCols) {
110
+ if (!actualCols.includes(col)) {
111
+ console.error(`✗ Column '${table}.${col}' required by Rust contract but missing after TS migration`);
112
+ errors++;
113
+ }
114
+ }
115
+ }
116
+
117
+ store.close();
118
+ rmSync(dir, { recursive: true, force: true });
119
+
120
+ if (errors > 0) {
121
+ console.error(`\n${errors} schema drift error(s) detected. Update the TS v5 migration in factstore.ts.`);
122
+ process.exit(1);
123
+ } else {
124
+ console.log(`✓ All ${Object.keys(contract.tables).length} tables and columns verified — TS is compatible with Rust schema v${contract.schema_version}`);
125
+ }