@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/README.md +8 -2
- package/artifacts/skills/common.md +4 -0
- package/artifacts/skills/thatch-code-review.md +10 -4
- package/artifacts/skills/thatch-review-mentorship.md +174 -0
- package/artifacts/skills/thatch-review-synthesizer.md +57 -6
- package/docs/dev/README.md +3 -1
- package/docs/dev/features/README.md +3 -2
- package/docs/dev/features/behavior-engine.md +5 -3
- package/docs/dev/features/database.md +2 -2
- package/docs/dev/features/memory-store.md +13 -0
- package/docs/dev/features/multi-host.md +2 -2
- package/docs/dev/features/notifications.md +106 -0
- package/docs/dev/features/prediction-engine.md +6 -3
- package/docs/dev/features/qa-system.md +18 -17
- package/docs/dev/gotchas.md +18 -0
- package/docs/dev/mcp-parity.md +1 -1
- package/docs/dev/skills.md +9 -6
- package/docs/user/README.md +37 -13
- package/docs/user/code-review.md +4 -3
- package/docs/user/memory.md +3 -0
- package/docs/user/notifications.md +84 -0
- package/docs/user/setup.md +4 -2
- package/docs/user/skills.md +2 -1
- package/package.json +2 -1
- package/src/behavior.ts +1 -1
- package/src/config.ts +103 -0
- package/src/db.ts +2 -2
- package/src/embeddings.ts +183 -8
- package/src/index.ts +4 -0
- package/src/notify.ts +123 -0
- package/src/prediction.ts +1 -1
- package/src/prompts.ts +27 -3
- package/src/scoring-engine.ts +25 -4
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +249 -40
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
556
|
-
if (!matcherId) matcherId = ctx.db.createMatcher(
|
|
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 (!
|
|
602
|
+
if (!existing) {
|
|
565
603
|
// No near-identical prediction exists; create one and link it.
|
|
566
|
-
predictionId = ctx.db.createPrediction(
|
|
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] ${
|
|
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
|
|
596
|
-
return `[linked] "${
|
|
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
|
|
621
|
-
|
|
622
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
703
|
-
|
|
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 =
|
|
754
|
+
let behaviorId = existing?.id;
|
|
706
755
|
if (!behaviorId) {
|
|
707
|
-
behaviorId = ctx.db.createBehavior(
|
|
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] ${
|
|
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
|
|
716
|
-
return `[linked] "${
|
|
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
|
|
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
|
|
780
|
-
|
|
781
|
-
|
|
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
|
|
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,
|