diff --git a/package.json b/package.json index b113898..271d2f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@four-bytes/four-opencode-brain", - "version": "1.5.1", + "version": "1.5.0", "description": "Unified brain plugin — single SQLite DB for RAG search, memory, and knowledge base with content-hash dedup and per-session LRU cache", "license": "Apache-2.0", "type": "module", @@ -18,7 +18,7 @@ "four-bytes" ], "dependencies": { - "@four-bytes/opencode-plugin-lib": "github:four-bytes/opencode-plugin-lib#v0.2.0", + "@four-bytes/opencode-plugin-lib": "github:four-bytes/opencode-plugin-lib", "@opencode-ai/plugin": "1.16.2", "@opentui/core": "0.3.2", "@opentui/solid": "0.3.2", diff --git a/src/knowledge/store.ts b/src/knowledge/store.ts index a24808c..6236b2f 100644 --- a/src/knowledge/store.ts +++ b/src/knowledge/store.ts @@ -292,6 +292,12 @@ export function kbRecord(db: Database, input: KbRecordInput): KbOccurrence { throw new Error(`Entry not found: ${input.entry_key} (kind: ${input.kind})`); } + // Validate outcome + const VALID_OUTCOMES = ["fixed", "failed", "workaround", "observed"]; + if (!VALID_OUTCOMES.includes(input.outcome)) { + throw new Error(`Invalid outcome: "${input.outcome}". Must be one of: ${VALID_OUTCOMES.join(", ")}`); + } + const id = generateId(); db.run( `INSERT INTO knowledge_occurrences (id, entry_key, kind, project_ref, repo_ref, issue_ref, commit_ref, observed_symptoms, outcome) diff --git a/src/schema.ts b/src/schema.ts index 44030e0..92909d2 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -347,8 +347,8 @@ export function createSchema(db: Database): void { root_cause TEXT, canonical_solution TEXT, tags TEXT, - confidence REAL NOT NULL DEFAULT 0.0, - review_state TEXT NOT NULL DEFAULT 'draft', + confidence REAL NOT NULL DEFAULT 0.0 CHECK(confidence >= 0.0 AND confidence <= 1.0), + review_state TEXT NOT NULL DEFAULT 'draft' CHECK(review_state IN ('draft','reviewed','accepted','rejected','superseded')), superseded_by TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT, @@ -357,6 +357,10 @@ export function createSchema(db: Database): void { `); // ---- Migration: add entity_type CHECK for existing databases ----------- + migrateConfidenceCheck(db); + migrateReviewStateCheck(db); + migrateOccurrenceOutcomeCheck(db); + migrateKnowledgeFts(db); migrateEntityTypeCheck(db); db.exec(` @@ -369,7 +373,7 @@ export function createSchema(db: Database): void { issue_ref TEXT, commit_ref TEXT, observed_symptoms TEXT, - outcome TEXT NOT NULL, + outcome TEXT NOT NULL CHECK(outcome IN ('fixed','failed','workaround','observed')), occurred_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (entry_key, kind) REFERENCES knowledge_entries(entry_key, kind) ON DELETE CASCADE ) @@ -700,3 +704,195 @@ function migrateEntityTypeCheck(db: Database): void { } } } + +// ---- KB quality migration: confidence CHECK constraint ------------------ + +function migrateConfidenceCheck(db: Database): void { + for (let attempt = 0; attempt < 3; attempt++) { + try { + db.exec(` + CREATE TRIGGER IF NOT EXISTS knowledge_entries_check_confidence_bi + BEFORE INSERT ON knowledge_entries + BEGIN + SELECT CASE + WHEN NEW.confidence < 0.0 OR NEW.confidence > 1.0 + THEN RAISE(ABORT, 'Invalid confidence: ' || NEW.confidence || ' (must be 0.0-1.0)') + END; + END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS knowledge_entries_check_confidence_bu + BEFORE UPDATE ON knowledge_entries + BEGIN + SELECT CASE + WHEN NEW.confidence < 0.0 OR NEW.confidence > 1.0 + THEN RAISE(ABORT, 'Invalid confidence: ' || NEW.confidence || ' (must be 0.0-1.0)') + END; + END + `); + log("info", "schema", "Applied confidence CHECK migration via triggers"); + return; + } catch (err) { + if (attempt < 2) { + log("warn", "schema", `confidence migration attempt ${attempt + 1} failed (locked), retrying...`); + Bun.sleepSync(1000); + } else { + log("warn", "schema", `confidence CHECK migration deferred: ${String(err)}`); + } + } + } +} + +// ---- KB quality migration: review_state CHECK constraint ------------------ + +function migrateReviewStateCheck(db: Database): void { + for (let attempt = 0; attempt < 3; attempt++) { + try { + db.exec(` + CREATE TRIGGER IF NOT EXISTS knowledge_entries_check_review_state_bi + BEFORE INSERT ON knowledge_entries + BEGIN + SELECT CASE + WHEN NEW.review_state NOT IN ('draft','reviewed','accepted','rejected','superseded') + THEN RAISE(ABORT, 'Invalid review_state: ' || NEW.review_state) + END; + END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS knowledge_entries_check_review_state_bu + BEFORE UPDATE ON knowledge_entries + BEGIN + SELECT CASE + WHEN NEW.review_state NOT IN ('draft','reviewed','accepted','rejected','superseded') + THEN RAISE(ABORT, 'Invalid review_state: ' || NEW.review_state) + END; + END + `); + log("info", "schema", "Applied review_state CHECK migration via triggers"); + return; + } catch (err) { + if (attempt < 2) { + log("warn", "schema", `review_state migration attempt ${attempt + 1} failed (locked), retrying...`); + Bun.sleepSync(1000); + } else { + log("warn", "schema", `review_state CHECK migration deferred: ${String(err)}`); + } + } + } +} + +// ---- KB quality migration: occurrence outcome CHECK constraint ----------- + +function migrateOccurrenceOutcomeCheck(db: Database): void { + for (let attempt = 0; attempt < 3; attempt++) { + try { + db.exec(` + CREATE TRIGGER IF NOT EXISTS knowledge_occurrences_check_outcome_bi + BEFORE INSERT ON knowledge_occurrences + BEGIN + SELECT CASE + WHEN NEW.outcome NOT IN ('fixed','failed','workaround','observed') + THEN RAISE(ABORT, 'Invalid outcome: ' || NEW.outcome) + END; + END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS knowledge_occurrences_check_outcome_bu + BEFORE UPDATE ON knowledge_occurrences + BEGIN + SELECT CASE + WHEN NEW.outcome NOT IN ('fixed','failed','workaround','observed') + THEN RAISE(ABORT, 'Invalid outcome: ' || NEW.outcome) + END; + END + `); + log("info", "schema", "Applied occurrence outcome CHECK migration via triggers"); + return; + } catch (err) { + if (attempt < 2) { + log("warn", "schema", `outcome migration attempt ${attempt + 1} failed (locked), retrying...`); + Bun.sleepSync(1000); + } else { + log("warn", "schema", `outcome CHECK migration deferred: ${String(err)}`); + } + } + } +} + +// ---- KB quality migration: rebuild FTS with entry_key/kind/entity_type ----- + +function migrateKnowledgeFts(db: Database): void { + for (let attempt = 0; attempt < 3; attempt++) { + try { + // Check if FTS already has the new columns + const ftsInfo = db.query<{ name: string }, []>( + "SELECT name FROM pragma_table_info('entries_fts') WHERE name = 'entry_key'" + ).get(); + if (ftsInfo) { + log("debug", "schema", "FTS already has entry_key column — skipping rebuild"); + return; + } + + // Drop old FTS (non-destructive — content table untouched) + db.run("DROP TABLE IF EXISTS entries_fts"); + + // Recreate with full column coverage + db.exec(` + CREATE VIRTUAL TABLE entries_fts USING fts5( + entry_key, + kind, + entity_type, + title, + description, + root_cause, + canonical_solution, + tags, + content='knowledge_entries', + content_rowid='rowid' + ) + `); + + // Rebuild FTS index from existing data + db.run("INSERT INTO entries_fts(entries_fts) VALUES('rebuild')"); + log("info", "schema", "Rebuilt knowledge FTS with entry_key, kind, entity_type columns"); + + // Recreate FTS sync triggers with full column coverage + db.exec("DROP TRIGGER IF EXISTS entries_ai"); + db.exec("DROP TRIGGER IF EXISTS entries_ad"); + db.exec("DROP TRIGGER IF EXISTS entries_au"); + + db.exec(` + CREATE TRIGGER entries_ai AFTER INSERT ON knowledge_entries BEGIN + INSERT INTO entries_fts(rowid, entry_key, kind, entity_type, title, description, root_cause, canonical_solution, tags) + VALUES (new.rowid, new.entry_key, new.kind, new.entity_type, new.title, new.description, new.root_cause, new.canonical_solution, new.tags); + END + `); + + db.exec(` + CREATE TRIGGER entries_ad AFTER DELETE ON knowledge_entries BEGIN + INSERT INTO entries_fts(entries_fts, rowid, entry_key, kind, entity_type, title, description, root_cause, canonical_solution, tags) + VALUES ('delete', old.rowid, old.entry_key, old.kind, old.entity_type, old.title, old.description, old.root_cause, old.canonical_solution, old.tags); + END + `); + + db.exec(` + CREATE TRIGGER entries_au AFTER UPDATE ON knowledge_entries BEGIN + INSERT INTO entries_fts(entries_fts, rowid, entry_key, kind, entity_type, title, description, root_cause, canonical_solution, tags) + VALUES ('delete', old.rowid, old.entry_key, old.kind, old.entity_type, old.title, old.description, old.root_cause, old.canonical_solution, old.tags); + INSERT INTO entries_fts(rowid, entry_key, kind, entity_type, title, description, root_cause, canonical_solution, tags) + VALUES (new.rowid, new.entry_key, new.kind, new.entity_type, new.title, new.description, new.root_cause, new.canonical_solution, new.tags); + END + `); + + log("info", "schema", "Recreated FTS sync triggers with full 8-column coverage"); + return; + } catch (err) { + if (attempt < 2) { + log("warn", "schema", `FTS migration attempt ${attempt + 1} failed (locked), retrying...`); + Bun.sleepSync(1000); + } else { + log("warn", "schema", `FTS migration deferred: ${String(err)}`); + } + } + } +}