@jeffober/thatch 0.1.48 → 0.2.1

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 (50) hide show
  1. package/README.md +43 -4
  2. package/artifacts/skills/thatch-fact-extractor.md +1 -1
  3. package/artifacts/skills/thatch-memory-verify.md +2 -2
  4. package/bin/thatch +31 -16
  5. package/docs/dev/README.md +39 -13
  6. package/docs/dev/features/behavior-engine.md +3 -2
  7. package/docs/dev/features/cicd.md +1 -1
  8. package/docs/dev/features/compaction-recovery.md +1 -1
  9. package/docs/dev/features/cross-session-chat.md +12 -3
  10. package/docs/dev/features/extraction.md +10 -2
  11. package/docs/dev/features/hygiene.md +3 -1
  12. package/docs/dev/features/memory-store.md +1 -1
  13. package/docs/dev/features/multi-host.md +4 -2
  14. package/docs/dev/features/notifications.md +121 -4
  15. package/docs/dev/features/nudge-pipeline.md +16 -2
  16. package/docs/dev/features/opencode-plugin.md +133 -0
  17. package/docs/dev/features/qa-system.md +43 -0
  18. package/docs/dev/features/repo-identity.md +1 -1
  19. package/docs/dev/features/session-lifecycle.md +6 -3
  20. package/docs/dev/features/watchers.md +12 -5
  21. package/docs/dev/gotchas.md +94 -4
  22. package/docs/dev/mcp-parity.md +4 -3
  23. package/docs/dev/setup-and-hooks.md +7 -2
  24. package/docs/plans/llm-alerts.md +287 -0
  25. package/docs/plans/prediction-consolidation.md +3 -3
  26. package/docs/user/README.md +8 -4
  27. package/docs/user/memory.md +1 -1
  28. package/docs/user/notifications.md +63 -4
  29. package/docs/user/setup.md +4 -0
  30. package/docs/user/watchers.md +15 -7
  31. package/package.json +3 -1
  32. package/src/alerts.ts +360 -0
  33. package/src/capabilities.ts +174 -0
  34. package/src/chat.ts +80 -5
  35. package/src/commands.ts +44 -5
  36. package/src/config.ts +32 -1
  37. package/src/db.ts +157 -12
  38. package/src/embeddings.ts +43 -0
  39. package/src/extraction.ts +210 -0
  40. package/src/index.ts +77 -1339
  41. package/src/opencode/v1.ts +54 -0
  42. package/src/opencode/v2.ts +605 -0
  43. package/src/os-args.ts +99 -0
  44. package/src/prompts.ts +181 -31
  45. package/src/runtime.ts +2004 -0
  46. package/src/seed-behaviors.ts +1 -1
  47. package/src/session-db.ts +32 -0
  48. package/src/tool-defs.ts +340 -46
  49. package/src/tools.ts +16 -35
  50. package/src/watchers.ts +109 -14
package/src/tool-defs.ts CHANGED
@@ -2,24 +2,29 @@ import { z } from "zod";
2
2
  import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
3
3
  import type { EmbeddingModel } from "./embeddings";
4
4
  import {
5
+ ALERT_EVENT_KINDS,
5
6
  CONFIG_SECTIONS,
7
+ alertEventPrefsSchema,
8
+ alertsPrefsSchema,
6
9
  chatEnabled,
7
10
  chatPrefsSchema,
8
11
  loadConfig,
12
+ mergeAlertsPrefs,
9
13
  mergeChatPrefs,
10
14
  mergeNotificationPrefs,
11
15
  notificationDefaults,
12
16
  notificationPrefsSchema,
13
17
  saveConfig,
18
+ type AlertsPrefs,
14
19
  type ChatPrefs,
15
20
  type Config,
16
21
  type ConfigSection,
17
22
  type NotificationPrefs,
18
23
  } from "./config";
19
24
  import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } from "./notify";
20
- import { predictionVerb, chatInboxFrame } from "./prompts";
25
+ import { predictionVerb, formatWhenLine, chatInboxFrame } from "./prompts";
21
26
  import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
22
- import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
27
+ import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, type WatcherRegistry, type Watcher, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
23
28
  import { CHAT_STALE_MS, chatLiveness, humanAge, renderChatParticipant, sortChatRoster, splitChatRoster, type ChatHostKind } from "./chat";
24
29
  import { detectWorktreeKind } from "./git";
25
30
 
@@ -49,8 +54,39 @@ function dedupScanStores(store: string): string[] {
49
54
  return store === "global" ? ["global"] : [store, "global"];
50
55
  }
51
56
 
