@jeffober/thatch 0.1.18 → 0.1.19

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/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
 
package/src/prompts.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { PredictionNudgeItem } from "./db";
2
+
1
3
  export function systemPrompt(repo: string): string {
2
4
  return `# Persistence
3
5
 
@@ -7,7 +9,9 @@ knowledge so future sessions can build on what you've already learned.
7
9
  Tools: thatch_memory_remember, thatch_memory_recall, thatch_memory_list,
8
10
  thatch_memory_show, thatch_memory_forget, thatch_store_list,
9
11
  thatch_find_duplicates, thatch_dedup_mark_checked,
10
- thatch_extraction_done
12
+ thatch_extraction_done,
13
+ thatch_prediction_query, thatch_prediction_update, thatch_prediction_list,
14
+ thatch_prediction_delete
11
15
 
12
16
  ## Stores
13
17
 
@@ -60,6 +64,27 @@ whether you've discovered knowledge worth persisting. Use thatch_memory_recall
60
64
  to check for duplicates, then thatch_memory_remember for new findings. Then
61
65
  deliver your response.
62
66
 
67
+ ## User Decision Model
68
+
69
+ A statistical model of the user's decision-making preferences is maintained by
70
+ the prediction engine. Scored predictions may appear above as "User decision
71
+ model" context; these are learned patterns about what the user tends to
72
+ prefer in specific situations.
73
+
74
+ When facing a judgment call about scope, appropriateness, or methodology:
75
+ - If a prediction is strong (high confidence, sufficient evidence), follow it
76
+ - If predictions conflict or confidence is thin, surface the uncertainty
77
+ naturally: "I think you usually prefer X here, but I'm not sure; what do
78
+ you want?"
79
+ - When the user responds to a surfaced prediction, use thatch_prediction_update
80
+ to reinforce or weaken the model
81
+
82
+ When the user corrects you, answers your question, or provides a clear signal
83
+ about their preferences:
84
+ 1. Use thatch_prediction_query to check for existing matchers and predictions
85
+ 2. Use thatch_prediction_update to create, reinforce, or weaken a prediction
86
+ 3. Use thatch_prediction_delete to remove a prediction created in error
87
+
63
88
  ## What to Store
64
89
 
65
90
  - **Global store**: user observations, agent personality, system environment
@@ -150,8 +175,9 @@ Tools are prefixed in Claude Code: \`mcp__thatch__memory_remember\`,
150
175
  \`mcp__thatch__memory_recall\`, \`mcp__thatch__memory_list\`,
151
176
  \`mcp__thatch__memory_show\`, \`mcp__thatch__memory_forget\`,
152
177
  \`mcp__thatch__store_list\`, \`mcp__thatch__find_duplicates\`,
153
- \`mcp__thatch__dedup_mark_checked\`, \`mcp__thatch__extraction_done\`.
154
- Bare names used below for readability.
178
+ \`mcp__thatch__dedup_mark_checked\`, \`mcp__thatch__extraction_done\`,
179
+ \`mcp__thatch__prediction_query\`, \`mcp__thatch__prediction_update\`,
180
+ \`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`. Bare names used below for readability.
155
181
 
156
182
  ## Stores
157
183
 
@@ -204,6 +230,27 @@ whether you've discovered knowledge worth persisting. Use memory_recall to
204
230
  check for duplicates, then memory_remember for new findings. Then deliver
205
231
  your response.
206
232
 
233
+ ## User Decision Model
234
+
235
+ A statistical model of the user's decision-making preferences is maintained by
236
+ the prediction engine. Scored predictions may appear above as "User decision
237
+ model" context; these are learned patterns about what the user tends to
238
+ prefer in specific situations.
239
+
240
+ When facing a judgment call about scope, appropriateness, or methodology:
241
+ - If a prediction is strong (high confidence, sufficient evidence), follow it
242
+ - If predictions conflict or confidence is thin, surface the uncertainty
243
+ naturally: "I think you usually prefer X here, but I'm not sure; what do
244
+ you want?"
245
+ - When the user responds to a surfaced prediction, use prediction_update
246
+ to reinforce or weaken the model
247
+
248
+ When the user corrects you, answers your question, or provides a clear signal
249
+ about their preferences:
250
+ 1. Use prediction_query to check for existing matchers and predictions
251
+ 2. Use prediction_update to create, reinforce, or weaken a prediction
252
+ 3. Use prediction_delete to remove a prediction created in error
253
+
207
254
  ## What to Store
208
255
 
209
256
  - **Global store**: user observations, agent personality, system environment
@@ -356,8 +403,9 @@ Tools are prefixed in Cursor: \`mcp__thatch__memory_remember\`,
356
403
  \`mcp__thatch__memory_recall\`, \`mcp__thatch__memory_list\`,
357
404
  \`mcp__thatch__memory_show\`, \`mcp__thatch__memory_forget\`,
358
405
  \`mcp__thatch__store_list\`, \`mcp__thatch__find_duplicates\`,
359
- \`mcp__thatch__dedup_mark_checked\`, \`mcp__thatch__extraction_done\`.
360
- Bare names used below for readability.
406
+ \`mcp__thatch__dedup_mark_checked\`, \`mcp__thatch__extraction_done\`,
407
+ \`mcp__thatch__prediction_query\`, \`mcp__thatch__prediction_update\`,
408
+ \`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`. Bare names used below for readability.
361
409
 
362
410
  ## Stores
363
411
 
@@ -410,6 +458,27 @@ whether you've discovered knowledge worth persisting. Use memory_recall to
410
458
  check for duplicates, then memory_remember for new findings. Then deliver
411
459
  your response.
412
460
 
461
+ ## User Decision Model
462
+
463
+ A statistical model of the user's decision-making preferences is maintained by
464
+ the prediction engine. Scored predictions may appear above as "User decision
465
+ model" context; these are learned patterns about what the user tends to
466
+ prefer in specific situations.
467
+
468
+ When facing a judgment call about scope, appropriateness, or methodology:
469
+ - If a prediction is strong (high confidence, sufficient evidence), follow it
470
+ - If predictions conflict or confidence is thin, surface the uncertainty
471
+ naturally: "I think you usually prefer X here, but I'm not sure; what do
472
+ you want?"
473
+ - When the user responds to a surfaced prediction, use prediction_update
474
+ to reinforce or weaken the model
475
+
476
+ When the user corrects you, answers your question, or provides a clear signal
477
+ about their preferences:
478
+ 1. Use prediction_query to check for existing matchers and predictions
479
+ 2. Use prediction_update to create, reinforce, or weaken a prediction
480
+ 3. Use prediction_delete to remove a prediction created in error
481
+
413
482
  ## What to Store
414
483
 
415
484
  - **Global store**: user observations, agent personality, system environment
@@ -495,3 +564,18 @@ function formatRecallNudge(matches: NudgeMatch[], toolName: string): string {
495
564
  const etc = n > 2 ? ", etc." : "";
496
565
  return `[thatch] ${n} ${word} ${verb} to this prompt (${labels}${etc}). Use ${toolName} before responding.`;
497
566
  }
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // Prediction nudge: auto-injected when matchers fire for the user's prompt.
570
+ // Descriptive, not directive: the system prompt instructions govern how to
571
+ // act on the scored predictions (follow strong, surface weak/competing,
572
+ // update on user response).
573
+ // ---------------------------------------------------------------------------
574
+
575
+ export function predictionNudge(items: PredictionNudgeItem[]): string {
576
+ const lines = items.map((p) => {
577
+ const verb = p.evidence_count === 0 ? "you may prefer" : "you tend to";
578
+ return `- [${p.confidence.toFixed(2)} conf, ${p.evidence_count} tests] When ${p.matcher_description}: ${verb} ${p.statement}`;
579
+ });
580
+ return `[thatch] User decision model\n${lines.join("\n")}`;
581
+ }
package/src/sideband.ts CHANGED
@@ -3,7 +3,7 @@ import { unlinkSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { createServer, connect, type Server, type Socket } from "node:net";
6
- import type { ThatchDB } from "./db";
6
+ import type { ThatchDB, PredictionNudgeItem } from "./db";
7
7
  import type { EmbeddingModel } from "./embeddings";
8
8
 
9
9
  /**
@@ -24,7 +24,7 @@ export interface SidebandMatch {
24
24
  }
25
25
 
26
26
  interface MatchRequest {
27
- method: "match";
27
+ method: "match" | "predictions";
28
28
  text: string;
29
29
  stores: string[];
30
30
  threshold: number;
@@ -34,6 +34,7 @@ interface MatchRequest {
34
34
  interface MatchResponse {
35
35
  ok: boolean;
36
36
  matches?: SidebandMatch[];
37
+ predictions?: PredictionNudgeItem[];
37
38
  error?: string;
38
39
  }
39
40
 
@@ -115,13 +116,22 @@ export class SidebandServer {
115
116
  return;
116
117
  }
117
118
 
118
- if (req.method !== "match") {
119
+ if (req.method !== "match" && req.method !== "predictions") {
119
120
  this.#respond(socket, { ok: false, error: `Unknown method: ${req.method}` });
120
121
  return;
121
122
  }
122
123
 
123
124
  try {
124
125
  const embedding = await this.#model.queryEmbed(req.text);
126
+
127
+ if (req.method === "predictions") {
128
+ const predictions = this.#db.scorePredictionNudge(
129
+ req.stores, embedding, req.threshold, 5,
130
+ );
131
+ this.#respond(socket, { ok: true, predictions });
132
+ return;
133
+ }
134
+
125
135
  const results = this.#db.search(req.stores, embedding, { limit: req.limit });
126
136
  const matches: SidebandMatch[] = results
127
137
  .filter((r) => r._score >= req.threshold)
@@ -156,16 +166,45 @@ export function sidebandMatch(
156
166
  limit: number,
157
167
  timeoutMs = 2000,
158
168
  ): Promise<SidebandMatch[] | null> {
169
+ return sidebandRequest("match", socketPath, text, stores, threshold, limit, timeoutMs)
170
+ .then((res) => res?.matches ?? null);
171
+ }
172
+
173
+ /**
174
+ * Connect to the sideband server and request scored predictions for a prompt.
175
+ * Returns null on any failure; callers treat null as "skip the prediction nudge."
176
+ */
177
+ export function sidebandPredictions(
178
+ socketPath: string,
179
+ text: string,
180
+ stores: string[],
181
+ threshold: number,
182
+ limit: number,
183
+ timeoutMs = 2000,
184
+ ): Promise<PredictionNudgeItem[] | null> {
185
+ return sidebandRequest("predictions", socketPath, text, stores, threshold, limit, timeoutMs)
186
+ .then((res) => res?.predictions ?? null);
187
+ }
188
+
189
+ function sidebandRequest(
190
+ method: "match" | "predictions",
191
+ socketPath: string,
192
+ text: string,
193
+ stores: string[],
194
+ threshold: number,
195
+ limit: number,
196
+ timeoutMs: number,
197
+ ): Promise<MatchResponse | null> {
159
198
  return new Promise((resolve) => {
160
199
  let settled = false;
161
- const done = (val: SidebandMatch[] | null) => {
200
+ const done = (val: MatchResponse | null) => {
162
201
  if (settled) return;
163
202
  settled = true;
164
203
  resolve(val);
165
204
  };
166
205
 
167
206
  const socket = connect(socketPath);
168
- const req = JSON.stringify({ method: "match", text, stores, threshold, limit }) + "\n";
207
+ const req = JSON.stringify({ method, text, stores, threshold, limit }) + "\n";
169
208
  let buf = "";
170
209
 
171
210
  const timer = setTimeout(() => {
@@ -173,14 +212,13 @@ export function sidebandMatch(
173
212
  done(null);
174
213
  }, timeoutMs);
175
214
 
176
- socket.on("error", () => {
215
+ socket.on("error", (err: NodeJS.ErrnoException) => {
177
216
  clearTimeout(timer);
178
- // Stale socket file left by a crashed server — clean it up so the next
179
- // connection attempt doesn't hit ECONNREFUSED again.
180
- try {
181
- unlinkSync(socketPath);
182
- } catch {
183
- // Already gone or never existed.
217
+ // Only remove the socket file if it's a stale-file situation
218
+ // (ECONNREFUSED). Other errors (timeout, server error) should
219
+ // not delete a live server's socket.
220
+ if (err.code === "ECONNREFUSED" || err.code === "ENOENT") {
221
+ try { unlinkSync(socketPath); } catch { /* already gone */ }
184
222
  }
185
223
  done(null);
186
224
  });
@@ -197,7 +235,7 @@ export function sidebandMatch(
197
235
  clearTimeout(timer);
198
236
  try {
199
237
  const res = JSON.parse(buf.slice(0, nl)) as MatchResponse;
200
- done(res.ok ? (res.matches ?? []) : null);
238
+ done(res);
201
239
  } catch {
202
240
  done(null);
203
241
  }
package/src/tool-defs.ts CHANGED
@@ -2,6 +2,17 @@ import { z } from "zod";
2
2
  import type { ThatchDB, DedupCandidate } from "./db";
3
3
  import type { EmbeddingModel } from "./embeddings";
4
4
 
5
+ // Near-duplicate thresholds for matcher/prediction dedup at creation time.
6
+ // Matches the thatch_find_duplicates threshold (0.85).
7
+ const MATCHER_DEDUP_COSINE = 0.85;
8
+ const PREDICTION_DEDUP_COSINE = 0.85;
9
+
10
+ // Minimum matcher cosine to consider a prediction relevant. Matches the
11
+ // auto-fire threshold in index.ts (PREDICTION_THRESHOLD). The query tool
12
+ // should not return predictions from near-zero-similarity matchers that
13
+ // would never fire in the auto-fire.
14
+ const PREDICTION_QUERY_THRESHOLD = 0.45;
15
+
5
16
  /**
6
17
  * Shared context passed to every tool's execute function. Framework-agnostic —
7
18
  * neither opencode nor MCP specific. The plugin wires real defaults; tests and
@@ -367,6 +378,194 @@ const extractionDoneDef: ToolDef = {
367
378
  },
368
379
  };
369
380
 
381
+ // ---------------------------------------------------------------------------
382
+ // Prediction engine: user decision model
383
+ // ---------------------------------------------------------------------------
384
+
385
+ const predictionQueryDef: ToolDef = {
386
+ name: "prediction_query",
387
+ description:
388
+ "Query the user decision model for scored predictions matching a " +
389
+ "context. Returns predictions with confidence and evidence count. " +
390
+ "Use when facing a judgment call about scope, appropriateness, or " +
391
+ "methodology and the auto-injected prediction block did not already " +
392
+ "cover the situation.",
393
+ args: {
394
+ context: z.string().describe(
395
+ "The situation or context to match against. Describe the decision being faced.",
396
+ ),
397
+ store: z.string().optional().describe(
398
+ "Store to search. Defaults to the project store and global together.",
399
+ ),
400
+ },
401
+ async execute(args, ctx) {
402
+ const stores = args.store
403
+ ? [args.store as string]
404
+ : [ctx.defaultStore, "global"];
405
+ const embedding = await ctx.model.queryEmbed(args.context as string);
406
+ const matchers = ctx.db.findMatchers(stores, embedding, { limit: 5 })
407
+ .filter((m) => m.score >= PREDICTION_QUERY_THRESHOLD);
408
+ if (matchers.length === 0) return "No matching predictions found.";
409
+ const scored = ctx.db.scorePredictions(matchers);
410
+ if (scored.length === 0) return "No matching predictions found.";
411
+ return scored.map((s) => {
412
+ const verb = s.evidence_count === 0 ? "you may prefer" : "you tend to";
413
+ return `[${s.confidence.toFixed(2)} conf, ${s.evidence_count} tests] ` +
414
+ `When ${s.matcher_description}: ${verb} ${s.statement}`;
415
+ }).join("\n");
416
+ },
417
+ };
418
+
419
+ const predictionUpdateDef: ToolDef = {
420
+ name: "prediction_update",
421
+ description:
422
+ "Create, reinforce, or weaken a prediction in the user decision " +
423
+ "model. Use when the user corrects you, answers a question, or " +
424
+ "provides a clear signal about their preferences or decision-making " +
425
+ "strategy. The tool handles matcher and prediction lookup, dedup, " +
426
+ "and confidence adjustment automatically.",
427
+ args: {
428
+ matcher: z.string().describe(
429
+ "Description of the situation. What decision was being made?",
430
+ ),
431
+ prediction: z.string().describe(
432
+ "The user's preference or tendency in this situation.",
433
+ ),
434
+ signal: z.enum(["confirm", "disconfirm", "soft", "create"]).describe(
435
+ "What happened: confirm (user confirmed the prediction), " +
436
+ "disconfirm (user pushed back), soft (weak disconfirm, user " +
437
+ "partially disagreed), create (new observation).",
438
+ ),
439
+ rationale: z.string().describe(
440
+ "Why this prediction was formed or updated. What did the user say or do?",
441
+ ),
442
+ store: z.string().optional().describe(
443
+ "Store to write to. Defaults to the project store.",
444
+ ),
445
+ },
446
+ async execute(args, ctx) {
447
+ const store = (args.store as string) || ctx.defaultStore;
448
+ const matcherText = args.matcher as string;
449
+ const predictionText = args.prediction as string;
450
+ const signal = args.signal as "confirm" | "disconfirm" | "soft" | "create";
451
+ const rationale = args.rationale as string;
452
+
453
+ const matcherEmbed = await ctx.model.passageEmbed(matcherText);
454
+ const predEmbed = await ctx.model.passageEmbed(predictionText);
455
+
456
+ // All read-modify-write mutations run inside a transaction so a
457
+ // failure mid-sequence (FK violation, I/O error) rolls back the
458
+ // entire operation rather than leaving orphans (matcher without
459
+ // prediction, prediction without edge, edge without confidence).
460
+ return ctx.db.transaction(() => {
461
+ // Dedup matchers: find an existing matcher above the cosine threshold
462
+ // rather than always creating a new one.
463
+ let matcherId = ctx.db.findNearestMatcher(store, matcherEmbed, MATCHER_DEDUP_COSINE)?.id;
464
+ if (!matcherId) matcherId = ctx.db.createMatcher(store, matcherText, matcherEmbed, ctx.model.name);
465
+
466
+ // Store-wide dedup: search the entire store for a near-identical
467
+ // prediction, not just this matcher's edges. If found, link this
468
+ // matcher to the existing prediction via an edge rather than
469
+ // creating a second row with the same statement.
470
+ let predictionId = ctx.db.findNearestPrediction(store, predEmbed, PREDICTION_DEDUP_COSINE)?.id;
471
+
472
+ if (!predictionId) {
473
+ // No near-identical prediction exists; create one and link it.
474
+ predictionId = ctx.db.createPrediction(store, predictionText, rationale, predEmbed, ctx.model.name);
475
+ ctx.db.createEdge(matcherId, predictionId, 1.0);
476
+
477
+ // When the signal is confirm/disconfirm/soft (not create), apply
478
+ // it immediately so the first signal isn't lost. The prediction
479
+ // starts at p0 with 0 evidence; without this, the agent's
480
+ // "confirm" would be discarded and the prediction would have
481
+ // 0 confirms.
482
+ if (signal !== "create") {
483
+ ctx.db.adjustConfidence(predictionId, signal === "soft" ? "soft" : signal === "confirm" ? "confirm" : "disconfirm");
484
+ ctx.db.addProvenance(predictionId, signal, rationale);
485
+ const updated = ctx.db.getPrediction(predictionId);
486
+ return `[created + ${signal}] "${updated?.statement ?? predictionText}" confidence=${(updated?.confidence ?? 0).toFixed(2)} (${updated?.confirm_count ?? 0}/${updated?.disconfirm_count ?? 0})`;
487
+ }
488
+ ctx.db.addProvenance(predictionId, "create", rationale);
489
+ return `[created] ${store} :: "${predictionText}" for "${matcherText}"`;
490
+ }
491
+
492
+ // Ensure an edge links this matcher to the existing prediction.
493
+ // createEdge uses ON CONFLICT DO NOTHING, so existing edge weights
494
+ // are preserved.
495
+ ctx.db.createEdge(matcherId, predictionId, 1.0);
496
+
497
+ // "create" on an existing prediction means the agent re-observed
498
+ // the same preference in a new context. Link the edge and record
499
+ // provenance, but do NOT adjust confidence — "create" is
500
+ // confidence-neutral, not a disconfirm.
501
+ if (signal === "create") {
502
+ ctx.db.addProvenance(predictionId, "create", rationale);
503
+ const existing = ctx.db.getPrediction(predictionId);
504
+ return `[linked] "${existing?.statement ?? predictionText}" for "${matcherText}" confidence=${(existing?.confidence ?? 0).toFixed(2)} (${existing?.confirm_count ?? 0}/${existing?.disconfirm_count ?? 0})`;
505
+ }
506
+
507
+ // Signal is confirm, disconfirm, or soft. Map the tool's 4-value
508
+ // enum to adjustConfidence's 3-value enum.
509
+ ctx.db.adjustConfidence(predictionId, signal === "soft" ? "soft" : signal === "confirm" ? "confirm" : "disconfirm");
510
+ ctx.db.addProvenance(predictionId, signal, rationale);
511
+ const updated = ctx.db.getPrediction(predictionId);
512
+ return `[${signal}] "${updated?.statement ?? predictionText}" confidence=${(updated?.confidence ?? 0).toFixed(2)} (${updated?.confirm_count ?? 0}/${updated?.disconfirm_count ?? 0})`;
513
+ });
514
+ },
515
+ };
516
+
517
+ const predictionListDef: ToolDef = {
518
+ name: "prediction_list",
519
+ description:
520
+ "List all predictions in the user decision model with their matchers, " +
521
+ "confidence, and evidence count. For inspection and debugging.",
522
+ args: {
523
+ store: z.string().optional().describe(
524
+ "Which store to list. Defaults to the project store.",
525
+ ),
526
+ },
527
+ async execute(args, ctx) {
528
+ const store = (args.store as string) || ctx.defaultStore;
529
+ const predictions = ctx.db.listPredictions(store);
530
+ if (predictions.length === 0) return `No predictions in "${store}".`;
531
+ return predictions.map((p) => {
532
+ const matchers = p.matchers.map((m) => ` - "${m.description}" (w:${m.weight})`).join("\n");
533
+ const provenance = ctx.db.getProvenance(p.id);
534
+ const provLines = provenance.map((pr) => ` - [${pr.created_at.slice(0, 10)}] ${pr.signal}: ${pr.detail ?? ""}`).join("\n");
535
+ return `[${p.confidence.toFixed(2)} conf, ${p.evidence_count} tests] ${p.statement}` +
536
+ (p.rationale ? `\n rationale: ${p.rationale}` : "") +
537
+ (matchers ? `\n matchers:\n${matchers}` : "") +
538
+ (provLines ? `\n provenance:\n${provLines}` : "");
539
+ }).join("\n\n");
540
+ },
541
+ };
542
+
543
+ const predictionDeleteDef: ToolDef = {
544
+ name: "prediction_delete",
545
+ description:
546
+ "Delete a prediction from the user decision model. Useful when a " +
547
+ "prediction was created in error or is no longer relevant. Edges " +
548
+ "and provenance are deleted automatically (cascade).",
549
+ args: {
550
+ statement: z.string().describe(
551
+ "The prediction statement to delete. Use prediction_list to find the exact text; matching is semantic (cosine >= 0.85).",
552
+ ),
553
+ store: z.string().optional().describe(
554
+ "Store to delete from. Defaults to the project store.",
555
+ ),
556
+ },
557
+ async execute(args, ctx) {
558
+ const store = (args.store as string) || ctx.defaultStore;
559
+ const statementText = args.statement as string;
560
+ const predEmbed = await ctx.model.passageEmbed(statementText);
561
+ const prediction = ctx.db.findNearestPrediction(store, predEmbed, PREDICTION_DEDUP_COSINE);
562
+ if (!prediction) return `No prediction matching "${statementText}" found in "${store}".`;
563
+ const deleted = ctx.db.deletePrediction(prediction.id);
564
+ if (!deleted) return `Failed to delete prediction "${prediction.statement}".`;
565
+ return `[deleted] "${prediction.statement}" from "${store}"`;
566
+ },
567
+ };
568
+
370
569
  /**
371
570
  * All tool definitions, in the order they should be presented to the agent.
372
571
  * The opencode plugin wraps each in `tool()`; the MCP server exposes them
@@ -382,4 +581,8 @@ export const TOOL_DEFS: ToolDef[] = [
382
581
  findDuplicatesDef,
383
582
  markCheckedDef,
384
583
  extractionDoneDef,
584
+ predictionQueryDef,
585
+ predictionUpdateDef,
586
+ predictionListDef,
587
+ predictionDeleteDef,
385
588
  ];