@jeffober/thatch 0.1.35 → 0.1.36

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 CHANGED
@@ -83,6 +83,15 @@ Thatch gives your agent three layers of persistent context:
83
83
  situations arise. The agent ham/spams each surfaced rule to train the
84
84
  classifier. Confidence adjusts the same way as predictions. An anti-laziness
85
85
  guard in the prompt prevents the agent from codifying shortcuts.
86
+ - **Conversation search** -- the agent can search its own past opencode
87
+ conversations by substring or regex and retrieve full messages, including
88
+ complete tool inputs and outputs. Available as tools (`session_search`,
89
+ `session_get`, opencode only) and as CLI subcommands (`thatch session
90
+ list/get/transcript/search`, JSONL output designed for piping to `jq`).
91
+ - **Watchers** -- event-driven notifications from external sources (opencode
92
+ only). The agent registers a watch on a GitHub PR; thatch polls it in the
93
+ background and prompts the session when comments, commits, CI results, or
94
+ status changes arrive. Notifications carry pointer data only.
86
95
 
87
96
  Plus **26 skills** for memory workflows, structured multi-specialist code
88
97
  review, review response, change and feature walkthroughs, memory verification,
@@ -98,6 +107,12 @@ host.
98
107
  Everything is local. The embedding model downloads once from Hugging Face Hub
99
108
  and is cached. No data leaves your machine.
100
109
 
110
+ One exception: watchers (opencode only) call the GitHub API through the `gh`
111
+ CLI to poll PRs you explicitly asked to watch, using your existing gh
112
+ authentication. Thatch never sees or stores the token, and notifications
113
+ carry pointer data only - GitHub comment text never enters your context
114
+ unless the agent fetches it.
115
+
101
116
  ## Development
102
117
 
103
118
  ```bash
package/bin/thatch CHANGED
@@ -9,6 +9,7 @@ import { BgeEmbeddingModel } from "../src/embeddings";
9
9
  import { hygieneReport } from "../src/hygiene";
10
10
  import { claudeSessionStartReminder } from "../src/prompts";
11
11
  import { setupClaudeCode, setupCursor } from "../src/setup";
12
+ import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToTranscriptEntries, partToFullJson, messageToFullJson, type SessionPart } from "../src/session-db";
12
13
 
13
14
  const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
14
15
  const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
@@ -34,6 +35,15 @@ function usage(): never {
34
35
  thatch flush-predictions [--json] Query predictions for the prompt (hook only)
35
36
  thatch setup --claude [--cursor] [--global]
36
37
  Install config + instructions + hooks + skills
38
+ thatch session list -s <id> [--after T] [--before T]
39
+ JSONL timeline of a session's parts (one line per part)
40
+ thatch session get --id <part_id|msg_id>
41
+ Full JSON of one part (tool parts include their output)
42
+ or one message with all its parts
43
+ thatch session transcript -s <id>
44
+ Full conversation as OpenAI chat-completions JSONL
45
+ thatch session search <query> [--session <id>] [--regex] [--limit N]
46
+ JSONL of matching parts across sessions (decoded content)
37
47
 
38
48
  Stores default to the repo detected from git remote. Use "global" for the
39
49
  global store. Use "all" with search to search project + global together.`);
@@ -67,6 +77,148 @@ function projectDir(): string {
67
77
  return process.env.CURSOR_PROJECT_DIR ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
68
78
  }
69
79
 
