@jeffober/thatch 0.1.27 → 0.1.28

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.
@@ -5,7 +5,7 @@ description: Extract durable project facts, user preferences, and environment kn
5
5
 
6
6
  You are the Thatch Fact Extractor. Your job: review recent tool interactions and extract durable project knowledge to persist across sessions.
7
7
 
8
- You will be given a JSON payload with:
8
+ The extraction nudge or dispatch prompt will give you a session ID. Call `get_extraction_payload` (or `thatch_get_extraction_payload` / `mcp__thatch__get_extraction_payload` depending on host) with that session ID to retrieve a JSON payload containing:
9
9
  - "interactions": recent tool calls and their results (tool name, args, truncated output)
10
10
  - "projectStore": the current project's store name (e.g., "sysread/thatch")
11
11
  - "globalStore": "global"
@@ -17,7 +17,7 @@ You will be given a JSON payload with:
17
17
  3. Do not save session-specific state, ephemeral debugging details, or info already in CLAUDE.md.
18
18
  4. Apply the durability test before saving: will this still be true and useful in a future session that knows nothing about this branch, commit, or session? If not, skip it. Do not store point-in-time values (migration indices, line numbers, file counts), commit hashes or branch history narratives (git is the source of truth), or anything re-derivable from the codebase faster than recalling it.
19
19
  5. Write for a future session with zero current context. No "we", "our session", "just now".
