@jeffober/thatch 0.1.28 → 0.1.29

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.
Files changed (60) hide show
  1. package/README.md +24 -10
  2. package/artifacts/skills/thatch-code-review.md +9 -7
  3. package/artifacts/skills/thatch-review-economy.md +97 -0
  4. package/artifacts/skills/thatch-review-followup.md +1 -1
  5. package/artifacts/skills/thatch-review-synthesizer.md +15 -5
  6. package/bin/qa-run +38 -0
  7. package/bin/thatch +10 -5
  8. package/docs/dev/README.md +71 -18
  9. package/docs/dev/features/README.md +33 -0
  10. package/docs/dev/features/behavior-engine.md +271 -0
  11. package/docs/dev/features/cicd.md +89 -0
  12. package/docs/dev/features/cli.md +81 -0
  13. package/docs/dev/features/compaction-recovery.md +48 -0
  14. package/docs/dev/features/database.md +244 -0
  15. package/docs/dev/features/deduplication.md +102 -0
  16. package/docs/dev/features/extraction.md +253 -0
  17. package/docs/dev/features/hygiene.md +81 -0
  18. package/docs/dev/features/memory-store.md +154 -0
  19. package/docs/dev/features/multi-host.md +228 -0
  20. package/docs/dev/features/nudge-pipeline.md +282 -0
  21. package/docs/dev/features/prediction-engine.md +253 -0
  22. package/docs/dev/features/qa-system.md +205 -0
  23. package/docs/dev/features/session-lifecycle.md +110 -0
  24. package/docs/dev/features/setup.md +199 -0
  25. package/docs/dev/features/sideband.md +104 -0
  26. package/docs/dev/gotchas.md +3 -3
  27. package/docs/dev/mcp-parity.md +6 -4
  28. package/docs/dev/setup-and-hooks.md +4 -4
  29. package/docs/dev/skills.md +10 -8
  30. package/docs/plans/README.md +2 -2
  31. package/docs/user/README.md +58 -20
  32. package/docs/user/behavior-engine.md +85 -0
  33. package/docs/user/cli.md +98 -0
  34. package/docs/user/code-review.md +73 -0
  35. package/docs/user/deduplication.md +70 -0
  36. package/docs/user/default-behaviors.md +75 -0
  37. package/docs/user/extraction.md +74 -0
  38. package/docs/user/hygiene.md +76 -0
  39. package/docs/user/memory.md +81 -0
  40. package/docs/user/prediction-engine.md +87 -0
  41. package/docs/user/setup.md +110 -0
  42. package/docs/user/skills.md +97 -0
  43. package/package.json +2 -2
  44. package/src/behavior.ts +120 -0
  45. package/src/db.ts +160 -417
  46. package/src/embeddings.ts +0 -41
  47. package/src/extraction.ts +2 -2
  48. package/src/git.ts +3 -1
  49. package/src/index.ts +55 -9
  50. package/src/mcp.ts +30 -1
  51. package/src/prediction.ts +127 -0
  52. package/src/prompts.ts +135 -188
  53. package/src/scoring-engine.ts +374 -0
  54. package/src/seed-behaviors.ts +339 -0
  55. package/src/setup.ts +2 -6
  56. package/src/sideband.ts +36 -8
  57. package/src/skills.ts +1 -0
  58. package/src/tool-defs.ts +172 -25
  59. package/src/vector-math.ts +30 -0
  60. package/docs/qa/README.md +0 -175
package/src/db.ts CHANGED
@@ -1,4 +1,14 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { blobToVector, cosineSimilarity } from "./vector-math";
3
+ import {
4
+ PredictionEngine,
5
+ } from "./prediction";
6
+ import { BehaviorEngine } from "./behavior";
7
+ import { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine";
8
+
9
+ export { cosineSimilarity } from "./vector-math";
10
+ export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction } from "./prediction";
11
+ export type { BehaviorNudgeItem, BehaviorRow, ScoredBehavior } from "./behavior";
2
12
 
