@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.
@@ -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.35",
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" && event.properties.status?.type === "idle") {
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
- 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
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
+ }