@jeffober/thatch 0.1.17 → 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 +56 -2
- 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/extract-queue.ts +0 -7
- package/src/extraction.ts +18 -5
- package/src/index.ts +86 -28
- package/src/mcp.ts +2 -1
- package/src/prompts.ts +89 -5
- package/src/sideband.ts +51 -13
- package/src/tool-defs.ts +203 -0
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
|
];
|