@jeffober/thatch 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -10
- package/artifacts/skills/thatch-code-review.md +9 -7
- package/artifacts/skills/thatch-fact-extractor.md +2 -2
- package/artifacts/skills/thatch-review-economy.md +97 -0
- package/artifacts/skills/thatch-review-followup.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +15 -5
- package/bin/qa-run +38 -0
- package/bin/thatch +13 -7
- package/docs/dev/README.md +76 -22
- package/docs/dev/features/README.md +33 -0
- package/docs/dev/features/behavior-engine.md +271 -0
- package/docs/dev/features/cicd.md +89 -0
- package/docs/dev/features/cli.md +81 -0
- package/docs/dev/features/compaction-recovery.md +48 -0
- package/docs/dev/features/database.md +244 -0
- package/docs/dev/features/deduplication.md +102 -0
- package/docs/dev/features/extraction.md +253 -0
- package/docs/dev/features/hygiene.md +81 -0
- package/docs/dev/features/memory-store.md +154 -0
- package/docs/dev/features/multi-host.md +228 -0
- package/docs/dev/features/nudge-pipeline.md +282 -0
- package/docs/dev/features/prediction-engine.md +253 -0
- package/docs/dev/features/qa-system.md +205 -0
- package/docs/dev/features/session-lifecycle.md +110 -0
- package/docs/dev/features/setup.md +199 -0
- package/docs/dev/features/sideband.md +104 -0
- package/docs/dev/gotchas.md +3 -3
- package/docs/dev/mcp-parity.md +7 -5
- package/docs/dev/setup-and-hooks.md +4 -4
- package/docs/dev/skills.md +10 -8
- package/docs/plans/README.md +2 -2
- package/docs/user/README.md +58 -20
- package/docs/user/behavior-engine.md +85 -0
- package/docs/user/cli.md +98 -0
- package/docs/user/code-review.md +73 -0
- package/docs/user/deduplication.md +70 -0
- package/docs/user/default-behaviors.md +75 -0
- package/docs/user/extraction.md +74 -0
- package/docs/user/hygiene.md +76 -0
- package/docs/user/memory.md +81 -0
- package/docs/user/prediction-engine.md +87 -0
- package/docs/user/setup.md +110 -0
- package/docs/user/skills.md +97 -0
- package/package.json +2 -2
- package/src/behavior.ts +120 -0
- package/src/db.ts +160 -417
- package/src/embeddings.ts +0 -41
- package/src/extraction.ts +2 -2
- package/src/git.ts +3 -1
- package/src/index.ts +66 -14
- package/src/mcp.ts +59 -2
- package/src/prediction.ts +127 -0
- package/src/prompts.ts +155 -200
- package/src/scoring-engine.ts +374 -0
- package/src/seed-behaviors.ts +339 -0
- package/src/setup.ts +2 -6
- package/src/sideband.ts +36 -8
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +235 -9
- package/src/tools.ts +11 -1
- package/src/vector-math.ts +30 -0
- package/docs/qa/README.md +0 -175
package/src/tool-defs.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import type { ThatchDB, DedupCandidate } from "./db";
|
|
2
|
+
import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
|
|
3
3
|
import type { EmbeddingModel } from "./embeddings";
|
|
4
|
+
import { predictionVerb } from "./prompts";
|
|
4
5
|
|
|
5
|
-
// Near-duplicate thresholds for matcher/prediction dedup at
|
|
6
|
-
// Matches the thatch_find_duplicates threshold (0.85).
|
|
6
|
+
// Near-duplicate thresholds for matcher/prediction/behavior dedup at
|
|
7
|
+
// creation time. Matches the thatch_find_duplicates threshold (0.85).
|
|
7
8
|
const MATCHER_DEDUP_COSINE = 0.85;
|
|
8
9
|
const PREDICTION_DEDUP_COSINE = 0.85;
|
|
10
|
+
const BEHAVIOR_DEDUP_COSINE = 0.85;
|
|
9
11
|
|
|
10
12
|
// Minimum matcher cosine to consider a prediction relevant. Matches the
|
|
11
13
|
// auto-fire threshold in index.ts (PREDICTION_THRESHOLD). The query tool
|
|
12
14
|
// should not return predictions from near-zero-similarity matchers that
|
|
13
15
|
// would never fire in the auto-fire.
|
|
14
|
-
const PREDICTION_QUERY_THRESHOLD = 0.
|
|
16
|
+
const PREDICTION_QUERY_THRESHOLD = 0.60;
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Shared context passed to every tool's execute function. Framework-agnostic -
|
|
@@ -22,6 +24,18 @@ export interface CoreContext {
|
|
|
22
24
|
db: ThatchDB;
|
|
23
25
|
model: EmbeddingModel;
|
|
24
26
|
defaultStore: string;
|
|
27
|
+
/** Peeks the extraction buffer for a session and returns the serialized
|
|
28
|
+
* JSON payload (same shape as buildExtractionPayload). Returns null when
|
|
29
|
+
* no interactions are queued. Used by the get_extraction_payload tool so
|
|
30
|
+
* the sub-agent fetches the payload as a tool response instead of
|
|
31
|
+
* receiving it inline in the nudge text. */
|
|
32
|
+
extractionPayloadProvider?: (sessionID: string) => string | null;
|
|
33
|
+
/** Drains a session's extraction queue. On the MCP path this deletes the
|
|
34
|
+
* file-backed queue and resets the missed-nudge counter. On the opencode
|
|
35
|
+
* path this is unused (the tool.execute.after hook handles drain via
|
|
36
|
+
* childToParent). Called by extraction_done when the sub-agent passes
|
|
37
|
+
* the parent's session_id. */
|
|
38
|
+
drainExtractionQueue?: (sessionID: string) => void;
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
/**
|
|
@@ -56,7 +70,7 @@ function formatEntry(
|
|
|
56
70
|
return parts.join("\n");
|
|
57
71
|
}
|
|
58
72
|
|
|
59
|
-
function formatRecallResult(entry:
|
|
73
|
+
function formatRecallResult(entry: MemoryRow & { _score: number }): string {
|
|
60
74
|
let meta = `store:${entry.store}`;
|
|
61
75
|
if (entry.branch) meta += ` branch:${entry.branch}`;
|
|
62
76
|
if (entry.confidence) meta += ` confidence:${entry.confidence}`;
|
|
@@ -351,6 +365,37 @@ const markCheckedDef: ToolDef = {
|
|
|
351
365
|
},
|
|
352
366
|
};
|
|
353
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Fetches the queued extraction payload for a session. The sub-agent calls
|
|
370
|
+
* this with the parent's session ID to retrieve the tool interactions that
|
|
371
|
+
* need fact extraction, instead of receiving them inline in the nudge text.
|
|
372
|
+
* This keeps the full payload out of the main session's context window.
|
|
373
|
+
*/
|
|
374
|
+
const getExtractionPayloadDef: ToolDef = {
|
|
375
|
+
name: "get_extraction_payload",
|
|
376
|
+
description:
|
|
377
|
+
"Retrieve the queued tool interactions for extraction. Call this with " +
|
|
378
|
+
"the session_id from the extraction nudge to get the JSON payload " +
|
|
379
|
+
"(interactions, projectStore, globalStore). Then run the " +
|
|
380
|
+
"thatch-fact-extractor skill on the returned payload.",
|
|
381
|
+
args: {
|
|
382
|
+
session_id: z.string().describe(
|
|
383
|
+
"The session ID from the extraction nudge. This is the parent session " +
|
|
384
|
+
"whose tool interactions are queued for extraction.",
|
|
385
|
+
),
|
|
386
|
+
},
|
|
387
|
+
async execute(args, ctx) {
|
|
388
|
+
if (!ctx.extractionPayloadProvider) {
|
|
389
|
+
return "Extraction payload retrieval is not available in this host.";
|
|
390
|
+
}
|
|
391
|
+
const payload = ctx.extractionPayloadProvider(args.session_id as string);
|
|
392
|
+
if (!payload) {
|
|
393
|
+
return "No queued tool interactions found for this session.";
|
|
394
|
+
}
|
|
395
|
+
return payload;
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
|
|
354
399
|
/**
|
|
355
400
|
* Extraction-buffer acknowledgment, with AMQP-style accept/complete roles.
|
|
356
401
|
*
|
|
@@ -369,6 +414,11 @@ const markCheckedDef: ToolDef = {
|
|
|
369
414
|
* file-backed queue is consumed on this call (or on any memory_remember),
|
|
370
415
|
* which is durable across interruption because the queue persists on disk
|
|
371
416
|
* until then.
|
|
417
|
+
*
|
|
418
|
+
* The optional session_id parameter lets a sub-agent drain the PARENT's
|
|
419
|
+
* file-backed queue on the MCP path, where the sub-agent's session ID
|
|
420
|
+
* differs from the parent's and appendBatch's self-detection would drain
|
|
421
|
+
* the wrong (empty) queue.
|
|
372
422
|
*/
|
|
373
423
|
const extractionDoneDef: ToolDef = {
|
|
374
424
|
name: "extraction_done",
|
|
@@ -377,9 +427,20 @@ const extractionDoneDef: ToolDef = {
|
|
|
377
427
|
"dispatching the fact-extractor to a sub-agent: accepts the buffer " +
|
|
378
428
|
"(quiets the nudge) while keeping entries until the extractor completes. " +
|
|
379
429
|
"In the fact-extractor sub-agent, call at the end of the run to mark the " +
|
|
380
|
-
"entries complete, even when nothing was worth saving."
|
|
381
|
-
|
|
382
|
-
|
|
430
|
+
"entries complete, even when nothing was worth saving. Pass session_id " +
|
|
431
|
+
"when running as a sub-agent to drain the parent session's queue.",
|
|
432
|
+
args: {
|
|
433
|
+
session_id: z.string().optional().describe(
|
|
434
|
+
"The parent session's ID, when called from a sub-agent on the MCP " +
|
|
435
|
+
"path. Drains the parent's file-backed queue. Omit when called from " +
|
|
436
|
+
"the parent session itself (opencode path handles drain via hooks).",
|
|
437
|
+
),
|
|
438
|
+
},
|
|
439
|
+
async execute(args, ctx) {
|
|
440
|
+
const sessionID = args.session_id as string | undefined;
|
|
441
|
+
if (sessionID && ctx.drainExtractionQueue) {
|
|
442
|
+
ctx.drainExtractionQueue(sessionID);
|
|
443
|
+
}
|
|
383
444
|
return "[acknowledged]";
|
|
384
445
|
},
|
|
385
446
|
};
|
|
@@ -415,7 +476,7 @@ const predictionQueryDef: ToolDef = {
|
|
|
415
476
|
const scored = ctx.db.scorePredictions(matchers);
|
|
416
477
|
if (scored.length === 0) return "No matching predictions found.";
|
|
417
478
|
return scored.map((s) => {
|
|
418
|
-
const verb = s.evidence_count
|
|
479
|
+
const verb = predictionVerb(s.evidence_count);
|
|
419
480
|
return `[${s.confidence.toFixed(2)} conf, ${s.evidence_count} tests] ` +
|
|
420
481
|
`When ${s.matcher_description}: ${verb} ${s.statement}`;
|
|
421
482
|
}).join("\n");
|
|
@@ -572,6 +633,166 @@ const predictionDeleteDef: ToolDef = {
|
|
|
572
633
|
},
|
|
573
634
|
};
|
|
574
635
|
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// Behavior engine: LLM self-discipline rules with ham/spam feedback
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
const behaviorCodifyDef: ToolDef = {
|
|
641
|
+
name: "behavior_codify",
|
|
642
|
+
description:
|
|
643
|
+
"Codify a self-discipline rule: when situation X arises, you should " +
|
|
644
|
+
"do Y. Use when you recognize a situation you should react to in a " +
|
|
645
|
+
"specific, repeatable way that is NOT a user preference (use " +
|
|
646
|
+
"prediction_update for those). The rule is about your own operational " +
|
|
647
|
+
"discipline, not what the user wants. Examples: check the whole " +
|
|
648
|
+
"codebase for a library before importing it; investigate disabled " +
|
|
649
|
+
"tests before touching the area; read a large function fully before " +
|
|
650
|
+
"editing it.",
|
|
651
|
+
args: {
|
|
652
|
+
situation: z.string().describe(
|
|
653
|
+
"Description of the situation that triggers this behavior. What context " +
|
|
654
|
+
"or task type makes this rule apply?",
|
|
655
|
+
),
|
|
656
|
+
behavior: z.string().describe(
|
|
657
|
+
"The behavioral rule. What should you do when this situation arises?",
|
|
658
|
+
),
|
|
659
|
+
rationale: z.string().describe(
|
|
660
|
+
"Why you are codifying this rule. What happened that made you realize " +
|
|
661
|
+
"this behavior is worth persisting?",
|
|
662
|
+
),
|
|
663
|
+
store: z.string().optional().describe(
|
|
664
|
+
"Store to write to. Defaults to the project store.",
|
|
665
|
+
),
|
|
666
|
+
},
|
|
667
|
+
async execute(args, ctx) {
|
|
668
|
+
const store = (args.store as string) || ctx.defaultStore;
|
|
669
|
+
const situationText = args.situation as string;
|
|
670
|
+
const behaviorText = args.behavior as string;
|
|
671
|
+
const rationale = args.rationale as string;
|
|
672
|
+
|
|
673
|
+
const matcherEmbed = await ctx.model.passageEmbed(situationText);
|
|
674
|
+
const behaviorEmbed = await ctx.model.passageEmbed(behaviorText);
|
|
675
|
+
|
|
676
|
+
return ctx.db.transaction(() => {
|
|
677
|
+
let matcherId = ctx.db.findNearestBehaviorMatcher(store, matcherEmbed, BEHAVIOR_DEDUP_COSINE)?.id;
|
|
678
|
+
if (!matcherId) matcherId = ctx.db.createBehaviorMatcher(store, situationText, matcherEmbed, ctx.model.name);
|
|
679
|
+
|
|
680
|
+
let behaviorId = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE)?.id;
|
|
681
|
+
if (!behaviorId) {
|
|
682
|
+
behaviorId = ctx.db.createBehavior(store, behaviorText, rationale, behaviorEmbed, ctx.model.name);
|
|
683
|
+
ctx.db.createBehaviorEdge(matcherId, behaviorId, 1.0);
|
|
684
|
+
ctx.db.addBehaviorProvenance(behaviorId, "codify", rationale);
|
|
685
|
+
return `[codified] ${store} :: "${behaviorText}" for "${situationText}"`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
ctx.db.createBehaviorEdge(matcherId, behaviorId, 1.0);
|
|
689
|
+
ctx.db.addBehaviorProvenance(behaviorId, "codify", rationale);
|
|
690
|
+
const existing = ctx.db.getBehavior(behaviorId);
|
|
691
|
+
return `[linked] "${existing?.statement ?? behaviorText}" for "${situationText}" confidence=${(existing?.confidence ?? 0).toFixed(2)} (${existing?.confirm_count ?? 0}/${existing?.disconfirm_count ?? 0})`;
|
|
692
|
+
});
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const behaviorFeedbackDef: ToolDef = {
|
|
697
|
+
name: "behavior_feedback",
|
|
698
|
+
description:
|
|
699
|
+
"Record ham/spam feedback on a surfaced behavior. When the behavior " +
|
|
700
|
+
"nudge surfaces rules and you evaluate each against the current " +
|
|
701
|
+
"situation, call this with relevant: true (ham) if the rule applies, " +
|
|
702
|
+
"or relevant: false (spam) if it does not. This trains the classifier " +
|
|
703
|
+
"so future nudges are more accurate. Also use when the user corrects " +
|
|
704
|
+
"your behavior and you realize a codified rule led you astray or " +
|
|
705
|
+
"should have been followed.",
|
|
706
|
+
args: {
|
|
707
|
+
behavior: z.string().describe(
|
|
708
|
+
"The behavior statement to provide feedback on. Use behavior_list " +
|
|
709
|
+
"to find the exact text; matching is semantic (cosine >= 0.85).",
|
|
710
|
+
),
|
|
711
|
+
relevant: z.boolean().describe(
|
|
712
|
+
"true (ham) if the behavior is relevant to the current situation. " +
|
|
713
|
+
"false (spam) if it is not relevant.",
|
|
714
|
+
),
|
|
715
|
+
context: z.string().describe(
|
|
716
|
+
"Brief description of the current situation, so the feedback is " +
|
|
717
|
+
"auditable in provenance.",
|
|
718
|
+
),
|
|
719
|
+
store: z.string().optional().describe(
|
|
720
|
+
"Store to search. Defaults to the project store.",
|
|
721
|
+
),
|
|
722
|
+
},
|
|
723
|
+
async execute(args, ctx) {
|
|
724
|
+
const store = (args.store as string) || ctx.defaultStore;
|
|
725
|
+
const behaviorText = args.behavior as string;
|
|
726
|
+
const relevant = args.relevant as boolean;
|
|
727
|
+
const contextText = args.context as string;
|
|
728
|
+
|
|
729
|
+
const behaviorEmbed = await ctx.model.passageEmbed(behaviorText);
|
|
730
|
+
const behavior = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE);
|
|
731
|
+
if (!behavior) return `No behavior matching "${behaviorText}" found in "${store}".`;
|
|
732
|
+
|
|
733
|
+
const signal = relevant ? "confirm" : "disconfirm";
|
|
734
|
+
ctx.db.transaction(() => {
|
|
735
|
+
ctx.db.adjustBehaviorConfidence(behavior.id, signal);
|
|
736
|
+
ctx.db.addBehaviorProvenance(behavior.id, signal, `${relevant ? "ham" : "spam"}: ${contextText}`);
|
|
737
|
+
});
|
|
738
|
+
const updated = ctx.db.getBehavior(behavior.id);
|
|
739
|
+
return `[${signal}] "${updated?.statement ?? behaviorText}" confidence=${(updated?.confidence ?? 0).toFixed(2)} (${updated?.confirm_count ?? 0}/${updated?.disconfirm_count ?? 0})`;
|
|
740
|
+
},
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
const behaviorListDef: ToolDef = {
|
|
744
|
+
name: "behavior_list",
|
|
745
|
+
description:
|
|
746
|
+
"List all codified behaviors with their matchers, confidence, and " +
|
|
747
|
+
"evidence count. For inspection and debugging.",
|
|
748
|
+
args: {
|
|
749
|
+
store: z.string().optional().describe(
|
|
750
|
+
"Which store to list. Defaults to the project store.",
|
|
751
|
+
),
|
|
752
|
+
},
|
|
753
|
+
async execute(args, ctx) {
|
|
754
|
+
const store = (args.store as string) || ctx.defaultStore;
|
|
755
|
+
const behaviors = ctx.db.listBehaviors(store);
|
|
756
|
+
if (behaviors.length === 0) return `No behaviors in "${store}".`;
|
|
757
|
+
return behaviors.map((b) => {
|
|
758
|
+
const matchers = b.matchers.map((m) => ` - "${m.description}" (w:${m.weight})`).join("\n");
|
|
759
|
+
const provenance = ctx.db.getBehaviorProvenance(b.id);
|
|
760
|
+
const provLines = provenance.map((pr) => ` - [${pr.created_at.slice(0, 10)}] ${pr.signal}: ${pr.detail ?? ""}`).join("\n");
|
|
761
|
+
return `[${b.confidence.toFixed(2)} conf, ${b.evidence_count} tests] ${b.statement}` +
|
|
762
|
+
(b.rationale ? `\n rationale: ${b.rationale}` : "") +
|
|
763
|
+
(matchers ? `\n matchers:\n${matchers}` : "") +
|
|
764
|
+
(provLines ? `\n provenance:\n${provLines}` : "");
|
|
765
|
+
}).join("\n\n");
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
const behaviorDeleteDef: ToolDef = {
|
|
770
|
+
name: "behavior_delete",
|
|
771
|
+
description:
|
|
772
|
+
"Delete a codified behavior. Useful when a behavior was created in " +
|
|
773
|
+
"error or is no longer relevant. Edges and provenance are deleted " +
|
|
774
|
+
"automatically (cascade).",
|
|
775
|
+
args: {
|
|
776
|
+
statement: z.string().describe(
|
|
777
|
+
"The behavior statement to delete. Use behavior_list to find the " +
|
|
778
|
+
"exact text; matching is semantic (cosine >= 0.85).",
|
|
779
|
+
),
|
|
780
|
+
store: z.string().optional().describe(
|
|
781
|
+
"Store to delete from. Defaults to the project store.",
|
|
782
|
+
),
|
|
783
|
+
},
|
|
784
|
+
async execute(args, ctx) {
|
|
785
|
+
const store = (args.store as string) || ctx.defaultStore;
|
|
786
|
+
const statementText = args.statement as string;
|
|
787
|
+
const behaviorEmbed = await ctx.model.passageEmbed(statementText);
|
|
788
|
+
const behavior = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE);
|
|
789
|
+
if (!behavior) return `No behavior matching "${statementText}" found in "${store}".`;
|
|
790
|
+
const deleted = ctx.db.deleteBehavior(behavior.id);
|
|
791
|
+
if (!deleted) return `Failed to delete behavior "${behavior.statement}".`;
|
|
792
|
+
return `[deleted] "${behavior.statement}" from "${store}"`;
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
|
|
575
796
|
/**
|
|
576
797
|
* All tool definitions, in the order they should be presented to the agent.
|
|
577
798
|
* The opencode plugin wraps each in `tool()`; the MCP server exposes them
|
|
@@ -587,8 +808,13 @@ export const TOOL_DEFS: ToolDef[] = [
|
|
|
587
808
|
findDuplicatesDef,
|
|
588
809
|
markCheckedDef,
|
|
589
810
|
extractionDoneDef,
|
|
811
|
+
getExtractionPayloadDef,
|
|
590
812
|
predictionQueryDef,
|
|
591
813
|
predictionUpdateDef,
|
|
592
814
|
predictionListDef,
|
|
593
815
|
predictionDeleteDef,
|
|
816
|
+
behaviorCodifyDef,
|
|
817
|
+
behaviorFeedbackDef,
|
|
818
|
+
behaviorListDef,
|
|
819
|
+
behaviorDeleteDef,
|
|
594
820
|
];
|
package/src/tools.ts
CHANGED
|
@@ -13,8 +13,18 @@ export function createTools(
|
|
|
13
13
|
db: ThatchDB,
|
|
14
14
|
model: EmbeddingModel,
|
|
15
15
|
defaultStore: string,
|
|
16
|
+
extensions?: {
|
|
17
|
+
extractionPayloadProvider?: CoreContext["extractionPayloadProvider"];
|
|
18
|
+
drainExtractionQueue?: CoreContext["drainExtractionQueue"];
|
|
19
|
+
},
|
|
16
20
|
): Record<string, ReturnType<typeof tool>> {
|
|
17
|
-
const ctx: CoreContext = {
|
|
21
|
+
const ctx: CoreContext = {
|
|
22
|
+
db,
|
|
23
|
+
model,
|
|
24
|
+
defaultStore,
|
|
25
|
+
extractionPayloadProvider: extensions?.extractionPayloadProvider,
|
|
26
|
+
drainExtractionQueue: extensions?.drainExtractionQueue,
|
|
27
|
+
};
|
|
18
28
|
|
|
19
29
|
const tools: Record<string, ReturnType<typeof tool>> = {};
|
|
20
30
|
for (const def of TOOL_DEFS) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconstructs a Float32Array from a stored BLOB. The Uint8Array bun:sqlite
|
|
3
|
+
* hands back may itself be a view, so honor its offset and length rather than
|
|
4
|
+
* reading the whole backing buffer.
|
|
5
|
+
*/
|
|
6
|
+
export function blobToVector(blob: Uint8Array): Float32Array {
|
|
7
|
+
return new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Cosine similarity between two Float32Array vectors of equal dimension.
|
|
12
|
+
* Returns a value in [-1, 1]. Normalized embeddings will be close to [0, 1].
|
|
13
|
+
* Throws on dimension mismatch - comparing vectors from different embedding
|
|
14
|
+
* spaces is always a caller bug; callers filter by length first.
|
|
15
|
+
*/
|
|
16
|
+
export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|
17
|
+
if (a.length !== b.length) {
|
|
18
|
+
throw new Error(`cosineSimilarity: dimension mismatch (${a.length} vs ${b.length})`);
|
|
19
|
+
}
|
|
20
|
+
let dot = 0;
|
|
21
|
+
let magA = 0;
|
|
22
|
+
let magB = 0;
|
|
23
|
+
for (let i = 0; i < a.length; i++) {
|
|
24
|
+
dot += a[i] * b[i];
|
|
25
|
+
magA += a[i] * a[i];
|
|
26
|
+
magB += b[i] * b[i];
|
|
27
|
+
}
|
|
28
|
+
const denominator = Math.sqrt(magA) * Math.sqrt(magB);
|
|
29
|
+
return denominator === 0 ? 0 : dot / denominator;
|
|
30
|
+
}
|
package/docs/qa/README.md
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
# QA — Test Plan
|
|
2
|
-
|
|
3
|
-
Thatch uses `bun:test`. The full suite runs in well under a second with no
|
|
4
|
-
network access and no writes outside temp directories.
|
|
5
|
-
|
|
6
|
-
## Test conventions
|
|
7
|
-
|
|
8
|
-
- **DB tests** create a real SQLite file in a `mkdtempSync` temp directory,
|
|
9
|
-
removed in `afterEach`. (Real files, not `:memory:` — WAL behavior differs.)
|
|
10
|
-
- **Embedding tests** use `MockEmbeddingModel`: hash-seeded deterministic
|
|
11
|
-
vectors, so identical texts embed identically and unrelated texts land
|
|
12
|
-
near-orthogonal. The real `BgeEmbeddingModel` is never instantiated in
|
|
13
|
-
tests — nothing downloads models.
|
|
14
|
-
- **Git unit tests** exercise `parseGitUrl` on strings; **git integration
|
|
15
|
-
tests** create throwaway repos in temp directories and run real git.
|
|
16
|
-
- **Tool tests** build tools via the factory functions with an injected temp
|
|
17
|
-
DB + mock model, then call `execute` directly.
|
|
18
|
-
- **Plugin tests** call the `server` entry with a mock client and redirect
|
|
19
|
-
`THATCH_DB_PATH` and `XDG_CONFIG_HOME` into a temp directory so skill
|
|
20
|
-
installation never touches the real `~/.config`.
|
|
21
|
-
|
|
22
|
-
## Running tests
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
bun test # all tests
|
|
26
|
-
bun test --watch # watch mode
|
|
27
|
-
mise run check # typecheck + bun test + markdownlint (the CI gate)
|
|
28
|
-
mise run typecheck # tsc alone
|
|
29
|
-
mise run lint-md # markdownlint only
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
`mise run check` is the canonical quality gate (also run by CI on push/PR to
|
|
33
|
-
`main`). Typecheck uses `tsconfig.check.json` (which includes the test files
|
|
34
|
-
that the build's `tsconfig.json` excludes). Markdownlint enforces structural
|
|
35
|
-
correctness on `README.md` and `docs/` excluding `docs/plans/`; see
|
|
36
|
-
`.markdownlint-cli2.jsonc` for rule config.
|
|
37
|
-
|
|
38
|
-
## Test files and coverage
|
|
39
|
-
|
|
40
|
-
### `tests/db.test.ts`
|
|
41
|
-
|
|
42
|
-
- `cosineSimilarity`: identical/orthogonal/opposite/zero vectors; throws on
|
|
43
|
-
dimension mismatch
|
|
44
|
-
- Schema creation, store creation/idempotence
|
|
45
|
-
- `slugify`: ASCII, unicode preservation, hash fallback for all-symbol labels
|
|
46
|
-
- `remember`: insert, duplicate rejection without `overwrite`, overwrite,
|
|
47
|
-
metadata, embedding round-trip through a view with non-zero `byteOffset`
|
|
48
|
-
- `recall`: ranking, multi-store, unknown store, empty store list, limit,
|
|
49
|
-
branch scoping, skipping dimension-mismatched entries
|
|
50
|
-
- `listEntries`, `showEntry`, `forgetEntry`
|
|
51
|
-
- Dedup: `findDuplicates` thresholding, checked-pair suppression, verdict
|
|
52
|
-
invalidation on overwrite and forget, dimension-mismatch pair skipping
|
|
53
|
-
- `findSimilar`: threshold/ranking, self-exclusion, dimension skipping, no
|
|
54
|
-
telemetry side effects
|
|
55
|
-
- Recall telemetry: returned rows (and only returned rows) stamped
|
|
56
|
-
- Hygiene queries: stale counting with recall-refresh, branch listing/counts
|
|
57
|
-
- Column migration: pre-telemetry databases gain the new columns on open
|
|
58
|
-
|
|
59
|
-
### `tests/git.test.ts`
|
|
60
|
-
|
|
61
|
-
- `parseGitUrl` across SSH shorthand, HTTPS/HTTP, `ssh://`, `git://`,
|
|
62
|
-
GitLab/self-hosted, hyphens/dots, trailing whitespace, unparseable input
|
|
63
|
-
|
|
64
|
-
### `tests/git-integration.test.ts`
|
|
65
|
-
|
|
66
|
-
- `detectRepo` against real temp git repos: HTTPS remote, SSH remote,
|
|
67
|
-
no-`.git`-suffix remote, no-remote fallback, non-git-directory fallback
|
|
68
|
-
- `listBranches`: local branches listed; empty outside a git repo
|
|
69
|
-
|
|
70
|
-
### `tests/embeddings.test.ts`
|
|
71
|
-
|
|
72
|
-
- `MockEmbeddingModel` contract: dims, loaded, determinism, query/passage
|
|
73
|
-
equivalence, distinct texts → distinct near-orthogonal vectors, model name
|
|
74
|
-
- `BgeEmbeddingModel` (with injected pipeline factory): lazy-load, loaded
|
|
75
|
-
state, concurrent load memoization, error retry, query prefix for
|
|
76
|
-
asymmetric search, passage has no prefix, model name tag, default model name
|
|
77
|
-
|
|
78
|
-
### `tests/tools.test.ts`
|
|
79
|
-
|
|
80
|
-
- `thatch_memory_remember`: happy path, duplicate rejection, overwrite,
|
|
81
|
-
explicit store, metadata
|
|
82
|
-
- `thatch_memory_recall`: default repo+global scope, empty results, store
|
|
83
|
-
override, limit
|
|
84
|
-
- `thatch_memory_list` / `show` / `forget`: found, not-found, store override
|
|
85
|
-
- `thatch_store_list`
|
|
86
|
-
- `thatch_find_duplicates`: surfaces similar pairs, cluster grouping,
|
|
87
|
-
no-match message, store override, skips checked pairs
|
|
88
|
-
- `thatch_dedup_mark_checked`: records verdict, store override
|
|
89
|
-
- Write-time collision warning: fires on similar content, silent for
|
|
90
|
-
unrelated content, never warns against itself on overwrite
|
|
91
|
-
|
|
92
|
-
### `tests/plugin.test.ts`
|
|
93
|
-
|
|
94
|
-
- `server` export shape: all thirteen tools, every hook present, tools carry
|
|
95
|
-
description/args/execute
|
|
96
|
-
- System transform and compaction hooks append their text
|
|
97
|
-
- `tool.execute.after` → `chat.message` extraction round-trip: nudge carries
|
|
98
|
-
the JSON payload, buffers are per-session, flush is one-shot, `thatch_*`
|
|
99
|
-
tools are excluded from buffering
|
|
100
|
-
- Skill files land under the redirected `XDG_CONFIG_HOME`
|
|
101
|
-
- `event` handler: calls `client.session.prompt` on `session.created` with
|
|
102
|
-
correct session ID and nudge text; ignores non-`session.created` events
|
|
103
|
-
- `sessionStartReminder`: includes store name and recall instructions
|
|
104
|
-
- Recall nudge via `chat.message`: surfaces when prompt matches a stored
|
|
105
|
-
memory, no nudge for unrelated or short prompts, extraction priority
|
|
106
|
-
- Prediction auto-fire via `chat.message`: surfaces when prompt matches a
|
|
107
|
-
stored matcher, no nudge for unrelated prompts, prediction and recall
|
|
108
|
-
nudges fire independently as separate synthetic parts
|
|
109
|
-
|
|
110
|
-
### `tests/tool-defs.test.ts`
|
|
111
|
-
|
|
112
|
-
- Tool count (13) and name list
|
|
113
|
-
- Zod schema validation for each tool's args
|
|
114
|
-
- Execute functions: `memory_remember` save/duplicate/overwrite/archived,
|
|
115
|
-
`memory_recall` default/empty/archived, `memory_list`/`show`/`forget`,
|
|
116
|
-
`store_list`, `find_duplicates` clean store
|
|
117
|
-
- Prediction execute functions: `prediction_update` create/confirm/disconfirm/
|
|
118
|
-
create-on-existing (dedup links without disconfirming), `prediction_query`
|
|
119
|
-
matching/unrelated, `prediction_list` with provenance, `prediction_delete`
|
|
120
|
-
found/not-found
|
|
121
|
-
|
|
122
|
-
### `tests/prediction.test.ts`
|
|
123
|
-
|
|
124
|
-
- Schema: tables exist, `populationP0` fallback on empty store
|
|
125
|
-
- Matcher creation and lookup: `findMatchers` ranking, `findNearestMatcher`
|
|
126
|
-
threshold/dedup
|
|
127
|
-
- Prediction creation: `createPrediction` seeds at p0, `adjustConfidence`
|
|
128
|
-
confirm/disconfirm/soft/multiple, non-existent ID no-op
|
|
129
|
-
- Edges and scoring: `createEdge` links, `scorePredictions` empty/dedup/
|
|
130
|
-
weight-preservation, `scorePredictionNudge` threshold filtering + dedup
|
|
131
|
-
- `findNearestPrediction`: store-wide search, threshold, cross-matcher
|
|
132
|
-
- Provenance: `addProvenance` + `getProvenance` read-back, `deletePrediction`
|
|
133
|
-
cascade to edges and provenance
|
|
134
|
-
- `listPredictions`: sorted by confidence, matchers included
|
|
135
|
-
- `populationP0`: under-20 fallback, population hit rate with sufficient evidence
|
|
136
|
-
|
|
137
|
-
### `tests/hygiene.test.ts`
|
|
138
|
-
|
|
139
|
-
- `hygieneReport`: silent on a healthy store; counts duplicate candidates,
|
|
140
|
-
stale memories, and orphaned branch-scoped memories (against a real temp
|
|
141
|
-
git repo); skips the branch check outside a git repo
|
|
142
|
-
- `sessionStartReminder` hygiene block: appended when present, omitted when
|
|
143
|
-
null, single-arg call unchanged
|
|
144
|
-
|
|
145
|
-
## Known gaps
|
|
146
|
-
|
|
147
|
-
- `BgeEmbeddingModel`'s default `PipelineFactory` (the real HF import) is
|
|
148
|
-
untested — exercising it means downloading the real model, which violates
|
|
149
|
-
the no-network rule. The lazy-load logic is tested with an injected mock
|
|
150
|
-
factory. The real model is exercised indirectly by any real opencode use.
|
|
151
|
-
- The skill install failure path in `index.ts` has no test (would require
|
|
152
|
-
mocking `fs` to force a write error).
|
|
153
|
-
- `bin/thatch` has no automated tests; it is a thin shell over `db.ts`.
|
|
154
|
-
|
|
155
|
-
## QA use-case suite
|
|
156
|
-
|
|
157
|
-
End-to-end QA scenarios live in `tests/qa/` as bun test files, not docs.
|
|
158
|
-
Each use case is a `uc-NNN-*.test.ts` file that defines preconditions,
|
|
159
|
-
steps, and expected results as string constants, then registers itself
|
|
160
|
-
via `registerUseCase` from `tests/qa/runner.ts`.
|
|
161
|
-
|
|
162
|
-
Most use cases shell out to `opencode run` with an isolated repo copy
|
|
163
|
-
(temp DB, temp config, temp home). Automatable use cases override the
|
|
164
|
-
`run` function with direct CLI/bun assertions. Manual-only use cases
|
|
165
|
-
(compaction, visual TUI) are skipped automatically.
|
|
166
|
-
|
|
167
|
-
Run the suite:
|
|
168
|
-
|
|
169
|
-
```bash
|
|
170
|
-
mise run qa # full run (spawns opencode sessions, costs model tokens)
|
|
171
|
-
mise run qa-dry-run # list use cases without spawning sessions
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
The regular test suite (`mise run check`) excludes `tests/qa/` by using
|
|
175
|
-
a flat glob (`bun test tests/*.test.ts`).
|