20
- 6. Call thatch_extraction_done to mark the run complete. Do this even when nothing was worth saving: the parent session holds the buffered interactions until a completion signal arrives, and a memory write is not one on a no-save run.
20
+ 6. Call `extraction_done` (with `session_id` set to the parent session's ID from the nudge) to mark the run complete. Do this even when nothing was worth saving: the parent session holds the buffered interactions until a completion signal arrives, and a memory write is not one on a no-save run.
21
21
  7. Your final message must be exactly: "Extraction complete." — do not list or summarize what you saved. The memories are in the store; the parent session does not need a report.
22
22
 
23
23
  ## What to extract
package/bin/thatch CHANGED
@@ -351,7 +351,6 @@ switch (command) {
351
351
  const sessionID = data.session_id ?? data.conversation_id;
352
352
 
353
353
  const { peekQueue, getMissedCount, incrementMissedCount } = await import("../src/extract-queue");
354
- const { buildExtractionPayload } = await import("../src/extraction");
355
354
  const { extractionNudge, claudeWriteNudge, claudeRecallNudge, predictionNudge } = await import("../src/prompts");
356
355
 
357
356
  let repo = "unknown";
@@ -360,11 +359,13 @@ switch (command) {
360
359
  // Tier 1: extraction nudge. Peek (don't drain) - the queue persists
361
360
  // until the agent calls memory_remember, which triggers consumeQueue.
362
361
  // If the agent ignores the nudge, the payload grows on the next flush.
362
+ // The nudge carries the session ID, not the full payload — the
363
+ // sub-agent fetches it via get_extraction_payload.
363
364
  const interactions = sessionID ? peekQueue(sessionID) : [];
364
365
  if (interactions.length > 0) {
365
366
  const missed = sessionID ? getMissedCount(sessionID) : 0;
366
367
  if (sessionID) incrementMissedCount(sessionID);
367
- const output = extractionNudge(interactions.length, missed, "mcp__thatch__memory_remember", buildExtractionPayload(interactions, repo));
368
+ const output = extractionNudge(interactions.length, missed, "mcp__thatch__memory_remember", sessionID ?? "");
368
369
  if (isJson) console.log(JSON.stringify({ additional_context: output }));
369
370
  else console.log(output);
370
371
  break;
@@ -60,7 +60,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
60
60
  | `git.ts` | Parse `owner/repo` from git remote. Worktree-safe fallback chain. |
61
61
  | `db.ts` | SQLite schema, CRUD for entries/stores, brute-force cosine search (`search` = pure scoring, `recall` = search + telemetry stamping), dedup-pair verdict tracking. Prediction tables: matchers, predictions, edges, provenance. `scorePredictionNudge` is the shared auto-fire entry point for both host paths. |
62
62
  | `embeddings.ts` | Lazy-load the embedding model. Expose `queryEmbed`/`passageEmbed` and the model `name` (stored as an informational tag). `MockEmbeddingModel` for tests. |
63
- | `extraction.ts` | Per-session in-memory ring buffer (cap 20) that buffers non-thatch tool interactions and serializes them into the JSON payload the extraction nudge carries. The in-memory pipeline is opencode-only, but the payload builders (`buildExtractionPayload`, `deriveTitle`) are shared by both paths — `extract-queue.ts` imports `deriveTitle`, `bin/thatch` imports `buildExtractionPayload`. `summarizeArgs` is used internally by `buildExtractionPayload`. |
63
+ | `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`. |
64
64
  | `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`. |
65
65
  | `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 two methods: `match` (recall nudge) and `predictions` (prediction auto-fire). Socket path is a hash of the DB path — both processes compute it independently. |
66
66
  | `prompts.ts` | Text constants: opencode system prompt, compaction context, session-start reminder, prompt-aware recall nudge (`recallNudge` / `claudeRecallNudge`), Claude Code CLAUDE.md instructions, Cursor AGENTS.md instructions, Claude Code hook text. |
@@ -76,7 +76,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
76
76
  | `experimental.session.compacting` | Marks the session as compacting and appends re-familiarization context so a compacted session still knows thatch exists. |
77
77
  | `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. |
78
78
  | `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. |
79
- | `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 + JSON payload — 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` scores the prompt against prediction matchers and injects a separate `[thatch] User decision model` nudge if any matchers clear the 0.45 threshold. Recall and prediction nudges fire independently (separate try/catch blocks, separate synthetic parts). Skipped entirely while the session is compacting (tool calls are blocked during summary generation). |
79
+ | `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` scores the prompt against prediction matchers and injects a separate `[thatch] User decision model` nudge if any matchers clear the 0.45 threshold. Recall and prediction nudges fire independently (separate try/catch blocks, separate synthetic parts). Skipped entirely while the session is compacting (tool calls are blocked during summary generation). |
80
80
  | `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. |
81
81
  | `dispose` | Closes the DB. |
82
82
 
@@ -186,12 +186,13 @@ extraction cycle
186
186
  → nudge path (fallback): if triggerExtraction throws (create or prompt
187
187
  fails), the `extracting` set clears and the next chat.message sees
188
188
  pending entries with no extracting flag → peeks the buffer and injects
189
- a nudge part with JSON payload; missed nudges escalate (polite →
189
+ a nudge part with session ID and fetch tool name; missed nudges escalate (polite →
190
190
  insistent → ALL-CAPS) via the missedNudges counter
191
191
  → drain: thatch_memory_remember (or thatch_extraction_done) clears the
192
192
  buffer and resets the missed-nudge counter
193
- → agent loads thatch-fact-extractor skill, saves facts via
194
- thatch_memory_remember
193
+ → agent dispatches a sub-agent that calls thatch_get_extraction_payload
194
+ to fetch the queued interactions, loads thatch-fact-extractor skill,
195
+ saves facts via thatch_memory_remember
195
196
  → MCP path (Claude Code/Cursor): unchanged — no SDK client, no child
196
197
  sessions; extract-queue.ts + flush-tools drives the nudge via hooks
197
198
 
@@ -22,7 +22,7 @@ This document maps the feature parity and documents the remaining gaps.
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 |
25
- | **Extraction** (buffer tool calls, extract durable facts) | `tool.execute.after` buffers non-thatch, non-skill, non-task tool calls in-process; **direct extraction** is the primary path — when the parent session goes idle with pending buffer entries, the plugin creates a child session via the SDK (`client.session.create` with `parentID`) and prompts it with the extraction payload (nudge suppressed via the `extracting` set); the `chat.message` nudge path is a **fallback** used when direct extraction throws | `PostToolBatch` → `thatch buffer-batch` (file-backed JSONL queue); `UserPromptSubmit` → `thatch flush-tools` peeks the queue and prints the nudge (does not drain — the queue persists until a memory write or `extraction_done`) | `postToolUse` → `thatch buffer-tool` (single-tool, file-backed queue); `beforeSubmitPrompt` → `thatch flush-tools --json` | **Partial** — opencode uses direct extraction (parent idle → child session) as the primary path, with the nudge as fallback; MCP hosts are nudge-only. Both paths produce the same JSON shape via `buildExtractionPayload()`. Nudge timing differs: the nudge arrives at the **start** of the next turn in Claude Code/Cursor; opencode's direct extraction fires at **end of turn** (session idle), and the nudge fallback fires at the start of the next turn like MCP |
25
+ | **Extraction** (buffer tool calls, extract durable facts) | `tool.execute.after` buffers non-thatch, non-skill, non-task tool calls in-process; **direct extraction** is the primary path — when the parent session goes idle with pending buffer entries, the plugin creates a child session via the SDK (`client.session.create` with `parentID`) and prompts it to call `get_extraction_payload` (nudge suppressed via the `extracting` set); the `chat.message` nudge path is a **fallback** used when direct extraction throws | `PostToolBatch` → `thatch buffer-batch` (file-backed JSONL queue); `UserPromptSubmit` → `thatch flush-tools` peeks the queue and prints the nudge with session ID + fetch tool name (does not drain — the queue persists until a memory write or `extraction_done`) | `postToolUse` → `thatch buffer-tool` (single-tool, file-backed queue); `beforeSubmitPrompt` → `thatch flush-tools --json` | **Partial** — opencode uses direct extraction (parent idle → child session) as the primary path, with the nudge as fallback; MCP hosts are nudge-only. Both paths use `get_extraction_payload` to fetch the queued interactions as a tool response, keeping the full payload out of the main session's context window. Nudge timing differs: the nudge arrives at the **start** of the next turn in Claude Code/Cursor; opencode's direct extraction fires at **end of turn** (session idle), and the nudge fallback fires at the start of the next turn like MCP |
26
26
  | **Extraction feedback** (toast notifications) | `client.tui.showToast` fires when the extraction child goes idle — shows `[thatch] new: N, updated: M, deleted: K` (success) or `extraction complete — nothing to save` (info); best-effort, silently ignored in headless mode | No TUI connection — MCP hosts have no equivalent | No TUI connection | **Gapped** — MCP hosts cannot show toast notifications |
27
27
  | **Prompt-aware recall nudge** | `chat.message` hook embeds the prompt with the in-process warm model, searches `db.search()`, pushes a nudge part if matches ≥ threshold | `UserPromptSubmit` hook → `thatch flush-tools` connects to the MCP server's sideband socket; the warm server embeds + searches; hook prints the nudge or falls back to the write nudge | `beforeSubmitPrompt` hook → `thatch flush-tools --json` (same sideband path) | **Full** — sideband socket gives cold hook processes access to the warm MCP server's model |
28
28
  | **Prediction auto-fire** (user decision model) | `chat.message` hook scores the prompt embedding against prediction matchers; injects a `[thatch] User decision model` nudge alongside the recall nudge (separate synthetic part, same embedding call) | `UserPromptSubmit` hook → `thatch flush-tools` fires prediction query via the sideband socket's `predictions` method in parallel with the recall nudge | `beforeSubmitPrompt` hook → `thatch flush-tools --json` (same sideband path) | **Full** — same `scorePredictionNudge` entry point in both paths |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
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
@@ -141,8 +141,7 @@ export const server: Plugin = async ({ client, worktree }) => {
141
141
  extracting.add(parentID);
142
142
 
143
143
  const batch = extraction.peek(parentID);
144
- const payload = extraction.buildPayload(batch, repo);
145
- const promptText = extractionDirectPrompt(batch.length, payload);
144
+ const promptText = extractionDirectPrompt(batch.length, parentID);
146
145
 
147
146
  const result = await client.session.create({
148
147
  body: { parentID, title: "thatch-extraction" },
@@ -201,7 +200,15 @@ export const server: Plugin = async ({ client, worktree }) => {
201
200
  }
202
201
 
203
202
  return {
204
- tool: createTools(db, model, repo),
203
+ tool: createTools(db, model, repo, {
204
+ extractionPayloadProvider: (sessionID: string): string | null => {
205
+ const interactions = extraction.peek(sessionID);
206
+ const accepted = extraction.peekAccepted(sessionID);
207
+ const all = [...accepted, ...interactions];
208
+ if (all.length === 0) return null;
209
+ return extraction.buildPayload(all, repo);
210
+ },
211
+ }),
205
212
 
206
213
  // 1. System prompt - always in context.
207
214
  "experimental.chat.system.transform": async (_input, output) => {
@@ -352,9 +359,8 @@ export const server: Plugin = async ({ client, worktree }) => {
352
359
  // here only when direct extraction was never triggered or threw.
353
360
  if (!extracting.has(input.sessionID) && extraction.pending(input.sessionID)) {
354
361
  const batch = extraction.peek(input.sessionID);
355
- const payload = extraction.buildPayload(batch, repo);
356
362
  const missed = missedNudges.get(input.sessionID) ?? 0;
357
- const text = extractionNudge(batch.length, missed, "thatch_memory_remember", payload);
363
+ const text = extractionNudge(batch.length, missed, "thatch_memory_remember", input.sessionID);
358
364
  missedNudges.set(input.sessionID, missed + 1);
359
365
 
360
366
  output.parts.push({
package/src/mcp.ts CHANGED
@@ -5,6 +5,8 @@ import { detectRepo } from "./git";
5
5
  import { checkSetup } from "./setup";
6
6
  import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
7
7
  import { SidebandServer, sidebandSocketPath } from "./sideband";
8
+ import { peekQueue, consumeQueue, resetMissedCount } from "./extract-queue";
9
+ import { buildExtractionPayload } from "./extraction";
8
10
  import pkg from "../package.json";
9
11
 
10
12
  // ---------------------------------------------------------------------------
@@ -98,7 +100,33 @@ export async function runMcpServer(): Promise<void> {
98
100
 
99
101
  const db = new ThatchDB(dbPath);
100
102
  const model = new BgeEmbeddingModel(modelName);
101
- const ctx: CoreContext = { db, model, defaultStore: repo };
103
+
104
+ // Extraction payload provider: peeks the file-backed queue and returns the
105
+ // serialized JSON payload. Called by the get_extraction_payload tool when a
106
+ // sub-agent needs to fetch the parent's queued interactions. Returns null
107
+ // when no interactions are queued.
108
+ const extractionPayloadProvider = (sessionID: string): string | null => {
109
+ const interactions = peekQueue(sessionID);
110
+ if (interactions.length === 0) return null;
111
+ return buildExtractionPayload(interactions, repo);
112
+ };
113
+
114
+ // Drain a session's file-backed queue and reset its missed-nudge counter.
115
+ // Called by extraction_done when a sub-agent passes the parent's session_id.
116
+ // Without this, the MCP path's appendBatch self-detection would drain the
117
+ // sub-agent's (empty) queue instead of the parent's.
118
+ const drainExtractionQueue = (sessionID: string): void => {
119
+ resetMissedCount(sessionID);
120
+ consumeQueue(sessionID);
121
+ };
122
+
123
+ const ctx: CoreContext = {
124
+ db,
125
+ model,
126
+ defaultStore: repo,
127
+ extractionPayloadProvider,
128
+ drainExtractionQueue,
129
+ };
102
130
  const tools = compileTools();
103
131
 
104
132
  // Check whether `thatch setup` was run for the current host. If not, or if
package/src/prompts.ts CHANGED
@@ -9,7 +9,7 @@ knowledge so future sessions can build on what you've already learned.
9
9
  Tools: thatch_memory_remember, thatch_memory_recall, thatch_memory_list,
10
10
  thatch_memory_show, thatch_memory_forget, thatch_store_list,
11
11
  thatch_find_duplicates, thatch_dedup_mark_checked,
12
- thatch_extraction_done,
12
+ thatch_extraction_done, thatch_get_extraction_payload,
13
13
  thatch_prediction_query, thatch_prediction_update, thatch_prediction_list,
14
14
  thatch_prediction_delete
15
15
 
@@ -214,6 +214,7 @@ Tools are prefixed in Claude Code: \`mcp__thatch__memory_remember\`,
214
214
  \`mcp__thatch__memory_show\`, \`mcp__thatch__memory_forget\`,
215
215
  \`mcp__thatch__store_list\`, \`mcp__thatch__find_duplicates\`,
216
216
  \`mcp__thatch__dedup_mark_checked\`, \`mcp__thatch__extraction_done\`,
217
+ \`mcp__thatch__get_extraction_payload\`,
217
218
  \`mcp__thatch__prediction_query\`, \`mcp__thatch__prediction_update\`,
218
219
  \`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`. Bare names used below for readability.
219
220
 
@@ -395,12 +396,17 @@ export function claudeWriteNudge(): string {
395
396
  * their respective tool name prefix. The missedCount parameter tracks
396
397
  * consecutive nudges delivered without any memory_remember call in between,
397
398
  * escalating the tone from polite to insistent to shouting.
399
+ *
400
+ * The sessionID replaces the inline payload: the nudge tells the model to
401
+ * dispatch a sub-agent that calls get_extraction_payload to fetch the queued
402
+ * interactions as a tool response. This keeps the full payload out of the
403
+ * main session's context window.
398
404
  */
399
405
  export function extractionNudge(
400
406
  count: number,
401
407
  missedCount: number,
402
408
  toolName: string,
403
- payload: string,
409
+ sessionID: string,
404
410
  ): string {
405
411
  const plural = count === 1 ? "" : "s";
406
412
  // opencode's task tool has a `background` parameter (requires
@@ -415,47 +421,48 @@ export function extractionNudge(
415
421
  ? "Dispatch a task with background: true"
416
422
  : "Spawn a background sub-agent";
417
423
  const drainTool = isOpencode ? "thatch_extraction_done" : "mcp__thatch__extraction_done";
424
+ const fetchTool = isOpencode
425
+ ? "thatch_get_extraction_payload"
426
+ : "mcp__thatch__get_extraction_payload";
418
427
  const acknowledge = `After dispatching, call ${drainTool} to acknowledge.`;
428
+ const subAgentPrompt = `Call ${fetchTool} with session_id "${sessionID}" to retrieve the queued tool interactions, then run the thatch-fact-extractor skill to extract durable facts. Call ${drainTool} with session_id "${sessionID}" when finished, even if nothing was worth saving.`;
419
429
 
420
430
  if (missedCount >= 3) {
421
431
  return `[thatch] YOU ARE IGNORING EXTRACTION INSTRUCTIONS. ` +
422
432
  `${count} TOOL INTERACTION${plural.toUpperCase()} ARE QUEUED AND YOU HAVE NOT PROCESSED ANY OF THEM. ` +
423
433
  `THE USER INSTALLED THIS PLUGIN FOR A REASON. ` +
424
- `${dispatch.toUpperCase()} NOW TO RUN THE thatch-fact-extractor SKILL. ` +
425
- `${acknowledge.toUpperCase()} ` +
426
- `USE ${toolName.toUpperCase()} TO SAVE DURABLE FACTS FROM THIS PAYLOAD:\n${payload}`;
434
+ `${dispatch.toUpperCase()} NOW WITH THIS PROMPT: "${subAgentPrompt.toUpperCase()}" ` +
435
+ `${acknowledge.toUpperCase()}`;
427
436
  }
428
437
  if (missedCount >= 2) {
429
438
  return `[thatch] YOU HAVE NOT PROCESSED YOUR PREVIOUS EXTRACTION PAYLOAD${plural.toUpperCase()}. ` +
430
439
  `${count} more tool interaction${plural} are now queued. ` +
431
- `${dispatch} to run the thatch-fact-extractor skill on this payload. ` +
432
- `${acknowledge} ` +
433
- `Use ${toolName} to save durable facts from this payload:\n${payload}`;
440
+ `${dispatch} with this prompt: "${subAgentPrompt}" ` +
441
+ `${acknowledge}`;
434
442
  }
435
443
  return `[thatch] ${dispatch} to run the thatch-fact-extractor skill ` +
436
444
  `on ${count} queued tool interaction${plural}. ` +
445
+ `Sub-agent prompt: "${subAgentPrompt}" ` +
437
446
  `${acknowledge} ` +
438
447
  `This is a system nudge, not user input. After dispatching and acknowledging, ` +
439
448
  `stop: do not advance pending work on this nudge's account. If you are ` +
440
- `waiting for the user to respond, continue waiting. ` +
441
- `Use ${toolName} to save durable facts from this payload:\n${payload}`;
449
+ `waiting for the user to respond, continue waiting.`;
442
450
  }
443
451
 
444
452
  /**
445
453
  * Prompt text sent directly to a child session the plugin created for
446
454
  * extraction. Unlike extractionNudge (which tells the model to dispatch a
447
455
  * sub-agent and call extraction_done), this prompt is already inside the
448
- * child - the model just runs the fact-extractor skill and saves facts.
449
- * The payload format is identical to the nudge path so the skill receives
450
- * the same contract either way.
456
+ * child - the model calls get_extraction_payload to fetch the queued
457
+ * interactions, runs the fact-extractor skill, and saves facts.
451
458
  */
452
- export function extractionDirectPrompt(count: number, payload: string): string {
459
+ export function extractionDirectPrompt(count: number, sessionID: string): string {
453
460
  const plural = count === 1 ? "" : "s";
454
461
  return `Run the thatch-fact-extractor skill to extract durable facts from ` +
455
462
  `${count} queued tool interaction${plural}. ` +
463
+ `Call thatch_get_extraction_payload with session_id "${sessionID}" to retrieve the interactions. ` +
456
464
  `Use thatch_memory_remember to save any durable facts you find. ` +
457
- `Call thatch_extraction_done when finished, even if nothing was worth saving.\n` +
458
- `Payload:\n${payload}`;
465
+ `Call thatch_extraction_done with session_id "${sessionID}" when finished, even if nothing was worth saving.`;
459
466
  }
460
467
 
461
468
  /**
@@ -463,8 +470,8 @@ export function extractionDirectPrompt(count: number, payload: string): string {
463
470
  * Maintained for any external callers; bin/thatch should use extractionNudge
464
471
  * directly with a missedCount from extract-queue.ts.
465
472
  */
466
- export function claudeExtractionNudge(count: number, payload: string): string {
467
- return extractionNudge(count, 0, "mcp__thatch__memory_remember", payload);
473
+ export function claudeExtractionNudge(count: number, sessionID: string): string {
474
+ return extractionNudge(count, 0, "mcp__thatch__memory_remember", sessionID);
468
475
  }
469
476
 
470
477
  /**
@@ -484,6 +491,7 @@ Tools are prefixed in Cursor: \`mcp__thatch__memory_remember\`,
484
491
  \`mcp__thatch__memory_show\`, \`mcp__thatch__memory_forget\`,
485
492
  \`mcp__thatch__store_list\`, \`mcp__thatch__find_duplicates\`,
486
493
  \`mcp__thatch__dedup_mark_checked\`, \`mcp__thatch__extraction_done\`,
494
+ \`mcp__thatch__get_extraction_payload\`,
487
495
  \`mcp__thatch__prediction_query\`, \`mcp__thatch__prediction_update\`,
488
496
  \`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`. Bare names used below for readability.
489
497
 
package/src/tool-defs.ts CHANGED
@@ -22,6 +22,18 @@ export interface CoreContext {
22
22
  db: ThatchDB;
23
23
  model: EmbeddingModel;
24
24
  defaultStore: string;
25
+ /** Peeks the extraction buffer for a session and returns the serialized
26
+ * JSON payload (same shape as buildExtractionPayload). Returns null when
27
+ * no interactions are queued. Used by the get_extraction_payload tool so
28
+ * the sub-agent fetches the payload as a tool response instead of
29
+ * receiving it inline in the nudge text. */
30
+ extractionPayloadProvider?: (sessionID: string) => string | null;
31
+ /** Drains a session's extraction queue. On the MCP path this deletes the
32
+ * file-backed queue and resets the missed-nudge counter. On the opencode
33
+ * path this is unused (the tool.execute.after hook handles drain via
34
+ * childToParent). Called by extraction_done when the sub-agent passes
35
+ * the parent's session_id. */
36
+ drainExtractionQueue?: (sessionID: string) => void;
25
37
  }
26
38
 
27
39
  /**
@@ -370,6 +382,61 @@ const markCheckedDef: ToolDef = {
370
382
  * which is durable across interruption because the queue persists on disk
371
383
  * until then.
372
384
  */
385
+ /**
386
+ * Fetches the queued extraction payload for a session. The sub-agent calls
387
+ * this with the parent's session ID to retrieve the tool interactions that
388
+ * need fact extraction, instead of receiving them inline in the nudge text.
389
+ * This keeps the full payload out of the main session's context window.
390
+ */
391
+ const getExtractionPayloadDef: ToolDef = {
392
+ name: "get_extraction_payload",
393
+ description:
394
+ "Retrieve the queued tool interactions for extraction. Call this with " +
395
+ "the session_id from the extraction nudge to get the JSON payload " +
396
+ "(interactions, projectStore, globalStore). Then run the " +
397
+ "thatch-fact-extractor skill on the returned payload.",
398
+ args: {
399
+ session_id: z.string().describe(
400
+ "The session ID from the extraction nudge. This is the parent session " +
401
+ "whose tool interactions are queued for extraction.",
402
+ ),
403
+ },
404
+ async execute(args, ctx) {
405
+ if (!ctx.extractionPayloadProvider) {
406
+ return "Extraction payload retrieval is not available in this host.";
407
+ }
408
+ const payload = ctx.extractionPayloadProvider(args.session_id as string);
409
+ if (!payload) {
410
+ return "No queued tool interactions found for this session.";
411
+ }
412
+ return payload;
413
+ },
414
+ };
415
+
416
+ /**
417
+ * Extraction-buffer acknowledgment, with AMQP-style accept/complete roles.
418
+ *
419
+ * Called in a PARENT session after dispatching the fact-extractor, it accepts
420
+ * the buffer: entries move to a holding area and the nudge quiets, but they
421
+ * are not dropped until the extractor completes. Called in a CHILD extractor
422
+ * at the end of its run, it completes the parent's accepted entries -
423
+ * including no-save runs that write no memory. If the child errors or is
424
+ * deleted before either signal, the host requeues the entries so the facts
425
+ * are not lost.
426
+ *
427
+ * The actual state changes happen in the host's post-tool hook
428
+ * (tool.execute.after for opencode, PostToolBatch/appendBatch for MCP) -
429
+ * this tool's execute function is a no-op confirmation. The tool exists so
430
+ * the model has a recognizable tool name to key on. In the MCP path the
431
+ * file-backed queue is consumed on this call (or on any memory_remember),
432
+ * which is durable across interruption because the queue persists on disk
433
+ * until then.
434
+ *
435
+ * The optional session_id parameter lets a sub-agent drain the PARENT's
436
+ * file-backed queue on the MCP path, where the sub-agent's session ID
437
+ * differs from the parent's and appendBatch's self-detection would drain
438
+ * the wrong (empty) queue.
439
+ */
373
440
  const extractionDoneDef: ToolDef = {
374
441
  name: "extraction_done",
375
442
  description:
@@ -377,9 +444,20 @@ const extractionDoneDef: ToolDef = {
377
444
  "dispatching the fact-extractor to a sub-agent: accepts the buffer " +
378
445
  "(quiets the nudge) while keeping entries until the extractor completes. " +
379
446
  "In the fact-extractor sub-agent, call at the end of the run to mark the " +
380
- "entries complete, even when nothing was worth saving.",
381
- args: {},
382
- async execute() {
447
+ "entries complete, even when nothing was worth saving. Pass session_id " +
448
+ "when running as a sub-agent to drain the parent session's queue.",
449
+ args: {
450
+ session_id: z.string().optional().describe(
451
+ "The parent session's ID, when called from a sub-agent on the MCP " +
452
+ "path. Drains the parent's file-backed queue. Omit when called from " +
453
+ "the parent session itself (opencode path handles drain via hooks).",
454
+ ),
455
+ },
456
+ async execute(args, ctx) {
457
+ const sessionID = args.session_id as string | undefined;
458
+ if (sessionID && ctx.drainExtractionQueue) {
459
+ ctx.drainExtractionQueue(sessionID);
460
+ }
383
461
  return "[acknowledged]";
384
462
  },
385
463
  };
@@ -587,6 +665,7 @@ export const TOOL_DEFS: ToolDef[] = [
587
665
  findDuplicatesDef,
588
666
  markCheckedDef,
589
667
  extractionDoneDef,
668
+ getExtractionPayloadDef,
590
669
  predictionQueryDef,
591
670
  predictionUpdateDef,
592
671
  predictionListDef,
package/src/tools.ts CHANGED
@@ -13,8 +13,18 @@ export function createTools(
13
13
  db: ThatchDB,
14
14
  model: EmbeddingModel,
15
15
  defaultStore: string,
16
+ extensions?: {
17
+ extractionPayloadProvider?: CoreContext["extractionPayloadProvider"];
18
+ drainExtractionQueue?: CoreContext["drainExtractionQueue"];
19
+ },
16
20
  ): Record<string, ReturnType<typeof tool>> {
17
- const ctx: CoreContext = { db, model, defaultStore };
21
+ const ctx: CoreContext = {
22
+ db,
23
+ model,
24
+ defaultStore,
25
+ extractionPayloadProvider: extensions?.extractionPayloadProvider,
26
+ drainExtractionQueue: extensions?.drainExtractionQueue,
27
+ };
18
28
 
19
29
  const tools: Record<string, ReturnType<typeof tool>> = {};
20
30
  for (const def of TOOL_DEFS) {