57
+ /**
58
+ * Resolve a behavior from feedback/delete text. The behavior nudge renders
59
+ * "When <matcher description>: <verb> <statement>" - and models copy back
60
+ * EITHER half (observed live three times: the situation half was fed back
61
+ * and statement search failed, even though the text was verbatim from the
62
+ * nudge). Try the statement first, then the matcher description resolved
63
+ * through its edge to the linked behavior. Returns the behavior row, an
64
+ * ambiguity list (the situation is shared by multiple behaviors), or null.
65
+ */
66
+ async function resolveBehaviorForFeedback(
67
+ ctx: CoreContext,
68
+ store: string,
69
+ text: string,
70
+ ): Promise<{ behavior: { id: string; statement: string }; via: string } | { ambiguous: string[] } | null> {
71
+ const embed = await ctx.model.passageEmbed(text);
72
+ const direct = ctx.db.findNearestBehavior(dedupScanStores(store), embed, BEHAVIOR_DEDUP_COSINE);
73
+ if (direct) return { behavior: direct, via: "statement" };
74
+ for (const s of dedupScanStores(store)) {
75
+ const matcher = ctx.db.findNearestBehaviorMatcher(s, embed, BEHAVIOR_DEDUP_COSINE);
76
+ if (!matcher) continue;
77
+ const linked = ctx.db.scoreBehaviors([{ id: matcher.id, description: matcher.description, score: 1 }]);
78
+ if (linked.length === 1) {
79
+ const row = ctx.db.getBehavior(linked[0].behavior_id);
80
+ if (row) return { behavior: row, via: "matcher description" };
81
+ return null;
82
+ }
83
+ if (linked.length > 1) return { ambiguous: linked.map((l) => l.statement) };
84
+ }
85
+ return null;
86
+ }
87
+
52
88
  // Minimum matcher cosine to consider a prediction relevant. Matches the
53
- // auto-fire threshold in index.ts (PREDICTION_THRESHOLD). The query tool
89
+ // auto-fire threshold in runtime.ts (PREDICTION_THRESHOLD). The query tool
54
90
  // should not return predictions from near-zero-similarity matchers that
55
91
  // would never fire in the auto-fire.
56
92
  const PREDICTION_QUERY_THRESHOLD = 0.60;
