@jeffober/thatch 0.1.38 → 0.1.40

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/tool-defs.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  import { z } from "zod";
2
2
  import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
3
3
  import type { EmbeddingModel } from "./embeddings";
4
+ import {
5
+ CONFIG_SECTIONS,
6
+ loadConfig,
7
+ mergeNotificationPrefs,
8
+ notificationDefaults,
9
+ notificationPrefsSchema,
10
+ saveConfig,
11
+ type Config,
12
+ type ConfigSection,
13
+ type NotificationPrefs,
14
+ } from "./config";
15
+ import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } from "./notify";
4
16
  import { predictionVerb } from "./prompts";
5
17
  import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
6
18
  import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
@@ -11,6 +23,26 @@ const MATCHER_DEDUP_COSINE = 0.85;
11
23
  const PREDICTION_DEDUP_COSINE = 0.85;
12
24
  const BEHAVIOR_DEDUP_COSINE = 0.85;
13
25
 
26
+ /**
27
+ * Resolves the write target for a store-scoped tool. detectRepo falls back
28
+ * to "unknown" when it cannot name a repo; rows written to a store called
29
+ * "unknown" are invisible to every auto-fire scan (which reads the project
30
+ * store and "global"), so an unidentified context falls back to global.
31
+ */
32
+ function resolveStore(args: Record<string, unknown>, ctx: CoreContext): string {
33
+ const store = (args.store as string) || ctx.defaultStore;
34
+ return store === "unknown" ? "global" : store;
35
+ }
36
+
37
+ /**
38
+ * The stores whose items a write must dedup against: the target store plus
39
+ * global, matching the stores the auto-fire nudge scans. A duplicate in
40
+ * either store would otherwise surface twice every turn.
41
+ */
42
+ function dedupScanStores(store: string): string[] {
43
+ return store === "global" ? ["global"] : [store, "global"];
44
+ }
45
+
14
46
  // Minimum matcher cosine to consider a prediction relevant. Matches the
15
47
  // auto-fire threshold in index.ts (PREDICTION_THRESHOLD). The query tool
16
48
  // should not return predictions from near-zero-similarity matchers that
@@ -42,6 +74,9 @@ export interface CoreContext {
42
74
  * path - MCP hosts have no poller, no event bus, and no way to deliver a
43
75
  * proactive prompt, so watch tools are opencode-only. */
44
76
  watchers?: WatcherRegistry;
77
+ /** Injectable command runner for notify_user. Defaults to Bun.spawn.
78
+ * Tests inject a mock so notifications never actually fire or speak. */
79
+ spawner?: Spawner;
45
80
  }
46
81
 