80
+ // ---------------------------------------------------------------------------
81
+ // session subcommand group - read-only archaeology on the opencode db
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Parses an ISO timestamp or epoch-milliseconds value into epoch ms. */
85
+ function parseTimeBound(value: string, flag: string): number {
86
+ const asNum = Number(value);
87
+ if (Number.isFinite(asNum) && value !== "") return asNum;
88
+ const parsed = Date.parse(value);
89
+ if (Number.isNaN(parsed)) {
90
+ console.error(`error: ${flag} expects an ISO timestamp or epoch ms, got "${value}"`);
91
+ process.exit(1);
92
+ }
93
+ return parsed;
94
+ }
95
+
96
+ /** Reads a flag's value (-flag value or --flag=value forms). */
97
+ function flagValue(flags: string[], ...names: string[]): string | undefined {
98
+ for (let i = 0; i < flags.length; i++) {
99
+ for (const name of names) {
100
+ if (flags[i] === name) return flags[i + 1];
101
+ if (flags[i]?.startsWith(`${name}=`)) return flags[i]!.slice(name.length + 1);
102
+ }
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ function openSessionDb(): SessionDB {
108
+ const path = resolveOpencodeDbPath();
109
+ if (!path) {
110
+ console.error(
111
+ "error: no opencode database found. The session commands inspect the\n" +
112
+ "session history of the opencode host; other hosts (Claude Code, Cursor)\n" +
113
+ "have no such database. Set OPENCODE_DB to point at an opencode.db file.",
114
+ );
115
+ process.exit(1);
116
+ }
117
+ return new SessionDB(path);
118
+ }
119
+
120
+ async function sessionCommand(rest: string[]): Promise<void> {
121
+ const [sub, ...restArgs] = rest;
122
+ const sessionID = flagValue(restArgs, "-s", "--session");
123
+ const after = flagValue(restArgs, "--after");
124
+ const before = flagValue(restArgs, "--before");
125
+
126
+ const db = openSessionDb();
127
+ try {
128
+ switch (sub) {
129
+ case "list": {
130
+ if (!sessionID) {
131
+ console.error("error: session list requires -s/--session <session id>");
132
+ process.exit(1);
133
+ }
134
+ const parts = db.listParts(
135
+ sessionID,
136
+ after ? parseTimeBound(after, "--after") : undefined,
137
+ before ? parseTimeBound(before, "--before") : undefined,
138
+ );
139
+ for (const part of parts) console.log(JSON.stringify(partToTimelineEntry(part)));
140
+ break;
141
+ }
142
+
143
+ case "get": {
144
+ const id = flagValue(restArgs, "--id");
145
+ if (!id) {
146
+ console.error("error: session get requires --id <part_id|msg_id>");
147
+ process.exit(1);
148
+ }
149
+ if (id.startsWith("msg_")) {
150
+ const message = db.getMessage(id);
151
+ if (!message) {
152
+ console.error(`error: no message "${id}" in ${db.path}`);
153
+ process.exit(1);
154
+ }
155
+ console.log(JSON.stringify(messageToFullJson(message), null, 2));
156
+ } else {
157
+ const part = db.getPart(id);
158
+ if (!part) {
159
+ console.error(`error: no part "${id}" in ${db.path}`);
160
+ process.exit(1);
161
+ }
162
+ console.log(JSON.stringify(partToFullJson(part), null, 2));
163
+ }
164
+ break;
165
+ }
166
+
167
+ case "transcript": {
168
+ if (!sessionID) {
169
+ console.error("error: session transcript requires -s/--session <session id>");
170
+ process.exit(1);
171
+ }
172
+ for (const message of db.listMessages(sessionID)) {
173
+ for (const entry of partToTranscriptEntries(message.role, message.parts)) {
174
+ console.log(JSON.stringify(entry));
175
+ }
176
+ }
177
+ break;
178
+ }
179
+
180
+ case "search": {
181
+ // Positional = args not preceded by a value-taking flag. Values like
182
+ // "50" or a session id don't start with "-", so predecessor checks
183
+ // are the only reliable discriminator.
184
+ const valueFlags = new Set(["-s", "--session", "--limit"]);
185
+ const positional = restArgs.filter((a, i) => !a.startsWith("-") && !valueFlags.has(restArgs[i - 1] ?? ""));
186
+ const query = positional[0];
187
+ if (!query) {
188
+ console.error("error: session search requires a query (substring, or regex with --regex)");
189
+ process.exit(1);
190
+ }
191
+ const limit = flagValue(restArgs, "--limit");
192
+ const matches = db.search(query, {
193
+ regex: restArgs.includes("--regex"),
194
+ sessionID,
195
+ limit: limit ? parseInt(limit, 10) : undefined,
196
+ });
197
+ // Titles let a cross-session result list be triaged without a
198
+ // second lookup per hit. Sessions are cached - matches cluster.
199
+ const titles = new Map<string, string | null>();
200
+ for (const part of matches) {
201
+ if (!titles.has(part.sessionId)) {
202
+ titles.set(part.sessionId, db.getSession(part.sessionId)?.title ?? null);
203
+ }
204
+ const entry: Record<string, unknown> = {
205
+ session_id: part.sessionId,
206
+ session_title: titles.get(part.sessionId),
207
+ ...partToTimelineEntry(part),
208
+ };
209
+ console.log(JSON.stringify(entry));
210
+ }
211
+ break;
212
+ }
213
+
214
+ default:
215
+ usage();
216
+ }
217
+ } finally {
218
+ db.close();
219
+ }
220
+ }
221
+
70
222
  switch (command) {
71
223
  case "--version":
72
224
  case "-v":
@@ -290,6 +442,11 @@ switch (command) {
290
442
  break;
291
443
  }
292
444
 
445
+ case "session": {
446
+ await sessionCommand(args);
447
+ break;
448
+ }
449
+
293
450
  case "buffer-batch": {
294
451
  // PostToolBatch hook: append the batch's tool_calls to the session's
295
452
  // JSONL queue. Silent on success - no stdout - so the next model call
@@ -31,12 +31,17 @@ Shared core
31
31
  ├── prediction.ts → prediction engine wrapper (thin layer over ScoringEngine)
32
32
  ├── behavior.ts → behavior engine wrapper (thin layer over ScoringEngine)
33
33
  ├── seed-behaviors.ts → default behavior seeding on first run
34
+ ├── session-db.ts → read-only opencode.db access: session timeline, full parts,
35
+ │ decoded-content search, OpenAI transcript builder (backs the
36
+ │ `thatch session` CLI and the opencode-only session tools)
34
37
  └── vector-math.ts → blobToVector + cosineSimilarity helpers
35
38
 
36
39
  OpenCode plugin path
37
40
  ├── index.ts → plugin entry: wires DB/model/extraction, registers tools + hooks
38
41
  ├── tools.ts → thin opencode tool() wrappers over tool-defs
39
- └── extraction.ts → in-memory ring buffer + shared payload builders
42
+ ├── extraction.ts → in-memory ring buffer + shared payload builders
43
+ └── watchers.ts → in-memory watcher registry + poller (opencode-only,
44
+ event-driven PR notifications delivered as prompts)
40
45
 
41
46
  MCP server path
42
47
  ├── mcp.ts → stdio JSON-RPC server: z.toJSONSchema() for tools/list,
@@ -67,6 +72,8 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
67
72
  | `embeddings.ts` | Lazy-load the embedding model. Expose `queryEmbed`/`passageEmbed` and the model `name` (stored as an informational tag). `MockEmbeddingModel` for tests. |
68
73
  | `extraction.ts` | Per-session in-memory ring buffer (cap 20) that buffers non-thatch tool interactions and serializes them into the JSON payload the `get_extraction_payload` tool returns. The in-memory pipeline is opencode-only, but the payload builders (`buildExtractionPayload`, `deriveTitle`) are shared by both paths — `extract-queue.ts` imports `deriveTitle`, `mcp.ts` imports `buildExtractionPayload` for the extraction payload provider. `summarizeArgs` is used internally by `buildExtractionPayload`. |
69
74
  | `extract-queue.ts` | File-backed per-session JSONL queue (caps 20, oldest dropped). Shared by the Claude Code and Cursor hook paths, which fire one-shot per event with no cross-call state. This is the MCP-side equivalent of `extraction.ts`. |
75
+ | `session-db.ts` | Read-only access to the opencode session database (opencode.db): session timeline (`session list`), full part/message retrieval (`session get`), OpenAI chat-completions transcript builder (`session transcript`), and decoded-content search (`session search`). Backs the `thatch session` CLI subcommands and the opencode-only `session_search`/`session_get` tools. Path resolution honors `OPENCODE_DB`, then the XDG default; the db opens read-only so a thatch bug cannot corrupt host history. |
76
+ | `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Watches a GitHub PR via `gh api` (injected `GhRunner`), diffs against last-seen state, queues pointer-only events per session, and delivers them through a plugin-supplied callback when the session is idle. Process-lifetime state by design - no SQLite. |
70
77
  | `sideband.ts` | Unix domain socket server + client. The MCP server (long-lived, warm model) runs `SidebandServer` so one-shot hook processes can ask it to embed a prompt and search for matches without loading the model themselves. Handles three methods: `match` (recall nudge), `predictions` (prediction auto-fire), and `behaviors` (behavior auto-fire). Socket path is a hash of the DB path — both processes compute it independently. |
71
78
  | `prompts.ts` | Text constants: opencode system prompt, compaction context, session-start reminder, prompt-aware recall nudge (`recallNudge` / `claudeRecallNudge`), prediction nudge (`predictionNudge`), behavior nudge (`behaviorNudge`), prediction verb selection (`predictionVerb`), Claude Code CLAUDE.md instructions, Cursor AGENTS.md instructions, Claude Code hook text. |
72
79
  | `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (25 skills: fact-extractor, dedup-classifier, project-primer, 8 review specialists, review synthesizer, review context, code archaeology, review followup, review response, change walkthrough, code walkthrough, session reflection, coding-workflow, thatch-pr-description, thatch-ticket-description, thatch-split-overlarge-pr, memory-verify, knowledge-export — work on all three hosts) and `OPENCODE_ONLY_SKILLS` (1 skill: code-review coordinator — requires sub-agent support, not installed for Claude Code or Cursor). `installSkills(dir, skills)` defaults to `SHARED_SKILLS`; the opencode plugin passes `[...SHARED_SKILLS, ...OPENCODE_ONLY_SKILLS]`. |
@@ -87,8 +94,8 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
87
94
  | `experimental.compaction.autocontinue` | Clears the compacting flag so `chat.message` nudges resume. Without this, nudges that instruct tool calls would fire during summary generation where tools are blocked. The `chat.message` hook also clears the flag if it fires for a session still in the compacting set but the incoming message has no compaction-type part — this handles compaction failure, where the session would otherwise be stuck with nudges off forever. |
88
95
  | `tool.execute.after` | Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call into the session's extraction buffer. (Skill/task are excluded — buffering them creates a feedback loop where the nudge triggers a skill load, which gets buffered, which triggers another nudge.) Memory writes (`thatch_memory_remember`) and `thatch_extraction_done` drain the buffer and reset the missed-nudge counter. For child sessions (`childToParent.has(sessionID)`), also tracks metrics: `remember` with `overwrite:false` → new++, `overwrite:true` → updated++, `forget` → deleted++. This is a plugin hook, NOT a bus event — do not move it into the `event` handler; the event bus has no such event and it will silently never fire. |
89
96
  | `chat.message` | Two priority tiers: (a) if extraction buffer has interactions and the session is NOT in the `extracting` set (direct extraction in progress), **peeks** the buffer (does NOT drain it) and injects a synthetic text part carrying the extraction nudge with the session ID and fetch tool name (not the full payload) — the sub-agent calls `thatch_get_extraction_payload` to retrieve the interactions as a tool response, keeping the full payload out of the main session's context window. The buffer persists until the agent writes a memory or calls `thatch_extraction_done`, so ignored nudges repeat and escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter; (b) otherwise, embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges (recall, prediction, behavior) fire independently in separate try/catch blocks with separate synthetic parts. Skipped entirely while the session is compacting (tool calls are blocked during summary generation). |
90
- | `event` | Subscribes to all session bus events. `session.created`: records `childToParent` + `parentSnapshots` (shallow copy of the parent's buffer for snapshot-aware drain), then sends the session-start reminder via `client.session.prompt` carrying the hygiene heartbeat (pending dedup pairs, stale count, orphaned branch memories) when any signal is non-zero. `session.status` (idle): if the session is a child, drains its snapshot, fires a toast with `childMetrics`, and deletes the child session; if the session is a parent with pending tool interactions and not already extracting, calls `triggerExtraction` to create a direct-extraction child. `session.error`: requeues the parent's buffer (child died without draining). `session.deleted`: cleans up `childToParent`, `parentSnapshots`, `childMetrics`, and `extracting`. `session.compacted`: clears the compacting flag so `chat.message` nudges resume. |
91
- | `dispose` | Closes the DB. |
97
+ | `event` | Subscribes to all session bus events. `session.created`: records `childToParent` + `parentSnapshots` (shallow copy of the parent's buffer for snapshot-aware drain), then sends the session-start reminder via `client.session.prompt` carrying the hygiene heartbeat (pending dedup pairs, stale count, orphaned branch memories) when any signal is non-zero. `session.status`: records the session's live status for the watcher delivery gate; on idle, if the session is a child, drains its snapshot, fires a toast with `childMetrics`, and deletes the child session; if the session is a parent with pending tool interactions and not already extracting, calls `triggerExtraction` to create a direct-extraction child, then flushes pending watcher events (the session just became deliverable). `session.error`: requeues the parent's buffer (child died without draining). `session.deleted`: cleans up `childToParent`, `parentSnapshots`, `childMetrics`, and `extracting`, and cancels the session's watchers. `session.compacted`: clears the compacting flag so `chat.message` nudges resume. |
98
+ | `dispose` | Stops the version checker and the watcher poller, closes the DB. |
92
99
 
93
100
  Hook failures are logged with a `[thatch]` prefix — never swallowed silently.
94
101
  Two of these hooks were dead for weeks because failures were invisible.
@@ -26,6 +26,7 @@ system, see [../skills.md](../skills.md). For non-obvious invariants, see
26
26
  | [sideband.md](sideband.md) | Sideband IPC (Unix socket for MCP host hook processes) |
27
27
  | [compaction-recovery.md](compaction-recovery.md) | Compaction recovery (opencode compaction hooks) |
28
28
  | [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking) |
29
+ | [watchers.md](watchers.md) | Event-driven notifications (in-memory registry, background poller, PR watching) |
29
30
  | [setup.md](setup.md) | Setup system (markers, checkSetup, auto-refresh, binary resolution) |
30
31
  | [cli.md](cli.md) | CLI (subcommands, environment variables) |
31
32
  | [database.md](database.md) | Database (schema, migrations, WAL, 11 tables) |
@@ -16,9 +16,12 @@ Thatch supports three host agents from a single codebase:
16
16
  - **Cursor MCP**---same stdio MCP server, `--json` hook output format,
17
17
  `mcp__thatch__` tool prefix, system prompt in AGENTS.md
18
18
 
19
- All three paths expose the same 18 tool definitions from `src/tool-defs.ts`.
20
- The tools are host-agnostic: each takes args and a `CoreContext`
21
- (`{ db, model, defaultStore }`) and returns a string. The host-specific
19
+ All three paths share the tool definitions from `src/tool-defs.ts`: 18
20
+ host-agnostic tools plus opencode-only tools (currently six:
21
+ `get_session_info`, `session_search`/`session_get`, and the
22
+ `watch_create`/`watch_list`/`watch_cancel` watcher tools), which the MCP server
23
+ filters out. The shared tools take args and a `CoreContext`
24
+ (`{ db, model, defaultStore }`) and return a string. The host-specific
22
25
  wrapping---prefixing, transport, session hooks---lives outside the core.
23
26
 
24
27
  ## The three paths
@@ -147,7 +150,7 @@ an asymmetry a reviewer can catch by diffing the tool lines.
147
150
 
148
151
  ## Tool name prefixing
149
152
 
150
- The 18 tool definitions in `src/tool-defs.ts` use bare names:
153
+ The tool definitions in `src/tool-defs.ts` use bare names:
151
154
  `memory_remember`, `memory_recall`, `memory_list`, etc. Each host applies its
152
155
  own prefix.
153
156
 
@@ -177,13 +180,16 @@ behavior, with some features having no MCP counterpart.
177
180
  | code-review coordinator skill | Yes | No | No |
178
181
  | Background sub-agents | Yes (experimental) | No | No |
179
182
  | Tool batching | N/A (in-process) | PostToolBatch (batch) | postToolUse (per-tool) |
183
+ | `get_session_info` (session identity) | Yes | No | No |
184
+ | `session_search` / `session_get` (conversation archaeology) | Yes (tools) | CLI only (`thatch session ...`) | CLI only (`thatch session ...`) |
180
185
 
181
186
  For the full parity matrix, see [../mcp-parity.md](../mcp-parity.md).
182
187
 
183
188
  ## Interactions with other features
184
189
 
185
- All 18 tools are shared across all hosts via the single source of truth in
186
- `src/tool-defs.ts`. Individual tool behavior is documented in
190
+ The 18 shared tools are available on all hosts via the single source of truth
191
+ in `src/tool-defs.ts`; opencode-only tools (e.g. `get_session_info`) are
192
+ filtered out by the MCP server. Individual tool behavior is documented in
187
193
  [memory-store.md](memory-store.md), [extraction.md](extraction.md),
188
194
  [prediction-engine.md](prediction-engine.md),
189
195
  [behavior-engine.md](behavior-engine.md), and
@@ -216,13 +222,13 @@ the opencode-only skills require sub-agent dispatch or in-process hooks.
216
222
  | `src/setup.ts` | Setup installer for Claude Code and Cursor---markers, hooks, skills |
217
223
  | `src/prompts.ts` | All three system prompt variants---`systemPrompt()`, `claudeInstructions()`, `cursorInstructions()` |
218
224
  | `src/tools.ts` | Thin opencode tool wrappers---imports tool-defs, adds `thatch_` prefix via `tool()` |
219
- | `src/tool-defs.ts` | Single source of truth for all 18 tool definitions---name, description, zod schema, execute |
225
+ | `src/tool-defs.ts` | Single source of truth for all tool definitions---name, description, zod schema, execute, `opencodeOnly` flag |
220
226
  | `bin/thatch` | CLI hook commands for MCP hosts---`reminder`, `buffer-tool`, `flush-tools`, `setup` |
221
227
 
222
228
  ## Key invariants
223
229
 
224
- - All three hosts share the same 18 tool definitions from `src/tool-defs.ts`. Adding a tool requires updating `TOOL_DEFS` and all three prompt functions.
230
+ - All hosts share the tool definitions from `src/tool-defs.ts` (18 shared + opencode-only tools marked `opencodeOnly: true`, which the MCP server filters out). Adding a tool requires updating `TOOL_DEFS` and all three prompt functions; opencode-only tools go in the opencode prompt only.
225
231
  - The MCP server always exposes bare names. The `mcp__thatch__` prefix is applied by the MCP client, not thatch.
226
232
  - The three system prompt variants are independent string constants in `src/prompts.ts` with no shared template. Editing shared prose in one requires mirroring in the other two, or they drift.
227
- - The tool list line in all three prompts must match `TOOL_DEFS` in `src/tool-defs.ts`. The historical failure mode: a tool is added to `TOOL_DEFS` but only two of three prompts are updated.
228
- - opencode-only features (direct extraction, compaction recovery, code-review coordinator, background sub-agents, TUI toasts) have no MCP counterpart.
233
+ - The tool list line in all three prompts must match `TOOL_DEFS` in `src/tool-defs.ts` (opencode's list includes opencode-only tools; the MCP lists exclude them). The historical failure mode: a tool is added to `TOOL_DEFS` but only two of three prompts are updated.
234
+ - opencode-only features (direct extraction, compaction recovery, code-review coordinator, background sub-agents, TUI toasts, `get_session_info`, `session_search`/`session_get`, PR watchers) have no MCP counterpart. The `thatch session` CLI subcommands work anywhere - they only need an opencode.db file to read.
@@ -0,0 +1,150 @@
1
+ # Watchers
2
+
3
+ Watchers are the general mechanism for event-driven notifications from
4
+ external sources: the model registers a watch on something outside the
5
+ session, thatch polls it in the background, and the plugin prompts the
6
+ session when a watched event happens. The first source is GitHub pull
7
+ requests. The registry, poller, and delivery are source-agnostic - a
8
+ future source only adds a fetch-and-diff function and event types.
9
+
10
+ User-facing behavior is documented in [docs/user/watchers.md](../../user/watchers.md).
11
+
12
+ ## What it does
13
+
14
+ - `thatch_watch_create` / `thatch_watch_list` / `thatch_watch_cancel` tools (opencode-only)
15
+ - A background poller in the plugin process diffs each watcher's target
16
+ against its last-seen state
17
+ - Delivery prompts the session with a synthetic part - the same
18
+ mechanism opencode uses for background task completions
19
+ - Notifications carry pointer data only (author, URL, counts); the
20
+ model fetches content on demand with `gh`
21
+
22
+ ## The process-lifetime decision
23
+
24
+ The registry lives in plugin memory, never in SQLite. Three reasons:
25
+
26
+ 1. **Ownership is structural.** opencode loads thatch in-process, so
27
+ the plugin process that registered a watcher is the only process
28
+ that can poll and deliver it. A shared registry would need
29
+ claiming logic to answer "which process polls this?" - and
30
+ version-skewed opencode instances sharing a DB have already caused
31
+ corruption once.
32
+ 2. **No orphans.** When the process exits, watcher state evaporates
33
+ with it. There is no cleanup path to get wrong.
34
+ 3. **Precedent.** The extraction pipeline's opencode path is also
35
+ in-memory; the file-backed queue exists only for the MCP path's
36
+ separate short-lived hook processes.
37
+
38
+ The cost: a restart loses watches. Accepted because the session that
39
+ created them loses its conversational context too - a notification
40
+ into a session that no longer knows why it is being watched is worse
41
+ than a lost watch.
42
+
43
+ `session.deleted` also cancels the session's watchers and pending
44
+ events, and `dispose` stops the poller. These are cheap insurance;
45
+ process exit is the real lifetime boundary in both TUI and serve mode
46
+ (plugins load per-instance in the server process under
47
+ `opencode serve`, so watchers work there too).
48
+
49
+ ## How it works
50
+
51
+ ### Registration (`watch_create`)
52
+
53
+ `create()` immediately fetches the PR's state as a baseline via
54
+ `fetchPrState()` - four `gh api` calls (the pull, issue comments,
55
+ review comments, check runs on the head SHA), three of them in
56
+ parallel. The baseline doubles as validation: a missing PR, wrong
57
+ repo, or broken gh setup fails registration with a real error instead
58
+ of producing a watcher that never fires. Watch creation is also gated
59
+ on a cached `gh --version` availability check.
60
+
61
+ ### Polling
62
+
63
+ A `setInterval` loop (unref'd - it never keeps the process alive)
64
+ runs `poll()` every 60 seconds (configurable). Each cycle:
65
+ fetch the current state, run `diffPrState()` against the watcher's
66
+ last-seen state, filter events to the watched types, queue them, and
67
+ attempt delivery. Poll errors are per-watcher; one bad PR never
68
+ blocks the others. The diff is a pure function, so it is unit-tested
69
+ without any network access.
70
+
71
+ Event detection:
72
+
73
+ - comments by id monotonicity (fetch is sorted descending)
74
+ - commits by head SHA change
75
+ - status/merged by field change
76
+ - description by SHA-256 of the body text (the body itself is never
77
+ stored)
78
+ - CI by check-run transitions into a completed status
79
+
80
+ A diff emits at most 10 events per watcher per cycle, so comment
81
+ floods collapse into one batch.
82
+
83
+ ### Delivery
84
+
85
+ Events queue in an in-memory pending map keyed by session. Delivery
86
+ is gated by a `canDeliver` predicate the plugin supplies: the session
87
+ must be idle (tracked from `session.status` events) and not
88
+ compacting. Proactive prompts are never injected into a running turn.
89
+
90
+ When the gate is open, the plugin calls `client.session.promptAsync`
91
+ on the session with a synthetic text part built by
92
+ `watcherNotificationNudge()`. This triggers a full model turn - the
93
+ model reads the notification and decides whether to act. The
94
+ notification text borrows the background-task-completion framing
95
+ (system event, not user input, not approval to advance other work),
96
+ following the same load-bearing wording discipline as the extraction
97
+ nudge.
98
+
99
+ Failed or gated deliveries stay pending and retry on the next poll
100
+ cycle or when the session next goes idle (the event hook calls
101
+ `deliverPending()` there).
102
+
103
+ ### Event vocabulary
104
+
105
+ `pr_comment`, `pr_review_comment`, `pr_review_reply`, `pr_commit`,
106
+ `pr_status`, `pr_description`, `pr_ci`. The tool's `events` argument
107
+ selects a subset; the default is all seven.
108
+
109
+ ## Interactions with other features
110
+
111
+ - Extraction pipeline ([extraction.md](extraction.md)): the
112
+ notification turn's tool calls are buffered and extracted like any
113
+ other turn. Watch tools themselves are `thatch_*`-prefixed and
114
+ excluded from buffering.
115
+ - Session lifecycle ([session-lifecycle.md](session-lifecycle.md)):
116
+ `session.status` feeds the delivery gate; `session.deleted`
117
+ cancels watchers.
118
+ - Multi-host ([multi-host.md](multi-host.md)): opencode-only. MCP
119
+ hosts have no poller, no event bus, and no channel to start a turn
120
+ proactively. A future CLI daemon could close this gap.
121
+
122
+ ## Configuration
123
+
124
+ - `THATCH_WATCH_POLL_SECONDS` - poll interval (default 60)
125
+ - `THATCH_WATCH_TTL_MINUTES` - watcher time-to-live (default 480)
126
+ - `THATCH_WATCH_MAX_PER_SESSION` - active watchers per session
127
+ (default 5)
128
+
129
+ ## Source files
130
+
131
+ - `src/watchers.ts` - registry, poller, gh CLI runner, diff functions
132
+ - `src/tool-defs.ts` - the three watch tool definitions
133
+ - `src/index.ts` - registry construction, delivery closure,
134
+ session.status tracking, session.deleted cleanup, dispose
135
+ - `src/prompts.ts` - `watcherNotificationNudge()`, system prompt
136
+ Watchers section
137
+ - `tests/watchers.test.ts` - registry, poll, and diff unit tests
138
+ (mocked gh, no network)
139
+ - `tests/qa/auto/uc-095-watchers.ts` - end-to-end lifecycle against a
140
+ mocked gh runner
141
+
142
+ ## Adding a new source type
143
+
144
+ The registry is source-agnostic in shape but currently hardcoded to
145
+ the github-pr fetch/diff pair. A new source type means: new event
146
+ types in `WATCHER_EVENT_TYPES`, a fetch/diff function pair, a
147
+ `source_type` discriminator on `Watcher` (add it when the second
148
+ source arrives - do not parameterize early), and the tool surface for
149
+ registering it. Keep the pointer-only notification rule: external
150
+ content enters the context on explicit fetch, never via notification.
@@ -18,7 +18,7 @@ This document maps the feature parity and documents the remaining gaps.
18
18
 
19
19
  | Feature | OpenCode plugin | Claude Code MCP + hooks | Cursor MCP + hooks | Parity |
20
20
  |---------|----------------|------------------------|---------------------|--------|
21
- | **Tools** (18, single source of truth) | Plugin tool registration, `thatch_` prefix | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix | **Full** |
21
+ | **Tools** (18 shared + 6 opencode-only, single source of truth) | Plugin tool registration, `thatch_` prefix | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | **Full** for the 18 shared tools; `get_session_info` (session identity) and `session_search`/`session_get` (conversation archaeology) are opencode-only — MCP hosts have no session concept. The `thatch session` CLI subcommands cover the same archaeology on any host with an opencode.db file. The `watch_*` tools (PR watchers) are opencode-only — MCP hosts have no poller or proactive-prompt channel |
22
22
  | **System prompt** (store names, usage rules) | `experimental.chat.system.transform` — dynamic, repo baked in at runtime | CLAUDE.md static text appended by `thatch setup`; repo auto-detected by the MCP server at startup | AGENTS.md static text appended by `thatch setup`; repo auto-detected at startup | **Approximate** — static text persists through compaction, so the agent retains the usage instructions, but the dynamic per-turn refresh is lost |
23
23
  | **Session-start reminder** (recall nudge + hygiene heartbeat) | `session.created` event → `client.session.prompt` injects a synthetic message | `SessionStart` hook → `thatch reminder`; stdout becomes context | `sessionStart` hook → `thatch reminder --json`; output is `additional_context` JSON | **Full** |
24
24
  | **Compaction context** (re-familiarize after compaction) | `experimental.session.compacting` hook appends context to the compaction output | `PostCompact` hook — side-effects only, **cannot inject context** | No equivalent hook | **Gapped** — see below |
@@ -119,10 +119,12 @@ and `--cursor` pass only the shared set.
119
119
  versions without users manually deleting files.
120
120
  - **Stale cleanup**: before installing, `installSkills` removes stale skill
121
121
  directories. Any `thatch-*` directory not in the current install set is
122
- deleted (the `thatch-` prefix is our namespace). Three skills renamed from
123
- non-prefixed to `thatch-`-prefixed in v0.1.27 (pr-description, ticket-description,
124
- split-overlarge-pr) are also cleaned up through v0.1.35; after that, those
125
- names are fair game for third parties and the cleanup stops.
122
+ deleted (the `thatch-` prefix is our namespace). Non-prefixed directories
123
+ are never touched: the v0.1.27 rename migration (pr-description,
124
+ ticket-description, split-overlarge-pr) cleaned up old installs only
125
+ through a version window that closed with v0.1.35, after which those names
126
+ are fair game for third parties and the cleanup no longer runs. Users
127
+ upgrading from before v0.1.27 delete the old directories manually.
126
128
  - **Idempotent**: re-running init or setup overwrites drifted content but leaves
127
129
  unrelated (non-`thatch-*`) skill files alone.
128
130
 
@@ -11,6 +11,7 @@ This README is the overview. Each feature has its own guide:
11
11
 
12
12
  - [memory.md](memory.md): persistent memory store
13
13
  - [extraction.md](extraction.md): automatic fact extraction from tool calls
14
+ - [watchers.md](watchers.md): event-driven notifications (PR watching)
14
15
  - [prediction-engine.md](prediction-engine.md): user decision model
15
16
  - [behavior-engine.md](behavior-engine.md): agent self-discipline rules
16
17
  - [default-behaviors.md](default-behaviors.md): what ships automatically
@@ -142,6 +143,29 @@ Stores are created automatically. No setup required.
142
143
  | `thatch_behavior_list` | List all codified behaviors with matchers, confidence, and provenance. |
143
144
  | `thatch_behavior_delete` | Delete a behavior by semantic match. Edges and provenance are cascade-deleted. |
144
145
 
146
+ ### Session tools (opencode only)
147
+
148
+ | Tool | What it does |
149
+ |------|-------------|
150
+ | `thatch_get_session_info` | Report the current session's ID and the agent name running this turn. opencode doesn't surface its session ID to the model; the agent needs it to fetch extraction payloads or look up past sessions. MCP hosts have no session concept, so this tool doesn't exist there. |
151
+ | `thatch_session_search` | Search past opencode conversations by substring or regex. Matches decoded message text, tool outputs, and reasoning across all sessions. Returns JSONL hits with ids for follow-up. |
152
+ | `thatch_session_get` | Retrieve the full content of one part or message found via `thatch_session_search`, including complete tool inputs and outputs. |
153
+
154
+ The same functionality is available on the command line as `thatch session
155
+ list`, `thatch session get`, `thatch session transcript`, and `thatch session
156
+ search` - JSONL output designed for piping to `jq` or `grep`.
157
+
158
+ ### Watcher tools (opencode only)
159
+
160
+ | Tool | What it does |
161
+ |------|-------------|
162
+ | `thatch_watch_create` | Watch a GitHub PR for events (comments, review comments and replies, commits, status changes, description edits, CI completions). The plugin polls in the background and prompts the session when a watched event happens. |
163
+ | `thatch_watch_list` | List this session's active watchers. |
164
+ | `thatch_watch_cancel` | Cancel one of this session's watchers by id. |
165
+
166
+ See [watchers.md](watchers.md) for the full behavior, lifetime, and
167
+ requirements.
168
+
145
169
  ## Automatic behaviors
146
170
 
147
171
  Beyond the tools, thatch hooks into opencode itself:
@@ -0,0 +1,79 @@
1
+ # Watchers
2
+
3
+ Watchers monitor external sources - today, GitHub pull requests - and
4
+ inject a notification into the conversation when something happens. You
5
+ ask the agent to watch a PR; thatch polls it in the background; the
6
+ agent gets woken up when a watched event occurs. No one has to keep
7
+ 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
+ thatch captures the PR's current state as a baseline, then polls the
13
+ GitHub API through the `gh` CLI once a minute. When the PR changes in a
14
+ way the watcher cares about, the plugin prompts the agent with a
15
+ notification.
16
+
17
+ Watched events:
18
+
19
+ - `pr_comment` - new top-level comments on the PR
20
+ - `pr_review_comment` - new inline comments on the diff
21
+ - `pr_review_reply` - replies to an existing review comment
22
+ - `pr_commit` - new commits (the head SHA moved)
23
+ - `pr_status` - the PR opened, closed, or merged
24
+ - `pr_description` - title or description edited
25
+ - `pr_ci` - a CI check run on the head SHA completed
26
+
27
+ Notifications are delivered as synthetic parts: the model sees them,
28
+ the transcript does not. A notification triggers a model turn, so the
29
+ agent can act on the event even while you are away.
30
+
31
+ ## Privacy model
32
+
33
+ Notifications carry pointer data only: who commented, what happened,
34
+ and a URL. Comment bodies, diff contents, and CI logs never enter the
35
+ context from the notification itself. This is deliberate - GitHub
36
+ comments are written by strangers, and untrusted text flowing into a
37
+ model's context is a prompt-injection vector. The agent fetches details
38
+ on demand with `gh` when it decides to act.
39
+
40
+ ## How to use it
41
+
42
+ Ask for it in conversation:
43
+
44
+ > Watch PR 1234 for review comments. If bugbot comments, handle it
45
+ > yourself. If a human comments, just tell me.
46
+
47
+ The handling policy lives in your instruction. State it when you ask -
48
+ the notification turn reads your policy from the conversation and
49
+ follows it. The notification says what happened and where, not what to
50
+ do about it.
51
+
52
+ Other tools:
53
+
54
+ - `thatch_watch_list` - shows the session's active watchers
55
+ - `thatch_watch_cancel` - cancels one by id
56
+
57
+ ## Lifetime
58
+
59
+ Watchers are in-memory and process-scoped:
60
+
61
+ - Ending the opencode process (or restarting it) drops all watchers.
62
+ - A watcher expires after 8 hours by default.
63
+ - Each session can hold up to 5 active watchers.
64
+
65
+ If opencode restarts while a watch is active, re-register it - the
66
+ session's conversation survives, so the policy you stated is still
67
+ there.
68
+
69
+ ## Requirements and limitations
70
+
71
+ - **opencode only.** MCP hosts (Claude Code, Cursor) have no way for a
72
+ plugin to start a conversation turn, so the watch tools are not
73
+ available there.
74
+ - **Requires the `gh` CLI** to be installed and authenticated. Watch
75
+ creation reports a clear error if it is not.
76
+ - **Polling, not webhooks.** Events appear on the next poll cycle
77
+ (60 seconds by default), not instantly.
78
+ - **No delivery during an active turn.** If the agent is mid-turn when
79
+ 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.36",
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",