@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/embeddings.ts CHANGED
@@ -82,44 +82,3 @@ export class BgeEmbeddingModel implements EmbeddingModel {
82
82
  return output.data as Float32Array;
83
83
  }
84
84
  }
85
-
86
- /**
87
- * Mock embedding model for tests. Returns deterministic vectors derived from
88
- * a hash of the input text via xorshift, so identical texts embed identically
89
- * and distinct texts land near-orthogonal - mirroring how unrelated content
90
- * behaves in a real embedding space. Never loads a model or touches the network.
91
- */
92
- export class MockEmbeddingModel implements EmbeddingModel {
93
- readonly dims = 384;
94
- readonly name = "mock";
95
- loaded = true;
96
-
97
- async load(): Promise<void> {}
98
-
99
- async queryEmbed(text: string): Promise<Float32Array> {
100
- return this.#embed(text);
101
- }
102
-
103
- async passageEmbed(text: string): Promise<Float32Array> {
104
- return this.#embed(text);
105
- }
106
-
107
- #embed(text: string): Float32Array {
108
- let h = 0;
109
- for (let i = 0; i < text.length; i++) {
110
- h = ((h << 5) - h) + text.charCodeAt(i);
111
- h |= 0;
112
- }
113
- h ^= 0x9e3779b9; // avoid the degenerate all-zero state for empty input
114
-
115
- const vec = new Float32Array(this.dims);
116
- for (let i = 0; i < this.dims; i++) {
117
- h ^= h << 13;
118
- h ^= h >>> 17;
119
- h ^= h << 5;
120
- h |= 0;
121
- vec[i] = h / 0x80000000;
122
- }
123
- return vec;
124
- }
125
- }
package/src/extraction.ts CHANGED
@@ -11,7 +11,7 @@ export interface ToolInteraction {
11
11
  * opencode plugin (in-process) and the Claude Code CLI (file-backed queue) so
12
12
  * the payload the agent ultimately receives has the same shape from either path.
13
13
  */
14
- export function summarizeArgs(tool: string, args: Record<string, unknown>): string {
14
+ function summarizeArgs(tool: string, args: Record<string, unknown>): string {
15
15
  const file = args.filePath ?? args.file_path;
16
16
  switch (tool.toLowerCase()) {
17
17
  case "read":
@@ -54,7 +54,7 @@ export function deriveTitle(tool: string, args: Record<string, unknown>): string
54
54
  }
55
55
  }
56
56
 
57
- export function truncate(text: string, maxLen: number): string {
57
+ function truncate(text: string, maxLen: number): string {
58
58
  if (text.length <= maxLen) return text;
59
59
  return text.slice(0, maxLen) + "...";
60
60
  }
package/src/git.ts CHANGED
@@ -69,8 +69,10 @@ export async function detectRepo(cwd?: string): Promise<string> {
69
69
  try {
70
70
  const gitDir = await $`git rev-parse --git-common-dir`.cwd(dir).quiet();
71
71
  if (gitDir.exitCode === 0) {
72
+ // Handle both "/path/to/.git" (absolute) and ".git" (relative,
73
+ // when cwd is the repo root itself).
72
74
  const d = gitDir.stdout.toString().trim();
73
- const parent = d.endsWith("/.git") ? d.slice(0, -5) : d;
75
+ const parent = d.endsWith("/.git") ? d.slice(0, -5) : (d === ".git" ? "" : d);
74
76
  const name = parent.split("/").pop() || "unknown";
75
77
  if (name !== "unknown") return name;
76
78
  }
package/src/index.ts CHANGED
@@ -12,11 +12,13 @@ import {
12
12
  extractionNudge,
13
13
  extractionDirectPrompt,
14
14
  predictionNudge,
15
+ behaviorNudge,
15
16
  type NudgeMatch,
16
17
  } from "./prompts";
17
18
  import { ExtractionPipeline, type ToolInteraction } from "./extraction";
18
19
  import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
19
20
  import { hygieneReport } from "./hygiene";
21
+ import { seedDefaultBehaviors } from "./seed-behaviors";
20
22
 
21
23
  // ---------------------------------------------------------------------------
22
24
  // V1 server export - tools, prompt injection, session hooks
@@ -31,10 +33,17 @@ const RECALL_THRESHOLD = parseFloat(process.env.THATCH_RECALL_THRESHOLD ?? "0.55
31
33
  // like "yes" or "ok" match too broadly to be useful.
32
34
  const MIN_PROMPT_LEN = 10;
33
35
 
34
- // Minimum cosine score for the prediction auto-fire. Lower than
35
- // RECALL_THRESHOLD because "this situation matches a known pattern" is
36
- // a weaker signal than "this prompt relates to a stored memory."
37
- const PREDICTION_THRESHOLD = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD ?? "0.45");
36
+ // Minimum cosine score for the prediction auto-fire. Higher than the
37
+ // recall nudge (0.55) because surfacing a user-preference nudge is more
38
+ // disruptive than surfacing a memory -- the agent may act on it or
39
+ // surface it to the user. 0.60 cuts noise in dense embedding spaces
40
+ // while still catching genuinely related contexts.
41
+ const PREDICTION_THRESHOLD = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD ?? "0.60");
42
+
43
+ // Same threshold for behavior auto-fire. Same rationale: surfacing a
44
+ // self-discipline rule is disruptive and should only fire when the
45
+ // situation genuinely matches.
46
+ const BEHAVIOR_THRESHOLD = parseFloat(process.env.THATCH_BEHAVIOR_THRESHOLD ?? "0.60");
38
47
 
39
48
  export const server: Plugin = async ({ client, worktree }) => {
40
49
  // The opencode server's cwd is wherever the server happened to start;
@@ -49,6 +58,13 @@ export const server: Plugin = async ({ client, worktree }) => {
49
58
  const model = new BgeEmbeddingModel(modelName);
50
59
  const extraction = new ExtractionPipeline();
51
60
 
61
+ // Seed default behaviors into the global store on first run, and
62
+ // update them when their content changes across releases. Idempotent:
63
+ // behaviors are matched by key stamp, not by cosine similarity, so
64
+ // user-codified behaviors are never overwritten. See seed-behaviors.ts
65
+ // for the full mechanism.
66
+ await seedDefaultBehaviors(db, model);
67
+
52
68
  // Sessions currently being compacted. chat.message nudges are skipped while
53
69
  // a session is in this set - the agent can't call tools during summary
54
70
  // generation, so a recall or extraction nudge would cause a blocked-tool
@@ -141,8 +157,7 @@ export const server: Plugin = async ({ client, worktree }) => {
141
157
  extracting.add(parentID);
142
158
 
143
159
  const batch = extraction.peek(parentID);
144
- const payload = extraction.buildPayload(batch, repo);
145
- const promptText = extractionDirectPrompt(batch.length, payload);
160
+ const promptText = extractionDirectPrompt(batch.length, parentID);
146
161
 
147
162
  const result = await client.session.create({
148
163
  body: { parentID, title: "thatch-extraction" },
@@ -201,7 +216,15 @@ export const server: Plugin = async ({ client, worktree }) => {
201
216
  }
202
217
 
203
218
  return {
204
- tool: createTools(db, model, repo),
219
+ tool: createTools(db, model, repo, {
220
+ extractionPayloadProvider: (sessionID: string): string | null => {
221
+ const interactions = extraction.peek(sessionID);
222
+ const accepted = extraction.peekAccepted(sessionID);
223
+ const all = [...accepted, ...interactions];
224
+ if (all.length === 0) return null;
225
+ return extraction.buildPayload(all, repo);
226
+ },
227
+ }),
205
228
 
206
229
  // 1. System prompt - always in context.
207
230
  "experimental.chat.system.transform": async (_input, output) => {
@@ -321,13 +344,14 @@ export const server: Plugin = async ({ client, worktree }) => {
321
344
  // 4. Per-message nudge - two priority tiers:
322
345
  // a. Extraction nudge: prior tool interactions are queued for fact
323
346
  // extraction (carries the JSON payload for thatch-fact-extractor).
324
- // b. Recall + prediction nudge: when no extraction is pending, embed
325
- // the user's prompt (shared embedding for both) and:
347
+ // b. Recall + prediction + behavior nudge: when no extraction is pending, embed
348
+ // the user's prompt (shared embedding for all three) and:
326
349
  // - search memories by cosine (recall nudge)
327
350
  // - search matchers by cosine, score predictions (prediction fire)
328
- // Both fire independently; either, both, or neither may inject.
329
- // The auto-fire reuses the embedding already computed for recall,
330
- // adding only one more cosine scan against the matchers table.
351
+ // - search behavior matchers by cosine, score behaviors (behavior fire)
352
+ // All three fire independently; any subset may inject.
353
+ // The auto-fires reuse the embedding already computed for recall,
354
+ // adding only cosine scans against the matchers tables.
331
355
  // No extra model calls.
332
356
  //
333
357
  // Skipped during compaction: the agent can't call tools while generating
@@ -352,9 +376,8 @@ export const server: Plugin = async ({ client, worktree }) => {
352
376
  // here only when direct extraction was never triggered or threw.
353
377
  if (!extracting.has(input.sessionID) && extraction.pending(input.sessionID)) {
354
378
  const batch = extraction.peek(input.sessionID);
355
- const payload = extraction.buildPayload(batch, repo);
356
379
  const missed = missedNudges.get(input.sessionID) ?? 0;
357
- const text = extractionNudge(batch.length, missed, "thatch_memory_remember", payload);
380
+ const text = extractionNudge(batch.length, missed, "thatch_memory_remember", input.sessionID);
358
381
  missedNudges.set(input.sessionID, missed + 1);
359
382
 
360
383
  output.parts.push({
@@ -442,6 +465,35 @@ export const server: Plugin = async ({ client, worktree }) => {
442
465
  } catch (err) {
443
466
  console.error(`[thatch] prediction nudge failed: ${err}`);
444
467
  }
468
+
469
+ // Behavior fire: independent of recall and prediction. Same
470
+ // embedding, one more cosine scan against behavior_matchers.
471
+ try {
472
+ const behaviorItems = db.scoreBehaviorNudge([repo, "global"], embedding, BEHAVIOR_THRESHOLD);
473
+ if (behaviorItems.length > 0) {
474
+ output.parts.push({
475
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
476
+ sessionID: input.sessionID,
477
+ messageID: input.messageID ?? output.message.id,
478
+ type: "text",
479
+ text: behaviorNudge(behaviorItems),
480
+ synthetic: true,
481
+ });
482
+ try {
483
+ await client.tui.showToast({
484
+ body: {
485
+ message: `\u{1F4AD} ${behaviorItems.length} behavior${behaviorItems.length === 1 ? "" : "s"} surfaced`,
486
+ variant: "info",
487
+ duration: 3000,
488
+ },
489
+ });
490
+ } catch {
491
+ // TUI may not be connected. Best-effort.
492
+ }
493
+ }
494
+ } catch (err) {
495
+ console.error(`[thatch] behavior nudge failed: ${err}`);
496
+ }
445
497
  } catch (err) {
446
498
  console.error(`[thatch] nudge hook failed: ${err}`);
447
499
  }
package/src/mcp.ts CHANGED
@@ -1,10 +1,14 @@
1
+ import { resolve } from "node:path";
1
2
  import { z } from "zod";
2
3
  import { ThatchDB } from "./db";
3
4
  import { BgeEmbeddingModel } from "./embeddings";
4
5
  import { detectRepo } from "./git";
5
- import { checkSetup } from "./setup";
6
+ import { checkSetup, setupClaudeCode, setupCursor } from "./setup";
6
7
  import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
7
8
  import { SidebandServer, sidebandSocketPath } from "./sideband";
9
+ import { peekQueue, consumeQueue, resetMissedCount } from "./extract-queue";
10
+ import { buildExtractionPayload } from "./extraction";
11
+ import { seedDefaultBehaviors } from "./seed-behaviors";
8
12
  import pkg from "../package.json";
9
13
 
10
14
  // ---------------------------------------------------------------------------
@@ -98,7 +102,35 @@ export async function runMcpServer(): Promise<void> {
98
102
 
99
103
  const db = new ThatchDB(dbPath);
100
104
  const model = new BgeEmbeddingModel(modelName);
101
- const ctx: CoreContext = { db, model, defaultStore: repo };
105
+
106
+ await seedDefaultBehaviors(db, model);
107
+
108
+ // Extraction payload provider: peeks the file-backed queue and returns the
109
+ // serialized JSON payload. Called by the get_extraction_payload tool when a
110
+ // sub-agent needs to fetch the parent's queued interactions. Returns null
111
+ // when no interactions are queued.
112
+ const extractionPayloadProvider = (sessionID: string): string | null => {
113
+ const interactions = peekQueue(sessionID);
114
+ if (interactions.length === 0) return null;
115
+ return buildExtractionPayload(interactions, repo);
116
+ };
117
+
118
+ // Drain a session's file-backed queue and reset its missed-nudge counter.
119
+ // Called by extraction_done when a sub-agent passes the parent's session_id.
120
+ // Without this, the MCP path's appendBatch self-detection would drain the
121
+ // sub-agent's (empty) queue instead of the parent's.
122
+ const drainExtractionQueue = (sessionID: string): void => {
123
+ resetMissedCount(sessionID);
124
+ consumeQueue(sessionID);
125
+ };
126
+
127
+ const ctx: CoreContext = {
128
+ db,
129
+ model,
130
+ defaultStore: repo,
131
+ extractionPayloadProvider,
132
+ drainExtractionQueue,
133
+ };
102
134
  const tools = compileTools();
103
135
 
104
136
  // Check whether `thatch setup` was run for the current host. If not, or if
@@ -112,6 +144,31 @@ export async function runMcpServer(): Promise<void> {
112
144
  console.error(`[thatch] ${setupWarning}`);
113
145
  }
114
146
 
147
+ // Auto-refresh: if setup was previously run (markers exist), re-run the
148
+ // install functions to update skills, instructions, and hooks that may have
149
+ // drifted since the last `thatch setup`. All operations are idempotent --
150
+ // they only write when content differs. This keeps MCP-host installations
151
+ // up to date without requiring the user to manually re-run setup after
152
+ // upgrading thatch. The MCP config itself is also re-written, but it
153
+ // writes the same command that's already running, so it's a no-op in
154
+ // practice.
155
+ if (setupStatus && setupStatus.status === "installed") {
156
+ const thatchBin = resolve(process.argv[1] ?? process.execPath);
157
+ const isGlobal = setupStatus.scope === "global";
158
+ try {
159
+ if (setupStatus.host === "claude") {
160
+ setupClaudeCode(thatchBin, isGlobal, projectDir);
161
+ } else if (setupStatus.host === "cursor") {
162
+ setupCursor(thatchBin, isGlobal, projectDir);
163
+ }
164
+ } catch (err) {
165
+ // Auto-refresh is best-effort. A failure here does not affect the
166
+ // MCP server's ability to serve tool calls -- it just means skills
167
+ // or instructions may be stale until the user re-runs setup.
168
+ console.error(`[thatch] auto-refresh of skills/instructions failed: ${err}`);
169
+ }
170
+ }
171
+
115
172
  // The sideband socket lets one-shot hook processes (flush-tools) ask the
116
173
  // warm MCP server to embed a prompt and search for matches - without
117
174
  // loading the ~34 MB model themselves. If the socket can't be opened
@@ -0,0 +1,127 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { ScoringEngine, type NudgeItem, PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine";
3
+
4
+ export { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT };
5
+
6
+ export interface MatcherRow {
7
+ id: string;
8
+ store: string;
9
+ description: string;
10
+ embedding: Uint8Array | null;
11
+ model: string | null;
12
+ created_at: string;
13
+ updated_at: string;
14
+ }
15
+
16
+ export interface PredictionRow {
17
+ id: string;
18
+ store: string;
19
+ statement: string;
20
+ rationale: string | null;
21
+ confidence: number;
22
+ confirm_count: number;
23
+ disconfirm_count: number;
24
+ created_at: string;
25
+ updated_at: string;
26
+ }
27
+
28
+ export interface PredictionNudgeItem extends NudgeItem {}
29
+
30
+ export interface ScoredPrediction {
31
+ matcher_id: string;
32
+ matcher_description: string;
33
+ prediction_id: string;
34
+ statement: string;
35
+ confidence: number;
36
+ evidence_count: number;
37
+ score: number;
38
+ rationale: string | null;
39
+ }
40
+
41
+ const config = {
42
+ matchersTable: "prediction_matchers",
43
+ itemsTable: "predictions",
44
+ edgesTable: "prediction_edges",
45
+ provenanceTable: "prediction_provenance",
46
+ itemForeignKey: "prediction_id",
47
+ };
48
+
49
+ /**
50
+ * Prediction engine: matchers (context patterns), predictions (graded
51
+ * confidence statements), edges (weighted matcher-to-prediction links),
52
+ * and provenance (audit trail). Delegates to ScoringEngine for all
53
+ * SQL and scoring logic. Wraps the generic return types with
54
+ * prediction-specific type names for caller clarity.
55
+ */
56
+ export class PredictionEngine {
57
+ #engine: ScoringEngine;
58
+
59
+ constructor(db: Database) {
60
+ this.#engine = new ScoringEngine(db, config);
61
+ }
62
+
63
+ findMatchers(stores: string[], queryEmbedding: Float32Array, opts?: { limit?: number }) {
64
+ return this.#engine.findMatchers(stores, queryEmbedding, opts);
65
+ }
66
+
67
+ scorePredictions(matchers: { id: string; description: string; score: number }[]): ScoredPrediction[] {
68
+ return this.#engine.scoreItems(matchers).map((s) => ({
69
+ matcher_id: s.matcher_id,
70
+ matcher_description: s.matcher_description,
71
+ prediction_id: s.item_id,
72
+ statement: s.statement,
73
+ confidence: s.confidence,
74
+ evidence_count: s.evidence_count,
75
+ score: s.score,
76
+ rationale: s.rationale,
77
+ }));
78
+ }
79
+
80
+ scorePredictionNudge(stores: string[], embedding: Float32Array, threshold: number, limit = 5): PredictionNudgeItem[] {
81
+ return this.#engine.scoreNudge(stores, embedding, threshold, limit);
82
+ }
83
+
84
+ findNearestMatcher(store: string, embedding: Float32Array, threshold: number): { id: string; description: string } | null {
85
+ return this.#engine.findNearestMatcher(store, embedding, threshold);
86
+ }
87
+
88
+ createMatcher(store: string, description: string, embedding: Float32Array, model: string): string {
89
+ return this.#engine.createMatcher(store, description, embedding, model);
90
+ }
91
+
92
+ findNearestPrediction(store: string, embedding: Float32Array, threshold: number): PredictionRow | null {
93
+ return this.#engine.findNearestItem(store, embedding, threshold) as PredictionRow | null;
94
+ }
95
+
96
+ createPrediction(store: string, statement: string, rationale: string, embedding: Float32Array, model: string): string {
97
+ return this.#engine.createItem(store, statement, rationale, embedding, model);
98
+ }
99
+
100
+ createEdge(matcherId: string, predictionId: string, weight: number): void {
101
+ return this.#engine.createEdge(matcherId, predictionId, weight);
102
+ }
103
+
104
+ adjustConfidence(predictionId: string, signal: "confirm" | "disconfirm" | "soft"): void {
105
+ return this.#engine.adjustConfidence(predictionId, signal);
106
+ }
107
+
108
+ getPrediction(predictionId: string): PredictionRow | null {
109
+ return this.#engine.getItem(predictionId) as PredictionRow | null;
110
+ }
111
+
112
+ addProvenance(predictionId: string, signal: string, detail: string): void {
113
+ return this.#engine.addProvenance(predictionId, signal, detail);
114
+ }
115
+
116
+ getProvenance(predictionId: string): { signal: string; detail: string | null; created_at: string }[] {
117
+ return this.#engine.getProvenance(predictionId);
118
+ }
119
+
120
+ deletePrediction(predictionId: string): boolean {
121
+ return this.#engine.deleteItem(predictionId);
122
+ }
123
+
124
+ listPredictions(store: string) {
125
+ return this.#engine.listItems(store);
126
+ }
127
+ }