47
82
  /**
@@ -177,7 +212,7 @@ const rememberDef: ToolDef = {
177
212
  ),
178
213
  },
179
214
  async execute(args, ctx) {
180
- const store = (args.store as string) || ctx.defaultStore;
215
+ const store = resolveStore(args, ctx);
181
216
  const label = args.label as string;
182
217
  const content = `# ${label}\n\n${args.content as string}`;
183
218
  const embedding = await ctx.model.passageEmbed(content);
@@ -261,7 +296,7 @@ const listDef: ToolDef = {
261
296
  ),
262
297
  },
263
298
  async execute(args, ctx) {
264
- const store = (args.store as string) || ctx.defaultStore;
299
+ const store = resolveStore(args, ctx);
265
300
  const entries = ctx.db.listEntries(store);
266
301
 
267
302
  if (entries.length === 0) return `No memories in "${store}".`;
@@ -291,7 +326,7 @@ const showDef: ToolDef = {
291
326
  ),
292
327
  },
293
328
  async execute(args, ctx) {
294
- const store = (args.store as string) || ctx.defaultStore;
329
+ const store = resolveStore(args, ctx);
295
330
  const entry = ctx.db.showEntry(store, args.label as string);
296
331
 
297
332
  if (!entry) return `No memory labeled "${args.label as string}" found in store "${store}".`;
@@ -312,7 +347,7 @@ const forgetDef: ToolDef = {
312
347
  ),
313
348
  },
314
349
  async execute(args, ctx) {
315
- const store = (args.store as string) || ctx.defaultStore;
350
+ const store = resolveStore(args, ctx);
316
351
  const deleted = ctx.db.forgetEntry(store, args.label as string);
317
352
 
318
353
  if (!deleted) return `No memory labeled "${args.label as string}" found in store "${store}".`;
@@ -351,7 +386,7 @@ const findDuplicatesDef: ToolDef = {
351
386
  ),
352
387
  },
353
388
  async execute(args, ctx) {
354
- const store = (args.store as string) || ctx.defaultStore;
389
+ const store = resolveStore(args, ctx);
355
390
  const threshold = (args.threshold as number) ?? 0.85;
356
391
  const candidates = ctx.db.findDuplicates(store, threshold);
357
392
 
@@ -379,7 +414,7 @@ const markCheckedDef: ToolDef = {
379
414
  ),
380
415
  },
381
416
  async execute(args, ctx) {
382
- const store = (args.store as string) || ctx.defaultStore;
417
+ const store = resolveStore(args, ctx);
383
418
  ctx.db.markPairChecked(
384
419
  store,
385
420
  ctx.db.slugify(args.label_a as string),
@@ -536,7 +571,7 @@ const predictionUpdateDef: ToolDef = {
536
571
  ),
537
572
  },
538
573
  async execute(args, ctx) {
539
- const store = (args.store as string) || ctx.defaultStore;
574
+ const store = resolveStore(args, ctx);
540
575
  const matcherText = args.matcher as string;
541
576
  const predictionText = args.prediction as string;
542
577
  const signal = args.signal as "confirm" | "disconfirm" | "soft" | "create";
@@ -550,20 +585,23 @@ const predictionUpdateDef: ToolDef = {
550
585
  // entire operation rather than leaving orphans (matcher without
551
586
  // prediction, prediction without edge, edge without confidence).
552
587
  return ctx.db.transaction(() => {
588
+ // Cross-store dedup: search the target store AND global for a
589
+ // near-identical prediction. The auto-fire nudge scans both stores,
590
+ // so two copies would surface twice every turn. When the prediction
591
+ // already exists in the other store, that store becomes the write
592
+ // target ("home") and the result echoes where it landed.
593
+ const existing = ctx.db.findNearestPrediction(dedupScanStores(store), predEmbed, PREDICTION_DEDUP_COSINE);
594
+ const home = existing?.store ?? store;
595
+ const storeTag = home === store ? "" : ` in ${home}`;
596
+
553
597
  // Dedup matchers: find an existing matcher above the cosine threshold
554
598
  // rather than always creating a new one.
555
- let matcherId = ctx.db.findNearestMatcher(store, matcherEmbed, MATCHER_DEDUP_COSINE)?.id;
556
- if (!matcherId) matcherId = ctx.db.createMatcher(store, matcherText, matcherEmbed, ctx.model.name);
557
-
558
- // Store-wide dedup: search the entire store for a near-identical
559
- // prediction, not just this matcher's edges. If found, link this
560
- // matcher to the existing prediction via an edge rather than
561
- // creating a second row with the same statement.
562
- let predictionId = ctx.db.findNearestPrediction(store, predEmbed, PREDICTION_DEDUP_COSINE)?.id;
599
+ let matcherId = ctx.db.findNearestMatcher(home, matcherEmbed, MATCHER_DEDUP_COSINE)?.id;
600
+ if (!matcherId) matcherId = ctx.db.createMatcher(home, matcherText, matcherEmbed, ctx.model.name);
563
601
 
564
- if (!predictionId) {
602
+ if (!existing) {
565
603
  // No near-identical prediction exists; create one and link it.
566
- predictionId = ctx.db.createPrediction(store, predictionText, rationale, predEmbed, ctx.model.name);
604
+ const predictionId = ctx.db.createPrediction(home, predictionText, rationale, predEmbed, ctx.model.name);
567
605
  ctx.db.createEdge(matcherId, predictionId, 1.0);
568
606
 
569
607
  // When the signal is confirm/disconfirm/soft (not create), apply
@@ -578,9 +616,11 @@ const predictionUpdateDef: ToolDef = {
578
616
  return `[created + ${signal}] "${updated?.statement ?? predictionText}" confidence=${(updated?.confidence ?? 0).toFixed(2)} (${updated?.confirm_count ?? 0}/${updated?.disconfirm_count ?? 0})`;
579
617
  }
580
618
  ctx.db.addProvenance(predictionId, "create", rationale);
581
- return `[created] ${store} :: "${predictionText}" for "${matcherText}"`;
619
+ return `[created] ${home} :: "${predictionText}" for "${matcherText}"`;
582
620
  }
583
621
 
622
+ const predictionId = existing.id;
623
+
584
624
  // Ensure an edge links this matcher to the existing prediction.
585
625
  // createEdge uses ON CONFLICT DO NOTHING, so existing edge weights
586
626
  // are preserved.
@@ -592,8 +632,8 @@ const predictionUpdateDef: ToolDef = {
592
632
  // confidence-neutral, not a disconfirm.
593
633
  if (signal === "create") {
594
634
  ctx.db.addProvenance(predictionId, "create", rationale);
595
- const existing = ctx.db.getPrediction(predictionId);
596
- return `[linked] "${existing?.statement ?? predictionText}" for "${matcherText}" confidence=${(existing?.confidence ?? 0).toFixed(2)} (${existing?.confirm_count ?? 0}/${existing?.disconfirm_count ?? 0})`;
635
+ const existingItem = ctx.db.getPrediction(predictionId);
636
+ return `[linked${storeTag}] "${existingItem?.statement ?? predictionText}" for "${matcherText}" confidence=${(existingItem?.confidence ?? 0).toFixed(2)} (${existingItem?.confirm_count ?? 0}/${existingItem?.disconfirm_count ?? 0})`;
597
637
  }
598
638
 
599
639
  // Signal is confirm, disconfirm, or soft. Map the tool's 4-value
@@ -601,7 +641,7 @@ const predictionUpdateDef: ToolDef = {
601
641
  ctx.db.adjustConfidence(predictionId, signal === "soft" ? "soft" : signal === "confirm" ? "confirm" : "disconfirm");
602
642
  ctx.db.addProvenance(predictionId, signal, rationale);
603
643
  const updated = ctx.db.getPrediction(predictionId);
604
- return `[${signal}] "${updated?.statement ?? predictionText}" confidence=${(updated?.confidence ?? 0).toFixed(2)} (${updated?.confirm_count ?? 0}/${updated?.disconfirm_count ?? 0})`;
644
+ return `[${signal}${storeTag}] "${updated?.statement ?? predictionText}" confidence=${(updated?.confidence ?? 0).toFixed(2)} (${updated?.confirm_count ?? 0}/${updated?.disconfirm_count ?? 0})`;
605
645
  });
606
646
  },
607
647
  };
@@ -613,13 +653,15 @@ const predictionListDef: ToolDef = {
613
653
  "confidence, and evidence count. For inspection and debugging.",
614
654
  args: {
615
655
  store: z.string().optional().describe(
616
- "Which store to list. Defaults to the project store.",
656
+ "Which store to list. Defaults to the project store and global.",
617
657
  ),
618
658
  },
619
659
  async execute(args, ctx) {
620
- const store = (args.store as string) || ctx.defaultStore;
621
- const predictions = ctx.db.listPredictions(store);
622
- if (predictions.length === 0) return `No predictions in "${store}".`;
660
+ const stores = args.store
661
+ ? [args.store as string]
662
+ : [ctx.defaultStore, "global"];
663
+ const predictions = stores.flatMap((s) => ctx.db.listPredictions(s));
664
+ if (predictions.length === 0) return `No predictions in "${stores.join('", "')}".`;
623
665
  return predictions.map((p) => {
624
666
  const matchers = p.matchers.map((m) => ` - "${m.description}" (w:${m.weight})`).join("\n");
625
667
  const provenance = ctx.db.getProvenance(p.id);
@@ -647,7 +689,7 @@ const predictionDeleteDef: ToolDef = {
647
689
  ),
648
690
  },
649
691
  async execute(args, ctx) {
650
- const store = (args.store as string) || ctx.defaultStore;
692
+ const store = resolveStore(args, ctx);
651
693
  const statementText = args.statement as string;
652
694
  const predEmbed = await ctx.model.passageEmbed(statementText);
653
695
  const prediction = ctx.db.findNearestPrediction(store, predEmbed, PREDICTION_DEDUP_COSINE);
@@ -690,7 +732,7 @@ const behaviorCodifyDef: ToolDef = {
690
732
  ),
691
733
  },
692
734
  async execute(args, ctx) {
693
- const store = (args.store as string) || ctx.defaultStore;
735
+ const store = resolveStore(args, ctx);
694
736
  const situationText = args.situation as string;
695
737
  const behaviorText = args.behavior as string;
696
738
  const rationale = args.rationale as string;
@@ -699,21 +741,28 @@ const behaviorCodifyDef: ToolDef = {
699
741
  const behaviorEmbed = await ctx.model.passageEmbed(behaviorText);
700
742
 
701
743
  return ctx.db.transaction(() => {
702
- let matcherId = ctx.db.findNearestBehaviorMatcher(store, matcherEmbed, BEHAVIOR_DEDUP_COSINE)?.id;
703
- if (!matcherId) matcherId = ctx.db.createBehaviorMatcher(store, situationText, matcherEmbed, ctx.model.name);
744
+ // Cross-store dedup: same rationale as prediction_update. The
745
+ // auto-fire nudge scans the target store and global, so a duplicate
746
+ // in either store would surface twice every turn.
747
+ const existing = ctx.db.findNearestBehavior(dedupScanStores(store), behaviorEmbed, BEHAVIOR_DEDUP_COSINE);
748
+ const home = existing?.store ?? store;
749
+ const storeTag = home === store ? "" : ` in ${home}`;
750
+
751
+ let matcherId = ctx.db.findNearestBehaviorMatcher(home, matcherEmbed, BEHAVIOR_DEDUP_COSINE)?.id;
752
+ if (!matcherId) matcherId = ctx.db.createBehaviorMatcher(home, situationText, matcherEmbed, ctx.model.name);
704
753
 
705
- let behaviorId = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE)?.id;
754
+ let behaviorId = existing?.id;
706
755
  if (!behaviorId) {
707
- behaviorId = ctx.db.createBehavior(store, behaviorText, rationale, behaviorEmbed, ctx.model.name);
756
+ behaviorId = ctx.db.createBehavior(home, behaviorText, rationale, behaviorEmbed, ctx.model.name);
708
757
  ctx.db.createBehaviorEdge(matcherId, behaviorId, 1.0);
709
758
  ctx.db.addBehaviorProvenance(behaviorId, "codify", rationale);
710
- return `[codified] ${store} :: "${behaviorText}" for "${situationText}"`;
759
+ return `[codified] ${home} :: "${behaviorText}" for "${situationText}"`;
711
760
  }
712
761
 
713
762
  ctx.db.createBehaviorEdge(matcherId, behaviorId, 1.0);
714
763
  ctx.db.addBehaviorProvenance(behaviorId, "codify", rationale);
715
- const existing = ctx.db.getBehavior(behaviorId);
716
- return `[linked] "${existing?.statement ?? behaviorText}" for "${situationText}" confidence=${(existing?.confidence ?? 0).toFixed(2)} (${existing?.confirm_count ?? 0}/${existing?.disconfirm_count ?? 0})`;
764
+ const behavior = ctx.db.getBehavior(behaviorId);
765
+ return `[linked${storeTag}] "${behavior?.statement ?? behaviorText}" for "${situationText}" confidence=${(behavior?.confidence ?? 0).toFixed(2)} (${behavior?.confirm_count ?? 0}/${behavior?.disconfirm_count ?? 0})`;
717
766
  });
718
767
  },
719
768
  };
@@ -746,7 +795,7 @@ const behaviorFeedbackDef: ToolDef = {
746
795
  ),
747
796
  },
748
797
  async execute(args, ctx) {
749
- const store = (args.store as string) || ctx.defaultStore;
798
+ const store = resolveStore(args, ctx);
750
799
  const behaviorText = args.behavior as string;
751
800
  const relevant = args.relevant as boolean;
752
801
  const contextText = args.context as string;
@@ -772,13 +821,15 @@ const behaviorListDef: ToolDef = {
772
821
  "evidence count. For inspection and debugging.",
773
822
  args: {
774
823
  store: z.string().optional().describe(
775
- "Which store to list. Defaults to the project store.",
824
+ "Which store to list. Defaults to the project store and global.",
776
825
  ),
777
826
  },
778
827
  async execute(args, ctx) {
779
- const store = (args.store as string) || ctx.defaultStore;
780
- const behaviors = ctx.db.listBehaviors(store);
781
- if (behaviors.length === 0) return `No behaviors in "${store}".`;
828
+ const stores = args.store
829
+ ? [args.store as string]
830
+ : [ctx.defaultStore, "global"];
831
+ const behaviors = stores.flatMap((s) => ctx.db.listBehaviors(s));
832
+ if (behaviors.length === 0) return `No behaviors in "${stores.join('", "')}".`;
782
833
  return behaviors.map((b) => {
783
834
  const matchers = b.matchers.map((m) => ` - "${m.description}" (w:${m.weight})`).join("\n");
784
835
  const provenance = ctx.db.getBehaviorProvenance(b.id);
@@ -807,7 +858,7 @@ const behaviorDeleteDef: ToolDef = {
807
858
  ),
808
859
  },
809
860
  async execute(args, ctx) {
810
- const store = (args.store as string) || ctx.defaultStore;
861
+ const store = resolveStore(args, ctx);
811
862
  const statementText = args.statement as string;
812
863
  const behaviorEmbed = await ctx.model.passageEmbed(statementText);
813
864
  const behavior = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE);
@@ -818,6 +869,161 @@ const behaviorDeleteDef: ToolDef = {
818
869
  },
819
870
  };
820
871
 
872
+ // ---------------------------------------------------------------------------
873
+ // Config and notification tools. The config is a single JSON file beside
874
+ // thatch.db, hand-editable and also managed through these tools. notify_user
875
+ // shells out to the platform's banner and TTS commands - see src/notify.ts.
876
+ // ---------------------------------------------------------------------------
877
+
878
+ /** Notification preference fields, in presentation order. */
879
+ const NOTIFICATION_FIELDS = ["mode", "voice", "sound"] as const;
880
+
881
+ /** Renders one section with per-field default annotations for echo output. */
882
+ function renderNotificationSection(prefs: NotificationPrefs | undefined): string {
883
+ const defaults = notificationDefaults();
884
+ const lines = ["notifications:"];
885
+ for (const field of NOTIFICATION_FIELDS) {
886
+ const current = prefs?.[field];
887
+ const fallback = defaults[field];
888
+ lines.push(` ${field}: ${current ?? "<unset>"}${fallback ? ` (default: ${fallback})` : ""}`);
889
+ }
890
+ return lines.join("\n");
891
+ }
892
+
893
+ /** Drops keys explicitly set to undefined so a merge never overwrites. */
894
+ function stripUndefined(obj: Record<string, unknown>): Record<string, unknown> {
895
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
896
+ }
897
+
898
+ const configGetDef: ToolDef = {
899
+ name: "config_get",
900
+ description:
901
+ "Read thatch's user config, optionally restricted to one section. " +
902
+ "Values are annotated with their defaults; unset fields show <unset>. " +
903
+ "The config file (~/.config/thatch/config.json, beside thatch.db) is " +
904
+ "also hand-editable. Call this before config_set.",
905
+ args: {
906
+ section: z.enum(["notifications"]).optional().describe(
907
+ "Restrict output to one section. Omit to read all sections.",
908
+ ),
909
+ },
910
+ async execute(args) {
911
+ const loaded = loadConfig();
912
+ const lines: string[] = [];
913
+ if (loaded.warning) lines.push(`[warning] ${loaded.warning}`, "");
914
+ const sections: ConfigSection[] = args.section
915
+ ? [args.section as ConfigSection]
916
+ : [...CONFIG_SECTIONS];
917
+ for (const name of sections) {
918
+ if (name === "notifications") {
919
+ lines.push(renderNotificationSection(loaded.config.notifications));
920
+ }
921
+ }
922
+ lines.push("", `file: ${loaded.path}`);
923
+ return lines.join("\n");
924
+ },
925
+ };
926
+
927
+ const configSetDef: ToolDef = {
928
+ name: "config_set",
929
+ description:
930
+ "Update thatch's user config. Field-level merge: only fields you pass " +
931
+ "change; omitted fields and omitted sections keep their current values. " +
932
+ "Returns the resulting section so you can verify the write. Call " +
933
+ "config_get first to see current values and defaults.",
934
+ args: {
935
+ notifications: z.strictObject({
936
+ mode: z.enum(["both", "banner", "voice", "none"]).optional().describe(
937
+ "Default channels for notify_user: both, banner only, voice only, " +
938
+ "or none (disable notifications entirely).",
939
+ ),
940
+ voice: z.string().optional().describe(
941
+ "Spoken voice name (macOS default: Zarvox). Omit to use the " +
942
+ "platform default.",
943
+ ),
944
+ sound: z.string().optional().describe(
945
+ "Banner alert sound (macOS, from /System/Library/Sounds; default: " +
946
+ "Submarine). Omit to use the platform default.",
947
+ ),
948
+ }).optional().describe(
949
+ "Notification preferences to update. Fields you omit keep their " +
950
+ "current values.",
951
+ ),
952
+ },
953
+ async execute(args) {
954
+ const loaded = loadConfig();
955
+ const config: Config = { ...loaded.config };
956
+ const patch = args.notifications as Record<string, unknown> | undefined;
957
+ if (!patch || Object.keys(stripUndefined(patch)).length === 0) {
958
+ return (
959
+ "Nothing to update: pass a section with fields to change.\n" +
960
+ renderNotificationSection(config.notifications)
961
+ );
962
+ }
963
+ config.notifications = notificationPrefsSchema.parse(
964
+ mergeNotificationPrefs(config.notifications, stripUndefined(patch)),
965
+ );
966
+ const path = saveConfig(config);
967
+ return (
968
+ `[saved] ${path}\n` +
969
+ renderNotificationSection(config.notifications)
970
+ );
971
+ },
972
+ };
973
+
974
+ const notifyUserDef: ToolDef = {
975
+ name: "notify_user",
976
+ description:
977
+ "Notify the user out-of-band: desktop banner and/or spoken voice " +
978
+ "(macOS: Notification Center + say; Linux: notify-send + spd-say/espeak). " +
979
+ "Use for long-running terminal-event outcomes worth interrupting for: " +
980
+ "CI results, merge or deploy completion, watcher events. ALWAYS include " +
981
+ "source: a word or two identifying the session or work item (ticket " +
982
+ "number or feature name) so the user knows which session spoke. Honors " +
983
+ "the user's configured preferences; pass channel to override for this " +
984
+ "call only. If the configured mode is none, the tool no-ops and says " +
985
+ "so. The result reports command success only - OS focus modes can " +
986
+ "silently suppress banners.",
987
+ args: {
988
+ message: z.string().describe(
989
+ "The notification text. Spell out letter sequences so " +
990
+ "text-to-speech pronounces them (\"C I green\", not \"CI green\").",
991
+ ),
992
+ title: z.string().optional().describe(
993
+ "Banner title. Defaults to source, then \"thatch\".",
994
+ ),
995
+ source: z.string().optional().describe(
996
+ "Short label identifying the session or work item (ticket number or " +
997
+ "feature name), e.g. \"PLAT-280\". Prepended to the spoken message.",
998
+ ),
999
+ channel: z.enum(["both", "banner", "voice"]).optional().describe(
1000
+ "Override the configured mode for this call only.",
1001
+ ),
1002
+ voice: z.string().optional().describe(
1003
+ "Override the configured voice for this call.",
1004
+ ),
1005
+ },
1006
+ async execute(args, ctx) {
1007
+ const loaded = loadConfig();
1008
+ const prefs = loaded.config.notifications ?? {};
1009
+ const mode = prefs.mode ?? "both";
1010
+ if (mode === "none") {
1011
+ return "[skipped] notifications are disabled (notifications.mode: none). " +
1012
+ "Use thatch config_set to re-enable if the user asks.";
1013
+ }
1014
+ const channel = (args.channel as NotifyChannel | undefined) ?? (mode as NotifyChannel);
1015
+ return sendNotification({
1016
+ message: args.message as string,
1017
+ title: args.title as string | undefined,
1018
+ source: args.source as string | undefined,
1019
+ channel,
1020
+ voice: (args.voice as string | undefined) ?? prefs.voice,
1021
+ sound: prefs.sound,
1022
+ }, ctx.spawner ?? defaultSpawner);
1023
+ },
1024
+ };
1025
+
1026
+
821
1027
  /**
822
1028
  * Reports the calling session's identity. opencode does not surface its
823
1029
  * session ID to the model, so the model cannot learn it any other way -
@@ -1148,6 +1354,9 @@ export const TOOL_DEFS: ToolDef[] = [
1148
1354
  behaviorFeedbackDef,
1149
1355
  behaviorListDef,
1150
1356
  behaviorDeleteDef,
1357
+ configGetDef,
1358
+ configSetDef,
1359
+ notifyUserDef,
1151
1360
  getSessionInfoDef,
1152
1361
  sessionSearchDef,
1153
1362
  sessionGetDef,