@jeffober/thatch 0.2.2 → 0.2.4
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/bin/thatch +77 -46
- package/docs/dev/README.md +21 -25
- package/docs/dev/features/cli.md +1 -0
- package/docs/dev/features/extraction.md +120 -81
- package/docs/dev/features/nudge-pipeline.md +44 -39
- package/docs/dev/features/session-lifecycle.md +34 -20
- package/docs/dev/features/setup.md +7 -0
- package/docs/dev/features/watchers.md +9 -0
- package/docs/dev/gotchas.md +20 -26
- package/docs/dev/mcp-parity.md +16 -17
- package/docs/dev/setup-and-hooks.md +1 -1
- package/docs/user/cli.md +2 -1
- package/docs/user/setup.md +13 -0
- package/docs/user/watchers.md +6 -0
- package/package.json +1 -1
- package/src/chat.ts +1 -1
- package/src/extract-queue.ts +16 -1
- package/src/extraction.ts +4 -3
- package/src/opencode/v2.ts +4 -3
- package/src/prompts.ts +12 -33
- package/src/runtime.ts +110 -107
- package/src/setup.ts +50 -0
- package/src/tool-defs.ts +39 -31
- package/src/watchers.ts +31 -0
package/bin/thatch
CHANGED
|
@@ -8,7 +8,7 @@ import { detectRepo } from "../src/git";
|
|
|
8
8
|
import { BgeEmbeddingModel } from "../src/embeddings";
|
|
9
9
|
import { hygieneReport } from "../src/hygiene";
|
|
10
10
|
import { claudeSessionStartReminder } from "../src/prompts";
|
|
11
|
-
import { setupClaudeCode, setupCursor, registerClaudeMcpServer } from "../src/setup";
|
|
11
|
+
import { setupClaudeCode, setupCursor, setupSkillsOnly, registerClaudeMcpServer } from "../src/setup";
|
|
12
12
|
import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToTranscriptEntries, partToFullJson, messageToFullJson, type SessionPart } from "../src/session-db";
|
|
13
13
|
import { chatTailDiff, chatTailBacklog, filterChatTailRows, formatChatTailJsonl, CHAT_TAIL_DEFAULT_LIMIT, parseChatTimeBound, CHAT_STALE_MS, CHAT_RENUDGE_MINUTES, chatLiveness, humanAge, splitChatRoster, sortChatRoster, mcpSessionID, isoMinutesAgo, renderChatParticipant, resolveRegisteredPredecessor, type ChatSessionRow, type ChatTailFilter } from "../src/chat";
|
|
14
14
|
import { chatEnabled, loadConfig } from "../src/config";
|
|
@@ -38,8 +38,10 @@ function usage(): never {
|
|
|
38
38
|
thatch flush-tools [--json] Peek the queue + print an extraction nudge (hook only)
|
|
39
39
|
thatch chat-notify Print a chat follow-up message when mail is unread (Cursor stop hook)
|
|
40
40
|
thatch flush-predictions [--json] Query predictions for the prompt (hook only)
|
|
41
|
-
thatch setup --claude [--cursor] [--local|--global]
|
|
42
|
-
Install config + instructions + hooks + skills
|
|
41
|
+
thatch setup --claude [--cursor] [--local|--global] [--skills-only]
|
|
42
|
+
Install config + instructions + hooks + skills.
|
|
43
|
+
--skills-only refreshes just the skill files
|
|
44
|
+
(skips MCP config, instructions, hooks, commands)
|
|
43
45
|
thatch session list -s <id> [--after T] [--before T]
|
|
44
46
|
JSONL timeline of a session's parts (one line per part)
|
|
45
47
|
thatch session get --id <part_id|msg_id>
|
|
@@ -839,6 +841,10 @@ switch (command) {
|
|
|
839
841
|
const isLocal = args.includes("--local");
|
|
840
842
|
const isClaude = args.includes("--claude");
|
|
841
843
|
const isCursor = args.includes("--cursor");
|
|
844
|
+
// --skills-only narrows the run to the skill install: same host and
|
|
845
|
+
// scope rules, but MCP config, instructions, hooks, and commands are
|
|
846
|
+
// left untouched.
|
|
847
|
+
const skillsOnly = args.includes("--skills-only");
|
|
842
848
|
|
|
843
849
|
if (isGlobal && isLocal) {
|
|
844
850
|
console.error("Choose one scope: --local or --global.");
|
|
@@ -875,52 +881,73 @@ switch (command) {
|
|
|
875
881
|
};
|
|
876
882
|
|
|
877
883
|
if (isClaude) {
|
|
878
|
-
|
|
884
|
+
if (skillsOnly) {
|
|
885
|
+
const result = setupSkillsOnly("claude", isGlobal, dir);
|
|
879
886
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
console.log(
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
console.log(
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
if (reg.status === "registered") {
|
|
899
|
-
console.log(` MCP server: ${reg.detail}`);
|
|
900
|
-
} else if (reg.status === "already-registered") {
|
|
901
|
-
console.log(` MCP server: already registered (claude mcp get thatch to inspect)`);
|
|
902
|
-
} else {
|
|
903
|
-
console.log("To register the MCP server globally, run:");
|
|
904
|
-
console.log(` ${result.mcpAddCommand}`);
|
|
905
|
-
console.log(` (${reg.detail})`);
|
|
887
|
+
console.log(`Thatch skills updated for Claude Code (${isGlobal ? "global" : "project-local"}).`);
|
|
888
|
+
console.log();
|
|
889
|
+
printSkills(result);
|
|
890
|
+
console.log();
|
|
891
|
+
console.log("Restart Claude Code for the changes to take effect.");
|
|
892
|
+
} else {
|
|
893
|
+
const result = setupClaudeCode(thatchBin, isGlobal, dir);
|
|
894
|
+
|
|
895
|
+
console.log(`Thatch setup complete for Claude Code (${isGlobal ? "global" : "project-local"}).`);
|
|
896
|
+
console.log();
|
|
897
|
+
if (result.mcpConfig) {
|
|
898
|
+
console.log(` MCP config: ${result.mcpConfig}`);
|
|
899
|
+
}
|
|
900
|
+
console.log(` Instructions: ${result.claudeMd}`);
|
|
901
|
+
console.log(` Hooks: ${result.settings}`);
|
|
902
|
+
printSkills(result);
|
|
903
|
+
if (result.commands.length > 0) {
|
|
904
|
+
console.log(` Commands: ${result.commands.length} written (${result.commands.map((p) => p.split("/").pop()).join(", ")})`);
|
|
906
905
|
}
|
|
907
906
|
console.log();
|
|
907
|
+
if (result.mcpAddCommand) {
|
|
908
|
+
// Global setup cannot write ~/.claude.json directly, so run the
|
|
909
|
+
// registration on the user's behalf. Idempotent: an existing
|
|
910
|
+
// registration is left alone; a missing or failing claude CLI
|
|
911
|
+
// falls back to printing the manual command.
|
|
912
|
+
const reg = await registerClaudeMcpServer(thatchBin);
|
|
913
|
+
if (reg.status === "registered") {
|
|
914
|
+
console.log(` MCP server: ${reg.detail}`);
|
|
915
|
+
} else if (reg.status === "already-registered") {
|
|
916
|
+
console.log(` MCP server: already registered (claude mcp get thatch to inspect)`);
|
|
917
|
+
} else {
|
|
918
|
+
console.log("To register the MCP server globally, run:");
|
|
919
|
+
console.log(` ${result.mcpAddCommand}`);
|
|
920
|
+
console.log(` (${reg.detail})`);
|
|
921
|
+
}
|
|
922
|
+
console.log();
|
|
923
|
+
}
|
|
924
|
+
console.log("Restart Claude Code for the changes to take effect.");
|
|
908
925
|
}
|
|
909
|
-
console.log("Restart Claude Code for the changes to take effect.");
|
|
910
926
|
}
|
|
911
927
|
|
|
912
928
|
if (isCursor) {
|
|
913
|
-
|
|
929
|
+
if (skillsOnly) {
|
|
930
|
+
const result = setupSkillsOnly("cursor", isGlobal, dir);
|
|
914
931
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
932
|
+
console.log();
|
|
933
|
+
console.log(`Thatch skills updated for Cursor (${isGlobal ? "global" : "project-local"}).`);
|
|
934
|
+
console.log();
|
|
935
|
+
printSkills(result);
|
|
936
|
+
console.log();
|
|
937
|
+
console.log("Restart Cursor for the changes to take effect.");
|
|
938
|
+
} else {
|
|
939
|
+
const result = setupCursor(thatchBin, isGlobal, dir);
|
|
940
|
+
|
|
941
|
+
console.log();
|
|
942
|
+
console.log(`Thatch setup complete for Cursor (${isGlobal ? "global" : "project-local"}).`);
|
|
943
|
+
console.log();
|
|
944
|
+
console.log(` MCP config: ${result.mcpConfig}`);
|
|
945
|
+
console.log(` Instructions: ${result.agentsMd}`);
|
|
946
|
+
console.log(` Hooks: ${result.hooks}`);
|
|
947
|
+
printSkills(result);
|
|
948
|
+
console.log();
|
|
949
|
+
console.log("Restart Cursor for the changes to take effect.");
|
|
950
|
+
}
|
|
924
951
|
}
|
|
925
952
|
break;
|
|
926
953
|
}
|
|
@@ -1054,15 +1081,19 @@ switch (command) {
|
|
|
1054
1081
|
}
|
|
1055
1082
|
|
|
1056
1083
|
// Tier 1: extraction nudge. Peek (don't drain) - the queue persists
|
|
1057
|
-
// until the
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
// sub-agent
|
|
1084
|
+
// until the extractor completes (extraction_done with the parent's
|
|
1085
|
+
// session_id) or the parent writes a memory itself. The parent's
|
|
1086
|
+
// dispatch-time ack resets the escalation counter but does NOT drain:
|
|
1087
|
+
// a drain at ack time deleted the queue before the sub-agent fetched
|
|
1088
|
+
// (the accept-before-fetch loss). If the agent ignores the nudge, the
|
|
1089
|
+
// payload grows on the next flush. The nudge carries the session ID,
|
|
1090
|
+
// not the full payload - the sub-agent fetches it via
|
|
1091
|
+
// get_extraction_payload.
|
|
1061
1092
|
const interactions = sessionID ? peekQueue(sessionID) : [];
|
|
1062
1093
|
if (interactions.length > 0) {
|
|
1063
1094
|
const missed = sessionID ? getMissedCount(sessionID) : 0;
|
|
1064
1095
|
if (sessionID) incrementMissedCount(sessionID);
|
|
1065
|
-
const output = extractionNudge(interactions.length, missed,
|
|
1096
|
+
const output = extractionNudge(interactions.length, missed, sessionID ?? "");
|
|
1066
1097
|
emit(output, versionWarning, isJson, chatLine);
|
|
1067
1098
|
break;
|
|
1068
1099
|
}
|
package/docs/dev/README.md
CHANGED
|
@@ -127,7 +127,7 @@ methods. The v1 hook shapes:
|
|
|
127
127
|
| `command.execute.before` | Arms the wrap-up greenlight check: `/thatch/compact` and `/thatch/exit` mark their session in `pendingWrapUp`. The next `session.status` idle resolves it by inspecting the final assistant message for the greenlight token and, when present, triggering the TUI action (`session_compact` via `executeCommand`, `app.exit` via `publish`; the exit path unregisters the session from the chat directory first). See [features/session-lifecycle.md](features/session-lifecycle.md). |
|
|
128
128
|
| `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. |
|
|
129
129
|
| `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++. For `thatch_chat_*` tool calls, builds the transcript echo (`chatEchoText`) and posts it as a visible non-synthetic `noReply` part. 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. |
|
|
130
|
-
| `chat.message` |
|
|
130
|
+
| `chat.message` | Recall nudge: 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 fire independently in separate try/catch blocks with separate synthetic parts. There is deliberately NO extraction nudge here - extraction is plugin-driven at session idle (see the extraction feature doc); the old model-driven handshake raced its own state machine and was removed. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation), and for task-dispatched sub-agent sessions (`childToParent` without `extractionChildren`) - their tool lists may exclude the thatch tools and their work is driven by the dispatch prompt. |
|
|
131
131
|
| `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 and chat delivery gates; 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 and pending chat mail (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`, cancels the session's watchers, and unregisters it from the chat directory (the crash path — a dead process fires no event — is covered by heartbeat staleness). `session.compacted`: clears the compacting flag so `chat.message` nudges resume. |
|
|
132
132
|
| `dispose` | Stops the version checker, the watcher poller, and the chat poller, closes the DB. |
|
|
133
133
|
|
|
@@ -171,11 +171,11 @@ Two of these hooks were dead for weeks because failures were invisible.
|
|
|
171
171
|
check for durable knowledge (via `thatch_memory_recall` for dedup, then
|
|
172
172
|
`thatch_memory_remember`) before composing a final response after
|
|
173
173
|
substantial work. No plugin hook fires between generation and response
|
|
174
|
-
delivery, so the only viable path is prompt instruction.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
is uncertain.
|
|
174
|
+
delivery, so the only viable path is prompt instruction. If the agent
|
|
175
|
+
forgets to save, extraction still sweeps the buffered tool interactions
|
|
176
|
+
at the session's next idle (plugin-driven; there is no model-facing
|
|
177
|
+
extraction nudge any more). This is an experiment — model reliability on
|
|
178
|
+
meta-instructions is uncertain.
|
|
179
179
|
10. **Background completion narration is suppressed, not prevented.** When a
|
|
180
180
|
background sub-agent completes, opencode injects a `<task_result>` block
|
|
181
181
|
into the parent session and triggers a full model generation. Thatch
|
|
@@ -243,27 +243,23 @@ extraction cycle
|
|
|
243
243
|
→ tool.execute.after buffers non-thatch, non-skill, non-task tool calls
|
|
244
244
|
per session (max 20); for child sessions, also tracks new/updated/deleted
|
|
245
245
|
metrics
|
|
246
|
-
→ direct extraction (
|
|
246
|
+
→ direct extraction (the only opencode path):
|
|
247
247
|
parent goes idle (session.status idle) with pending tool interactions
|
|
248
|
-
→
|
|
249
|
-
|
|
250
|
-
client.session.create, and prompts it with
|
|
248
|
+
→ stale accepted entries requeue (15-min bound), then triggerExtraction
|
|
249
|
+
adds parentID to the `extracting` set, creates a child session via
|
|
250
|
+
client.session.create, and prompts it with extractionDirectPrompt
|
|
251
|
+
(the parent's session ID is interpolated by the plugin)
|
|
252
|
+
→ child fetches the payload via thatch_get_extraction_payload - the
|
|
253
|
+
fetch records its claim (the delivery record)
|
|
251
254
|
→ child runs the fact-extractor skill, writes memories via
|
|
252
|
-
thatch_memory_remember
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
a nudge part with session ID and fetch tool name; missed nudges escalate (polite →
|
|
261
|
-
insistent → ALL-CAPS) via the missedNudges counter
|
|
262
|
-
→ drain: thatch_memory_remember (or thatch_extraction_done) clears the
|
|
263
|
-
buffer and resets the missed-nudge counter
|
|
264
|
-
→ agent dispatches a sub-agent that calls thatch_get_extraction_payload
|
|
265
|
-
to fetch the queued interactions, loads thatch-fact-extractor skill,
|
|
266
|
-
saves facts via thatch_memory_remember
|
|
255
|
+
thatch_memory_remember - each write (or extraction_done) completes
|
|
256
|
+
the child's claimed delivery, consuming only what it received
|
|
257
|
+
→ child goes idle → event handler finalizes, fires a toast with
|
|
258
|
+
childMetrics, deletes the child session
|
|
259
|
+
→ failure recovery (no model-facing nudge exists): a child error or
|
|
260
|
+
deletion requeues what it held to pending; a completion signal that
|
|
261
|
+
never comes is bounded by the 15-minute stale reaper; either way the
|
|
262
|
+
next idle re-triggers extraction from the same pending buffer
|
|
267
263
|
→ MCP path (Claude Code/Cursor): unchanged — no SDK client, no child
|
|
268
264
|
sessions; extract-queue.ts + flush-tools drives the nudge via hooks
|
|
269
265
|
|
package/docs/dev/features/cli.md
CHANGED
|
@@ -85,6 +85,7 @@ Read-only window on the cross-session chat directory ([cross-session-chat.md](cr
|
|
|
85
85
|
| `THATCH_RECALL_THRESHOLD` | Cosine cutoff for recall nudge | `0.55` |
|
|
86
86
|
| `THATCH_PREDICTION_THRESHOLD` | Cutoff for prediction auto-fire | `0.60` |
|
|
87
87
|
| `THATCH_BEHAVIOR_THRESHOLD` | Cutoff for behavior auto-fire | `0.60` |
|
|
88
|
+
| `THATCH_CHILD_RECONCILE_MS` | Reload window before a restored extraction child is judged finished (test knob) | `10000` |
|
|
88
89
|
| `CLAUDE_PROJECT_DIR` | Claude Code project dir | `process.cwd()` |
|
|
89
90
|
| `CURSOR_PROJECT_DIR` | Cursor project dir | falls through |
|
|
90
91
|
| `CLAUDE_CONFIG_DIR` | Claude config dir | `~/.claude` |
|
|
@@ -15,19 +15,23 @@ directly for conversation-derived knowledge.
|
|
|
15
15
|
|
|
16
16
|
- Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call for later
|
|
17
17
|
extraction
|
|
18
|
-
- Two paths: **direct extraction** via a child session (
|
|
19
|
-
**nudge-based extraction** (
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
|
|
18
|
+
- Two paths: **direct extraction** via a plugin-created child session (the
|
|
19
|
+
only opencode path) and **nudge-based extraction** (MCP hosts only)
|
|
20
|
+
- The MCP extraction nudge escalates: polite (0–1 missed), insistent (2),
|
|
21
|
+
all-caps shouting (3+). There is deliberately no opencode nudge: the
|
|
22
|
+
model-driven handshake it required raced its own state machine (acks
|
|
23
|
+
before fetches, no-claim completions wiping in-flight sets, mis-targeted
|
|
24
|
+
session ids making every transition a silent no-op) and was removed —
|
|
25
|
+
see "Key invariants"
|
|
23
26
|
- `thatch_get_extraction_payload` fetches queued interactions as JSON,
|
|
24
|
-
keeping the full payload out of the main session's context window
|
|
25
|
-
|
|
27
|
+
keeping the full payload out of the main session's context window; the
|
|
28
|
+
fetch is also the **delivery record** (it claims the entries for that
|
|
29
|
+
fetcher)
|
|
30
|
+
- `thatch_extraction_done` completes only the calling child's claimed
|
|
31
|
+
delivery — never the whole accepted set
|
|
26
32
|
- AMQP-style buffer lifecycle for opencode: pending → accepted → completed,
|
|
27
33
|
with requeue on failure
|
|
28
34
|
- File-backed JSONL queue for MCP hosts (no cross-call state)
|
|
29
|
-
- Parent-child session drain: the child drains the parent's snapshot,
|
|
30
|
-
preserving interleaved-turn entries added after the snapshot was taken
|
|
31
35
|
|
|
32
36
|
## How it works
|
|
33
37
|
|
|
@@ -71,57 +75,70 @@ calls are buffered for later extraction.
|
|
|
71
75
|
- Filters the same tools as opencode (`mcp__thatch__memory_remember`,
|
|
72
76
|
`mcp__thatch__extraction_done`, `mcp__thatch__*`, `skill`, `task`, `agent`)
|
|
73
77
|
|
|
74
|
-
### Direct extraction (opencode,
|
|
78
|
+
### Direct extraction (opencode, the only opencode path)
|
|
75
79
|
|
|
76
80
|
When a parent session goes idle (`session.status` idle event) with pending
|
|
77
|
-
buffer interactions
|
|
81
|
+
buffer interactions — after `requeueStaleAccepted()` has returned any
|
|
82
|
+
timed-out accepted entries to pending:
|
|
78
83
|
|
|
79
84
|
1. `triggerExtraction` adds the parent ID to the `extracting` set — this
|
|
80
|
-
|
|
85
|
+
gates re-triggering while a child runs
|
|
81
86
|
2. Peeks the buffer to count pending interactions
|
|
82
87
|
3. Creates a child session via
|
|
83
88
|
`client.session.create({ parentID, title: "thatch-extraction" })`
|
|
84
89
|
4. The `session.created` event fires, populating `childToParent` and
|
|
85
|
-
`parentSnapshots` (a snapshot of the full pending buffer at dispatch
|
|
90
|
+
`parentSnapshots` (a snapshot of the full pending buffer at dispatch
|
|
91
|
+
time — journal-recovery data only; claims subsumed the snapshot drain)
|
|
86
92
|
5. Adds the child ID to the `extractionChildren` set
|
|
87
|
-
6. Prompts the child with `extractionDirectPrompt(count, sessionID)`
|
|
93
|
+
6. Prompts the child with `extractionDirectPrompt(count, sessionID)` — the
|
|
94
|
+
plugin interpolates the parent's session ID into the prompt, so no
|
|
95
|
+
model ever has to copy one
|
|
88
96
|
7. If background sub-agents are enabled
|
|
89
97
|
(`OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS`): `promptAsync`. Otherwise:
|
|
90
98
|
fire-and-forget `prompt`
|
|
91
99
|
8. On prompt failure: `cleanupChild` removes the child from all maps and
|
|
92
|
-
deletes the child session
|
|
100
|
+
deletes the child session. The entries stay pending; the next idle
|
|
101
|
+
retries (there is no nudge fallback)
|
|
93
102
|
|
|
94
103
|
The child session then:
|
|
95
104
|
|
|
96
|
-
- Calls `thatch_get_extraction_payload`
|
|
105
|
+
- Calls `thatch_get_extraction_payload` — the fetch **claims** the entries
|
|
106
|
+
for this fetcher (claim = accept + delivery record)
|
|
97
107
|
- Runs the [thatch-fact-extractor](../skills.md) skill
|
|
98
108
|
- Writes memories via `thatch_memory_remember`
|
|
99
109
|
- Goes idle
|
|
100
110
|
|
|
101
111
|
On child idle (`session.status` idle event, extraction child):
|
|
102
112
|
|
|
103
|
-
1.
|
|
104
|
-
|
|
105
|
-
|
|
113
|
+
1. `completeClaimed(childID)` — consumes only the entries this child
|
|
114
|
+
claimed via its fetch. A no-claim idle is a no-op: a child that never
|
|
115
|
+
fetched processed nothing, and its idle signal must not drop entries
|
|
116
|
+
another extractor holds
|
|
106
117
|
2. Fires a toast with extraction metrics (only if memories were actually
|
|
107
118
|
written)
|
|
108
|
-
3.
|
|
109
|
-
4.
|
|
110
|
-
5.
|
|
111
|
-
6. Deletes the child session
|
|
119
|
+
3. Cleans up all maps
|
|
120
|
+
4. `consume(childID)` — drains the child's own buffer
|
|
121
|
+
5. Deletes the child session
|
|
112
122
|
|
|
113
|
-
|
|
123
|
+
If the extraction child is deleted before completing,
|
|
124
|
+
`requeueClaimed`/`requeueAccepted` returns what it held to pending. If its
|
|
125
|
+
completion signal never comes at all, `requeueStaleAccepted` (15 min)
|
|
126
|
+
returns the accepted entries to pending — the next idle re-extracts them.
|
|
114
127
|
|
|
115
|
-
|
|
116
|
-
|
|
128
|
+
### Nudge-based extraction (MCP hosts only)
|
|
129
|
+
|
|
130
|
+
Claude Code and Cursor have no SDK client to create child sessions and no
|
|
131
|
+
plugin lifecycle (each hook invocation is a fresh process), so extraction
|
|
132
|
+
there is still model-driven. When the buffer has pending interactions, the
|
|
133
|
+
next user prompt (`UserPromptSubmit`/`beforeSubmitPrompt`) gets an
|
|
134
|
+
extraction nudge telling the agent to spawn a sub-agent for the
|
|
135
|
+
fact-extractor skill.
|
|
117
136
|
|
|
118
|
-
- The next user message (`chat.message` for opencode,
|
|
119
|
-
`UserPromptSubmit`/`beforeSubmitPrompt` for MCP) gets an extraction nudge
|
|
120
137
|
- The nudge carries the session ID and fetch tool name — not the full
|
|
121
138
|
payload — so the sub-agent calls `thatch_get_extraction_payload` to
|
|
122
139
|
retrieve the interactions as a tool response
|
|
123
140
|
|
|
124
|
-
**Nudge escalation** (via the
|
|
141
|
+
**Nudge escalation** (via the file-backed missed counter):
|
|
125
142
|
|
|
126
143
|
| Missed count | Tone |
|
|
127
144
|
|--------------|------|
|
|
@@ -129,26 +146,29 @@ the `extracting` set) **and** the buffer has pending interactions:
|
|
|
129
146
|
| 2 | Insistent |
|
|
130
147
|
| 3+ | All-caps shouting |
|
|
131
148
|
|
|
132
|
-
The
|
|
133
|
-
`
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
149
|
+
The file-backed queue persists until the extractor completes
|
|
150
|
+
(`extraction_done` with the parent's `session_id`) or the parent writes a
|
|
151
|
+
memory itself. The parent's dispatch-time ack resets the escalation
|
|
152
|
+
counter but does NOT drain the queue — a drain at ack time deleted the
|
|
153
|
+
queue before the sub-agent fetched (the accept-before-fetch loss; the
|
|
154
|
+
opencode path removed the handshake for the same failure class).
|
|
137
155
|
|
|
138
156
|
### Buffer lifecycle (opencode, AMQP-style)
|
|
139
157
|
|
|
140
158
|
Buffered interactions move through four states in `ExtractionPipeline`
|
|
141
159
|
(`src/extraction.ts`):
|
|
142
160
|
|
|
143
|
-
- **pending** — interactions in the ring buffer. The
|
|
144
|
-
`
|
|
145
|
-
- **accepted** — moved from pending by `accept()
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
161
|
+
- **pending** — interactions in the ring buffer. The idle trigger
|
|
162
|
+
(`triggerExtraction`) fires on them; there is no model-facing nudge.
|
|
163
|
+
- **accepted** — moved from pending by `accept()` (a parent's legacy ack,
|
|
164
|
+
or a fetcher's claim). Entries are held, not dropped, and are still
|
|
165
|
+
served by the payload provider.
|
|
166
|
+
- **completed** — dropped by `completeClaimed()` (the fetcher's completion
|
|
167
|
+
signal) or `completeAccepted()` (the parent session being deleted —
|
|
168
|
+
nothing exists to replay into).
|
|
169
|
+
- **requeued** — moved back to pending by `requeueClaimed()` /
|
|
170
|
+
`requeueAccepted()` (child error or deletion before completing) or
|
|
171
|
+
`requeueStaleAccepted()` (15-minute completion timeout).
|
|
152
172
|
|
|
153
173
|
Key methods:
|
|
154
174
|
|
|
@@ -157,36 +177,42 @@ Key methods:
|
|
|
157
177
|
| `push()` | Add interaction to pending buffer (capped at 20) |
|
|
158
178
|
| `peek()` | Read without clearing |
|
|
159
179
|
| `consume()` | Delete the session's pending buffer (called on memory write) |
|
|
160
|
-
| `accept()` | Move pending to accepted —
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
`
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
`
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
+
| `accept()` | Move pending to accepted — hold entries (non-destructive) |
|
|
181
|
+
| `claim()` | Accept + record WHICH fetcher received the entries — the delivery record |
|
|
182
|
+
| `completeClaimed()` | Drop only the completing fetcher's claimed entries |
|
|
183
|
+
| `completeAccepted()` | Drop accepted entries — used on parent deletion |
|
|
184
|
+
| `requeueClaimed()` / `requeueAccepted()` | Move held entries back to pending — extractor died |
|
|
185
|
+
| `requeueStaleAccepted()` | Requeue accepted entries held longer than 15 min |
|
|
186
|
+
|
|
187
|
+
### Parent-child delivery (claim semantics)
|
|
188
|
+
|
|
189
|
+
The payload fetch is the delivery record:
|
|
190
|
+
|
|
191
|
+
1. `thatch_get_extraction_payload` with a fetcher identity calls
|
|
192
|
+
`claim(parentID, fetcherID)`: the pending buffer is accepted and the
|
|
193
|
+
fetcher's claim records exactly which entries it received
|
|
194
|
+
2. The fetcher's completion signal (`extraction_done` or
|
|
195
|
+
`memory_remember` from that child) calls `completeClaimed(fetcherID)` —
|
|
196
|
+
consuming only the claimed entries, by reference identity
|
|
197
|
+
3. A completion from a fetcher with no claim is a **no-op** — it processed
|
|
198
|
+
nothing, so it must not drop entries another extractor holds (a sibling
|
|
199
|
+
sub-agent going idle, or a mis-ordered ack, once wiped the whole
|
|
200
|
+
accepted set this way — the accept-then-racing-completion loss)
|
|
201
|
+
4. Entries that arrived after the fetch stay pending; the next idle
|
|
202
|
+
re-extracts them
|
|
180
203
|
|
|
181
204
|
### MCP file-backed queue drain
|
|
182
205
|
|
|
183
206
|
- `drainExtractionQueue(sessionID)` calls `resetMissedCount` +
|
|
184
207
|
`consumeQueue` (deletes the JSONL file)
|
|
185
208
|
- Triggered by: `thatch_extraction_done` called with the parent's
|
|
186
|
-
`session_id
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
consumes the queue inline
|
|
209
|
+
`session_id` (the extractor's completion), or `thatch_memory_remember`
|
|
210
|
+
called by the parent itself
|
|
211
|
+
- `appendBatch` in `extract-queue.ts` self-detects `memory_remember` and
|
|
212
|
+
resets the counter + consumes the queue inline. It detects
|
|
213
|
+
`extraction_done` too, but only resets the counter — draining at the
|
|
214
|
+
parent's dispatch-time ack deleted the queue before the sub-agent
|
|
215
|
+
fetched (the accept-before-fetch loss)
|
|
190
216
|
|
|
191
217
|
### The `extraction_done` tool
|
|
192
218
|
|
|
@@ -197,6 +223,10 @@ continues working while the child extracts.
|
|
|
197
223
|
- The real state transitions happen in the host's post-tool hook
|
|
198
224
|
(`tool.execute.after` for opencode, `PostToolBatch`/`appendBatch` for MCP)
|
|
199
225
|
- The tool exists primarily so the model has a recognizable name to key on
|
|
226
|
+
- On opencode there is no parent ack any more: a parent-side
|
|
227
|
+
`extraction_done` is tolerated as a non-destructive accept (entries
|
|
228
|
+
held, not dropped) for backward compatibility with an in-flight older
|
|
229
|
+
nudge
|
|
200
230
|
|
|
201
231
|
### The `get_extraction_payload` tool
|
|
202
232
|
|
|
@@ -226,14 +256,15 @@ continues working while the child extracts.
|
|
|
226
256
|
- [Memory store](memory-store.md) — extraction writes memories via
|
|
227
257
|
`thatch_memory_remember`
|
|
228
258
|
- [Nudge pipeline](nudge-pipeline.md) — the extraction nudge is tier 1
|
|
229
|
-
(highest priority, returns early)
|
|
259
|
+
(highest priority, returns early) on MCP hosts; opencode's
|
|
260
|
+
`chat.message` runs the recall tier only
|
|
230
261
|
- [Session lifecycle](session-lifecycle.md) — direct extraction is triggered
|
|
231
262
|
by `session.status` idle; child lifecycle is managed by the event handler
|
|
232
263
|
- [Skills](../skills.md) — the thatch-fact-extractor skill is dispatched to
|
|
233
264
|
child sessions
|
|
234
265
|
- [Multi-host](multi-host.md) — in-memory ring buffer for opencode,
|
|
235
266
|
file-backed queue for MCP hosts
|
|
236
|
-
- [Compaction recovery](compaction-recovery.md) —
|
|
267
|
+
- [Compaction recovery](compaction-recovery.md) — nudges are
|
|
237
268
|
suppressed during compaction (tools are blocked)
|
|
238
269
|
|
|
239
270
|
## Source files
|
|
@@ -242,25 +273,33 @@ continues working while the child extracts.
|
|
|
242
273
|
|------|----------------|
|
|
243
274
|
| `src/extraction.ts` | In-memory ring buffer (`ExtractionPipeline`), shared payload builders (`buildExtractionPayload`, `deriveTitle`, `summarizeArgs`) |
|
|
244
275
|
| `src/extract-queue.ts` | File-backed JSONL queue for MCP hosts |
|
|
245
|
-
| `src/runtime.ts` | opencode hooks: `tool.execute.after`, `session.status` idle, `session.created`, `session.error`, `session.deleted`, `chat.message` (
|
|
276
|
+
| `src/runtime.ts` | opencode hooks: `tool.execute.after`, `session.status` idle (direct extraction trigger), `session.created`, `session.error`, `session.deleted`, `chat.message` (recall/prediction/behavior nudges; no extraction nudge) -- shared by the v1/v2 adapters |
|
|
246
277
|
| `bin/thatch` | `buffer-batch`, `buffer-tool`, `flush-tools` subcommands |
|
|
247
278
|
| `src/prompts.ts` | `extractionNudge` (with escalation), `extractionDirectPrompt` |
|
|
248
279
|
|
|
249
280
|
## Key invariants
|
|
250
281
|
|
|
251
|
-
1. **Tool filtering is absolute.** `thatch_*`, `skill`,
|
|
252
|
-
never buffered. Buffering them would echo the store
|
|
253
|
-
a feedback loop.
|
|
254
|
-
2. **
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
282
|
+
1. **Tool filtering is absolute.** `thatch_*`, `skill`, `task`, and
|
|
283
|
+
`subagent` tools are never buffered. Buffering them would echo the store
|
|
284
|
+
into itself or create a feedback loop.
|
|
285
|
+
2. **Extraction never depends on model cooperation on opencode.** The
|
|
286
|
+
plugin creates the extraction child, interpolates the session ID, and
|
|
287
|
+
drives the lifecycle from events. The old model-driven handshake (model
|
|
288
|
+
dispatches, parent acks, child copies the ID) raced its own state
|
|
289
|
+
machine — the September 2026 dispatch-loop report (accept-before-fetch
|
|
290
|
+
loss + never-terminating re-nudge) — and was removed. The opencode
|
|
291
|
+
`chat.message` hook computes no extraction nudge.
|
|
292
|
+
3. **Completion is claim-scoped.** Only a fetcher that recorded a claim
|
|
293
|
+
(via `get_extraction_payload`) can consume entries, and only the entries
|
|
294
|
+
it received. No-claim completions are no-ops; no transition drops
|
|
295
|
+
entries another extractor holds. The stale reaper bounds any orphan
|
|
296
|
+
linger at 15 minutes.
|
|
259
297
|
4. **`tool.execute.after` is a plugin hook, not a bus event.** The event bus
|
|
260
298
|
has no such event. Moving the buffering logic into the `event` handler
|
|
261
299
|
silently never fires it.
|
|
262
300
|
5. **MCP host hooks must be silent.** `PostToolBatch`/`postToolUse` produce
|
|
263
301
|
no stdout — only `flush-tools` prints. Any stdout delays the agent loop.
|
|
264
|
-
6. **The
|
|
265
|
-
|
|
266
|
-
|
|
302
|
+
6. **The MCP queue is durable until the extractor completes.** The
|
|
303
|
+
parent's dispatch-time ack must not drain it (see the invariant above);
|
|
304
|
+
the drain happens at the extractor's completion or the parent's own
|
|
305
|
+
memory write.
|