@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/src/index.ts CHANGED
@@ -10,9 +10,10 @@ import {
10
10
  sessionStartReminder,
11
11
  recallNudge,
12
12
  extractionNudge,
13
+ predictionNudge,
13
14
  type NudgeMatch,
14
15
  } from "./prompts";
15
- import { ExtractionPipeline } from "./extraction";
16
+ import { ExtractionPipeline, type ToolInteraction } from "./extraction";
16
17
  import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
17
18
  import { hygieneReport } from "./hygiene";
18
19
 
@@ -29,6 +30,11 @@ const RECALL_THRESHOLD = parseFloat(process.env.THATCH_RECALL_THRESHOLD ?? "0.55
29
30
  // like "yes" or "ok" match too broadly to be useful.
30
31
  const MIN_PROMPT_LEN = 10;
31
32
 
33
+ // Minimum cosine score for the prediction auto-fire. Lower than
34
+ // RECALL_THRESHOLD because "this situation matches a known pattern" is
35
+ // a weaker signal than "this prompt relates to a stored memory."
36
+ const PREDICTION_THRESHOLD = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD ?? "0.45");
37
+
32
38
  export const server: Plugin = async ({ client, worktree }) => {
33
39
  // The opencode server's cwd is wherever the server happened to start;
34
40
  // `worktree` is the project this plugin instance actually serves.
@@ -64,6 +70,14 @@ export const server: Plugin = async ({ client, worktree }) => {
64
70
  // Populated from session.created events that carry a parentID.
65
71
  const childToParent = new Map<string, string>();
66
72
 
73
+ // Snapshot of the parent's buffer at the moment each child was dispatched.
74
+ // When the child writes a memory, consumeSnapshot drains only these entries
75
+ // (by reference identity), preserving interleaved-turn entries that arrived
76
+ // while the sub-agent was running. Without this, the child's memory_remember
77
+ // would drain the parent's ENTIRE buffer — silently dropping facts from
78
+ // any tool calls the parent made concurrently with the sub-agent.
79
+ const parentSnapshots = new Map<string, ToolInteraction[]>();
80
+
67
81
  // Skills always install to the global opencode config — installing into the
68
82
  // worktree would mutate the user's repo (untracked files in git status).
69
83
  // A failed install degrades the nudge workflow but must not kill the plugin.
@@ -113,9 +127,11 @@ export const server: Plugin = async ({ client, worktree }) => {
113
127
  // grows with each missed cycle.
114
128
  //
115
129
  // A memory_remember call in a child session (sub-agent) also drains
116
- // the parent's buffer. Without this, dispatching the fact-extractor as
117
- // a background task writes memories in the child but never clears the
118
- // parent's queue — the nudge replays every turn.
130
+ // the parent's buffer — but only the entries that existed at dispatch
131
+ // time (the snapshot). Entries from interleaved turns survive so their
132
+ // facts aren't silently dropped. Without this, dispatching the
133
+ // fact-extractor as a background task writes memories in the child but
134
+ // never clears the parent's queue — the nudge replays every turn.
119
135
  //
120
136
  // thatch_extraction_done is an explicit acknowledgment tool the model
121
137
  // calls in the parent session after dispatching. Covers the case where
@@ -125,12 +141,19 @@ export const server: Plugin = async ({ client, worktree }) => {
125
141
  if (input.tool === "thatch_memory_remember") {
126
142
  extraction.consume(input.sessionID);
127
143
  missedNudges.delete(input.sessionID);
128
- // If this is a child session, also drain the parent so the parent's
129
- // extraction nudge stops replaying after a sub-agent writes memories
130
- // on its behalf.
144
+ // If this is a child session, drain only the parent's snapshot entries
145
+ // (those present at dispatch time). Interleaved-turn entries survive.
146
+ // If no snapshot was recorded (e.g. session.created event never
147
+ // arrived), fall back to draining the entire parent buffer.
131
148
  const parentID = childToParent.get(input.sessionID);
132
149
  if (parentID) {
133
- extraction.consume(parentID);
150
+ const snapshot = parentSnapshots.get(input.sessionID);
151
+ if (snapshot) {
152
+ extraction.consumeSnapshot(parentID, snapshot);
153
+ parentSnapshots.delete(input.sessionID);
154
+ } else {
155
+ extraction.consume(parentID);
156
+ }
134
157
  missedNudges.delete(parentID);
135
158
  }
136
159
  return;
@@ -156,9 +179,14 @@ export const server: Plugin = async ({ client, worktree }) => {
156
179
  // 4. Per-message nudge — two priority tiers:
157
180
  // a. Extraction nudge: prior tool interactions are queued for fact
158
181
  // extraction (carries the JSON payload for thatch-fact-extractor).
159
- // b. Recall nudge: the user's prompt semantically matches existing
160
- // memories — surface that prior knowledge exists before the agent
161
- // responds. Uses the in-process warm model (no sideband needed).
182
+ // b. Recall + prediction nudge: when no extraction is pending, embed
183
+ // the user's prompt (shared embedding for both) and:
184
+ // - search memories by cosine (recall nudge)
185
+ // - search matchers by cosine, score predictions (prediction fire)
186
+ // Both fire independently; either, both, or neither may inject.
187
+ // The auto-fire reuses the embedding already computed for recall,
188
+ // adding only one more cosine scan against the matchers table.
189
+ // No extra model calls.
162
190
  //
163
191
  // Skipped during compaction: the agent can't call tools while generating
164
192
  // a summary, so a nudge that says "use thatch_memory_recall" triggers a
@@ -195,41 +223,71 @@ export const server: Plugin = async ({ client, worktree }) => {
195
223
  if (promptText.length < MIN_PROMPT_LEN) return;
196
224
 
197
225
  const embedding = await model.queryEmbed(promptText);
198
- const results = db.search([repo, "global"], embedding, { limit: 5 });
199
- const matches: NudgeMatch[] = results
200
- .filter((r) => r._score >= RECALL_THRESHOLD)
201
- .map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
202
226
 
203
- if (matches.length === 0) return;
227
+ // Recall nudge: separate try/catch so a memory-search failure
228
+ // does not block the prediction fire (they share only the embedding).
229
+ try {
230
+ const results = db.search([repo, "global"], embedding, { limit: 5 });
231
+ const matches: NudgeMatch[] = results
232
+ .filter((r) => r._score >= RECALL_THRESHOLD)
233
+ .map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
204
234
 
205
- output.parts.push({
206
- id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
207
- sessionID: input.sessionID,
208
- messageID: input.messageID ?? output.message.id,
209
- type: "text",
210
- text: recallNudge(matches),
211
- synthetic: true,
212
- });
235
+ if (matches.length > 0) {
236
+ output.parts.push({
237
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
238
+ sessionID: input.sessionID,
239
+ messageID: input.messageID ?? output.message.id,
240
+ type: "text",
241
+ text: recallNudge(matches),
242
+ synthetic: true,
243
+ });
244
+ }
245
+ } catch (err) {
246
+ console.error(`[thatch] recall nudge failed: ${err}`);
247
+ }
248
+
249
+ // Prediction fire: independent of recall; a failure here does
250
+ // not affect the recall nudge that may have already been pushed.
251
+ try {
252
+ const predItems = db.scorePredictionNudge([repo, "global"], embedding, PREDICTION_THRESHOLD);
253
+ if (predItems.length > 0) {
254
+ output.parts.push({
255
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
256
+ sessionID: input.sessionID,
257
+ messageID: input.messageID ?? output.message.id,
258
+ type: "text",
259
+ text: predictionNudge(predItems),
260
+ synthetic: true,
261
+ });
262
+ }
263
+ } catch (err) {
264
+ console.error(`[thatch] prediction nudge failed: ${err}`);
265
+ }
213
266
  } catch (err) {
214
- console.error(`[thatch] recall nudge failed: ${err}`);
267
+ console.error(`[thatch] nudge hook failed: ${err}`);
215
268
  }
216
269
  },
217
270
 
218
271
  // 5. Session-start reminder, carrying the hygiene heartbeat. Hygiene is
219
272
  // best-effort: a failure there must not cost the reminder itself.
220
273
  //
221
- // Also tracks parent-child session relationships for fix A (cross-session
222
- // buffer drain). session.created with a parentID records the mapping;
223
- // session.deleted cleans it up to avoid unbounded growth.
274
+ // Also tracks parent-child session relationships for cross-session buffer
275
+ // drain. session.created with a parentID records the mapping AND
276
+ // snapshots the parent's current buffer — so the child's later
277
+ // memory_remember drains only those snapshot entries, not the parent's
278
+ // entire buffer (which may have grown from interleaved turns).
279
+ // session.deleted cleans both maps to avoid unbounded growth.
224
280
  event: async ({ event }) => {
225
281
  if (event.type === "session.created") {
226
282
  const info = event.properties.info;
227
283
  if (info.parentID) {
228
284
  childToParent.set(info.id, info.parentID);
285
+ parentSnapshots.set(info.id, [...extraction.peek(info.parentID)]);
229
286
  }
230
287
  }
231
288
  if (event.type === "session.deleted") {
232
289
  childToParent.delete(event.properties.info.id);
290
+ parentSnapshots.delete(event.properties.info.id);
233
291
  return;
234
292
  }
235
293
 
package/src/mcp.ts CHANGED
@@ -5,6 +5,7 @@ import { detectRepo } from "./git";
5
5
  import { checkSetup } from "./setup";
6
6
  import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
7
7
  import { SidebandServer, sidebandSocketPath } from "./sideband";
8
+ import pkg from "../package.json";
8
9
 
9
10
  // ---------------------------------------------------------------------------
10
11
  // JSON-RPC 2.0 types
@@ -34,7 +35,7 @@ const INTERNAL_ERROR = -32603;
34
35
  // ---------------------------------------------------------------------------
35
36
 
36
37
  const SERVER_NAME = "thatch";
37
- const SERVER_VERSION = "0.1.16";
38
+ const SERVER_VERSION = pkg.version;
38
39
 
39
40
  /**
40
41
  * MCP capabilities declared in the initialize response. Thatch is a tools-only
package/src/prompts.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { PredictionNudgeItem } from "./db";
2
+
1
3
  export function systemPrompt(repo: string): string {
2
4
  return `# Persistence
3
5
 
@@ -7,7 +9,9 @@ knowledge so future sessions can build on what you've already learned.
7
9
  Tools: thatch_memory_remember, thatch_memory_recall, thatch_memory_list,
8
10
  thatch_memory_show, thatch_memory_forget, thatch_store_list,
9
11
  thatch_find_duplicates, thatch_dedup_mark_checked,
10
- thatch_extraction_done
12
+ thatch_extraction_done,
13
+ thatch_prediction_query, thatch_prediction_update, thatch_prediction_list,
14
+ thatch_prediction_delete
11
15
 
12
16
  ## Stores
13
17
 
@@ -60,6 +64,27 @@ whether you've discovered knowledge worth persisting. Use thatch_memory_recall
60
64
  to check for duplicates, then thatch_memory_remember for new findings. Then
61
65
  deliver your response.
62
66
 
67
+ ## User Decision Model
68
+
69
+ A statistical model of the user's decision-making preferences is maintained by
70
+ the prediction engine. Scored predictions may appear above as "User decision
71
+ model" context; these are learned patterns about what the user tends to
72
+ prefer in specific situations.
73
+
74
+ When facing a judgment call about scope, appropriateness, or methodology:
75
+ - If a prediction is strong (high confidence, sufficient evidence), follow it
76
+ - If predictions conflict or confidence is thin, surface the uncertainty
77
+ naturally: "I think you usually prefer X here, but I'm not sure; what do
78
+ you want?"
79
+ - When the user responds to a surfaced prediction, use thatch_prediction_update
80
+ to reinforce or weaken the model
81
+
82
+ When the user corrects you, answers your question, or provides a clear signal
83
+ about their preferences:
84
+ 1. Use thatch_prediction_query to check for existing matchers and predictions
85
+ 2. Use thatch_prediction_update to create, reinforce, or weaken a prediction
86
+ 3. Use thatch_prediction_delete to remove a prediction created in error
87
+
63
88
  ## What to Store
64
89
 
65
90
  - **Global store**: user observations, agent personality, system environment
@@ -150,8 +175,9 @@ Tools are prefixed in Claude Code: \`mcp__thatch__memory_remember\`,
150
175
  \`mcp__thatch__memory_recall\`, \`mcp__thatch__memory_list\`,
151
176
  \`mcp__thatch__memory_show\`, \`mcp__thatch__memory_forget\`,
152
177
  \`mcp__thatch__store_list\`, \`mcp__thatch__find_duplicates\`,
153
- \`mcp__thatch__dedup_mark_checked\`, \`mcp__thatch__extraction_done\`.
154
- Bare names used below for readability.
178
+ \`mcp__thatch__dedup_mark_checked\`, \`mcp__thatch__extraction_done\`,
179
+ \`mcp__thatch__prediction_query\`, \`mcp__thatch__prediction_update\`,
180
+ \`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`. Bare names used below for readability.
155
181
 
156
182
  ## Stores
157
183
 
@@ -204,6 +230,27 @@ whether you've discovered knowledge worth persisting. Use memory_recall to
204
230
  check for duplicates, then memory_remember for new findings. Then deliver
205
231
  your response.
206
232
 
233
+ ## User Decision Model
234
+
235
+ A statistical model of the user's decision-making preferences is maintained by
236
+ the prediction engine. Scored predictions may appear above as "User decision
237
+ model" context; these are learned patterns about what the user tends to
238
+ prefer in specific situations.
239
+
240
+ When facing a judgment call about scope, appropriateness, or methodology:
241
+ - If a prediction is strong (high confidence, sufficient evidence), follow it
242
+ - If predictions conflict or confidence is thin, surface the uncertainty
243
+ naturally: "I think you usually prefer X here, but I'm not sure; what do
244
+ you want?"
245
+ - When the user responds to a surfaced prediction, use prediction_update
246
+ to reinforce or weaken the model
247
+
248
+ When the user corrects you, answers your question, or provides a clear signal
249
+ about their preferences:
250
+ 1. Use prediction_query to check for existing matchers and predictions
251
+ 2. Use prediction_update to create, reinforce, or weaken a prediction
252
+ 3. Use prediction_delete to remove a prediction created in error
253
+
207
254
  ## What to Store
208
255
 
209
256
  - **Global store**: user observations, agent personality, system environment
@@ -356,8 +403,9 @@ Tools are prefixed in Cursor: \`mcp__thatch__memory_remember\`,
356
403
  \`mcp__thatch__memory_recall\`, \`mcp__thatch__memory_list\`,
357
404
  \`mcp__thatch__memory_show\`, \`mcp__thatch__memory_forget\`,
358
405
  \`mcp__thatch__store_list\`, \`mcp__thatch__find_duplicates\`,
359
- \`mcp__thatch__dedup_mark_checked\`, \`mcp__thatch__extraction_done\`.
360
- Bare names used below for readability.
406
+ \`mcp__thatch__dedup_mark_checked\`, \`mcp__thatch__extraction_done\`,
407
+ \`mcp__thatch__prediction_query\`, \`mcp__thatch__prediction_update\`,
408
+ \`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`. Bare names used below for readability.
361
409
 
362
410
  ## Stores
363
411
 
@@ -410,6 +458,27 @@ whether you've discovered knowledge worth persisting. Use memory_recall to
410
458
  check for duplicates, then memory_remember for new findings. Then deliver
411
459
  your response.
412
460
 
461
+ ## User Decision Model
462
+
463
+ A statistical model of the user's decision-making preferences is maintained by
464
+ the prediction engine. Scored predictions may appear above as "User decision
465
+ model" context; these are learned patterns about what the user tends to
466
+ prefer in specific situations.
467
+
468
+ When facing a judgment call about scope, appropriateness, or methodology:
469
+ - If a prediction is strong (high confidence, sufficient evidence), follow it
470
+ - If predictions conflict or confidence is thin, surface the uncertainty
471
+ naturally: "I think you usually prefer X here, but I'm not sure; what do
472
+ you want?"
473
+ - When the user responds to a surfaced prediction, use prediction_update
474
+ to reinforce or weaken the model
475
+
476
+ When the user corrects you, answers your question, or provides a clear signal
477
+ about their preferences:
478
+ 1. Use prediction_query to check for existing matchers and predictions
479
+ 2. Use prediction_update to create, reinforce, or weaken a prediction
480
+ 3. Use prediction_delete to remove a prediction created in error
481
+
413
482
  ## What to Store
414
483
 
415
484
  - **Global store**: user observations, agent personality, system environment
@@ -495,3 +564,18 @@ function formatRecallNudge(matches: NudgeMatch[], toolName: string): string {
495
564
  const etc = n > 2 ? ", etc." : "";
496
565
  return `[thatch] ${n} ${word} ${verb} to this prompt (${labels}${etc}). Use ${toolName} before responding.`;
497
566
  }
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // Prediction nudge: auto-injected when matchers fire for the user's prompt.
570
+ // Descriptive, not directive: the system prompt instructions govern how to
571
+ // act on the scored predictions (follow strong, surface weak/competing,
572
+ // update on user response).
573
+ // ---------------------------------------------------------------------------
574
+
575
+ export function predictionNudge(items: PredictionNudgeItem[]): string {
576
+ const lines = items.map((p) => {
577
+ const verb = p.evidence_count === 0 ? "you may prefer" : "you tend to";
578
+ return `- [${p.confidence.toFixed(2)} conf, ${p.evidence_count} tests] When ${p.matcher_description}: ${verb} ${p.statement}`;
579
+ });
580
+ return `[thatch] User decision model\n${lines.join("\n")}`;
581
+ }
package/src/sideband.ts CHANGED
@@ -3,7 +3,7 @@ import { unlinkSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { createServer, connect, type Server, type Socket } from "node:net";
6
- import type { ThatchDB } from "./db";
6
+ import type { ThatchDB, PredictionNudgeItem } from "./db";
7
7
  import type { EmbeddingModel } from "./embeddings";
8
8
 
9
9
  /**
@@ -24,7 +24,7 @@ export interface SidebandMatch {
24
24
  }
25
25
 
26
26
  interface MatchRequest {
27
- method: "match";
27
+ method: "match" | "predictions";
28
28
  text: string;
29
29
  stores: string[];
30
30
  threshold: number;
@@ -34,6 +34,7 @@ interface MatchRequest {
34
34
  interface MatchResponse {
35
35
  ok: boolean;
36
36
  matches?: SidebandMatch[];
37
+ predictions?: PredictionNudgeItem[];
37
38
  error?: string;
38
39
  }
39
40
 
@@ -115,13 +116,22 @@ export class SidebandServer {
115
116
  return;
116
117
  }
117
118
 
118
- if (req.method !== "match") {
119
+ if (req.method !== "match" && req.method !== "predictions") {
119
120
  this.#respond(socket, { ok: false, error: `Unknown method: ${req.method}` });
120
121
  return;
121
122
  }
122
123
 
123
124
  try {
124
125
  const embedding = await this.#model.queryEmbed(req.text);
126
+
127
+ if (req.method === "predictions") {
128
+ const predictions = this.#db.scorePredictionNudge(
129
+ req.stores, embedding, req.threshold, 5,
130
+ );
131
+ this.#respond(socket, { ok: true, predictions });
132
+ return;
133
+ }
134
+
125
135
  const results = this.#db.search(req.stores, embedding, { limit: req.limit });
126
136
  const matches: SidebandMatch[] = results
127
137
  .filter((r) => r._score >= req.threshold)
@@ -156,16 +166,45 @@ export function sidebandMatch(
156
166
  limit: number,
157
167
  timeoutMs = 2000,
158
168
  ): Promise<SidebandMatch[] | null> {
169
+ return sidebandRequest("match", socketPath, text, stores, threshold, limit, timeoutMs)
170
+ .then((res) => res?.matches ?? null);
171
+ }
172
+
173
+ /**
174
+ * Connect to the sideband server and request scored predictions for a prompt.
175
+ * Returns null on any failure; callers treat null as "skip the prediction nudge."
176
+ */
177
+ export function sidebandPredictions(
178
+ socketPath: string,
179
+ text: string,
180
+ stores: string[],
181
+ threshold: number,
182
+ limit: number,
183
+ timeoutMs = 2000,
184
+ ): Promise<PredictionNudgeItem[] | null> {
185
+ return sidebandRequest("predictions", socketPath, text, stores, threshold, limit, timeoutMs)
186
+ .then((res) => res?.predictions ?? null);
187
+ }
188
+
189
+ function sidebandRequest(
190
+ method: "match" | "predictions",
191
+ socketPath: string,
192
+ text: string,
193
+ stores: string[],
194
+ threshold: number,
195
+ limit: number,
196
+ timeoutMs: number,
197
+ ): Promise<MatchResponse | null> {
159
198
  return new Promise((resolve) => {
160
199
  let settled = false;
161
- const done = (val: SidebandMatch[] | null) => {
200
+ const done = (val: MatchResponse | null) => {
162
201
  if (settled) return;
163
202
  settled = true;
164
203
  resolve(val);
165
204
  };
166
205
 
167
206
  const socket = connect(socketPath);
168
- const req = JSON.stringify({ method: "match", text, stores, threshold, limit }) + "\n";
207
+ const req = JSON.stringify({ method, text, stores, threshold, limit }) + "\n";
169
208
  let buf = "";
170
209
 
171
210
  const timer = setTimeout(() => {
@@ -173,14 +212,13 @@ export function sidebandMatch(
173
212
  done(null);
174
213
  }, timeoutMs);
175
214
 
176
- socket.on("error", () => {
215
+ socket.on("error", (err: NodeJS.ErrnoException) => {
177
216
  clearTimeout(timer);
178
- // Stale socket file left by a crashed server — clean it up so the next
179
- // connection attempt doesn't hit ECONNREFUSED again.
180
- try {
181
- unlinkSync(socketPath);
182
- } catch {
183
- // Already gone or never existed.
217
+ // Only remove the socket file if it's a stale-file situation
218
+ // (ECONNREFUSED). Other errors (timeout, server error) should
219
+ // not delete a live server's socket.
220
+ if (err.code === "ECONNREFUSED" || err.code === "ENOENT") {
221
+ try { unlinkSync(socketPath); } catch { /* already gone */ }
184
222
  }
185
223
  done(null);
186
224
  });
@@ -197,7 +235,7 @@ export function sidebandMatch(
197
235
  clearTimeout(timer);
198
236
  try {
199
237
  const res = JSON.parse(buf.slice(0, nl)) as MatchResponse;
200
- done(res.ok ? (res.matches ?? []) : null);
238
+ done(res);
201
239
  } catch {
202
240
  done(null);
203
241
  }