@jeffober/thatch 0.1.48 → 0.2.1
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 +43 -4
- package/artifacts/skills/thatch-fact-extractor.md +1 -1
- package/artifacts/skills/thatch-memory-verify.md +2 -2
- package/bin/thatch +31 -16
- package/docs/dev/README.md +39 -13
- package/docs/dev/features/behavior-engine.md +3 -2
- package/docs/dev/features/cicd.md +1 -1
- package/docs/dev/features/compaction-recovery.md +1 -1
- package/docs/dev/features/cross-session-chat.md +12 -3
- package/docs/dev/features/extraction.md +10 -2
- package/docs/dev/features/hygiene.md +3 -1
- package/docs/dev/features/memory-store.md +1 -1
- package/docs/dev/features/multi-host.md +4 -2
- package/docs/dev/features/notifications.md +121 -4
- package/docs/dev/features/nudge-pipeline.md +16 -2
- package/docs/dev/features/opencode-plugin.md +133 -0
- package/docs/dev/features/qa-system.md +43 -0
- package/docs/dev/features/repo-identity.md +1 -1
- package/docs/dev/features/session-lifecycle.md +6 -3
- package/docs/dev/features/watchers.md +12 -5
- package/docs/dev/gotchas.md +94 -4
- package/docs/dev/mcp-parity.md +4 -3
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/plans/llm-alerts.md +287 -0
- package/docs/plans/prediction-consolidation.md +3 -3
- package/docs/user/README.md +8 -4
- package/docs/user/memory.md +1 -1
- package/docs/user/notifications.md +63 -4
- package/docs/user/setup.md +4 -0
- package/docs/user/watchers.md +15 -7
- package/package.json +3 -1
- package/src/alerts.ts +360 -0
- package/src/capabilities.ts +174 -0
- package/src/chat.ts +80 -5
- package/src/commands.ts +44 -5
- package/src/config.ts +32 -1
- package/src/db.ts +157 -12
- package/src/embeddings.ts +43 -0
- package/src/extraction.ts +210 -0
- package/src/index.ts +77 -1339
- package/src/opencode/v1.ts +54 -0
- package/src/opencode/v2.ts +605 -0
- package/src/os-args.ts +99 -0
- package/src/prompts.ts +181 -31
- package/src/runtime.ts +2004 -0
- package/src/seed-behaviors.ts +1 -1
- package/src/session-db.ts +32 -0
- package/src/tool-defs.ts +340 -46
- package/src/tools.ts +16 -35
- package/src/watchers.ts +109 -14
package/README.md
CHANGED
|
@@ -21,7 +21,10 @@ everything runs on your machine.
|
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
On next start, OpenCode npm-installs thatch and its tools are available
|
|
24
|
-
immediately.
|
|
24
|
+
immediately. Works with both opencode 1.x and 2.x (the same package supports
|
|
25
|
+
both plugin APIs). Until the next release ships, npm's `latest` still
|
|
26
|
+
targets 1.x only. For async extraction (child sessions run in the
|
|
27
|
+
background):
|
|
25
28
|
|
|
26
29
|
```bash
|
|
27
30
|
export OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
|
|
@@ -30,6 +33,39 @@ export OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
|
|
|
30
33
|
Without this env var, extraction still works - the child session runs
|
|
31
34
|
synchronously (fire-and-forget) instead of asynchronously.
|
|
32
35
|
|
|
36
|
+
### What works in opencode 2.x
|
|
37
|
+
|
|
38
|
+
The v2 plugin API is missing a few surfaces the v1 API had. On 2.x:
|
|
39
|
+
|
|
40
|
+
- **No toasts.** Toasts are dropped on v2 (no publish surface). The
|
|
41
|
+
model-facing nudges they accompanied - watcher and chat wake nudges -
|
|
42
|
+
still arrive; the extraction-result and other TUI-only toasts have no
|
|
43
|
+
replacement.
|
|
44
|
+
- **Plugin state survives reloads.** Editing the plugin (or `opencode
|
|
45
|
+
plugin update`) reloads it, but extraction buffers, watcher
|
|
46
|
+
registrations, and armed wrap-ups are journaled and restored; a process
|
|
47
|
+
restart restores the state of a resumed (`-c`/`-s`) session. Other
|
|
48
|
+
sessions' watcher registrations survive as dormant definitions: resuming
|
|
49
|
+
such a session re-arms its watches on the first message (expired ones
|
|
50
|
+
are dropped and reported), and a live session in the same project gets a
|
|
51
|
+
one-line notice that a dead session's watch will re-arm if resumed.
|
|
52
|
+
- **Extraction children are visible.** The fact-extractor child sessions are
|
|
53
|
+
top-level sessions the host cannot delete, so they accumulate in the
|
|
54
|
+
session picker, and "continue last session" (`-c`) can land in one after
|
|
55
|
+
any session that triggered extraction.
|
|
56
|
+
- **`/thatch/compact` does not auto-compact.** The checklist and memory
|
|
57
|
+
flush run, but the compaction itself cannot be triggered from the plugin
|
|
58
|
+
API - run `/compact` yourself after the wrap-up completes. `/thatch/exit`
|
|
59
|
+
cannot auto-exit for the same reason.
|
|
60
|
+
- **No `-c` session listing.** The chat resume listing degrades.
|
|
61
|
+
|
|
62
|
+
Everything else - tools, memory, nudges, chat, watchers - behaves the
|
|
63
|
+
same on both versions, with one caveat: v2 has no turn-free delivery
|
|
64
|
+
(noReply), so the features that lean on it - the chat transcript echo
|
|
65
|
+
bubble, the session-start reminder, and the post-reload re-attach
|
|
66
|
+
notice - do not render on 2.x. Their model-facing content still
|
|
67
|
+
arrives; only the bubble rendering is lost.
|
|
68
|
+
|
|
33
69
|
Then **prime your project memory** by running `thatch prime` in your project directory.
|
|
34
70
|
This launches an `opencode` session to build an initial map of the code base
|
|
35
71
|
and seed the memory.
|
|
@@ -169,10 +205,13 @@ Thatch gives your agent:
|
|
|
169
205
|
the agent lists them and nothing fires -- you decide when to retry.
|
|
170
206
|
- **Notifications + user config** -- the agent can ping you out-of-band when a
|
|
171
207
|
long-running outcome lands: a desktop banner, a spoken voice
|
|
172
|
-
announcement, or both (macOS and Linux).
|
|
208
|
+
announcement, or both (macOS and Linux). On opencode, thatch also watches
|
|
209
|
+
the session and alerts you automatically when the LLM pauses for your
|
|
210
|
+
input (a question or permission prompt) or finishes a round of real work
|
|
211
|
+
-- bookkeeping rounds stay silent. Preferences live in a
|
|
173
212
|
hand-editable config file (`~/.config/thatch/config.json`) that the agent
|
|
174
|
-
manages through `config_get`/`config_set` -- ask it to change your voice
|
|
175
|
-
|
|
213
|
+
manages through `config_get`/`config_set` -- ask it to change your voice,
|
|
214
|
+
quiet notifications entirely, or tune which alerts fire.
|
|
176
215
|
|
|
177
216
|
Plus **skills** for memory workflows, structured multi-specialist code
|
|
178
217
|
review, review response, plan refinement, change and feature walkthroughs,
|
|
@@ -18,7 +18,7 @@ The extraction nudge or dispatch prompt will give you a session ID. Call `get_ex
|
|
|
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
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
|
-
7. Your final message must be exactly: "Extraction complete."
|
|
21
|
+
7. Your final message must be exactly: "Extraction complete. This is the extractor's final report - a background-task completion, not an extraction nudge; the buffer is drained. Never re-dispatch an extractor because a completion arrived - a new extraction nudge is the only dispatch signal." - do not list or summarize what you saved. The memories are in the store; the parent session does not need a report. The frame rides with the completion on purpose: a bare "Extraction complete." was indistinguishable from nudge traffic mid-loop, and the parent model misread it as new work (the September 2026 re-dispatch loop).
|
|
22
22
|
|
|
23
23
|
## What to extract
|
|
24
24
|
|
|
@@ -20,7 +20,7 @@ This skill is the catch.
|
|
|
20
20
|
|
|
21
21
|
Call `thatch_memory_show` with the label (or accept the memory content directly if dispatched by a coordinator that already read it).
|
|
22
22
|
|
|
23
|
-
Note the
|
|
23
|
+
Note the age line ("This memory was last updated N ago"). A memory that was last updated recently is less likely to be stale than one that has not been touched in months.
|
|
24
24
|
|
|
25
25
|
## Step 2: Classify Claims
|
|
26
26
|
|
|
@@ -111,6 +111,6 @@ If no corrections were needed, report "all claims verified, no updates needed."
|
|
|
111
111
|
|
|
112
112
|
# Proactive Use
|
|
113
113
|
|
|
114
|
-
When `thatch_memory_show` or `thatch_memory_recall` returns a memory
|
|
114
|
+
When `thatch_memory_show` or `thatch_memory_recall` returns a memory whose age line shows it has not been updated in a while (more than a few weeks), consider loading this skill to fact-check it before relying on its code-related claims. The age prose ("This memory was last updated N ago") is included in the memory output for this purpose.
|
|
115
115
|
|
|
116
116
|
This is not mandatory for every old memory. Use judgment: a memory about a stable architectural decision may be fine for months, while a memory about file locations or function names is more likely to drift.
|
package/bin/thatch
CHANGED
|
@@ -49,11 +49,13 @@ function usage(): never {
|
|
|
49
49
|
Full conversation as OpenAI chat-completions JSONL
|
|
50
50
|
thatch session search <query> [--session <id>] [--regex] [--limit N]
|
|
51
51
|
JSONL of matching parts across sessions (decoded content)
|
|
52
|
-
thatch chat list [--stale N|all] Registered chat sessions
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
thatch chat list [--stale N|all] Registered chat sessions. Default shows
|
|
53
|
+
only active (fresh-heartbeat) sessions:
|
|
54
|
+
name, session id, age, status, project,
|
|
55
|
+
topic. --stale adds the stale section:
|
|
56
|
+
N bounds it to sessions seen in the
|
|
57
|
+
last N days (default 1); all shows
|
|
58
|
+
every stale session.
|
|
57
59
|
thatch chat tail [--once] [--limit N|all] [--match RE]
|
|
58
60
|
[--from NAME] [--to NAME] [--since DT] [--until DT]
|
|
59
61
|
Follow cross-session chat as a JSONL
|
|
@@ -415,7 +417,7 @@ async function sessionCommand(rest: string[]): Promise<void> {
|
|
|
415
417
|
// ---------------------------------------------------------------------------
|
|
416
418
|
|
|
417
419
|
/** Column headers for the `chat list` roster, in display order. */
|
|
418
|
-
const CHAT_ROSTER_HEADER = ["NAME", "AGE", "STATUS", "PROJECT", "TOPIC"];
|
|
420
|
+
const CHAT_ROSTER_HEADER = ["NAME", "SESSION ID", "AGE", "STATUS", "PROJECT", "TOPIC"];
|
|
419
421
|
|
|
420
422
|
/**
|
|
421
423
|
* Renders the `chat list` roster as two sections - Active and Stale - each
|
|
@@ -436,6 +438,7 @@ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null
|
|
|
436
438
|
const renderTable = (rows: ChatSessionRow[]) => {
|
|
437
439
|
const cells = (r: ChatSessionRow) => [
|
|
438
440
|
r.name,
|
|
441
|
+
r.session_id,
|
|
439
442
|
humanAge(r.last_seen),
|
|
440
443
|
chatLiveness(r),
|
|
441
444
|
r.project ?? "unknown",
|
|
@@ -447,9 +450,10 @@ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null
|
|
|
447
450
|
const align = (line: string[]) => line.map((c, i) => (i < line.length - 1 ? c.padEnd(widths[i]) : c));
|
|
448
451
|
const header = align(CHAT_ROSTER_HEADER).map((h) => paint("1", h)).join(" ");
|
|
449
452
|
const body = rows.map((r) => {
|
|
450
|
-
const [name, age, status, project, topic] = align(cells(r));
|
|
453
|
+
const [name, sessionID, age, status, project, topic] = align(cells(r));
|
|
451
454
|
return [
|
|
452
455
|
paint("32", name),
|
|
456
|
+
paint("2", sessionID),
|
|
453
457
|
paint("2", age),
|
|
454
458
|
paint(status === "stale" ? "31" : "32", status),
|
|
455
459
|
project,
|
|
@@ -491,21 +495,32 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
491
495
|
// sessions from burying the fresh signal. --stale all restores the
|
|
492
496
|
// unbounded view. A bad value dies loudly rather than silently
|
|
493
497
|
// shrinking the roster.
|
|
494
|
-
|
|
498
|
+
// Default: active sessions only. --stale [N|all] opts into the
|
|
499
|
+
// stale section - bare --stale keeps the one-day display cap, N
|
|
500
|
+
// bounds it, all removes it. The value is parsed by hand (not
|
|
501
|
+
// flagValue) because bare --stale is meaningful.
|
|
502
|
+
const showStale = restArgs.includes("--stale");
|
|
503
|
+
const staleIdx = restArgs.indexOf("--stale");
|
|
504
|
+
const staleNext = showStale ? restArgs[staleIdx + 1] : undefined;
|
|
495
505
|
let staleCapMs: number | null = 24 * 60 * 60 * 1000;
|
|
496
|
-
if (
|
|
497
|
-
if (
|
|
498
|
-
else if (!/^\d+(\.\d+)?$/.test(
|
|
499
|
-
console.error(`error: --stale expects a positive number of days or "all", got "${
|
|
506
|
+
if (staleNext !== undefined) {
|
|
507
|
+
if (staleNext === "all") staleCapMs = null;
|
|
508
|
+
else if (!/^\d+(\.\d+)?$/.test(staleNext) || Number(staleNext) <= 0) {
|
|
509
|
+
console.error(`error: --stale expects a positive number of days or "all", got "${staleNext}"`);
|
|
500
510
|
process.exit(1);
|
|
501
|
-
} else staleCapMs = Number(
|
|
511
|
+
} else staleCapMs = Number(staleNext) * 24 * 60 * 60 * 1000;
|
|
502
512
|
}
|
|
503
|
-
|
|
513
|
+
let sessions = db.listChatSessions();
|
|
514
|
+
if (!showStale) sessions = sessions.filter((r) => chatLiveness(r) === "fresh");
|
|
504
515
|
if (sessions.length === 0) {
|
|
505
|
-
console.log(
|
|
516
|
+
console.log(
|
|
517
|
+
showStale
|
|
518
|
+
? "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register."
|
|
519
|
+
: "No active sessions. (Stale sessions are hidden; run with --stale to include them.)",
|
|
520
|
+
);
|
|
506
521
|
break;
|
|
507
522
|
}
|
|
508
|
-
console.log(formatChatRoster(sessions, staleCapMs));
|
|
523
|
+
console.log(formatChatRoster(sessions, showStale ? staleCapMs : null));
|
|
509
524
|
break;
|
|
510
525
|
}
|
|
511
526
|
|
package/docs/dev/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Development
|
|
2
2
|
|
|
3
|
+
Feature docs describe behavior in opencode v1 `client.*` terms; the opencode v2 equivalents and degrades are catalogued in [features/opencode-plugin.md](features/opencode-plugin.md).
|
|
4
|
+
|
|
3
5
|
## Architecture
|
|
4
6
|
|
|
5
7
|
Thatch has three integration paths sharing a common core:
|
|
@@ -38,8 +40,21 @@ Shared core
|
|
|
38
40
|
└── vector-math.ts → blobToVector + cosineSimilarity helpers
|
|
39
41
|
|
|
40
42
|
OpenCode plugin path
|
|
41
|
-
├── index.ts → plugin entry:
|
|
42
|
-
|
|
43
|
+
├── index.ts → dual-shape plugin entry: merged default export
|
|
44
|
+
│ ({id, setup, server}) loads on opencode v1 AND v2;
|
|
45
|
+
│ lazy-imports the adapter matching the host
|
|
46
|
+
├── opencode/v1.ts → v1 adapter: @opencode-ai/plugin hooks object
|
|
47
|
+
├── opencode/v2.ts → v2 adapter: @opencode/plugin promise-context domains
|
|
48
|
+
├── capabilities.ts → HostCapabilities seam: the host operations the
|
|
49
|
+
│ runtime needs; capabilitiesFromClient bridges the
|
|
50
|
+
│ v1 SDK client
|
|
51
|
+
├── runtime.ts → shared plugin runtime: nudges, system prompt
|
|
52
|
+
│ injection, session events, extraction triggers,
|
|
53
|
+
│ child-session bookkeeping (host-agnostic)
|
|
54
|
+
├── os-args.ts → pure argv/OS-command-line helpers (startup session
|
|
55
|
+
│ resolution; SDK-free)
|
|
56
|
+
├── tools.ts → thin opencode tool() wrappers (v1); tool-defs.ts
|
|
57
|
+
│ owns buildCoreContext (shared per-call context)
|
|
43
58
|
├── extraction.ts → in-memory ring buffer + shared payload builders
|
|
44
59
|
├── watchers.ts → in-memory watcher registry + poller (opencode-only,
|
|
45
60
|
│ event-driven PR notifications delivered as prompts)
|
|
@@ -67,9 +82,13 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
67
82
|
| Module | Responsibility |
|
|
68
83
|
|--------|---------------|
|
|
69
84
|
| `tool-defs.ts` | **Single source of truth** for all tools. Each tool has a name, description, zod schema (args), and execute function. Framework-agnostic — neither opencode nor MCP specific. |
|
|
70
|
-
| `tools.ts` | Thin opencode wrappers.
|
|
85
|
+
| `tools.ts` | Thin opencode wrappers. `buildCoreContext` constructs the shared per-call context; `createTools` wraps each tool-def in opencode's `tool()` with a `thatch_` prefix (v1 only). |
|
|
71
86
|
| `mcp.ts` | Stdio JSON-RPC 2.0 server. Compiles zod schemas to JSON Schema via `z.toJSONSchema()` for `tools/list`. Validates args via `z.object().parse()` in `tools/call`. All logging to stderr (stdout is the transport). |
|
|
72
|
-
| `index.ts` | OpenCode plugin entry
|
|
87
|
+
| `index.ts` | Dual-shape OpenCode plugin entry: the merged default export (`{ id, setup, server }`) loads on opencode v1 (reads `default.server`) and v2 (reads `default.setup`, strips excess keys). Lazy-imports the host adapter so each host's SDK resolves only under its own runtime. |
|
|
88
|
+
| `opencode/v1.ts` + `opencode/v2.ts` | Host adapters. v1 builds HostCapabilities from the PluginInput client and returns the hooks object; v2 registers through the promise-context domains (ToolEditor, session.hook, event.subscribe) and degrades the surfaces v2 lacks. |
|
|
89
|
+
| `capabilities.ts` | The `HostCapabilities` seam: the host operations the shared runtime needs (session create/prompt/get/list/messages/delete, toast, compaction trigger, app exit). `capabilitiesFromClient` bridges the v1 SDK client. |
|
|
90
|
+
| `runtime.ts` | Shared plugin runtime: wires DB, model, extraction; the nudge tiers, system prompt injection, session event handling, child-session bookkeeping, wrap-up resolution. Host-agnostic: consumed by both adapters. |
|
|
91
|
+
| `os-args.ts` | Pure argv / OS-command-line helpers for startup session resolution (`-s`/`-c`). SDK-free. |
|
|
73
92
|
| `setup.ts` | `thatch setup --claude` / `--cursor` installer. Writes MCP config (`.mcp.json` / `.cursor/mcp.json`), appends to CLAUDE.md / AGENTS.md (idempotent), installs hooks in settings.json / hooks.json, installs skills. |
|
|
74
93
|
| `hygiene.ts` | Hygiene report: pending dedup pairs, stale count, orphaned branch memories. Shared by the plugin's session-start hook and the CLI's `thatch reminder` command. |
|
|
75
94
|
| `git.ts` | Parse `owner/repo` from git remote. Worktree-safe fallback chain, plus recovery from the `repo_paths` cache when the directory is deleted, and the spawn-cwd fallback. See [features/repo-identity.md](features/repo-identity.md). |
|
|
@@ -78,7 +97,8 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
78
97
|
| `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`. |
|
|
79
98
|
| `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`. |
|
|
80
99
|
| `config.ts` | User config file (`~/.config/thatch/config.json`, beside the DB): Zod section schemas (strict — unknown keys fail validation), load with missing/invalid fallback to defaults, field-level merge, atomic save (temp + rename). Backs the `config_get`/`config_set` tools. |
|
|
81
|
-
| `notify.ts` | Out-of-band notification dispatch: darwin (`osascript` banner + `/usr/bin/say` voice), linux (`notify-send` + `spd-say`/`espeak`), other platforms report unsupported. Injectable spawner (`CoreContext.spawner`) so tests never fire real commands. Backs the `notify_user` tool. |
|
|
100
|
+
| `notify.ts` | Out-of-band notification dispatch: darwin (`osascript` banner + `/usr/bin/say` voice), linux (`notify-send` + `spd-say`/`espeak`), other platforms report unsupported. Injectable spawner (`CoreContext.spawner`) so tests never fire real commands. Backs the `notify_user` tool and the alert state machine. |
|
|
101
|
+
| `alerts.ts` | LLM alert state machine (opencode-only): pause/done/error decisions from session events, per-session dedup (no timer-based debounce - one alert per busy->idle transition, per request id for pauses). Fed by `runtime.ts`'s event hook; delivers through `notify.ts`. Plain memory by design (a reload loses at most one transition). See [../plans/llm-alerts.md](../plans/llm-alerts.md). |
|
|
82
102
|
| `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. |
|
|
83
103
|
| `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Three sources: GitHub PRs and branches via `gh api` (injected `GhRunner`), and local shell commands via `bash -c` (injected `CommandRunner`) run as condition variables - only the exit code is read, never the command's output. Diffs against last-seen state, queues events (pointer data plus machine status like check conclusions and exit codes, never external content) per session, and delivers them through a plugin-supplied callback when the session is idle. The `pollSeconds` getter surfaces the poll cadence to tool output and notifications. Process-lifetime state by design - no SQLite. |
|
|
84
104
|
| `chat.ts` | Cross-session chat between opencode sessions on one machine (opencode-only). `ChatStore` holds the shared directory and inbox in SQLite so any process can read/write them; `ChatPoller` heartbeats hosted sessions and delivers wake prompts through an injected callback when the recipient is idle - only the recipient's host process delivers, so there is no cross-process double-delivery. Heartbeat staleness flags crashed sessions; a per-recipient nudge rate cap is the anti-loop hard brake. See [features/cross-session-chat.md](features/cross-session-chat.md). |
|
|
@@ -96,7 +116,9 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
96
116
|
|
|
97
117
|
## Plugin hooks
|
|
98
118
|
|
|
99
|
-
`
|
|
119
|
+
The v1 adapter (`src/opencode/v1.ts`) registers these opencode integration
|
|
120
|
+
points; the v2 adapter routes its domain hooks into the same shared runtime
|
|
121
|
+
methods. The v1 hook shapes:
|
|
100
122
|
|
|
101
123
|
| Hook | What it does |
|
|
102
124
|
|------|-------------|
|
|
@@ -105,7 +127,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
105
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). |
|
|
106
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. |
|
|
107
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. |
|
|
108
|
-
| `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. 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). |
|
|
130
|
+
| `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. 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. |
|
|
109
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. |
|
|
110
132
|
| `dispose` | Stops the version checker, the watcher poller, and the chat poller, closes the DB. |
|
|
111
133
|
|
|
@@ -157,11 +179,15 @@ Two of these hooks were dead for weeks because failures were invisible.
|
|
|
157
179
|
10. **Background completion narration is suppressed, not prevented.** When a
|
|
158
180
|
background sub-agent completes, opencode injects a `<task_result>` block
|
|
159
181
|
into the parent session and triggers a full model generation. Thatch
|
|
160
|
-
cannot cancel this turn (no pre-response hook).
|
|
161
|
-
fact-extractor skill's return value is
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
182
|
+
cannot cancel this turn (no pre-response hook). The mitigations: the
|
|
183
|
+
fact-extractor skill's return value is a fixed string so the injected
|
|
184
|
+
block has nothing to narrate AND the string self-identifies as a
|
|
185
|
+
completion, not an extraction nudge (a bare "Extraction complete." was
|
|
186
|
+
misread mid-loop as new nudge traffic and re-dispatched - the September
|
|
187
|
+
2026 re-dispatch loop); the system prompt's "Background Task
|
|
188
|
+
Completions" section instructs the model not to narrate completions or
|
|
189
|
+
treat them as approval to act, and that a completion is never a
|
|
190
|
+
re-dispatch signal.
|
|
165
191
|
11. **Prediction engine is a statistical model, not an LLM call.** The query
|
|
166
192
|
(embed prompt, cosine-match against matchers, score linked predictions)
|
|
167
193
|
is mechanical — same shape as `thatch_memory_recall` but against different
|
|
@@ -187,7 +213,7 @@ Two of these hooks were dead for weeks because failures were invisible.
|
|
|
187
213
|
## Data flow
|
|
188
214
|
|
|
189
215
|
```text
|
|
190
|
-
thatch_memory_remember(label
|
|
216
|
+
thatch_memory_remember(label?, content)
|
|
191
217
|
→ model.passageEmbed("# label\n\ncontent") → Float32Array
|
|
192
218
|
→ db.findSimilar(store, embedding) — write-time collision check (no telemetry)
|
|
193
219
|
→ db.remember(store, label, content, embedding, model.name, opts)
|
|
@@ -128,8 +128,9 @@ Parameters: `behavior` (statement, semantic match at cosine >= 0.85),
|
|
|
128
128
|
|
|
129
129
|
### Default behavior seeding (`src/seed-behaviors.ts`)
|
|
130
130
|
|
|
131
|
-
Runs at startup in both the opencode plugin (`src/
|
|
132
|
-
server (`src/mcp.ts`), after DB and
|
|
131
|
+
Runs at startup in both the opencode plugin (`src/runtime.ts`, shared by
|
|
132
|
+
the v1 and v2 adapters) and the MCP server (`src/mcp.ts`), after DB and
|
|
133
|
+
model initialization.
|
|
133
134
|
|
|
134
135
|
Seeds multiple default behaviors into the global store — session wrap-up
|
|
135
136
|
(check for uncommitted changes, untracked files, stale artifacts), work
|
|
@@ -13,7 +13,7 @@ The CI gate is the never-merge-broken guard before a release. All three must pas
|
|
|
13
13
|
|
|
14
14
|
## Publish pipeline (.github/workflows/publish.yml)
|
|
15
15
|
|
|
16
|
-
Triggered by pushing a `v*` tag. Publishes to npm via OIDC trusted publishing — no stored npm token.
|
|
16
|
+
Triggered by pushing a `v*` tag. Publishes to npm via OIDC trusted publishing — no stored npm token. The tag push does not trigger ci.yml, so the publish workflow re-runs the full CI gate (typecheck, tests, markdownlint) across the same dual-Bun matrix as its own release gate.
|
|
17
17
|
|
|
18
18
|
Key details:
|
|
19
19
|
- `id-token: write` permission lets npm authenticate through GitHub's OIDC exchange
|
|
@@ -37,7 +37,7 @@ During summary generation, tools are blocked. A nudge that instructs the agent t
|
|
|
37
37
|
|
|
38
38
|
## Source files
|
|
39
39
|
|
|
40
|
-
- `src/
|
|
40
|
+
- `src/runtime.ts` -- all four hooks/events (experimental.session.compacting, experimental.compaction.autocontinue, session.compacted event, chat.message compaction guard on v1; session.hook("compaction")/("prompt") on v2), shared by both adapters
|
|
41
41
|
- `src/prompts.ts` -- compactionContext function
|
|
42
42
|
|
|
43
43
|
## Key invariants
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Cross-Session Chat
|
|
2
2
|
|
|
3
|
+
Unless noted, `client.*` names below are the opencode v1 mapping; the v2 equivalents and degrades are in [opencode-plugin.md](opencode-plugin.md).
|
|
4
|
+
|
|
3
5
|
Cross-session chat lets independent opencode sessions on one machine message
|
|
4
6
|
each other through thatch: sessions join a shared directory (automatically,
|
|
5
7
|
under assigned names), see each other in the list, and messages land in
|
|
@@ -189,7 +191,14 @@ rather than one so a single late poll cycle does not flap the roster.
|
|
|
189
191
|
Heartbeat age is the ONLY liveness signal, by design. A host process id
|
|
190
192
|
cannot serve: the same session is re-hosted by a new process on every
|
|
191
193
|
`-s` resume, so a pid stamped on the row lies as soon as a different
|
|
192
|
-
harness beats it (it says dead while the heartbeat proves alive).
|
|
194
|
+
harness beats it (it says dead while the heartbeat proves alive). RESTART
|
|
195
|
+
grace: a new daemon re-hosts the dead one's hosted set under a bounded
|
|
196
|
+
window (`CHAT_REHOST_GRACE_MS`, 24h, `THATCH_CHAT_REHOST_GRACE_MINUTES`
|
|
197
|
+
to override) - open TUI tabs reconnect but emit no events until typed
|
|
198
|
+
into, so without the grace they would read stale while alive-idle and
|
|
199
|
+
mail to them would not wake the tab. Sessions that emit activity
|
|
200
|
+
graduate to permanent hosting; still-silent ones (closed tabs) age out
|
|
201
|
+
at the grace and go stale. `isStale`
|
|
193
202
|
(src/chat.ts) is the single rule; `chatLiveness`, the `chat_send` note,
|
|
194
203
|
and the broadcast skip all call it, so they cannot disagree. `chat_list`
|
|
195
204
|
groups the result into Active and Stale sections - the stale section's
|
|
@@ -394,8 +403,8 @@ them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
|
|
|
394
403
|
chat_name_counters and the auto column), the NOCASE collation migration
|
|
395
404
|
and topic column migration, delegated methods
|
|
396
405
|
- `src/tool-defs.ts` - the chat tool definitions
|
|
397
|
-
- `src/
|
|
398
|
-
registration (`osProcessArgs`, `startupSessionId`), idle
|
|
406
|
+
- `src/runtime.ts` - poller construction, delivery closure, `-s` startup
|
|
407
|
+
registration (`osProcessArgs`, `startupSessionId` from `src/os-args.ts`), idle
|
|
399
408
|
auto-registration + topic refresh, idle flush,
|
|
400
409
|
session.deleted unregister, dispose, transcript echo in
|
|
401
410
|
tool.execute.after
|
|
@@ -36,7 +36,7 @@ directly for conversation-derived knowledge.
|
|
|
36
36
|
After every tool execution, non-`thatch_*`, non-`skill`, non-`task` tool
|
|
37
37
|
calls are buffered for later extraction.
|
|
38
38
|
|
|
39
|
-
**opencode** — `tool.execute.after` hook in `src/
|
|
39
|
+
**opencode** — `tool.execute.after` hook (v1) / `tool.hook` (v2) in `src/runtime.ts`:
|
|
40
40
|
|
|
41
41
|
- In-memory ring buffer per session (`ExtractionPipeline` in
|
|
42
42
|
`src/extraction.ts`)
|
|
@@ -50,6 +50,14 @@ calls are buffered for later extraction.
|
|
|
50
50
|
itself; `skill`/`task` meta-tools would create a feedback loop (extraction
|
|
51
51
|
triggers a skill load, which gets buffered, which triggers another
|
|
52
52
|
extraction)
|
|
53
|
+
- Code Mode `execute` calls are **unwrapped before filtering**
|
|
54
|
+
(`unwrapExecuteThatchCalls` in `src/extraction.ts`): when the call's code
|
|
55
|
+
invokes `tools.thatch_*` tools, the wrapped tools' hook semantics run
|
|
56
|
+
(ack, drain, metrics) and the call itself is never buffered. Matching on
|
|
57
|
+
the outer tool name alone re-queued the pipeline's own dispatch/ack
|
|
58
|
+
traffic every cycle — each extraction run queued the next one, producing
|
|
59
|
+
an infinite dispatch/ack loop (observed live, September 2026). Execute
|
|
60
|
+
calls that touch no thatch tools buffer as normal.
|
|
53
61
|
|
|
54
62
|
**MCP hosts** — `bin/thatch`, `src/extract-queue.ts`:
|
|
55
63
|
|
|
@@ -234,7 +242,7 @@ continues working while the child extracts.
|
|
|
234
242
|
|------|----------------|
|
|
235
243
|
| `src/extraction.ts` | In-memory ring buffer (`ExtractionPipeline`), shared payload builders (`buildExtractionPayload`, `deriveTitle`, `summarizeArgs`) |
|
|
236
244
|
| `src/extract-queue.ts` | File-backed JSONL queue for MCP hosts |
|
|
237
|
-
| `src/
|
|
245
|
+
| `src/runtime.ts` | opencode hooks: `tool.execute.after`, `session.status` idle, `session.created`, `session.error`, `session.deleted`, `chat.message` (nudge tier 1) -- shared by the v1/v2 adapters |
|
|
238
246
|
| `bin/thatch` | `buffer-batch`, `buffer-tool`, `flush-tools` subcommands |
|
|
239
247
|
| `src/prompts.ts` | `extractionNudge` (with escalation), `extractionDirectPrompt` |
|
|
240
248
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Hygiene System
|
|
2
2
|
|
|
3
|
+
Unless noted, `client.*` names below are the opencode v1 mapping; the v2 equivalents and degrades are in [opencode-plugin.md](opencode-plugin.md).
|
|
4
|
+
|
|
3
5
|
Store maintenance signals surfaced at session start. The plugin never deletes
|
|
4
6
|
memories itself — it surfaces problems and lets the agent tend the store.
|
|
5
7
|
|
|
@@ -52,7 +54,7 @@ instructions for the agent to tend the store when convenient.
|
|
|
52
54
|
|
|
53
55
|
`hygieneReport(db, repo, worktree)` in `src/hygiene.ts` is shared by:
|
|
54
56
|
|
|
55
|
-
- The opencode plugin's session-start hook (`src/
|
|
57
|
+
- The opencode plugin's session-start hook (`src/runtime.ts`)
|
|
56
58
|
- The CLI's `thatch reminder` and `thatch hygiene` subcommands (`bin/thatch`)
|
|
57
59
|
|
|
58
60
|
## Interactions with other features
|
|
@@ -58,7 +58,7 @@ The "global" store always exists — it is inserted at schema init (`INSERT OR I
|
|
|
58
58
|
### Write path (memory_remember)
|
|
59
59
|
|
|
60
60
|
```text
|
|
61
|
-
agent calls memory_remember(label
|
|
61
|
+
agent calls memory_remember(label?, content, opts) - label is derived from the content's first heading or line when omitted
|
|
62
62
|
→ model.passageEmbed("# label\n\ncontent") → Float32Array
|
|
63
63
|
→ db.findSimilar(store, embedding, { excludeSlug }) → write-time collision check
|
|
64
64
|
→ db.remember(store, label, content, embedding, model.name, opts)
|
|
@@ -32,7 +32,7 @@ wrapping---prefixing, transport, session hooks---lives outside the core.
|
|
|
32
32
|
|
|
33
33
|
## The three paths
|
|
34
34
|
|
|
35
|
-
### opencode plugin (`src/
|
|
35
|
+
### opencode plugin (`src/opencode/v1.ts` + `src/opencode/v2.ts`)
|
|
36
36
|
|
|
37
37
|
Runs inside opencode's Bun runtime as a loaded plugin. No setup command is
|
|
38
38
|
needed---opencode discovers the plugin from `opencode.json` and loads it at
|
|
@@ -228,7 +228,9 @@ the opencode-only skills require sub-agent dispatch or in-process hooks.
|
|
|
228
228
|
|
|
229
229
|
| File | Role |
|
|
230
230
|
|------|------|
|
|
231
|
-
| `src/index.ts` |
|
|
231
|
+
| `src/index.ts` | dual-shape plugin entry (merged default export loads on opencode v1 and v2) |
|
|
232
|
+
| `src/opencode/v1.ts` + `src/opencode/v2.ts` | host adapters: hooks (v1) vs promise-context domains (v2) |
|
|
233
|
+
| `src/runtime.ts` | shared plugin runtime: nudges, system prompt injection, session events |
|
|
232
234
|
| `src/mcp.ts` | MCP server (shared by Claude Code and Cursor)---stdio JSON-RPC, tool dispatch |
|
|
233
235
|
| `src/setup.ts` | Setup installer for Claude Code and Cursor---markers, hooks, skills |
|
|
234
236
|
| `src/prompts.ts` | All three system prompt variants---`systemPrompt()`, `claudeInstructions()`, `cursorInstructions()` |
|