@@ -64,12 +100,16 @@ export interface CoreContext {
64
100
  db: ThatchDB;
65
101
  model: EmbeddingModel;
66
102
  defaultStore: string;
67
- /** Peeks the extraction buffer for a session and returns the serialized
68
- * JSON payload (same shape as buildExtractionPayload). Returns null when
69
- * no interactions are queued. Used by the get_extraction_payload tool so
70
- * the sub-agent fetches the payload as a tool response instead of
71
- * receiving it inline in the nudge text. */
72
- extractionPayloadProvider?: (sessionID: string) => string | null;
103
+ /** Fetches a session's extraction payload for a fetcher. Returns the
104
+ * serialized JSON payload (same shape as buildExtractionPayload), or
105
+ * null when no interactions are queued. With a fetcherID, the fetch is
106
+ * a CLAIM: the fetched entries stay accepted and that fetcher's
107
+ * completion signal consumes only this delivery (sibling sub-agents
108
+ * going idle cannot drop it). Without a fetcherID, the fetch is a pure
109
+ * peek. Used by the get_extraction_payload tool so the sub-agent
110
+ * fetches the payload as a tool response instead of receiving it
111
+ * inline in the nudge text. */
112
+ extractionPayloadProvider?: (sessionID: string, fetcherID?: string) => string | null;
73
113
  /** Drains a session's extraction queue. On the MCP path this deletes the
74
114
  * file-backed queue and resets the missed-nudge counter. On the opencode
75
115
  * path this is unused (the tool.execute.after hook handles drain via
@@ -129,6 +169,73 @@ export interface ToolDef {
129
169
  // Formatting helpers - shared by all tools that render entries
130
170
  // ---------------------------------------------------------------------------
131
171
 
172
+ /**
173
+ * Human-readable age of a memory, rendered at output time so the reading
174
+ * model can weigh staleness semantically (an ISO timestamp does not get
175
+ * weighed; "47 days ago" does). Keyed on updated_at - the last CONTENT
176
+ * change - and deliberately NOT on last_recalled_at: hygiene's staleness
177
+ * sweep treats a recall as refreshing the clock, but for content accuracy
178
+ * the opposite holds. A memory recalled yesterday but written eight months
179
+ * ago is exactly the case where the world may have moved on since the
180
+ * claim was recorded. Freshness-of-use is not freshness-of-claim.
181
+ *
182
+ * Bucket boundaries are a formatting choice, independent of hygiene's
183
+ * 90-day staleness window (db.ts STALE_DAYS) - the two 90s are a
184
+ * coincidence, do not couple them. Compact sibling: humanAge() in
185
+ * chat.ts renders roster-style "5m ago" for chat surfaces; keep the two
186
+ * apart when changing either.
187
+ */
188
+ export function formatAge(iso: string, now: Date = new Date()): string {
189
+ const then = new Date(iso).getTime();
190
+ if (!Number.isFinite(then)) return "unknown age";
191
+ const seconds = Math.max(0, Math.floor((now.getTime() - then) / 1000));
192
+ if (seconds < 60) return "just now";
193
+ const minutes = Math.floor(seconds / 60);
194
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
195
+ const hours = Math.floor(minutes / 60);
196
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
197
+ const days = Math.floor(hours / 24);
198
+ if (days < 90) return `${days} day${days === 1 ? "" : "s"} ago`;
199
+ if (days < 365) return `${Math.floor(days / 30)} month${Math.floor(days / 30) === 1 ? "" : "s"} ago`;
200
+ const years = Math.floor(days / 365);
201
+ return `${years} year${years === 1 ? "" : "s"} ago`;
202
+ }
203
+
204
+ /**
205
+ * The age sentence inserted between the meta line and a memory's content,
206
+ * so the model weighs staleness BEFORE reading the claim it qualifies.
207
+ */
208
+ function ageLine(iso: string): string {
209
+ return `This memory was last updated ${formatAge(iso)}.`;
210
+ }
211
+
212
+ /**
213
+ * Caveat attached to memory_recall and memory_show tool output at render
214
+ * time - the persisted rows are untouched. The sibling of chatInboxFrame's
215
+ * untrusted-content framing in prompts.ts: a content-classification line
216
+ * that rides with the claims it qualifies, at the moment they enter the
217
+ * model's context, instead of a general rule it has to recall from the
218
+ * system prompt (or a nudge it learns to tune out). memory_list is
219
+ * excluded: a labels-only listing carries no artifact claims to qualify.
220
+ *
221
+ * Tool names use the BARE form (memory_remember, memory_forget) on
222
+ * purpose: tool-defs.ts is host-shared and has no host context. On MCP
223
+ * hosts the bare-name convention is established by the instructions in
224
+ * prompts.ts ("Bare names used below for readability"); on opencode the
225
+ * system prompt uses thatch_ spellings, but the model sees its own tool
226
+ * list and maps the bare name without a stated convention. If either
227
+ * convention changes, update this string with it.
228
+ */
229
+ const memoryCaveat =
230
+ `[thatch] Memories are a point-in-time record - a guide to where to look, ` +
231
+ `not a source of truth. Verify artifact claims (code, docs, tickets, git) ` +
232
+ `against current sources before relying on them; if a memory has drifted, ` +
233
+ `correct it with memory_remember (overwrite: true) or memory_forget.`;
234
+
235
+ function withMemoryCaveat(body: string): string {
236
+ return `${memoryCaveat}\n\n${body}`;
237
+ }
238
+
132
239
  function formatEntry(
133
240
  entry: Awaited<ReturnType<ThatchDB["showEntry"]>>,
134
241
  ): string | null {
@@ -139,8 +246,8 @@ function formatEntry(
139
246
  if (entry.branch) meta += ` branch:${entry.branch}`;
140
247
  if (entry.confidence) meta += ` confidence:${entry.confidence}`;
141
248
  if (entry.archived) meta += " archived:true";
142
- meta += ` created:${entry.created_at} updated:${entry.updated_at}`;
143
- parts.push(meta, "", entry.content);
249
+ meta += ` created:${entry.created_at}`;
250
+ parts.push(meta, "", ageLine(entry.updated_at), entry.content);
144
251
  return parts.join("\n");
145
252
  }
146
253
 
@@ -149,9 +256,8 @@ function formatRecallResult(entry: MemoryRow & { _score: number }): string {
149
256
  if (entry.branch) meta += ` branch:${entry.branch}`;
150
257
  if (entry.confidence) meta += ` confidence:${entry.confidence}`;
151
258
  if (entry.archived) meta += " archived:true";
152
- meta += ` updated:${entry.updated_at}`;
153
259
  const score = entry._score.toFixed(3);
154
- return `[${meta}] [score:${score}]\n${entry.content}`;
260
+ return `[${meta}] [score:${score}]\n${ageLine(entry.updated_at)}\n${entry.content}`;
155
261
  }
156
262
 
157
263
  /**
@@ -159,8 +265,26 @@ function formatRecallResult(entry: MemoryRow & { _score: number }): string {
159
265
  * so a topic fragmented across N entries reads as one cluster instead of
160
266
  * O(N²) pairs. Verdicts stay pairwise (markChecked) - this is presentation only.
161
267
  */
162
- function renderClusters(candidates: DedupCandidate[]): string {
163
- const parent = new Map<string, string>();
268
+ /**
269
+ * Self-heal the watcher registry from the journal before a watch tool reads
270
+ * it. The runtime (and its registry) is rebuilt on session directory changes
271
+ * and plugin reloads, leaving journaled watchers absent from the live set
272
+ * while the old instance's poller keeps delivering: watch_list reported "No
273
+ * active watchers" and watch_cancel "No watcher in this session" for
274
+ * watchers that were alive and firing (observed + reproduced September
275
+ * 2026). Hydrating the CURRENT process's journal row for the session makes
276
+ * every watch-tool call converge. Cross-process (dormant) rows stay owned
277
+ * by the runtime's scanDormantWatchers and its revalidation.
278
+ */
279
+ function reconcileSessionWatchers(ctx: CoreContext, sessionID: string): void {
280
+ if (!ctx.watchers) return;
281
+ const row = ctx.db
282
+ .runtimeStateAll()
283
+ .find((r) => r.kind === "watchers" && r.sessionID === sessionID && r.pid === process.pid);
284
+ if (row && Array.isArray(row.value)) ctx.watchers.hydrate(row.value as Watcher[]);
285
+ }
286
+
287
+ function renderClusters(candidates: DedupCandidate[]): string { const parent = new Map<string, string>();
164
288
  const find = (x: string): string => {
165
289
  let root = parent.get(x) ?? x;
166
290
  while (root !== (parent.get(root) ?? root)) root = parent.get(root) ?? root;
@@ -202,8 +326,9 @@ const rememberDef: ToolDef = {
202
326
  "Each memory should be focused on a single topic. Write memories as " +
203
327
  "reference material for a future instance of yourself with zero context.",
204
328
  args: {
205
- label: z.string().describe(
206
- "Short descriptive title. Used for deduplication - same label in the same store is the same entry.",
329
+ label: z.string().optional().describe(
330
+ "Short descriptive title. Used for deduplication - same label in the same store is the same entry. " +
331
+ "Omit to derive it from the content's first heading or line (capped at 80 characters).",
207
332
  ),
208
333
  content: z.string().describe(
209
334
  "The information to remember. Self-contained, understandable without session context.",
@@ -228,8 +353,32 @@ const rememberDef: ToolDef = {
228
353
  },
229
354
  async execute(args, ctx) {
230
355
  const store = resolveStore(args, ctx);
231
- const label = args.label as string;
232
- const content = `# ${label}\n\n${args.content as string}`;
356
+ const rawContent = (args.content as string | undefined)?.trim();
357
+ if (!rawContent) {
358
+ return "No content provided - pass the information to remember in the content parameter.";
359
+ }
360
+ let label = (args.label as string | undefined)?.trim();
361
+ let content = rawContent;
362
+ if (!label) {
363
+ // Label omitted (or arrived under a misnamed key that schema
364
+ // validation stripped): derive it from the content so the write
365
+ // still lands instead of erroring. A leading markdown heading
366
+ // becomes the label and is stripped from the stored content
367
+ // (heading-only content keeps the heading text as the body so the
368
+ // entry is never empty). The 80-char cap keeps a derived label -
369
+ // which can be a whole first line - from bloating list output and
370
+ // slug keys; matches deriveTitle's cap in extraction.ts.
371
+ const firstLine = rawContent.split("\n")[0]?.trim() ?? "";
372
+ const heading = /^#{1,6}\s+(.+)$/.exec(firstLine);
373
+ if (heading) {
374
+ label = heading[1].trim().slice(0, 80);
375
+ const rest = rawContent.split("\n").slice(1).join("\n").trim();
376
+ content = rest.length > 0 ? rest : heading[1].trim();
377
+ } else {
378
+ label = firstLine.slice(0, 80);
379
+ }
380
+ }
381
+ content = `# ${label}\n\n${content}`;
233
382
  const embedding = await ctx.model.passageEmbed(content);
234
383
 
235
384
  const similar = ctx.db.findSimilar(store, embedding, {
@@ -296,7 +445,7 @@ const recallDef: ToolDef = {
296
445
 
297
446
  if (results.length === 0) return "No matching memories found.";
298
447
 
299
- return results.map(formatRecallResult).join("\n\n-----\n\n");
448
+ return withMemoryCaveat(results.map(formatRecallResult).join("\n\n-----\n\n"));
300
449
  },
301
450
  };
302
451
 
@@ -346,7 +495,7 @@ const showDef: ToolDef = {
346
495
 
347
496
  if (!entry) return `No memory labeled "${args.label as string}" found in store "${store}".`;
348
497
 
349
- return formatEntry(entry) || "Error formatting entry.";
498
+ return withMemoryCaveat(formatEntry(entry) || "Error formatting entry.");
350
499
  },
351
500
  };
352
501
 
@@ -474,7 +623,7 @@ const getExtractionPayloadDef: ToolDef = {
474
623
  if (!sessionID) {
475
624
  return 'session_id is required on this host (it has no session context). Pass the parent session\'s session_id from the extraction nudge.';
476
625
  }
477
- const payload = ctx.extractionPayloadProvider(sessionID);
626
+ const payload = ctx.extractionPayloadProvider(sessionID, host?.sessionID);
478
627
  if (!payload) {
479
628
  return "No queued tool interactions found for this session.";
480
629
  }
@@ -488,8 +637,10 @@ const getExtractionPayloadDef: ToolDef = {
488
637
  * Called in a PARENT session after dispatching the fact-extractor, it accepts
489
638
  * the buffer: entries move to a holding area and the nudge quiets, but they
490
639
  * are not dropped until the extractor completes. Called in a CHILD extractor
491
- * at the end of its run, it completes the parent's accepted entries -
492
- * including no-save runs that write no memory. If the child errors or is
640
+ * at the end of its run, it completes the entries THAT child claimed via its
641
+ * payload fetch - never the whole accepted set, so a sibling sub-agent's
642
+ * completion cannot drop another extractor's in-flight payload. Completion
643
+ * includes no-save runs that write no memory. If the child errors or is
493
644
  * deleted before either signal, the host requeues the entries so the facts
494
645
  * are not lost.
495
646
  *
@@ -563,8 +714,7 @@ const predictionQueryDef: ToolDef = {
563
714
  if (scored.length === 0) return "No matching predictions found.";
564
715
  return scored.map((s) => {
565
716
  const verb = predictionVerb(s.evidence_count);
566
- return `[${s.confidence.toFixed(2)} conf, ${s.evidence_count} tests] ` +
567
- `When ${s.matcher_description}: ${verb} ${s.statement}`;
717
+ return formatWhenLine(s.confidence, s.evidence_count, s.matcher_description, s.statement, verb);
568
718
  }).join("\n");
569
719
  },
570
720
  };
@@ -576,13 +726,25 @@ const predictionUpdateDef: ToolDef = {
576
726
  "model. Use when the user corrects you, answers a question, or " +
577
727
  "provides a clear signal about their preferences or decision-making " +
578
728
  "strategy. The tool handles matcher and prediction lookup, dedup, " +
579
- "and confidence adjustment automatically.",
729
+ "and confidence adjustment automatically. " +
730
+ "ALL FOUR of matcher, prediction, signal, and rationale are required " +
731
+ "in the SAME call - calls omitting any of them fail validation. " +
732
+ "signal must be exactly one of: create, confirm, disconfirm, soft " +
733
+ "(use \"create\" for a new observation - never a prose description).",
580
734
  args: {
581
735
  matcher: z.string().describe(
582
- "Description of the situation. What decision was being made?",
736
+ "Description of the situation. What decision was being made? Word it " +
737
+ "for semantic matching: after writing it, re-read it against " +
738
+ "everything that ought to trigger it and widen the wording to the " +
739
+ "broadest surface of the class - a matcher that only names this " +
740
+ "incident never fires for the next one.",
583
741
  ),
584
742
  prediction: z.string().describe(
585
- "The user's preference or tendency in this situation.",
743
+ "The user's preference or tendency in this situation. Phrase it as a " +
744
+ "tendency starting with a base-form verb (e.g. \"prefer X over Y\", " +
745
+ "\"push back on Z\") rather than " +
746
+ "a full sentence - the nudge and query renderers prefix the evidence " +
747
+ "verb (\"you tend to ...\") onto it.",
586
748
  ),
587
749
  signal: z.enum(["confirm", "disconfirm", "soft", "create"]).describe(
588
750
  "What happened: confirm (user confirmed the prediction), " +
@@ -740,11 +902,19 @@ const behaviorCodifyDef: ToolDef = {
740
902
  "discipline, not what the user wants. Examples: check the whole " +
741
903
  "codebase for a library before importing it; investigate disabled " +
742
904
  "tests before touching the area; read a large function fully before " +
743
- "editing it.",
905
+ "editing it. When a review, bug, or incident reveals a CLASS of " +
906
+ "mistake you are prone to (not a one-off), codify a guard against the " +
907
+ "class. ALL THREE of situation, behavior, and rationale are required " +
908
+ "in the SAME call - calls omitting any of them fail validation.",
744
909
  args: {
745
910
  situation: z.string().describe(
746
911
  "Description of the situation that triggers this behavior. What context " +
747
- "or task type makes this rule apply?",
912
+ "or task type makes this rule apply? Word it for semantic matching: " +
913
+ "describe the context a future session will be in when this class of " +
914
+ "trouble starts, then re-read your wording against everything that " +
915
+ "ought to trigger it and widen it to the broadest surface of the " +
916
+ "class - a matcher that only names this incident never fires for the " +
917
+ "next one.",
748
918
  ),
749
919
  behavior: z.string().describe(
750
920
  "The behavioral rule. What should you do when this situation arises?",
@@ -802,11 +972,19 @@ const behaviorFeedbackDef: ToolDef = {
802
972
  "or relevant: false (spam) if it does not. This trains the classifier " +
803
973
  "so future nudges are more accurate. Also use when the user corrects " +
804
974
  "your behavior and you realize a codified rule led you astray or " +
805
- "should have been followed.",
975
+ "should have been followed. The nudge line carries two texts - the " +
976
+ "situation (\"When ...\") and the behavior statement (after \"do\") - " +
977
+ "and EITHER is accepted: the tool resolves the situation back to its " +
978
+ "linked behavior. If it reports no match, the behavior may live in a " +
979
+ "different store than your default (seeded behaviors live in the " +
980
+ "global store): list with behavior_list and retry, passing the store " +
981
+ "the behavior was listed from.",
806
982
  args: {
807
983
  behavior: z.string().describe(
808
- "The behavior statement to provide feedback on. Use behavior_list " +
809
- "to find the exact text; matching is semantic (cosine >= 0.85).",
984
+ "The behavior statement to provide feedback on. The situation line " +
985
+ "from the nudge (\"When ...\") is also accepted and resolves to its " +
986
+ "linked behavior. Use behavior_list to find the exact text; " +
987
+ "matching is semantic (cosine >= 0.85).",
810
988
  ),
811
989
  relevant: z.boolean().describe(
812
990
  "true (ham) if the behavior is relevant to the current situation. " +
@@ -826,9 +1004,20 @@ const behaviorFeedbackDef: ToolDef = {
826
1004
  const relevant = args.relevant as boolean;
827
1005
  const contextText = args.context as string;
828
1006
 
829
- const behaviorEmbed = await ctx.model.passageEmbed(behaviorText);
830
- const behavior = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE);
831
- if (!behavior) return `No behavior matching "${behaviorText}" found in "${store}".`;
1007
+ const resolved = await resolveBehaviorForFeedback(ctx, store, behaviorText);
1008
+ if (!resolved) {
1009
+ return (
1010
+ `No behavior matching "${behaviorText}" found in any of ${dedupScanStores(store).join(", ")} ` +
1011
+ `(tried both statements and matcher descriptions). Use behavior_list to see the exact statements.`
1012
+ );
1013
+ }
1014
+ if ("ambiguous" in resolved) {
1015
+ return (
1016
+ `That text matches a situation shared by multiple behaviors - re-run with the exact statement: ` +
1017
+ resolved.ambiguous.map((s) => `"${s}"`).join("; ")
1018
+ );
1019
+ }
1020
+ const behavior = resolved.behavior;
832
1021
 
833
1022
  const signal = relevant ? "confirm" : "disconfirm";
834
1023
  ctx.db.transaction(() => {
@@ -876,7 +1065,9 @@ const behaviorDeleteDef: ToolDef = {
876
1065
  "automatically (cascade).",
877
1066
  args: {
878
1067
  statement: z.string().describe(
879
- "The behavior statement to delete. Use behavior_list to find the " +
1068
+ "The behavior statement to delete. The situation line from the " +
1069
+ "nudge (\"When ...\") is also accepted and resolves to its linked " +
1070
+ "behavior. Use behavior_list to find the " +
880
1071
  "exact text; matching is semantic (cosine >= 0.85).",
881
1072
  ),
882
1073
  store: z.string().optional().describe(
@@ -886,12 +1077,24 @@ const behaviorDeleteDef: ToolDef = {
886
1077
  async execute(args, ctx) {
887
1078
  const store = resolveStore(args, ctx);
888
1079
  const statementText = args.statement as string;
889
- const behaviorEmbed = await ctx.model.passageEmbed(statementText);
890
- const behavior = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE);
891
- if (!behavior) return `No behavior matching "${statementText}" found in "${store}".`;
1080
+ const resolved = await resolveBehaviorForFeedback(ctx, store, statementText);
1081
+ if (!resolved) {
1082
+ return (
1083
+ `No behavior matching "${statementText}" found in any of ${dedupScanStores(store).join(", ")} ` +
1084
+ `(tried both statements and matcher descriptions). Use behavior_list to see the exact statements.`
1085
+ );
1086
+ }
1087
+ if ("ambiguous" in resolved) {
1088
+ return (
1089
+ `That text matches a situation shared by multiple behaviors - re-run with the exact statement: ` +
1090
+ resolved.ambiguous.map((s) => `"${s}"`).join("; ")
1091
+ );
1092
+ }
1093
+ const behavior = resolved.behavior;
1094
+ const rowBefore = ctx.db.getBehavior(behavior.id);
892
1095
  const deleted = ctx.db.deleteBehavior(behavior.id);
893
1096
  if (!deleted) return `Failed to delete behavior "${behavior.statement}".`;
894
- return `[deleted] "${behavior.statement}" from "${store}"`;
1097
+ return `[deleted] "${behavior.statement}" from "${rowBefore?.store ?? store}"`;
895
1098
  },
896
1099
  };
897
1100
 
@@ -925,6 +1128,15 @@ function renderChatSection(prefs: ChatPrefs | undefined): string {
925
1128
  ].join("\n");
926
1129
  }
927
1130
 
1131
+ /** Renders the alerts section. Every event defaults to banner. */
1132
+ function renderAlertsSection(prefs: AlertsPrefs | undefined): string {
1133
+ const lines = ["alerts:"];
1134
+ for (const kind of ALERT_EVENT_KINDS) {
1135
+ lines.push(` ${kind}.mode: ${prefs?.[kind]?.mode ?? "<unset>"} (default: banner)`);
1136
+ }
1137
+ return lines.join("\n");
1138
+ }
1139
+
928
1140
  /** Drops keys explicitly set to undefined so a merge never overwrites. */
929
1141
  function stripUndefined(obj: Record<string, unknown>): Record<string, unknown> {
930
1142
  return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
@@ -938,7 +1150,7 @@ const configGetDef: ToolDef = {
938
1150
  "The config file (~/.config/thatch/config.json, beside thatch.db) is " +
939
1151
  "also hand-editable. Call this before config_set.",
940
1152
  args: {
941
- section: z.enum(["notifications", "chat"]).optional().describe(
1153
+ section: z.enum(["notifications", "chat", "alerts"]).optional().describe(
942
1154
  "Restrict output to one section. Omit to read all sections.",
943
1155
  ),
944
1156
  },
@@ -956,6 +1168,9 @@ const configGetDef: ToolDef = {
956
1168
  if (name === "chat") {
957
1169
  lines.push(renderChatSection(loaded.config.chat));
958
1170
  }
1171
+ if (name === "alerts") {
1172
+ lines.push(renderAlertsSection(loaded.config.alerts));
1173
+ }
959
1174
  }
960
1175
  lines.push("", `file: ${loaded.path}`);
961
1176
  return lines.join("\n");
@@ -1002,19 +1217,40 @@ const configSetDef: ToolDef = {
1002
1217
  "Chat preferences to update. Fields you omit keep their current " +
1003
1218
  "values.",
1004
1219
  ),
1220
+ alerts: z.strictObject({
1221
+ pause: alertEventPrefsSchema.optional().describe(
1222
+ "Alert when the LLM blocks on a question or permission prompt.",
1223
+ ),
1224
+ done: alertEventPrefsSchema.optional().describe(
1225
+ "Alert when a round of real work finishes. Rounds triggered by " +
1226
+ "synthetic input (thatch nudges, task completions, watcher " +
1227
+ "wake-ups) and rounds of only bookkeeping tools stay silent.",
1228
+ ),
1229
+ error: alertEventPrefsSchema.optional().describe(
1230
+ "Alert when a session fails with nothing to recover it (user " +
1231
+ "aborts stay silent; retried failures stay silent).",
1232
+ ),
1233
+ }).optional().describe(
1234
+ "Automatic alert preferences (desktop notification when the LLM " +
1235
+ "pauses or finishes). Each event takes a mode: both, banner only, " +
1236
+ "voice only, or none. Default: banner.",
1237
+ ),
1005
1238
  },
1006
1239
  async execute(args) {
1007
1240
  const loaded = loadConfig();
1008
1241
  const config: Config = { ...loaded.config };
1009
1242
  const notifPatch = args.notifications as Record<string, unknown> | undefined;
1010
1243
  const chatPatch = args.chat as Record<string, unknown> | undefined;
1244
+ const alertsPatch = args.alerts as Record<string, unknown> | undefined;
1011
1245
  const notifClean = notifPatch ? stripUndefined(notifPatch) : {};
1012
1246
  const chatClean = chatPatch ? stripUndefined(chatPatch) : {};
1013
- if (Object.keys(notifClean).length === 0 && Object.keys(chatClean).length === 0) {
1247
+ const alertsClean = alertsPatch ? stripUndefined(alertsPatch) : {};
1248
+ if (Object.keys(notifClean).length === 0 && Object.keys(chatClean).length === 0 && Object.keys(alertsClean).length === 0) {
1014
1249
  return (
1015
1250
  "Nothing to update: pass a section with fields to change.\n" +
1016
1251
  renderNotificationSection(config.notifications) + "\n" +
1017
- renderChatSection(config.chat)
1252
+ renderChatSection(config.chat) + "\n" +
1253
+ renderAlertsSection(config.alerts)
1018
1254
  );
1019
1255
  }
1020
1256
  if (Object.keys(notifClean).length > 0) {
@@ -1027,11 +1263,17 @@ const configSetDef: ToolDef = {
1027
1263
  mergeChatPrefs(config.chat, chatClean),
1028
1264
  );
1029
1265
  }
1266
+ if (Object.keys(alertsClean).length > 0) {
1267
+ config.alerts = alertsPrefsSchema.parse(
1268
+ mergeAlertsPrefs(config.alerts, alertsClean),
1269
+ );
1270
+ }
1030
1271
  const path = saveConfig(config);
1031
1272
  return (
1032
1273
  `[saved] ${path}\n` +
1033
1274
  (Object.keys(notifClean).length > 0 ? renderNotificationSection(config.notifications) + "\n" : "") +
1034
- (Object.keys(chatClean).length > 0 ? renderChatSection(config.chat) : "")
1275
+ (Object.keys(chatClean).length > 0 ? renderChatSection(config.chat) + "\n" : "") +
1276
+ (Object.keys(alertsClean).length > 0 ? renderAlertsSection(config.alerts) : "")
1035
1277
  );
1036
1278
  },
1037
1279
  };
@@ -1275,6 +1517,7 @@ const watchCreateDef: ToolDef = {
1275
1517
  if (!ctx.watchers) {
1276
1518
  return "Watching is unavailable: no watcher registry was wired by this host.";
1277
1519
  }
1520
+ reconcileSessionWatchers(ctx, host.sessionID);
1278
1521
  const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
1279
1522
  const events = (args.events as PrWatcherEventType[] | undefined) ?? [...PR_EVENT_TYPES];
1280
1523
  const result = await ctx.watchers.createPr(host.sessionID, repo, args.pr as number, events, { once: args.once === true });
@@ -1314,6 +1557,7 @@ const watchListDef: ToolDef = {
1314
1557
  if (!ctx.watchers) {
1315
1558
  return "Watching is unavailable: no watcher registry was wired by this host.";
1316
1559
  }
1560
+ reconcileSessionWatchers(ctx, host.sessionID);
1317
1561
  const watchers = ctx.watchers.listForSession(host.sessionID);
1318
1562
  if (watchers.length === 0) return "No active watchers.";
1319
1563
  const minutesLeft = (w: { expiresAt: number }) => Math.max(0, Math.round((w.expiresAt - Date.now()) / 60_000));
@@ -1379,6 +1623,7 @@ const watchBranchCreateDef: ToolDef = {
1379
1623
  if (!ctx.watchers) {
1380
1624
  return "Watching is unavailable: no watcher registry was wired by this host.";
1381
1625
  }
1626
+ reconcileSessionWatchers(ctx, host.sessionID);
1382
1627
  const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
1383
1628
  const events = (args.events as BranchWatcherEventType[] | undefined) ?? [...BRANCH_EVENT_TYPES];
1384
1629
  const result = await ctx.watchers.createBranch(
@@ -1463,6 +1708,7 @@ const watchCommandCreateDef: ToolDef = {
1463
1708
  if (!ctx.watchers) {
1464
1709
  return "Watching is unavailable: no watcher registry was wired by this host.";
1465
1710
  }
1711
+ reconcileSessionWatchers(ctx, host.sessionID);
1466
1712
  const dir = (args.cd as string | undefined) ?? ctx.projectDir;
1467
1713
  if (!dir) {
1468
1714
  return "Watching is unavailable: no project directory was wired by this host.";
@@ -1503,6 +1749,7 @@ const watchCancelDef: ToolDef = {
1503
1749
  if (!ctx.watchers) {
1504
1750
  return "Watching is unavailable: no watcher registry was wired by this host.";
1505
1751
  }
1752
+ reconcileSessionWatchers(ctx, host.sessionID);
1506
1753
  const cancelled = ctx.watchers.cancel(host.sessionID, args.id as string);
1507
1754
  if (!cancelled) return `No watcher "${args.id}" in this session.`;
1508
1755
  return `[cancelled] ${args.id}`;
@@ -2006,3 +2253,50 @@ export const TOOL_DEFS: ToolDef[] = [
2006
2253
  chatBroadcastDef,
2007
2254
  chatStatusDef,
2008
2255
  ];
2256
+
2257
+ /** Extensions a host adapter passes into the shared CoreContext. */
2258
+ export interface CoreContextExtensions {
2259
+ extractionPayloadProvider?: CoreContext["extractionPayloadProvider"];
2260
+ drainExtractionQueue?: CoreContext["drainExtractionQueue"];
2261
+ watcherRegistry?: WatcherRegistry;
2262
+ projectDir?: string;
2263
+ }
2264
+
2265
+ /**
2266
+ * Builds the host-agnostic CoreContext both adapters share: the opencode
2267
+ * plugin path and the MCP server each construct a CoreContext once per
2268
+ * init and reuse it for every tool call. This helper is the plugin-side
2269
+ * constructor; the MCP server builds its own inline (src/mcp.ts).
2270
+ *
2271
+ * Lives in this module - NOT in tools.ts - because tools.ts imports the v1
2272
+ * SDK at runtime, and the v2 adapter's entry graph must stay SDK-free (the
2273
+ * v2 plugin install skips optional peers, so evaluating the v1 SDK there
2274
+ * crashes the load).
2275
+ */
2276
+ export function buildCoreContext(
2277
+ db: ThatchDB,
2278
+ model: EmbeddingModel,
2279
+ defaultStore: string,
2280
+ extensions?: CoreContextExtensions,
2281
+ ): CoreContext {
2282
+ return {
2283
+ db,
2284
+ model,
2285
+ defaultStore,
2286
+ extractionPayloadProvider: extensions?.extractionPayloadProvider,
2287
+ drainExtractionQueue: extensions?.drainExtractionQueue,
2288
+ watchers: extensions?.watcherRegistry,
2289
+ projectDir: extensions?.projectDir,
2290
+ };
2291
+ }
2292
+
2293
+ /**
2294
+ * Trim opencode's per-call ToolContext to the host-agnostic fields the
2295
+ * shared definitions know about. Both adapters do this same two-field trim;
2296
+ * tests and MCP paths pass no host context.
2297
+ */
2298
+ export function trimHostContext(
2299
+ hostContext: { sessionID: string; agent: string } | undefined,
2300
+ ): HostToolContext | undefined {
2301
+ return hostContext ? { sessionID: hostContext.sessionID, agent: hostContext.agent } : undefined;
2302
+ }