Selaa lähdekoodia

feat(memory): add SQLite temporal memory database (#4)

Uses Node.js built-in node:sqlite (Node 22+, zero dependencies).

Tables:
- facts: temporal facts with valid_from/valid_until chapter numbers
  Enables queries like "what did character X know in chapter 5?"
- chapter_summaries: indexed by chapter, searchable by character name

Backward compatible — markdown truth files remain the primary persistence.
MemoryDB is an acceleration index for context assembly.
Ma 7 kuukautta sitten
vanhempi
commit
cb9dd181c6
2 muutettua tiedostoa jossa 193 lisäystä ja 0 poistoa
  1. 1 0
      packages/core/src/index.ts
  2. 192 0
      packages/core/src/state/memory-db.ts

+ 1 - 0
packages/core/src/index.ts

@@ -39,6 +39,7 @@ export { buildFanficCanonSection, buildCharacterVoiceProfiles, buildFanficModeIn
 export { fetchUrl, searchWeb } from "./utils/web-search.js";
 export { fetchUrl, searchWeb } from "./utils/web-search.js";
 export { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "./utils/context-filter.js";
 export { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "./utils/context-filter.js";
 export { ConsolidatorAgent } from "./agents/consolidator.js";
 export { ConsolidatorAgent } from "./agents/consolidator.js";
+export { MemoryDB, type Fact, type StoredSummary } from "./state/memory-db.js";
 export { StateValidatorAgent } from "./agents/state-validator.js";
 export { StateValidatorAgent } from "./agents/state-validator.js";
 export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
 export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
 export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js";
 export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js";

+ 192 - 0
packages/core/src/state/memory-db.ts

@@ -0,0 +1,192 @@
+/**
+ * Temporal memory database for InkOS truth files.
+ *
+ * Uses Node.js built-in SQLite (node:sqlite, Node 22+).
+ * Stores facts with temporal validity (valid_from/valid_until chapter numbers),
+ * enabling precise queries like "what did character X know in chapter 5?"
+ *
+ * Backward compatible: existing markdown truth files are still the primary
+ * persistence layer. MemoryDB is an acceleration index built alongside them.
+ */
+
+import { DatabaseSync } from "node:sqlite";
+import { join } from "node:path";
+
+export interface Fact {
+  readonly id?: number;
+  readonly subject: string;
+  readonly predicate: string;
+  readonly object: string;
+  readonly validFromChapter: number;
+  readonly validUntilChapter: number | null;
+  readonly sourceChapter: number;
+}
+
+export interface StoredSummary {
+  readonly chapter: number;
+  readonly title: string;
+  readonly characters: string;
+  readonly events: string;
+  readonly stateChanges: string;
+  readonly hookActivity: string;
+  readonly mood: string;
+  readonly chapterType: string;
+}
+
+export class MemoryDB {
+  private db: InstanceType<typeof DatabaseSync>;
+
+  constructor(bookDir: string) {
+    const dbPath = join(bookDir, "story", "memory.db");
+    this.db = new DatabaseSync(dbPath);
+    this.db.exec("PRAGMA journal_mode = WAL");
+    this.migrate();
+  }
+
+  private migrate(): void {
+    this.db.exec(`
+      CREATE TABLE IF NOT EXISTS facts (
+        id INTEGER PRIMARY KEY AUTOINCREMENT,
+        subject TEXT NOT NULL,
+        predicate TEXT NOT NULL,
+        object TEXT NOT NULL,
+        valid_from_chapter INTEGER NOT NULL,
+        valid_until_chapter INTEGER,
+        source_chapter INTEGER NOT NULL,
+        created_at TEXT NOT NULL DEFAULT (datetime('now'))
+      );
+
+      CREATE TABLE IF NOT EXISTS chapter_summaries (
+        chapter INTEGER PRIMARY KEY,
+        title TEXT NOT NULL,
+        characters TEXT NOT NULL DEFAULT '',
+        events TEXT NOT NULL DEFAULT '',
+        state_changes TEXT NOT NULL DEFAULT '',
+        hook_activity TEXT NOT NULL DEFAULT '',
+        mood TEXT NOT NULL DEFAULT '',
+        chapter_type TEXT NOT NULL DEFAULT ''
+      );
+
+      CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject);
+      CREATE INDEX IF NOT EXISTS idx_facts_valid ON facts(valid_from_chapter, valid_until_chapter);
+      CREATE INDEX IF NOT EXISTS idx_facts_source ON facts(source_chapter);
+    `);
+  }
+
+  // ---------------------------------------------------------------------------
+  // Facts (temporal)
+  // ---------------------------------------------------------------------------
+
+  /** Add a new fact. */
+  addFact(fact: Omit<Fact, "id">): number {
+    const stmt = this.db.prepare(
+      `INSERT INTO facts (subject, predicate, object, valid_from_chapter, valid_until_chapter, source_chapter)
+       VALUES (?, ?, ?, ?, ?, ?)`,
+    );
+    const result = stmt.run(
+      fact.subject, fact.predicate, fact.object,
+      fact.validFromChapter, fact.validUntilChapter ?? null, fact.sourceChapter,
+    );
+    return Number(result.lastInsertRowid);
+  }
+
+  /** Invalidate a fact (set valid_until). */
+  invalidateFact(id: number, untilChapter: number): void {
+    this.db.prepare(
+      "UPDATE facts SET valid_until_chapter = ? WHERE id = ?",
+    ).run(untilChapter, id);
+  }
+
+  /** Get all currently valid facts (valid_until is null). */
+  getCurrentFacts(): ReadonlyArray<Fact> {
+    return this.db.prepare(
+      "SELECT * FROM facts WHERE valid_until_chapter IS NULL ORDER BY subject, predicate",
+    ).all() as unknown as Fact[];
+  }
+
+  /** Get facts about a specific subject that are valid at a given chapter. */
+  getFactsAt(subject: string, chapter: number): ReadonlyArray<Fact> {
+    return this.db.prepare(
+      `SELECT * FROM facts
+       WHERE subject = ? AND valid_from_chapter <= ?
+       AND (valid_until_chapter IS NULL OR valid_until_chapter > ?)
+       ORDER BY predicate`,
+    ).all(subject, chapter, chapter) as unknown as Fact[];
+  }
+
+  /** Get all facts about a subject (including historical). */
+  getFactHistory(subject: string): ReadonlyArray<Fact> {
+    return this.db.prepare(
+      "SELECT * FROM facts WHERE subject = ? ORDER BY valid_from_chapter",
+    ).all(subject) as unknown as Fact[];
+  }
+
+  /** Search facts by predicate (e.g., all "location" facts). */
+  getFactsByPredicate(predicate: string): ReadonlyArray<Fact> {
+    return this.db.prepare(
+      "SELECT * FROM facts WHERE predicate = ? AND valid_until_chapter IS NULL ORDER BY subject",
+    ).all(predicate) as unknown as Fact[];
+  }
+
+  /** Get facts relevant to a set of character names. */
+  getFactsForCharacters(names: ReadonlyArray<string>): ReadonlyArray<Fact> {
+    if (names.length === 0) return [];
+    const placeholders = names.map(() => "?").join(",");
+    return this.db.prepare(
+      `SELECT * FROM facts WHERE subject IN (${placeholders}) AND valid_until_chapter IS NULL ORDER BY subject, predicate`,
+    ).all(...names) as unknown as Fact[];
+  }
+
+  // ---------------------------------------------------------------------------
+  // Chapter summaries
+  // ---------------------------------------------------------------------------
+
+  /** Upsert a chapter summary. */
+  upsertSummary(summary: StoredSummary): void {
+    this.db.prepare(
+      `INSERT OR REPLACE INTO chapter_summaries (chapter, title, characters, events, state_changes, hook_activity, mood, chapter_type)
+       VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+    ).run(
+      summary.chapter, summary.title, summary.characters, summary.events,
+      summary.stateChanges, summary.hookActivity, summary.mood, summary.chapterType,
+    );
+  }
+
+  /** Get summaries for a range of chapters. */
+  getSummaries(fromChapter: number, toChapter: number): ReadonlyArray<StoredSummary> {
+    return this.db.prepare(
+      "SELECT * FROM chapter_summaries WHERE chapter >= ? AND chapter <= ? ORDER BY chapter",
+    ).all(fromChapter, toChapter) as unknown as StoredSummary[];
+  }
+
+  /** Get summaries matching any of the given character names. */
+  getSummariesByCharacters(names: ReadonlyArray<string>): ReadonlyArray<StoredSummary> {
+    if (names.length === 0) return [];
+    const conditions = names.map(() => "characters LIKE ?").join(" OR ");
+    const params = names.map((n) => `%${n}%`);
+    return this.db.prepare(
+      `SELECT * FROM chapter_summaries WHERE ${conditions} ORDER BY chapter`,
+    ).all(...params) as unknown as StoredSummary[];
+  }
+
+  /** Get total chapter count. */
+  getChapterCount(): number {
+    const row = this.db.prepare("SELECT COUNT(*) as count FROM chapter_summaries").get() as unknown as { count: number };
+    return row.count;
+  }
+
+  /** Get the most recent N summaries. */
+  getRecentSummaries(count: number): ReadonlyArray<StoredSummary> {
+    return this.db.prepare(
+      "SELECT * FROM chapter_summaries ORDER BY chapter DESC LIMIT ?",
+    ).all(count) as unknown as ReadonlyArray<StoredSummary>;
+  }
+
+  // ---------------------------------------------------------------------------
+  // Lifecycle
+  // ---------------------------------------------------------------------------
+
+  close(): void {
+    this.db.close();
+  }
+}