3
13
  export interface MemoryRow {
4
14
  slug: string;
@@ -34,46 +44,6 @@ export interface DedupCandidate {
34
44
  score: number;
35
45
  }
36
46
 
37
- export interface MatcherRow {
38
- id: string;
39
- store: string;
40
- description: string;
41
- embedding: Uint8Array | null;
42
- model: string | null;
43
- created_at: string;
44
- updated_at: string;
45
- }
46
-
47
- export interface PredictionRow {
48
- id: string;
49
- store: string;
50
- statement: string;
51
- rationale: string | null;
52
- confidence: number;
53
- confirm_count: number;
54
- disconfirm_count: number;
55
- created_at: string;
56
- updated_at: string;
57
- }
58
-
59
- export interface PredictionNudgeItem {
60
- confidence: number;
61
- evidence_count: number;
62
- matcher_description: string;
63
- statement: string;
64
- }
65
-
66
- export interface ScoredPrediction {
67
- matcher_id: string;
68
- matcher_description: string;
69
- prediction_id: string;
70
- statement: string;
71
- confidence: number;
72
- evidence_count: number;
73
- score: number;
74
- rationale: string | null;
75
- }
76
-
77
47
  /**
78
48
  * SQLite-backed store for thatch. All stores live in a single database
79
49
  * partitioned by a `store` column. Embeddings are raw Float32Array bytes
@@ -81,6 +51,8 @@ export interface ScoredPrediction {
81
51
  */
82
52
  export class ThatchDB {
83
53
  #db: Database;
54
+ #predictions: PredictionEngine;
55
+ #behaviors: BehaviorEngine;
84
56
 
85
57
  constructor(path: string) {
86
58
  this.#db = new Database(path, { create: true });
@@ -92,6 +64,8 @@ export class ThatchDB {
92
64
  // existing FK to stores(name), which was declarative but unenforced
93
65
  // before this branch.
94
66
  this.#db.run("PRAGMA foreign_keys = ON");
67
+ this.#predictions = new PredictionEngine(this.#db);
68
+ this.#behaviors = new BehaviorEngine(this.#db);
95
69
  this.#initSchema();
96
70
  }
97
71
 
@@ -187,6 +161,63 @@ export class ThatchDB {
187
161
  )
188
162
  `);
189
163
 
164
+ // Behavior engine: the LLM's codified self-discipline rules. Same
165
+ // shape as the prediction engine (matchers, behaviors, edges,
166
+ // provenance) but separate tables because the semantics differ.
167
+ // Predictions model what the USER wants; behaviors model what the
168
+ // LLM should do. The LLM grades its own behaviors (ham/spam), while
169
+ // the user is the ground truth for predictions.
170
+ this.#db.run(`
171
+ CREATE TABLE IF NOT EXISTS behavior_matchers (
172
+ id TEXT PRIMARY KEY,
173
+ store TEXT NOT NULL REFERENCES stores(name),
174
+ description TEXT NOT NULL,
175
+ embedding BLOB,
176
+ model TEXT,
177
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
178
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
179
+ )
180
+ `);
181
+
182
+ this.#db.run(`
183
+ CREATE TABLE IF NOT EXISTS behaviors (
184
+ id TEXT PRIMARY KEY,
185
+ store TEXT NOT NULL REFERENCES stores(name),
186
+ statement TEXT NOT NULL,
187
+ rationale TEXT,
188
+ embedding BLOB,
189
+ model TEXT,
190
+ confidence REAL NOT NULL DEFAULT 0.5,
191
+ confirm_count REAL NOT NULL DEFAULT 0,
192
+ disconfirm_count REAL NOT NULL DEFAULT 0,
193
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
194
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
195
+ )
196
+ `);
197
+
198
+ this.#db.run(`
199
+ CREATE TABLE IF NOT EXISTS behavior_edges (
200
+ matcher_id TEXT NOT NULL,
201
+ behavior_id TEXT NOT NULL,
202
+ weight REAL NOT NULL DEFAULT 1.0,
203
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
204
+ PRIMARY KEY (matcher_id, behavior_id),
205
+ FOREIGN KEY (matcher_id) REFERENCES behavior_matchers(id) ON DELETE CASCADE,
206
+ FOREIGN KEY (behavior_id) REFERENCES behaviors(id) ON DELETE CASCADE
207
+ )
208
+ `);
209
+
210
+ this.#db.run(`
211
+ CREATE TABLE IF NOT EXISTS behavior_provenance (
212
+ id TEXT PRIMARY KEY,
213
+ behavior_id TEXT NOT NULL,
214
+ signal TEXT NOT NULL,
215
+ detail TEXT,
216
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
217
+ FOREIGN KEY (behavior_id) REFERENCES behaviors(id) ON DELETE CASCADE
218
+ )
219
+ `);
220
+
190
221
  this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES ('global')");
191
222
 
192
223
  this.#migrateColumns();
@@ -554,380 +585,127 @@ export class ThatchDB {
554
585
  }
555
586
 
556
587
  // ---------------------------------------------------------------------------
557
- // Prediction engine: matchers, predictions, edges, provenance
588
+ // Prediction engine: delegates to PredictionEngine
558
589
  // ---------------------------------------------------------------------------
559
590
 
560
- // Bayesian confidence model constants. The posterior is:
561
- // p = (confirm_count + K * P0) / (confirm_count + disconfirm_count + K)
562
- // K is the prior strength (pseudo-evidence count): K=5 means 5
563
- // pseudo-evidence "anchors" the prior. P0 is the prior probability
564
- // (p=0.5 = "no preference either way"). W_SOFT is the fractional
565
- // weight for a soft (weak) signal: 0.25 means a soft disconfirm counts
566
- // as 1/4 of a full disconfirm.
567
- static readonly PREDICTION_K = 5;
568
- static readonly PREDICTION_P0 = 0.5;
569
- static readonly PREDICTION_W_SOFT = 0.25;
591
+ static readonly PREDICTION_K = PREDICTION_K;
592
+ static readonly PREDICTION_P0 = PREDICTION_P0;
593
+ static readonly PREDICTION_W_SOFT = PREDICTION_W_SOFT;
570
594
 
571
- /**
572
- * Brute-force cosine search over the matchers table for auto-fire.
573
- * Returns matchers ranked by similarity to the query embedding,
574
- * filtered by a noise floor (cosine >= 0.01) to exclude near-zero
575
- * and negative scores, and by model-space compatibility (dimension
576
- * match). Callers apply the actual relevance threshold (0.45 for
577
- * auto-fire in index.ts, caller-specified for sideband).
578
- */
579
- findMatchers(
580
- stores: string[],
581
- queryEmbedding: Float32Array,
582
- opts?: { limit?: number },
583
- ): { id: string; description: string; score: number }[] {
584
- if (stores.length === 0) return [];
585
- const limit = opts?.limit ?? 5;
586
- const placeholders = stores.map(() => "?").join(", ");
587
- const rows = this.#db
588
- .query(
589
- `SELECT id, description, embedding FROM prediction_matchers
590
- WHERE store IN (${placeholders}) AND embedding IS NOT NULL`,
591
- )
592
- .all(...(stores as [string, ...string[]])) as any[];
595
+ findMatchers(stores: string[], queryEmbedding: Float32Array, opts?: { limit?: number }) {
596
+ return this.#predictions.findMatchers(stores, queryEmbedding, opts);
597
+ }
593
598
 
594
- const scored: { id: string; description: string; score: number }[] = [];
595
- for (const r of rows) {
596
- const emb = blobToVector(r.embedding);
597
- if (emb.length !== queryEmbedding.length) continue;
598
- const score = cosineSimilarity(queryEmbedding, emb);
599
- if (score >= 0.01) {
600
- scored.push({ id: r.id, description: r.description, score: Math.round(score * 1000) / 1000 });
601
- }
602
- }
603
- scored.sort((a, b) => b.score - a.score);
604
- return scored.slice(0, limit);
599
+ scorePredictions(matchers: { id: string; description: string; score: number }[]) {
600
+ return this.#predictions.scorePredictions(matchers);
605
601
  }
606
602
 
607
- /**
608
- * Follows edges from matchers to scored predictions. Returns all
609
- * predictions reachable from any matching matcher, ranked by
610
- * cosine * weight * confidence.
611
- */
612
- scorePredictions(
613
- matchers: { id: string; description: string; score: number }[],
614
- ): ScoredPrediction[] {
615
- if (matchers.length === 0) return [];
616
- const matcherIds = matchers.map((m) => m.id);
617
- const placeholders = matcherIds.map(() => "?").join(", ");
618
- const rows = this.#db
619
- .query(
620
- `SELECT p.id, p.statement, p.rationale, p.confidence, p.confirm_count, p.disconfirm_count,
621
- e.matcher_id, e.weight
622
- FROM prediction_edges e
623
- JOIN predictions p ON e.prediction_id = p.id
624
- WHERE e.matcher_id IN (${placeholders})
625
- ORDER BY p.statement`,
626
- )
627
- .all(...(matcherIds as [string, ...string[]])) as any[];
603
+ scorePredictionNudge(stores: string[], embedding: Float32Array, threshold: number, limit = 5) {
604
+ return this.#predictions.scorePredictionNudge(stores, embedding, threshold, limit);
605
+ }
628
606
 
629
- const matcherMap = new Map(matchers.map((m) => [m.id, m]));
630
- const scored: ScoredPrediction[] = [];
631
- for (const r of rows) {
632
- const matcher = matcherMap.get(r.matcher_id);
633
- if (!matcher) continue;
634
-
635
- const confidence = r.confidence as number;
636
- const evidence = Math.round(r.confirm_count + r.disconfirm_count);
637
- const score = matcher.score * (r.weight as number) * confidence;
638
- scored.push({
639
- matcher_id: r.matcher_id,
640
- matcher_description: matcher.description,
641
- prediction_id: r.id,
642
- statement: r.statement,
643
- confidence: Math.round(confidence * 1000) / 1000,
644
- evidence_count: evidence,
645
- score: Math.round(score * 1000) / 1000,
646
- rationale: r.rationale,
647
- });
648
- }
649
- scored.sort((a, b) => b.score - a.score);
650
-
651
- // Dedup by prediction_id: multiple matchers may link to the same
652
- // prediction via separate edges. Keep only the highest-scoring
653
- // entry per prediction so the nudge doesn't repeat the same
654
- // prediction with different matcher contexts.
655
- const seen = new Set<string>();
656
- return scored.filter((s) => {
657
- if (seen.has(s.prediction_id)) return false;
658
- seen.add(s.prediction_id);
659
- return true;
660
- });
607
+ findNearestMatcher(store: string, embedding: Float32Array, threshold: number) {
608
+ return this.#predictions.findNearestMatcher(store, embedding, threshold);
661
609
  }
662
610
 
663
- /**
664
- * Full scoring pipeline for auto-fire and sideband: findMatchers,
665
- * filter by threshold, scorePredictions (which includes dedup by
666
- * prediction_id), slice, and map to PredictionNudgeItem. Shared by
667
- * index.ts (auto-fire) and sideband.ts (MCP path) to prevent
668
- * scoring-logic drift between the two host paths.
669
- */
670
- scorePredictionNudge(
671
- stores: string[],
672
- embedding: Float32Array,
673
- threshold: number,
674
- limit = 5,
675
- ): PredictionNudgeItem[] {
676
- const matchers = this.findMatchers(stores, embedding, { limit })
677
- .filter((m) => m.score >= threshold);
678
- if (matchers.length === 0) return [];
679
- return this.scorePredictions(matchers)
680
- .slice(0, limit)
681
- .map((s) => ({
682
- confidence: s.confidence,
683
- evidence_count: s.evidence_count,
684
- matcher_description: s.matcher_description,
685
- statement: s.statement,
686
- }));
611
+ createMatcher(store: string, description: string, embedding: Float32Array, model: string) {
612
+ return this.#predictions.createMatcher(store, description, embedding, model);
687
613
  }
688
614
 
689
- /**
690
- * Finds the nearest matcher by cosine similarity above a caller-
691
- * specified threshold. Used for dedup at prediction_update time
692
- * (threshold 0.85), not auto-fire (use findMatchers for that).
693
- */
694
- findNearestMatcher(
695
- store: string,
696
- embedding: Float32Array,
697
- threshold: number,
698
- ): MatcherRow | null {
699
- const rows = this.#db
700
- .query("SELECT id, store, description, embedding, model, created_at, updated_at FROM prediction_matchers WHERE store = ? AND embedding IS NOT NULL")
701
- .all(store) as any[];
615
+ findNearestPrediction(store: string, embedding: Float32Array, threshold: number) {
616
+ return this.#predictions.findNearestPrediction(store, embedding, threshold);
617
+ }
702
618
 
703
- let best: { row: any; score: number } | null = null;
704
- for (const r of rows) {
705
- const emb = blobToVector(r.embedding);
706
- if (emb.length !== embedding.length) continue;
707
- const score = cosineSimilarity(embedding, emb);
708
- if (score >= threshold && (!best || score > best.score)) {
709
- best = { row: r, score };
710
- }
711
- }
712
- if (!best) return null;
713
- return best.row as MatcherRow;
619
+ createPrediction(store: string, statement: string, rationale: string, embedding: Float32Array, model: string) {
620
+ return this.#predictions.createPrediction(store, statement, rationale, embedding, model);
714
621
  }
715
622
 
716
- /** Creates a new matcher (context pattern) in the store, returning its id. */
717
- createMatcher(
718
- store: string,
719
- description: string,
720
- embedding: Float32Array,
721
- model: string,
722
- ): string {
723
- this.ensureStore(store);
724
- const id = crypto.randomUUID();
725
- const blob = new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
726
- const now = new Date().toISOString();
727
- this.#db.run(
728
- "INSERT INTO prediction_matchers (id, store, description, embedding, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
729
- [id, store, description, blob, model, now, now] as any,
730
- );
731
- return id;
623
+ createEdge(matcherId: string, predictionId: string, weight: number) {
624
+ return this.#predictions.createEdge(matcherId, predictionId, weight);
732
625
  }
733
626
 
734
- /**
735
- * Finds the nearest prediction by cosine similarity across the
736
- * entire store (not scoped to a single matcher's edges). This
737
- * prevents duplicate predictions: when a new matcher is created,
738
- * it can link to an existing prediction via a new edge rather
739
- * than creating a second row with the same statement.
740
- */
741
- findNearestPrediction(
742
- store: string,
743
- embedding: Float32Array,
744
- threshold: number,
745
- ): PredictionRow | null {
746
- const rows = this.#db
747
- .query(
748
- `SELECT id, store, statement, rationale, confidence, confirm_count, disconfirm_count, created_at, updated_at, embedding
749
- FROM predictions
750
- WHERE store = ? AND embedding IS NOT NULL`,
751
- )
752
- .all(store) as any[];
627
+ adjustConfidence(predictionId: string, signal: "confirm" | "disconfirm" | "soft") {
628
+ return this.#predictions.adjustConfidence(predictionId, signal);
629
+ }
753
630
 
754
- let best: { row: any; score: number } | null = null;
755
- for (const r of rows) {
756
- const emb = blobToVector(r.embedding);
757
- if (emb.length !== embedding.length) continue;
758
- const score = cosineSimilarity(embedding, emb);
759
- if (score >= threshold && (!best || score > best.score)) {
760
- best = { row: r, score };
761
- }
762
- }
763
- if (!best) return null;
764
- const { embedding: _, ...rest } = best.row;
765
- return rest as PredictionRow;
631
+ getPrediction(predictionId: string) {
632
+ return this.#predictions.getPrediction(predictionId);
766
633
  }
767
634
 
768
- /**
769
- * Creates a new prediction, returning its id. Confidence is seeded
770
- * at p0 (the population prior).
771
- */
772
- createPrediction(
773
- store: string,
774
- statement: string,
775
- rationale: string,
776
- embedding: Float32Array,
777
- model: string,
778
- ): string {
779
- this.ensureStore(store);
780
- const id = crypto.randomUUID();
781
- const blob = new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
782
- const now = new Date().toISOString();
783
- this.#db.run(
784
- `INSERT INTO predictions (id, store, statement, rationale, embedding, model, confidence, confirm_count, disconfirm_count, created_at, updated_at)
785
- VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`,
786
- [id, store, statement, rationale, blob, model, ThatchDB.PREDICTION_P0, now, now] as any,
787
- );
788
- return id;
635
+ addProvenance(predictionId: string, signal: string, detail: string) {
636
+ return this.#predictions.addProvenance(predictionId, signal, detail);
789
637
  }
790
638
 
791
- /** Ensures an edge links a matcher to a prediction. Does not overwrite existing edge weight. */
792
- createEdge(matcherId: string, predictionId: string, weight: number): void {
793
- const now = new Date().toISOString();
794
- this.#db.run(
795
- `INSERT INTO prediction_edges (matcher_id, prediction_id, weight, created_at)
796
- VALUES (?, ?, ?, ?)
797
- ON CONFLICT(matcher_id, prediction_id) DO NOTHING`,
798
- [matcherId, predictionId, weight, now] as any,
799
- );
639
+ getProvenance(predictionId: string) {
640
+ return this.#predictions.getProvenance(predictionId);
800
641
  }
801
642
 
802
- /**
803
- * Adjusts a prediction's confidence by applying a signal, then
804
- * recomputing the Bayesian posterior. Mirrors samskara's
805
- * samskara_apply_evaluation: discount prior evidence is NOT applied
806
- * in v1 (no wall-clock decay), so counts accumulate monotonically.
807
- *
808
- * Signal mapping: "confirm" adds 1 to confirm_count; "disconfirm"
809
- * adds 1 to disconfirm_count; "soft" adds W_SOFT (0.25) to
810
- * disconfirm_count. The asymmetry (soft is a weak disconfirm, not
811
- * a weak confirm) is intentional: a soft signal means the user
812
- * partially disagreed, not partially agreed. There is no soft
813
- * confirm; use "confirm" for weak agreement.
814
- */
815
- adjustConfidence(predictionId: string, signal: "confirm" | "disconfirm" | "soft"): void {
816
- const deltaConfirm = signal === "confirm" ? 1 : 0;
817
- const deltaDisconfirm = signal === "disconfirm" ? 1 : (signal === "soft" ? ThatchDB.PREDICTION_W_SOFT : 0);
818
- const k = ThatchDB.PREDICTION_K;
819
- const p0 = ThatchDB.PREDICTION_P0;
820
- const now = new Date().toISOString();
821
- // Atomic UPDATE: uses the OLD column values in the confidence
822
- // expression, so (confirm_count + deltaConfirm) equals the new
823
- // confirm_count. No read-modify-write race between connections.
824
- this.#db.run(
825
- `UPDATE predictions
826
- SET confirm_count = confirm_count + ?,
827
- disconfirm_count = disconfirm_count + ?,
828
- confidence = (confirm_count + ? + ? * ?) / (confirm_count + ? + disconfirm_count + ? + ?),
829
- updated_at = ?
830
- WHERE id = ?`,
831
- [deltaConfirm, deltaDisconfirm, deltaConfirm, k, p0, deltaConfirm, deltaDisconfirm, k, now, predictionId] as any,
832
- );
643
+ deletePrediction(predictionId: string) {
644
+ return this.#predictions.deletePrediction(predictionId);
833
645
  }
834
646
 
835
- /**
836
- * Returns the population prior p0 for a store. Falls back to
837
- * PREDICTION_P0 (0.5) when total evidence < 20; otherwise returns
838
- * confirm_count / total_evidence. v2 feature: not yet wired into
839
- * createPrediction, which uses flat PREDICTION_P0. v1 uses the
840
- * flat prior until enough evidence exists to derive a population rate.
841
- */
842
- populationP0(store: string): number {
843
- const row = this.#db
844
- .query(
845
- `SELECT SUM(confirm_count) AS c, SUM(disconfirm_count) AS d FROM predictions WHERE store = ?`,
846
- )
847
- .get(store) as { c: number | null; d: number | null } | null;
848
- const total = (row?.c ?? 0) + (row?.d ?? 0);
849
- if (total < 20) return ThatchDB.PREDICTION_P0;
850
- return row!.c! / total;
647
+ listPredictions(store: string) {
648
+ return this.#predictions.listPredictions(store);
851
649
  }
852
650
 
853
- /**
854
- * Returns a prediction's metadata by id. Does not return the
855
- * embedding/model columns (creation-only, used by findNearestPrediction
856
- * for dedup at write time, not needed for display or scoring).
857
- */
858
- getPrediction(predictionId: string): PredictionRow | null {
859
- const row = this.#db
860
- .query(
861
- "SELECT id, store, statement, rationale, confidence, confirm_count, disconfirm_count, created_at, updated_at FROM predictions WHERE id = ?",
862
- )
863
- .get(predictionId) as any;
864
- if (!row) return null;
865
- return row as PredictionRow;
651
+ // ---------------------------------------------------------------------------
652
+ // Behavior engine: delegates to BehaviorEngine
653
+ // ---------------------------------------------------------------------------
654
+
655
+ findBehaviorMatchers(stores: string[], queryEmbedding: Float32Array, opts?: { limit?: number }) {
656
+ return this.#behaviors.findBehaviorMatchers(stores, queryEmbedding, opts);
866
657
  }
867
658
 
868
- /**
869
- * Records a provenance entry (signal type + detail) for a prediction.
870
- * Provenance is an audit trail for the inspector and agent.
871
- */
872
- addProvenance(predictionId: string, signal: string, detail: string): void {
873
- const id = crypto.randomUUID();
874
- this.#db.run(
875
- "INSERT INTO prediction_provenance (id, prediction_id, signal, detail) VALUES (?, ?, ?, ?)",
876
- [id, predictionId, signal, detail] as any,
877
- );
659
+ scoreBehaviors(matchers: { id: string; description: string; score: number }[]) {
660
+ return this.#behaviors.scoreBehaviors(matchers);
878
661
  }
879
662
 
880
- /** Returns recent provenance entries for a prediction (newest first). */
881
- getProvenance(predictionId: string): { signal: string; detail: string | null; created_at: string }[] {
882
- return this.#db
883
- .query("SELECT signal, detail, created_at FROM prediction_provenance WHERE prediction_id = ? ORDER BY rowid DESC LIMIT 10")
884
- .all(predictionId) as any[];
885
- }
886
-
887
- /** Deletes a prediction. Edges and provenance cascade via FK ON DELETE CASCADE. */
888
- deletePrediction(predictionId: string): boolean {
889
- const result = this.#db.run("DELETE FROM predictions WHERE id = ?", [predictionId]);
890
- return result.changes > 0;
891
- }
892
-
893
- /** Lists all predictions with their matchers, for the inspector. */
894
- listPredictions(store: string): {
895
- id: string;
896
- statement: string;
897
- rationale: string | null;
898
- confidence: number;
899
- evidence_count: number;
900
- matchers: { id: string; description: string; weight: number }[];
901
- }[] {
902
- const predRows = this.#db
903
- .query(
904
- `SELECT id, statement, rationale, confidence, confirm_count, disconfirm_count
905
- FROM predictions WHERE store = ? ORDER BY confidence DESC`,
906
- )
907
- .all(store) as any[];
663
+ scoreBehaviorNudge(stores: string[], embedding: Float32Array, threshold: number, limit = 5) {
664
+ return this.#behaviors.scoreBehaviorNudge(stores, embedding, threshold, limit);
665
+ }
908
666
 
909
- return predRows.map((p) => {
910
- const edgeRows = this.#db
911
- .query(
912
- `SELECT e.matcher_id, e.weight, m.description
913
- FROM prediction_edges e
914
- JOIN prediction_matchers m ON e.matcher_id = m.id
915
- WHERE e.prediction_id = ?`,
916
- )
917
- .all(p.id) as any[];
918
- return {
919
- id: p.id,
920
- statement: p.statement,
921
- rationale: p.rationale,
922
- confidence: Math.round(p.confidence * 1000) / 1000,
923
- evidence_count: Math.round(p.confirm_count + p.disconfirm_count),
924
- matchers: edgeRows.map((e) => ({
925
- id: e.matcher_id,
926
- description: e.description,
927
- weight: e.weight,
928
- })),
929
- };
930
- });
667
+ findNearestBehaviorMatcher(store: string, embedding: Float32Array, threshold: number) {
668
+ return this.#behaviors.findNearestBehaviorMatcher(store, embedding, threshold);
669
+ }
670
+
671
+ createBehaviorMatcher(store: string, description: string, embedding: Float32Array, model: string) {
672
+ return this.#behaviors.createBehaviorMatcher(store, description, embedding, model);
673
+ }
674
+
675
+ findNearestBehavior(store: string, embedding: Float32Array, threshold: number) {
676
+ return this.#behaviors.findNearestBehavior(store, embedding, threshold);
677
+ }
678
+
679
+ createBehavior(store: string, statement: string, rationale: string, embedding: Float32Array, model: string) {
680
+ return this.#behaviors.createBehavior(store, statement, rationale, embedding, model);
681
+ }
682
+
683
+ createBehaviorEdge(matcherId: string, behaviorId: string, weight: number) {
684
+ return this.#behaviors.createBehaviorEdge(matcherId, behaviorId, weight);
685
+ }
686
+
687
+ adjustBehaviorConfidence(behaviorId: string, signal: "confirm" | "disconfirm" | "soft") {
688
+ return this.#behaviors.adjustBehaviorConfidence(behaviorId, signal);
689
+ }
690
+
691
+ getBehavior(behaviorId: string) {
692
+ return this.#behaviors.getBehavior(behaviorId);
693
+ }
694
+
695
+ addBehaviorProvenance(behaviorId: string, signal: string, detail: string) {
696
+ return this.#behaviors.addBehaviorProvenance(behaviorId, signal, detail);
697
+ }
698
+
699
+ getBehaviorProvenance(behaviorId: string) {
700
+ return this.#behaviors.getBehaviorProvenance(behaviorId);
701
+ }
702
+
703
+ deleteBehavior(behaviorId: string) {
704
+ return this.#behaviors.deleteBehavior(behaviorId);
705
+ }
706
+
707
+ listBehaviors(store: string) {
708
+ return this.#behaviors.listBehaviors(store);
931
709
  }
932
710
 
933
711
  // ---------------------------------------------------------------------------
@@ -985,38 +763,3 @@ export class ThatchDB {
985
763
  this.#db.close();
986
764
  }
987
765
  }
988
-
989
- // ---------------------------------------------------------------------------
990
- // Vector math
991
- // ---------------------------------------------------------------------------
992
-
993
- /**
994
- * Reconstructs a Float32Array from a stored BLOB. The Uint8Array bun:sqlite
995
- * hands back may itself be a view, so honor its offset and length rather than
996
- * reading the whole backing buffer.
997
- */
998
- function blobToVector(blob: Uint8Array): Float32Array {
999
- return new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
1000
- }
1001
-
1002
- /**
1003
- * Cosine similarity between two Float32Array vectors of equal dimension.
1004
- * Returns a value in [-1, 1]. Normalized embeddings will be close to [0, 1].
1005
- * Throws on dimension mismatch - comparing vectors from different embedding
1006
- * spaces is always a caller bug; callers filter by length first.
1007
- */
1008
- export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
1009
- if (a.length !== b.length) {
1010
- throw new Error(`cosineSimilarity: dimension mismatch (${a.length} vs ${b.length})`);
1011
- }
1012
- let dot = 0;
1013
- let magA = 0;
1014
- let magB = 0;
1015
- for (let i = 0; i < a.length; i++) {
1016
- dot += a[i] * b[i];
1017
- magA += a[i] * a[i];
1018
- magB += b[i] * b[i];
1019
- }
1020
- const denominator = Math.sqrt(magA) * Math.sqrt(magB);
1021
- return denominator === 0 ? 0 : dot / denominator;
1022
- }