@jeffober/thatch 0.1.18 → 0.1.20

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/src/db.ts CHANGED
@@ -34,6 +34,46 @@ export interface DedupCandidate {
34
34
  score: number;
35
35
  }
36
36
 
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
+
37
77
  /**
38
78
  * SQLite-backed store for thatch. All stores live in a single database
39
79
  * partitioned by a `store` column. Embeddings are raw Float32Array bytes
@@ -46,6 +86,12 @@ export class ThatchDB {
46
86
  this.#db = new Database(path, { create: true });
47
87
  this.#db.run("PRAGMA journal_mode = WAL");
48
88
  this.#db.run("PRAGMA busy_timeout = 5000");
89
+ // Enforce FK constraints per connection. The prediction tables use
90
+ // ON DELETE CASCADE for edges and provenance; enabling this pragma
91
+ // ensures those cascades fire. Also enforces the entries table's
92
+ // existing FK to stores(name), which was declarative but unenforced
93
+ // before this branch.
94
+ this.#db.run("PRAGMA foreign_keys = ON");
49
95
  this.#initSchema();
50
96
  }
51
97
 
@@ -90,6 +136,57 @@ export class ThatchDB {
90
136
  )
91
137
  `);
92
138
 
139
+ this.#db.run(`
140
+ CREATE TABLE IF NOT EXISTS prediction_matchers (
141
+ id TEXT PRIMARY KEY,
142
+ store TEXT NOT NULL REFERENCES stores(name),
143
+ description TEXT NOT NULL,
144
+ embedding BLOB,
145
+ model TEXT,
146
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
147
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
148
+ )
149
+ `);
150
+
151
+ this.#db.run(`
152
+ CREATE TABLE IF NOT EXISTS predictions (
153
+ id TEXT PRIMARY KEY,
154
+ store TEXT NOT NULL REFERENCES stores(name),
155
+ statement TEXT NOT NULL,
156
+ rationale TEXT,
157
+ embedding BLOB,
158
+ model TEXT,
159
+ confidence REAL NOT NULL DEFAULT 0.5,
160
+ confirm_count REAL NOT NULL DEFAULT 0,
161
+ disconfirm_count REAL NOT NULL DEFAULT 0,
162
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
163
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
164
+ )
165
+ `);
166
+
167
+ this.#db.run(`
168
+ CREATE TABLE IF NOT EXISTS prediction_edges (
169
+ matcher_id TEXT NOT NULL,
170
+ prediction_id TEXT NOT NULL,
171
+ weight REAL NOT NULL DEFAULT 1.0,
172
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
173
+ PRIMARY KEY (matcher_id, prediction_id),
174
+ FOREIGN KEY (matcher_id) REFERENCES prediction_matchers(id) ON DELETE CASCADE,
175
+ FOREIGN KEY (prediction_id) REFERENCES predictions(id) ON DELETE CASCADE
176
+ )
177
+ `);
178
+
179
+ this.#db.run(`
180
+ CREATE TABLE IF NOT EXISTS prediction_provenance (
181
+ id TEXT PRIMARY KEY,
182
+ prediction_id TEXT NOT NULL,
183
+ signal TEXT NOT NULL,
184
+ detail TEXT,
185
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
186
+ FOREIGN KEY (prediction_id) REFERENCES predictions(id) ON DELETE CASCADE
187
+ )
188
+ `);
189
+
93
190
  this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES ('global')");
94
191
 
95
192
  this.#migrateColumns();
@@ -456,6 +553,383 @@ export class ThatchDB {
456
553
  return new Set(rows.map((r: any) => [r.slug_a, r.slug_b].sort().join("|")));
457
554
  }
458
555
 
556
+ // ---------------------------------------------------------------------------
557
+ // Prediction engine: matchers, predictions, edges, provenance
558
+ // ---------------------------------------------------------------------------
559
+
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;
570
+
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[];
593
+
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);
605
+ }
606
+
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[];
628
+
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
+ });
661
+ }
662
+
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
+ }));
687
+ }
688
+
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[];
702
+
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;
714
+ }
715
+
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;
732
+ }
733
+
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[];
753
+
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;
766
+ }
767
+
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;
789
+ }
790
+
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
+ );
800
+ }
801
+
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
+ );
833
+ }
834
+
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;
851
+ }
852
+
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;
866
+ }
867
+
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
+ );
878
+ }
879
+
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[];
908
+
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
+ });
931
+ }
932
+
459
933
  // ---------------------------------------------------------------------------
460
934
  // Hygiene — signals for the session-start heartbeat. Staleness means
461
935
  // neither written nor recalled since the cutoff; recall telemetry keeps
@@ -501,6 +975,12 @@ export class ThatchDB {
501
975
  );
502
976
  }
503
977
 
978
+ /** Runs fn inside a SQLite transaction (BEGIN/COMMIT/ROLLBACK). */
979
+ transaction<T>(fn: () => T): T {
980
+ const t = this.#db.transaction(fn);
981
+ return t();
982
+ }
983
+
504
984
  close(): void {
505
985
  this.#db.close();
506
986
  }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  sessionStartReminder,
11
11
  recallNudge,
12
12
  extractionNudge,
13
+ predictionNudge,
13
14
  type NudgeMatch,
