@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/README.md +54 -0
- package/bin/thatch +73 -7
- package/docs/in-progress/prediction-engine.md +404 -0
- package/docs/qa/README.md +33 -1
- package/docs/qa/use-cases/README.md +4 -0
- package/docs/qa/use-cases/UC-021-prediction-autofire.md +21 -0
- package/docs/qa/use-cases/UC-022-prediction-dedup.md +19 -0
- package/docs/qa/use-cases/UC-023-prediction-confidence-model.md +23 -0
- package/docs/qa/use-cases/UC-024-prediction-delete.md +20 -0
- package/package.json +1 -1
- package/src/db.ts +480 -0
- package/src/index.ts +54 -18
- package/src/prompts.ts +89 -5
- package/src/sideband.ts +51 -13
- package/src/tool-defs.ts +203 -0
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
|
|
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:
|
|
177
|
-
//
|
|
178
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
];
|