@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.
Files changed (62) hide show
  1. package/README.md +24 -10
  2. package/artifacts/skills/thatch-code-review.md +9 -7
  3. package/artifacts/skills/thatch-fact-extractor.md +2 -2
  4. package/artifacts/skills/thatch-review-economy.md +97 -0
  5. package/artifacts/skills/thatch-review-followup.md +1 -1
  6. package/artifacts/skills/thatch-review-synthesizer.md +15 -5
  7. package/bin/qa-run +38 -0
  8. package/bin/thatch +13 -7
  9. package/docs/dev/README.md +76 -22
  10. package/docs/dev/features/README.md +33 -0
  11. package/docs/dev/features/behavior-engine.md +271 -0
  12. package/docs/dev/features/cicd.md +89 -0
  13. package/docs/dev/features/cli.md +81 -0
  14. package/docs/dev/features/compaction-recovery.md +48 -0
  15. package/docs/dev/features/database.md +244 -0
  16. package/docs/dev/features/deduplication.md +102 -0
  17. package/docs/dev/features/extraction.md +253 -0
  18. package/docs/dev/features/hygiene.md +81 -0
  19. package/docs/dev/features/memory-store.md +154 -0
  20. package/docs/dev/features/multi-host.md +228 -0
  21. package/docs/dev/features/nudge-pipeline.md +282 -0
  22. package/docs/dev/features/prediction-engine.md +253 -0
  23. package/docs/dev/features/qa-system.md +205 -0
  24. package/docs/dev/features/session-lifecycle.md +110 -0
  25. package/docs/dev/features/setup.md +199 -0
  26. package/docs/dev/features/sideband.md +104 -0
  27. package/docs/dev/gotchas.md +3 -3
  28. package/docs/dev/mcp-parity.md +7 -5
  29. package/docs/dev/setup-and-hooks.md +4 -4
  30. package/docs/dev/skills.md +10 -8
  31. package/docs/plans/README.md +2 -2
  32. package/docs/user/README.md +58 -20
  33. package/docs/user/behavior-engine.md +85 -0
  34. package/docs/user/cli.md +98 -0
  35. package/docs/user/code-review.md +73 -0
  36. package/docs/user/deduplication.md +70 -0
  37. package/docs/user/default-behaviors.md +75 -0
  38. package/docs/user/extraction.md +74 -0
  39. package/docs/user/hygiene.md +76 -0
  40. package/docs/user/memory.md +81 -0
  41. package/docs/user/prediction-engine.md +87 -0
  42. package/docs/user/setup.md +110 -0
  43. package/docs/user/skills.md +97 -0
  44. package/package.json +2 -2
  45. package/src/behavior.ts +120 -0
  46. package/src/db.ts +160 -417
  47. package/src/embeddings.ts +0 -41
  48. package/src/extraction.ts +2 -2
  49. package/src/git.ts +3 -1
  50. package/src/index.ts +66 -14
  51. package/src/mcp.ts +59 -2
  52. package/src/prediction.ts +127 -0
  53. package/src/prompts.ts +155 -200
  54. package/src/scoring-engine.ts +374 -0
  55. package/src/seed-behaviors.ts +339 -0
  56. package/src/setup.ts +2 -6
  57. package/src/sideband.ts +36 -8
  58. package/src/skills.ts +1 -0
  59. package/src/tool-defs.ts +235 -9
  60. package/src/tools.ts +11 -1
  61. package/src/vector-math.ts +30 -0
  62. 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 creation time.
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.45;
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: any): string {
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
- args: {},
382
- async execute() {
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 === 0 ? "you may prefer" : "you tend to";
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 = { db, model, defaultStore };
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`).