14
15
  } from "./prompts";
15
16
  import { ExtractionPipeline, type ToolInteraction } from "./extraction";
@@ -29,6 +30,11 @@ const RECALL_THRESHOLD = parseFloat(process.env.THATCH_RECALL_THRESHOLD ?? "0.55
29
30
  // like "yes" or "ok" match too broadly to be useful.
30
31
  const MIN_PROMPT_LEN = 10;
31
32
 
33
+ // Minimum cosine score for the prediction auto-fire. Lower than
34
+ // RECALL_THRESHOLD because "this situation matches a known pattern" is
35
+ // a weaker signal than "this prompt relates to a stored memory."
36
+ const PREDICTION_THRESHOLD = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD ?? "0.45");
37
+
32
38
  export const server: Plugin = async ({ client, worktree }) => {
33
39
  // The opencode server's cwd is wherever the server happened to start;
34
40
  // `worktree` is the project this plugin instance actually serves.
@@ -69,7 +75,7 @@ export const server: Plugin = async ({ client, worktree }) => {
69
75
  // (by reference identity), preserving interleaved-turn entries that arrived
70
76
  // while the sub-agent was running. Without this, the child's memory_remember
71
77
  // would drain the parent's ENTIRE buffer — silently dropping facts from
72
- // any tool calls the parent made concurrenly with the sub-agent.
78
+ // any tool calls the parent made concurrently with the sub-agent.
73
79
  const parentSnapshots = new Map<string, ToolInteraction[]>();
74
80
 
75
81
  // Skills always install to the global opencode config — installing into the
@@ -173,9 +179,14 @@ export const server: Plugin = async ({ client, worktree }) => {
173
179
  // 4. Per-message nudge — two priority tiers:
174
180
  // a. Extraction nudge: prior tool interactions are queued for fact
175
181
  // extraction (carries the JSON payload for thatch-fact-extractor).
176
- // b. Recall nudge: the user's prompt semantically matches existing
177
- // memories — surface that prior knowledge exists before the agent
178
- // responds. Uses the in-process warm model (no sideband needed).
182
+ // b. Recall + prediction nudge: when no extraction is pending, embed
183
+ // the user's prompt (shared embedding for both) and:
184
+ // - search memories by cosine (recall nudge)
185
+ // - search matchers by cosine, score predictions (prediction fire)
186
+ // Both fire independently; either, both, or neither may inject.
187
+ // The auto-fire reuses the embedding already computed for recall,
188
+ // adding only one more cosine scan against the matchers table.
189
+ // No extra model calls.
179
190
  //
180
191
  // Skipped during compaction: the agent can't call tools while generating
181
192
  // a summary, so a nudge that says "use thatch_memory_recall" triggers a
@@ -212,23 +223,48 @@ export const server: Plugin = async ({ client, worktree }) => {
212
223
  if (promptText.length < MIN_PROMPT_LEN) return;
213
224
 
214
225
  const embedding = await model.queryEmbed(promptText);
215
- const results = db.search([repo, "global"], embedding, { limit: 5 });
216
- const matches: NudgeMatch[] = results
217
- .filter((r) => r._score >= RECALL_THRESHOLD)
218
- .map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
219
226
 
220
- if (matches.length === 0) return;
227
+ // Recall nudge: separate try/catch so a memory-search failure
228
+ // does not block the prediction fire (they share only the embedding).
229
+ try {
230
+ const results = db.search([repo, "global"], embedding, { limit: 5 });
231
+ const matches: NudgeMatch[] = results
232
+ .filter((r) => r._score >= RECALL_THRESHOLD)
233
+ .map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
221
234
 
222
- output.parts.push({
223
- id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
224
- sessionID: input.sessionID,
225
- messageID: input.messageID ?? output.message.id,
226
- type: "text",
227
- text: recallNudge(matches),
228
- synthetic: true,
229
- });
235
+ if (matches.length > 0) {
236
+ output.parts.push({
237
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
238
+ sessionID: input.sessionID,
239
+ messageID: input.messageID ?? output.message.id,
240
+ type: "text",
241
+ text: recallNudge(matches),
242
+ synthetic: true,
243
+ });
244
+ }
245
+ } catch (err) {
246
+ console.error(`[thatch] recall nudge failed: ${err}`);
247
+ }
248
+
249
+ // Prediction fire: independent of recall; a failure here does
250
+ // not affect the recall nudge that may have already been pushed.
251
+ try {
252
+ const predItems = db.scorePredictionNudge([repo, "global"], embedding, PREDICTION_THRESHOLD);
253
+ if (predItems.length > 0) {
254
+ output.parts.push({
255
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
256
+ sessionID: input.sessionID,
257
+ messageID: input.messageID ?? output.message.id,
258
+ type: "text",
259
+ text: predictionNudge(predItems),
260
+ synthetic: true,
261
+ });
262
+ }
263
+ } catch (err) {
264
+ console.error(`[thatch] prediction nudge failed: ${err}`);
265
+ }
230
266
  } catch (err) {
231
- console.error(`[thatch] recall nudge failed: ${err}`);
267
+ console.error(`[thatch] nudge hook failed: ${err}`);
232
268
  }
233
269
  },
234
270