@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.
- package/README.md +43 -4
- package/artifacts/skills/thatch-fact-extractor.md +1 -1
- package/artifacts/skills/thatch-memory-verify.md +2 -2
- package/bin/thatch +31 -16
- package/docs/dev/README.md +39 -13
- package/docs/dev/features/behavior-engine.md +3 -2
- package/docs/dev/features/cicd.md +1 -1
- package/docs/dev/features/compaction-recovery.md +1 -1
- package/docs/dev/features/cross-session-chat.md +12 -3
- package/docs/dev/features/extraction.md +10 -2
- package/docs/dev/features/hygiene.md +3 -1
- package/docs/dev/features/memory-store.md +1 -1
- package/docs/dev/features/multi-host.md +4 -2
- package/docs/dev/features/notifications.md +121 -4
- package/docs/dev/features/nudge-pipeline.md +16 -2
- package/docs/dev/features/opencode-plugin.md +133 -0
- package/docs/dev/features/qa-system.md +43 -0
- package/docs/dev/features/repo-identity.md +1 -1
- package/docs/dev/features/session-lifecycle.md +6 -3
- package/docs/dev/features/watchers.md +12 -5
- package/docs/dev/gotchas.md +94 -4
- package/docs/dev/mcp-parity.md +4 -3
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/plans/llm-alerts.md +287 -0
- package/docs/plans/prediction-consolidation.md +3 -3
- package/docs/user/README.md +8 -4
- package/docs/user/memory.md +1 -1
- package/docs/user/notifications.md +63 -4
- package/docs/user/setup.md +4 -0
- package/docs/user/watchers.md +15 -7
- package/package.json +3 -1
- package/src/alerts.ts +360 -0
- package/src/capabilities.ts +174 -0
- package/src/chat.ts +80 -5
- package/src/commands.ts +44 -5
- package/src/config.ts +32 -1
- package/src/db.ts +157 -12
- package/src/embeddings.ts +43 -0
- package/src/extraction.ts +210 -0
- package/src/index.ts +77 -1339
- package/src/opencode/v1.ts +54 -0
- package/src/opencode/v2.ts +605 -0
- package/src/os-args.ts +99 -0
- package/src/prompts.ts +181 -31
- package/src/runtime.ts +2004 -0
- package/src/seed-behaviors.ts +1 -1
- package/src/session-db.ts +32 -0
- package/src/tool-defs.ts +340 -46
- package/src/tools.ts +16 -35
- 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
|
|
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
|
-
/**
|
|
68
|
-
* JSON payload (same shape as buildExtractionPayload)
|
|
69
|
-
* no interactions are queued.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
|
|
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}
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
232
|
-
|
|
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
|
|
492
|
-
*
|
|
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
|
|
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.
|
|
809
|
-
"
|
|
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
|
|
830
|
-
|
|
831
|
-
|
|
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.
|
|
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
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
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
|
+
}
|