@jeffober/thatch 0.1.35 → 0.1.37
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 +15 -0
- package/bin/thatch +157 -0
- package/docs/dev/README.md +10 -3
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/multi-host.md +16 -10
- package/docs/dev/features/qa-system.md +3 -3
- package/docs/dev/features/watchers.md +161 -0
- package/docs/dev/mcp-parity.md +1 -1
- package/docs/dev/skills.md +7 -5
- package/docs/user/README.md +24 -0
- package/docs/user/watchers.md +91 -0
- package/package.json +1 -1
- package/src/index.ts +61 -2
- package/src/mcp.ts +4 -1
- package/src/prompts.ts +48 -9
- package/src/session-db.ts +452 -0
- package/src/skills.ts +22 -46
- package/src/tool-defs.ts +339 -3
- package/src/tools.ts +11 -3
- package/src/watchers.ts +787 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Watchers
|
|
2
|
+
|
|
3
|
+
Watchers monitor external sources - today, GitHub pull requests and
|
|
4
|
+
branches - and inject a notification into the conversation when
|
|
5
|
+
something happens. You ask the agent to watch a PR or a branch; thatch
|
|
6
|
+
polls it in the background; the agent gets woken up when a watched
|
|
7
|
+
event occurs. No one has to keep asking "any updates yet?"
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
The agent calls `thatch_watch_create` to register a watcher on a PR,
|
|
12
|
+
or `thatch_watch_branch_create` to register a watcher on a branch
|
|
13
|
+
(typically main). thatch captures the target's current state as a
|
|
14
|
+
baseline, then polls the GitHub API through the `gh` CLI once a
|
|
15
|
+
minute. When the target changes in a way the watcher cares about, the
|
|
16
|
+
plugin prompts the agent with a notification.
|
|
17
|
+
|
|
18
|
+
PR watched events:
|
|
19
|
+
|
|
20
|
+
- `pr_comment` - new top-level comments on the PR
|
|
21
|
+
- `pr_review_comment` - new inline comments on the diff
|
|
22
|
+
- `pr_review_reply` - replies to an existing review comment
|
|
23
|
+
- `pr_review_resolved` - a review thread resolved or reopened
|
|
24
|
+
- `pr_commit` - new commits (the head SHA moved)
|
|
25
|
+
- `pr_status` - the PR opened, closed, or merged
|
|
26
|
+
- `pr_description` - title or description edited
|
|
27
|
+
- `pr_ci` - a CI check run on the head SHA completed
|
|
28
|
+
|
|
29
|
+
Branch watched events:
|
|
30
|
+
|
|
31
|
+
- `branch_commit` - new commits land on the branch (e.g. a PR merged)
|
|
32
|
+
- `branch_ci` - a check run on the branch head completed
|
|
33
|
+
- `branch_workflow` - a GitHub Actions workflow run starts or finishes
|
|
34
|
+
on the branch, optionally filtered by workflow name (substring,
|
|
35
|
+
case-insensitive)
|
|
36
|
+
|
|
37
|
+
Notifications are delivered as synthetic parts: the model sees them,
|
|
38
|
+
the transcript does not. A notification triggers a model turn, so the
|
|
39
|
+
agent can act on the event even while you are away.
|
|
40
|
+
|
|
41
|
+
## Privacy model
|
|
42
|
+
|
|
43
|
+
Notifications carry pointer data only: who commented, what happened,
|
|
44
|
+
and a URL. Comment bodies, diff contents, and CI logs never enter the
|
|
45
|
+
context from the notification itself. This is deliberate - GitHub
|
|
46
|
+
comments are written by strangers, and untrusted text flowing into a
|
|
47
|
+
model's context is a prompt-injection vector. The agent fetches details
|
|
48
|
+
on demand with `gh` when it decides to act.
|
|
49
|
+
|
|
50
|
+
## How to use it
|
|
51
|
+
|
|
52
|
+
Ask for it in conversation:
|
|
53
|
+
|
|
54
|
+
> Watch PR 1234 for review comments. If bugbot comments, handle it
|
|
55
|
+
> yourself. If a human comments, just tell me.
|
|
56
|
+
|
|
57
|
+
The handling policy lives in your instruction. State it when you ask -
|
|
58
|
+
the notification turn reads your policy from the conversation and
|
|
59
|
+
follows it. The notification says what happened and where, not what to
|
|
60
|
+
do about it.
|
|
61
|
+
|
|
62
|
+
Other tools:
|
|
63
|
+
|
|
64
|
+
- `thatch_watch_branch_create` - watch a branch (typically main) for
|
|
65
|
+
commits, CI check runs, and workflow runs
|
|
66
|
+
- `thatch_watch_list` - shows the session's active watchers
|
|
67
|
+
- `thatch_watch_cancel` - cancels one by id
|
|
68
|
+
|
|
69
|
+
## Lifetime
|
|
70
|
+
|
|
71
|
+
Watchers are in-memory and process-scoped:
|
|
72
|
+
|
|
73
|
+
- Ending the opencode process (or restarting it) drops all watchers.
|
|
74
|
+
- A watcher expires after 8 hours by default.
|
|
75
|
+
- Each session can hold up to 5 active watchers.
|
|
76
|
+
|
|
77
|
+
If opencode restarts while a watch is active, re-register it - the
|
|
78
|
+
session's conversation survives, so the policy you stated is still
|
|
79
|
+
there.
|
|
80
|
+
|
|
81
|
+
## Requirements and limitations
|
|
82
|
+
|
|
83
|
+
- **opencode only.** MCP hosts (Claude Code, Cursor) have no way for a
|
|
84
|
+
plugin to start a conversation turn, so the watch tools are not
|
|
85
|
+
available there.
|
|
86
|
+
- **Requires the `gh` CLI** to be installed and authenticated. Watch
|
|
87
|
+
creation reports a clear error if it is not.
|
|
88
|
+
- **Polling, not webhooks.** Events appear on the next poll cycle
|
|
89
|
+
(60 seconds by default), not instantly.
|
|
90
|
+
- **No delivery during an active turn.** If the agent is mid-turn when
|
|
91
|
+
an event fires, the notification waits until the turn ends.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeffober/thatch",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
|
|
|
21
21
|
import { hygieneReport } from "./hygiene";
|
|
22
22
|
import { seedDefaultBehaviors } from "./seed-behaviors";
|
|
23
23
|
import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
|
|
24
|
+
import { WatcherRegistry, ghApiRun, ghAvailable } from "./watchers";
|
|
25
|
+
import { watcherNotificationNudge } from "./prompts";
|
|
24
26
|
import pkg from "../package.json";
|
|
25
27
|
|
|
26
28
|
// ---------------------------------------------------------------------------
|
|
@@ -83,6 +85,43 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
83
85
|
// non-restart.
|
|
84
86
|
const runningVersion = pkg.version;
|
|
85
87
|
|
|
88
|
+
// Latest observed opencode session status per session ("busy" | "idle" |
|
|
89
|
+
// "retry"). The event hook records these; the watcher registry's
|
|
90
|
+
// canDeliver gate reads them so proactive notification prompts only fire
|
|
91
|
+
// into idle sessions - never into a turn that is already running.
|
|
92
|
+
const sessionStatus = new Map<string, string>();
|
|
93
|
+
|
|
94
|
+
// In-memory watcher registry for proactive event notifications (GitHub PR
|
|
95
|
+
// watching today). Deliberately process-scoped: no SQLite, no cross-restart
|
|
96
|
+
// state. See src/watchers.ts for the rationale.
|
|
97
|
+
//
|
|
98
|
+
// Delivery prompts the session with a synthetic part - the same mechanism
|
|
99
|
+
// opencode uses for background task completions - so a watched event
|
|
100
|
+
// triggers a model turn even when the user is away. Events only carry
|
|
101
|
+
// pointer data; the model fetches details itself with gh.
|
|
102
|
+
const watchers = new WatcherRegistry({
|
|
103
|
+
deliver: async (sessionID, events) => {
|
|
104
|
+
// Events carry their watch's target label from the registry, so the
|
|
105
|
+
// notification header is correct for every source (PRs, branches,
|
|
106
|
+
// CI events whose URLs would not parse).
|
|
107
|
+
await client.session.promptAsync({
|
|
108
|
+
path: { id: sessionID },
|
|
109
|
+
body: {
|
|
110
|
+
parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events), synthetic: true }],
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
canDeliver: (sessionID) =>
|
|
115
|
+
!compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
|
|
116
|
+
ghRunner: ghApiRun,
|
|
117
|
+
});
|
|
118
|
+
// gh presence decides whether watch_create works; checked lazily by the
|
|
119
|
+
// tool, but log once at startup so misconfiguration is visible in debug logs.
|
|
120
|
+
void ghAvailable().then((ok) => {
|
|
121
|
+
if (!ok) console.error("[thatch] gh CLI not found - watch tools will report unavailable");
|
|
122
|
+
});
|
|
123
|
+
watchers.start();
|
|
124
|
+
|
|
86
125
|
// Sessions currently being compacted. chat.message nudges are skipped while
|
|
87
126
|
// a session is in this set - the agent can't call tools during summary
|
|
88
127
|
// generation, so a recall or extraction nudge would cause a blocked-tool
|
|
@@ -242,6 +281,7 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
242
281
|
if (all.length === 0) return null;
|
|
243
282
|
return extraction.buildPayload(all, repo);
|
|
244
283
|
},
|
|
284
|
+
watcherRegistry: watchers,
|
|
245
285
|
}),
|
|
246
286
|
|
|
247
287
|
// 1. System prompt - always in context.
|
|
@@ -600,8 +640,13 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
600
640
|
}
|
|
601
641
|
return;
|
|
602
642
|
}
|
|
603
|
-
if (event.type === "session.status"
|
|
643
|
+
if (event.type === "session.status") {
|
|
604
644
|
const sessionID = event.properties.sessionID;
|
|
645
|
+
const statusType = event.properties.status?.type;
|
|
646
|
+
// Record the latest status so the watcher registry can gate
|
|
647
|
+
// proactive prompt delivery on idle sessions.
|
|
648
|
+
if (sessionID && statusType) sessionStatus.set(sessionID, statusType);
|
|
649
|
+
if (statusType !== "idle") return;
|
|
605
650
|
const parentID = sessionID ? childToParent.get(sessionID) : undefined;
|
|
606
651
|
if (parentID && sessionID) {
|
|
607
652
|
// A child session went idle. Two cases:
|
|
@@ -685,6 +730,15 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
685
730
|
extracting.delete(sessionID);
|
|
686
731
|
}
|
|
687
732
|
}
|
|
733
|
+
// The session just became idle, so watcher events queued while it
|
|
734
|
+
// was busy can be delivered now - the poll interval may otherwise
|
|
735
|
+
// hold them for up to a full cycle. Best-effort; failures stay
|
|
736
|
+
// pending and retry on the next poll.
|
|
737
|
+
try {
|
|
738
|
+
await watchers.deliverPending();
|
|
739
|
+
} catch (err) {
|
|
740
|
+
console.error(`[thatch] watcher delivery on idle failed: ${err}`);
|
|
741
|
+
}
|
|
688
742
|
return;
|
|
689
743
|
}
|
|
690
744
|
if (event.type === "session.deleted") {
|
|
@@ -699,9 +753,13 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
699
753
|
parentSnapshots.delete(id);
|
|
700
754
|
childMetrics.delete(id);
|
|
701
755
|
extractionChildren.delete(id);
|
|
702
|
-
// A deleted parent takes its accepted entries with it
|
|
756
|
+
// A deleted parent takes its accepted entries with it, and its
|
|
757
|
+
// watchers die with it - the session that would receive their
|
|
758
|
+
// notifications no longer exists.
|
|
703
759
|
extraction.completeAccepted(id);
|
|
704
760
|
extracting.delete(id);
|
|
761
|
+
sessionStatus.delete(id);
|
|
762
|
+
watchers.cancelSession(id);
|
|
705
763
|
return;
|
|
706
764
|
}
|
|
707
765
|
|
|
@@ -739,6 +797,7 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
739
797
|
|
|
740
798
|
dispose: async () => {
|
|
741
799
|
stopVersionChecker();
|
|
800
|
+
watchers.dispose();
|
|
742
801
|
db.close();
|
|
743
802
|
},
|
|
744
803
|
};
|
package/src/mcp.ts
CHANGED
|
@@ -60,9 +60,12 @@ interface CompiledTool {
|
|
|
60
60
|
inputSchema: Record<string, unknown>;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function compileTools(): Map<string, CompiledTool> {
|
|
63
|
+
export function compileTools(): Map<string, CompiledTool> {
|
|
64
64
|
const map = new Map<string, CompiledTool>();
|
|
65
65
|
for (const def of TOOL_DEFS) {
|
|
66
|
+
// opencode-only tools depend on host capabilities MCP servers don't
|
|
67
|
+
// have (e.g. session identity via HostToolContext) - never expose them.
|
|
68
|
+
if (def.opencodeOnly) continue;
|
|
66
69
|
const schema = z.object(def.args);
|
|
67
70
|
map.set(def.name, {
|
|
68
71
|
def,
|
package/src/prompts.ts
CHANGED
|
@@ -17,13 +17,16 @@ Thatch provides persistent memory across opencode sessions. Use it to persist
|
|
|
17
17
|
knowledge so future sessions can build on what you've already learned.
|
|
18
18
|
|
|
19
19
|
Tools: thatch_memory_remember, thatch_memory_recall, thatch_memory_list,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
thatch_memory_show, thatch_memory_forget, thatch_store_list,
|
|
21
|
+
thatch_find_duplicates, thatch_dedup_mark_checked,
|
|
22
|
+
thatch_extraction_done, thatch_get_extraction_payload,
|
|
23
|
+
thatch_prediction_query, thatch_prediction_update, thatch_prediction_list,
|
|
24
|
+
thatch_prediction_delete,
|
|
25
|
+
thatch_behavior_codify, thatch_behavior_feedback, thatch_behavior_list,
|
|
26
|
+
thatch_behavior_delete, thatch_get_session_info,
|
|
27
|
+
thatch_session_search, thatch_session_get,
|
|
28
|
+
thatch_watch_create, thatch_watch_branch_create,
|
|
29
|
+
thatch_watch_list, thatch_watch_cancel
|
|
27
30
|
|
|
28
31
|
## Stores
|
|
29
32
|
|
|
@@ -65,7 +68,6 @@ workflows. The host auto-discovers them, but reach for them proactively:
|
|
|
65
68
|
- \`thatch-review-response\` - respond to code review on the user's own PR. Triage findings, fix bugs one by one, reply on each thread, then post a top-level summary comment.
|
|
66
69
|
- \`thatch-memory-verify\` - fact-check a single memory against the current codebase and correct stale claims. Use when a memory has an old \`updated_at\` date or when preparing a knowledge export.
|
|
67
70
|
- \`thatch-knowledge-export\` - compile everything thatch knows about a topic into a curated markdown file for knowledge transfer. Searches across stores, curates out personal noise, fact-checks code-related memories, and generates a clean export.
|
|
68
|
-
|
|
69
71
|
## When to Write
|
|
70
72
|
|
|
71
73
|
**One signal is enough.** Do not wait for confirmation.
|
|
@@ -107,6 +109,23 @@ the same prompt. If the re-dispatch also fails or times out, note the gap and
|
|
|
107
109
|
move on. Do not retry more than once - repeated failures indicate an
|
|
108
110
|
intractable prompt, not a transient hang.
|
|
109
111
|
|
|
112
|
+
## Watchers
|
|
113
|
+
|
|
114
|
+
Use thatch_watch_create to watch a GitHub PR for events (comments, review
|
|
115
|
+
comments and replies, review thread resolutions, commits, status changes,
|
|
116
|
+
description edits, CI check completions), or thatch_watch_branch_create to
|
|
117
|
+
watch a branch (typically main) for commit landings, check-run completions,
|
|
118
|
+
and workflow runs on the branch. thatch polls in the
|
|
119
|
+
background and injects a notification into this session when a watched event
|
|
120
|
+
happens. The
|
|
121
|
+
notification carries pointer data only (author, URL); fetch
|
|
122
|
+
details with the gh CLI when you decide to act. When the user asks you to
|
|
123
|
+
watch something, state your handling policy for notifications out loud first
|
|
124
|
+
("I'll report human comments and handle bugbot replies myself") so the
|
|
125
|
+
notification turn knows what to do. Treat watcher notifications like
|
|
126
|
+
background task completions: they are system events, not user input, and
|
|
127
|
+
not approval to advance other pending work.
|
|
128
|
+
|
|
110
129
|
## User Decision Model
|
|
111
130
|
|
|
112
131
|
A statistical model of the user's decision-making preferences is maintained by
|
|
@@ -270,6 +289,11 @@ Tools are prefixed in ${host}: \`mcp__thatch__memory_remember\`,
|
|
|
270
289
|
\`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`,
|
|
271
290
|
\`mcp__thatch__behavior_codify\`, \`mcp__thatch__behavior_feedback\`,
|
|
272
291
|
\`mcp__thatch__behavior_list\`, \`mcp__thatch__behavior_delete\`. Bare names used below for readability.
|
|
292
|
+
get_session_info, session_search, session_get, watch_create,
|
|
293
|
+
watch_branch_create, watch_list,
|
|
294
|
+
and watch_cancel are intentionally absent: they are opencode-only (MCP
|
|
295
|
+
hosts have no session concept, session database, or proactive-prompt
|
|
296
|
+
channel), so do not expect them here.
|
|
273
297
|
|
|
274
298
|
## Stores
|
|
275
299
|
|
|
@@ -311,7 +335,6 @@ workflows. The host auto-discovers them, but reach for them proactively:
|
|
|
311
335
|
- \`thatch-review-response\` - respond to code review on the user's own PR. Triage findings, fix bugs one by one, reply on each thread, then post a top-level summary comment.
|
|
312
336
|
- \`thatch-memory-verify\` - fact-check a single memory against the current codebase and correct stale claims. Use when a memory has an old \`updated_at\` date or when preparing a knowledge export.
|
|
313
337
|
- \`thatch-knowledge-export\` - compile everything thatch knows about a topic into a curated markdown file for knowledge transfer. Searches across stores, curates out personal noise, fact-checks code-related memories, and generates a clean export.
|
|
314
|
-
|
|
315
338
|
## When to Write
|
|
316
339
|
|
|
317
340
|
**One signal is enough.** Do not wait for confirmation.
|
|
@@ -668,3 +691,19 @@ export function behaviorNudge(items: BehaviorNudgeItem[]): string {
|
|
|
668
691
|
});
|
|
669
692
|
return `[thatch] Situational behaviors\n${lines.join("\n")}`;
|
|
670
693
|
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Watcher notification for the poller's promptAsync delivery. Injected as a
|
|
697
|
+
* synthetic part that triggers a model turn. Events carry pointer data only
|
|
698
|
+
* (author, URL) - never external content - so the model fetches details on
|
|
699
|
+
* demand with the gh CLI. The wrapper text borrows the background-task
|
|
700
|
+
* completion framing: a system event, not user input, not approval to
|
|
701
|
+
* advance other pending work.
|
|
702
|
+
*/
|
|
703
|
+
export function watcherNotificationNudge(target: string, events: { type: string; summary: string; url: string }[]): string {
|
|
704
|
+
const lines = events.map((e) => `- ${e.type}: ${e.summary} ${e.url}`);
|
|
705
|
+
return `[thatch] Watcher notification for ${target}
|
|
706
|
+
${lines.join("\n")}
|
|
707
|
+
|
|
708
|
+
This is a system notification, not user input. Decide whether to act now or keep waiting - the user's instructions from when the watch was created govern how to handle it. Fetch details with the gh CLI if you need them. Do not treat this notification as approval to advance other pending work. If you act, tell the user what you did and why; if not, stop and wait.`;
|
|
709
|
+
}
|