@jeffober/thatch 0.1.44 → 0.1.46

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.
@@ -66,6 +66,12 @@ substitution, matching the existing systemPrompt/mcpInstructions convention.
66
66
  The wrap-up commands (`compact`, `exit`) stay as-is; their checklist text
67
67
  becomes a core reused by both templates (already true via `sharedChecklist`).
68
68
 
69
+ `/thatch/extract` interacts with opencode's direct extraction (parent idle
70
+ spawns the extractor child; the nudge is only a fallback). The action must
71
+ reuse the same accept/complete semantics and must not fight the `extracting`
72
+ set: if direct extraction is already running for the session, the action
73
+ should report that and stop rather than double-dispatch.
74
+
69
75
  ## Host parity
70
76
 
71
77
  Commands are markdown or MCP prompts, synced per host. Behavior parity is the
@@ -74,30 +80,52 @@ goal; format differences are documented, not hidden.
74
80
  - **opencode** (existing): `installOpencodeCommands` syncs markdown to
75
81
  `~/.config/opencode/command/thatch/` on every plugin load.
76
82
  - **Claude Code**: native user-level slash commands read markdown from
77
- `~/.claude/commands/thatch/` with the same `$ARGUMENTS` substitution.
78
- `thatch setup --claude` gains a sync step (idempotent content-compare, same
79
- as the opencode installer). Differences: no plugin, so no
83
+ `~/.claude/commands/thatch/` (user scope) or `.claude/commands/thatch/`
84
+ (project scope), with the same `$ARGUMENTS` substitution. The sync lives in
85
+ `setup.ts` alongside the existing installers and follows their patterns:
86
+ idempotent content-compare like `installSkills`, both global and local
87
+ scopes, reported in the setup output next to the skills report.
88
+ (Deviation from the draft below: no `checkSetup` entry. That function
89
+ answers one question - "is thatch set up at all" - and command files
90
+ self-heal on every setup run, so flagging their absence as a startup
91
+ warning would be noise.) Differences: no plugin, so no
80
92
  `command.execute.before` arming -- greenlight-token wrap-up commands do not
81
- ship to Claude Code; and tool names use the `mcp__thatch__` prefix.
93
+ ship to Claude Code; and tool names use the `mcp__thatch__` prefix. MCP
94
+ hosts do have hooks now (session-start reminders, chat-wake stop hooks), so
95
+ hook wiring is not the barrier; the missing piece is only the plugin's TUI
96
+ control routes.
82
97
  - **Cursor**: no file-based commands. The stdio MCP server (`mcp.ts`) gains
83
98
  MCP Prompts support (`prompts/list`, `prompts/get`), which Cursor surfaces
84
- as slash commands. Prompt arguments use MCP's
99
+ as slash commands. The dispatch switch currently handles `initialize`,
100
+ `tools/list`, `tools/call`, and `ping`; prompts get a `compileTools`-style
101
+ `compilePrompts()` and two new cases. Prompt arguments use MCP's
85
102
  `{{argument}}`-style templating, so each action core gets an MCP-prompt
86
103
  rendering alongside the markdown rendering.
87
104
 
88
105
  README gains "What works in Claude Code" and "What works in Cursor" sections
89
- listing per-host support without restating the shared docs.
106
+ listing per-host support without restating the shared docs. The parity matrix
107
+ in `docs/dev/mcp-parity.md` gets an Actions row next to the existing
108
+ Wrap-up commands row.
90
109
 
91
110
  ## Session ID plumbing
92
111
 
93
- `get_extraction_payload` and `extraction_done` take `session_id`. Today the
94
- nudge carries the ID; an action has no way to know it.
112
+ Since the plan was drafted, `HostToolContext` (tool-defs.ts) landed: every
113
+ tool's `execute` receives the invoking session's ID as a third parameter on
114
+ the opencode path, and the watch tools already consume it. `extraction_done`
115
+ also already has the optional `session_id` with the omit-vs-parent rule in
116
+ its description. The remaining work is narrower than first planned:
117
+
118
+ - `get_extraction_payload`: `session_id` becomes optional. `execute` uses
119
+ `args.session_id ?? host?.sessionID`. On the opencode path the wrapper
120
+ supplies the invoking session, so omitted means "this session."
121
+ - MCP path: the host context is omitted (no session concept), so an omitted
122
+ `session_id` returns a clear error telling the model to pass the parent
123
+ session's ID from the extraction nudge.
95
124
 
96
- - opencode path: the thin wrappers in `tools.ts` receive the invoking
97
- session's ID from the host. `session_id` becomes optional there; omitted
98
- means "the current session."
99
- - MCP path: there is no session context in a tool call, so `session_id`
100
- stays required. The tool description and docs state this plainly.
125
+ One wrinkle to document: the zod arg shape is shared between hosts, so
126
+ optionality is global -- you cannot make the arg required on MCP and optional
127
+ on opencode at the schema level. Enforcement happens in `execute` per host,
128
+ and the arg description must carry the rule so the model self-corrects.
101
129
 
102
130
  The rule the LLM must apply, stated everywhere the tools are described:
103
131
 
@@ -105,10 +133,10 @@ The rule the LLM must apply, stated everywhere the tools are described:
105
133
  > parent session's `session_id` when running inside a sub-agent that was
106
134
  > dispatched to process another session's extraction queue.
107
135
 
108
- This rule appears in: the tool descriptions in `tool-defs.ts`, the extraction
136
+ This rule appears in: the arg descriptions in `tool-defs.ts`, the extraction
109
137
  prompt core (used by both nudge and action envelopes), the fact-extractor
110
- skill, and the dev docs. Tests cover both paths: omit-resolves-to-current and
111
- explicit-parent-ID (the existing sub-agent drain bug's regression path).
138
+ skill, and the dev docs. Tests cover both paths: omit-resolves-to-current
139
+ and explicit-parent-ID (the existing sub-agent drain bug's regression path).
112
140
 
113
141
  ## Decisions
114
142
 
@@ -127,18 +155,25 @@ explicit-parent-ID (the existing sub-agent drain bug's regression path).
127
155
  - `tests/commands.test.ts` (or the existing plugin tests): command-file
128
156
  content assertions per host, including that each action's markdown contains
129
157
  its core text verbatim.
158
+ - Claude setup tests: command sync writes, updates only on content change,
159
+ and `checkSetup` flags a missing or stale command file.
130
160
  - MCP tests: `prompts/list` returns every action; `prompts/get` interpolates
131
- arguments.
132
- - Plugin tests: `get_extraction_payload` without `session_id` resolves to the
133
- invoking session; with an explicit parent ID, drains the parent's queue.
161
+ arguments; an omitted-session-ID `get_extraction_payload` call errors with
162
+ the pass-the-parent-ID message (no host context on the MCP path).
163
+ - Plugin tests: `get_extraction_payload` without `session_id` resolves via
164
+ the host context to the invoking session; with an explicit parent ID,
165
+ drains the parent's queue (the existing sub-agent drain bug's regression
166
+ path).
134
167
  - Parity guard: a test asserts every opencode command def has a
135
168
  corresponding MCP prompt (and vice versa) except the documented exclusions
136
169
  (wrap-up greenlight commands on non-opencode hosts).
137
170
 
138
171
  ## Docs
139
172
 
140
- - `docs/dev/commands.md` (two-tier: dev doc + user-facing README section):
173
+ - `docs/dev/features/commands.md` and `docs/user/commands.md` (two-tier):
141
174
  action list, host parity matrix, session ID rule.
175
+ - `docs/dev/mcp-parity.md`: Actions row in the parity matrix; revisit the
176
+ Wrap-up commands row if greenlight arming ever lands on MCP hosts.
142
177
  - README: "What works in Claude Code" / "What works in Cursor" sections.
143
178
  - QA use cases: one auto use case for `/thatch/defrag` end-to-end, one for
144
179
  `/thatch/extract` with omitted session ID.
@@ -19,6 +19,7 @@ This README is the overview. Each feature has its own guide:
19
19
  - [default-behaviors.md](default-behaviors.md): what ships automatically
20
20
  - [hygiene.md](hygiene.md): store maintenance signals
21
21
  - [deduplication.md](deduplication.md): duplicate detection and resolution
22
+ - [commands.md](commands.md): `/thatch/*` slash commands (on-demand actions and wrap-ups)
22
23
  - [skills.md](skills.md): structured workflow skills
23
24
  - [code-review.md](code-review.md): multi-agent code review pipeline
24
25
  - [setup.md](setup.md): installation and configuration
@@ -242,7 +243,10 @@ Beyond the tools, thatch hooks into opencode itself:
242
243
  agent listed, and you re-run the command once they're handled. Text typed
243
244
  after the command reaches the agent first, labeled as a `User Message`
244
245
  section ahead of the checklist (say goodbye or hand off context:
245
- `/thatch/exit Good work - see you tomorrow`).
246
+ `/thatch/exit Good work - see you tomorrow`). Alongside the wrap-ups,
247
+ on-demand actions (`/thatch/defrag`, `/thatch/extract`, `/thatch/hygiene`,
248
+ `/thatch/reflect`) run the memory-maintenance behaviors whenever you want,
249
+ not just when a nudge fires -- see [commands.md](commands.md).
246
250
  A greenlit `/thatch/exit` also unregisters the session from the chat
247
251
  directory, so other sessions stop sending it mail. The commands
248
252
  self-install into `~/.config/opencode/command/thatch/` at plugin startup;
package/docs/user/cli.md CHANGED
@@ -46,6 +46,7 @@ Read-only access to the chat directory your opencode sessions share
46
46
 
47
47
  ```bash
48
48
  thatch chat list # who is registered: name, age, project, topic
49
+ thatch chat list --stale all # also show stale sessions older than a day
49
50
  thatch chat tail # follow sent and read events, live (JSONL)
50
51
  thatch chat tail --once # print the last 20 messages and exit
51
52
  thatch chat tail --once --limit 50 # a longer backlog (or --limit all)
@@ -59,9 +60,15 @@ Stale, each as aligned columns under a header row: how long since its
59
60
  last heartbeat, its status (`fresh` or `stale` for opencode sessions;
60
61
  `active` or `idle` for Claude Code and Cursor sessions, which only beat
61
62
  when a prompt runs), its project, and its topic (the session's title) -
62
- the fastest way to answer "which session should I talk to about X?" A
63
+ the fastest way to answer "which session should I talk to about X?"
64
+ Rows are grouped by project (alphabetical; sessions with no project
65
+ last), most recently seen first within each project. A
63
66
  stale session has missed two heartbeats, about a minute: the opencode
64
- process hosting it has probably stopped. Color is added only when the
67
+ process hosting it has probably stopped. The stale section shows rows
68
+ up to one day old by default; older ones collapse into a hidden-count
69
+ note so long-dead sessions cannot bury the fresh signal. Pass
70
+ `--stale N` (days) to widen the window or `--stale all` to see every
71
+ stale row. Color is added only when the
65
72
  output is a terminal; a pipe prints plain aligned text.
66
73
 
67
74
  `chat tail` prints the conversation as a JSONL event log: one JSON
@@ -160,7 +167,9 @@ thatch setup --claude [--cursor] [--global] # install config + hooks + skills
160
167
 
161
168
  ## Limitations
162
169
 
163
- - Requires `bun` on PATH. Thatch does not bundle its own runtime.
170
+ - For non-Nix installs, `bun` must be installed and on PATH. Thatch does
171
+ not bundle its own runtime (the Nix package bundles it; see the
172
+ README's Nix section).
164
173
  - `search` is limited to 10 results. There is no pagination.
165
174
  - `prime` requires an external CLI (`opencode`, `agent`, or `claude`)
166
175
  on PATH. If none is found, it exits with an error.
@@ -0,0 +1,50 @@
1
+ # Slash commands
2
+
3
+ Thatch ships `/thatch/*` commands you run yourself, on top of the nudges the
4
+ plugin fires on its own schedule. Nudges and commands share the same
5
+ instruction text, so a behavior works the same whether it fires automatically
6
+ or you invoke it.
7
+
8
+ ## Commands
9
+
10
+ - `/thatch/defrag` — find duplicate-candidate memory pairs and clusters,
11
+ classify each one, consolidate duplicates, and mark the rest checked.
12
+ Text after the command narrows the scope (for example, a store name).
13
+ - `/thatch/extract` — drain the extraction queue now, instead of waiting
14
+ for the next nudge or idle-time extraction. opencode only.
15
+ - `/thatch/hygiene` — run the hygiene report and act on it: stale
16
+ memories, memories scoped to deleted branches, and pending dedup pairs.
17
+ The agent asks before forgetting anything that may still matter.
18
+ - `/thatch/reflect` — run the session-reflection skill to persist what the
19
+ current session learned.
20
+ - `/thatch/compact` and `/thatch/exit` — the wrap-up commands. They run a
21
+ pre-flight checklist (flush extraction, finish promised memory writes,
22
+ surface loose ends) before a compaction or an exit, gated by a greenlight
23
+ token. opencode only; the greenlight mechanics are described in the
24
+ [user guide overview](README.md).
25
+
26
+ ## Host support
27
+
28
+ | Command | opencode | Claude Code | Cursor |
29
+ |---|---|---|---|
30
+ | `/thatch/defrag` | yes | yes | yes (as an MCP prompt) |
31
+ | `/thatch/extract` | yes | no | no |
32
+ | `/thatch/hygiene` | yes | yes | yes (as an MCP prompt) |
33
+ | `/thatch/reflect` | yes | yes | yes (as an MCP prompt) |
34
+ | `/thatch/compact`, `/thatch/exit` | yes | no | no |
35
+
36
+ Why the gaps: `/thatch/extract` needs the invoking session's ID, which only
37
+ the opencode plugin can supply. The wrap-up commands trigger TUI actions
38
+ through the plugin. Cursor has no file-based commands; its prompts come from
39
+ the MCP server (`prompts/list` and `prompts/get`).
40
+
41
+ ## Installation
42
+
43
+ - **opencode**: installed by the plugin on every load.
44
+ - **Claude Code**: installed by `thatch setup --claude` into the same scope
45
+ as the skills (`.claude/commands/thatch/` project-local, or the Claude
46
+ config dir for `--global`). Re-run setup to refresh after an upgrade.
47
+
48
+ Tool names inside the command text follow each host's spelling
49
+ (`thatch_memory_remember` on opencode, `mcp__thatch__memory_remember` on
50
+ Claude Code and Cursor).
@@ -8,8 +8,10 @@ between tabs all afternoon.
8
8
 
9
9
  ## What it does
10
10
 
11
- opencode sessions join the directory automatically, on their first idle
12
- moment. Names are assigned by thatch, never chosen: a name drawn from a
11
+ opencode sessions join the directory automatically, the moment their
12
+ first user message arrives (before the assistant responds), and a session
13
+ resumed with `opencode -c` or `opencode -s <id>` is back in the directory
14
+ at harness start with its old name. Names are assigned by thatch, never chosen: a name drawn from a
13
15
  built-in pool plus a counter (`al-go-rithm-00001`). The counter only ever
14
16
  increments, so a name is
15
17
  minted exactly once per machine - pruning an old session never reissues its
@@ -60,11 +62,13 @@ toggle reveals those output blocks if you want them.)
60
62
  ## How to use it
61
63
 
62
64
  With auto-registration you usually do nothing: a session joins the
63
- directory when it first goes idle, and a session continued with
64
- `opencode -s <id>` is registered again the moment its harness starts -
65
- same session id, same name, reclaimed. The agent gets a quiet toast with
66
- its assigned name when it first registers. To introduce two sessions,
67
- just tell each one who to talk to:
65
+ directory when its first user message arrives, and a session continued
66
+ with `opencode -c` or `opencode -s <id>` is registered again the moment
67
+ its harness starts - same session id, same name, reclaimed. The agent
68
+ gets a quiet toast with
69
+ its assigned name when it first registers, and a resumed session
70
+ announces its reclaimed name on its first prompt. To introduce two
71
+ sessions, just tell each one who to talk to:
68
72
 
69
73
  > Ask the other session whether the release has shipped; answer its
70
74
  > questions directly.
@@ -90,6 +90,7 @@ and `--json` output for hook commands.
90
90
  | `THATCH_DB_PATH` | `~/.config/thatch/thatch.db` | SQLite database path |
91
91
  | `THATCH_MODEL` | `Xenova/bge-small-en-v1.5` | Hugging Face model name for embeddings |
92
92
  | `THATCH_EMBEDDING_BACKEND` | `wasm` | Set to `native` to run embeddings on onnxruntime-node (NAPI) instead of the wasm runtime |
93
+ | `THATCH_MODEL_CACHE` | platform per-user cache dir | Where downloaded model files are stored |
93
94
  | `THATCH_RECALL_THRESHOLD` | `0.55` | Cosine threshold for recall nudge |
94
95
  | `THATCH_PREDICTION_THRESHOLD` | `0.60` | Cosine threshold for prediction auto-fire |
95
96
  | `THATCH_BEHAVIOR_THRESHOLD` | `0.60` | Cosine threshold for behavior auto-fire |
@@ -110,10 +111,15 @@ instructions are deployed without requiring you to manually re-run
110
111
 
111
112
  ## Limitations
112
113
 
113
- - `bun` must be installed and on PATH. Thatch does not bundle its
114
- own runtime.
114
+ - For non-Nix installs, `bun` must be installed and on PATH. Thatch does
115
+ not bundle its own runtime (the Nix package bundles it; see the
116
+ README's Nix section).
115
117
  - The embedding model (~34 MB) is downloaded once on first use and
116
- cached in `node_modules/@huggingface/transformers/.cache/`.
118
+ cached in the platform's per-user cache dir
119
+ (`~/Library/Caches/thatch/models` on macOS (or
120
+ `$XDG_CACHE_HOME/thatch/models` when `XDG_CACHE_HOME` is set),
121
+ `$XDG_CACHE_HOME/thatch/models` elsewhere). Override with
122
+ `THATCH_MODEL_CACHE`.
117
123
  - There is no web UI or dashboard. All interaction is through the
118
124
  agent's tool calls and the `thatch` CLI.
119
125
  - The MCP server is a long-lived process that keeps the embedding model
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
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/chat.ts CHANGED
@@ -304,17 +304,45 @@ export function chatLiveness(row: ChatSessionRow, now = Date.now()): "fresh" | "
304
304
  return stale ? "stale" : "fresh";
305
305
  }
306
306
 
307
+ /** Orders roster rows for display: project first (so a shared-tree reader
308
+ * sees each repo's sessions together; rows with no project sort last),
309
+ * then by heartbeat, most recently seen first, so the recoverable rows
310
+ * lead and the long-dead trail at the bottom. Pure ordering - it never
311
+ * drops rows; the stale display cap is splitChatRoster's job. */
312
+ export function sortChatRoster(rows: ChatSessionRow[]): ChatSessionRow[] {
313
+ return [...rows].sort((a, b) => {
314
+ const projectA = a.project ?? "\uffff";
315
+ const projectB = b.project ?? "\uffff";
316
+ if (projectA !== projectB) return projectA.localeCompare(projectB);
317
+ return Date.parse(b.last_seen) - Date.parse(a.last_seen);
318
+ });
319
+ }
320
+
307
321
  /** Splits the roster for the two-section display: Active (wake-able or
308
322
  * reachable - fresh opencode rows and every MCP row, which read mail at
309
323
  * their next prompt) and Stale (opencode rows whose harness has stopped
310
- * reporting; mail to them waits until they resume). */
311
- export function splitChatRoster(rows: ChatSessionRow[], now = Date.now()): { active: ChatSessionRow[]; stale: ChatSessionRow[] } {
324
+ * reporting; mail to them waits until they resume).
325
+ * staleCapMs bounds how old a stale row the roster displays, so
326
+ * long-dead sessions cannot bury the fresh signal; hidden rows come
327
+ * back only as the staleHidden count, so the CLI can say what it left
328
+ * out. null (the default) shows every stale row - the tool surface has
329
+ * no cap, only the CLI applies one. */
330
+ export function splitChatRoster(rows: ChatSessionRow[], now = Date.now(), staleCapMs: number | null = null): { active: ChatSessionRow[]; stale: ChatSessionRow[]; staleHidden: number } {
312
331
  const active: ChatSessionRow[] = [];
313
332
  const stale: ChatSessionRow[] = [];
333
+ let staleHidden = 0;
314
334
  for (const row of rows) {
315
- (chatLiveness(row, now) === "stale" ? stale : active).push(row);
335
+ if (chatLiveness(row, now) === "stale") {
336
+ if (staleCapMs !== null && now - Date.parse(row.last_seen) > staleCapMs) {
337
+ staleHidden++;
338
+ } else {
339
+ stale.push(row);
340
+ }
341
+ } else {
342
+ active.push(row);
343
+ }
316
344
  }
317
- return { active, stale };
345
+ return { active, stale, staleHidden };
318
346
  }
319
347
 
320
348
  /**
package/src/commands.ts CHANGED
@@ -1,12 +1,35 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import {
4
+ defragCore,
5
+ extractionCore,
6
+ hygieneCore,
7
+ mcpToolName,
8
+ opencodeToolName,
9
+ reflectCore,
10
+ type ToolNamer,
11
+ } from "./prompts";
3
12
 
4
13
  /**
5
- * Thatch's wrap-up slash commands for opencode, shipped as command markdown
6
- * files synced into the global opencode config dir (~/.config/opencode/command/
7
- * thatch/). The plugin writes them on every load so template updates self-heal
8
- * without a reinstall. Config is loaded before plugins during server startup,
9
- * so a first-ever install becomes visible on the next start.
14
+ * Thatch's slash commands, shipped as command markdown files synced into each
15
+ * host's command directory:
16
+ *
17
+ * - opencode: ~/.config/opencode/command/thatch/ - synced by the plugin on
18
+ * every load, so template updates self-heal without a reinstall. Config is
19
+ * loaded before plugins during server startup, so a first-ever install
20
+ * becomes visible on the next start.
21
+ * - Claude Code: ~/.claude/commands/thatch/ (or the project's .claude/
22
+ * commands/thatch/) - synced by `thatch setup --claude`, which has no
23
+ * plugin to do it on load.
24
+ *
25
+ * Two command families live here:
26
+ *
27
+ * - Actions (/thatch/defrag, extract, hygiene, reflect): user-invoked
28
+ * wrappers around a behavior that the nudges otherwise only run on their
29
+ * own schedule. Each action body is a prompt core from src/prompts.ts, so
30
+ * nudge and command wording cannot drift.
31
+ * - Wrap-ups (/thatch/compact, exit): greenlight-token commands that need
32
+ * the opencode plugin's TUI control routes, so they are opencode-only.
10
33
  */
11
34
 
12
35
  /** Greenlight token ending /thatch/compact's assistant response when compaction is safe. */
@@ -32,6 +55,71 @@ $ARGUMENTS
32
55
 
33
56
  (That section carries the text typed after the command. When it is empty, treat the user message as n/a - the command was run bare.)`;
34
57
 
58
+ /**
59
+ * One on-demand action: the command name (minus the thatch/ prefix), the
60
+ * description shown in the command menu, and the instruction body (a prompt
61
+ * core). opencodeOnly marks actions that need a session identity or other
62
+ * plugin-only capability; those are excluded from Claude Code command files
63
+ * and MCP prompts.
64
+ */
65
+ export interface ActionDef {
66
+ name: string;
67
+ description: string;
68
+ body: string;
69
+ opencodeOnly?: boolean;
70
+ }
71
+
72
+ /**
73
+ * The on-demand action set, shared by every host that renders actions. The
74
+ * bodies come from prompt cores so a wording change lands in the nudge and
75
+ * the command at once. `tool` renders tool names in the host's spelling.
76
+ */
77
+ export function actionDefs(tool: ToolNamer): ActionDef[] {
78
+ return [
79
+ {
80
+ name: "defrag",
81
+ description: "Consolidate duplicate and near-duplicate memories in the store",
82
+ body: defragCore(tool),
83
+ },
84
+ {
85
+ name: "extract",
86
+ description: "Drain the extraction queue now",
87
+ // Needs the invoking session's ID (via get_session_info, opencode-only)
88
+ // and the plugin's extraction payload provider.
89
+ opencodeOnly: true,
90
+ body: `First call thatch_get_session_info to learn this session's ID. Below, replace SESSION_ID with that value.
91
+
92
+ Dispatch a task with background: true and subagent_type: "general" (required for thatch tool access) with this prompt:
93
+
94
+ "${extractionCore("thatch_get_extraction_payload", "thatch_extraction_done", ' with session_id \\"SESSION_ID\\"')}"
95
+
96
+ After dispatching, call thatch_extraction_done to acknowledge.`,
97
+ },
98
+ {
99
+ name: "hygiene",
100
+ description: "Tend the memory store: stale entries, orphans, pending dedup pairs",
101
+ body: hygieneCore(),
102
+ },
103
+ {
104
+ name: "reflect",
105
+ description: "Persist what this session learned as memories",
106
+ body: reflectCore(tool),
107
+ },
108
+ ];
109
+ }
110
+
111
+ /** Renders one action as a full command markdown file. */
112
+ export function renderActionCommand(action: ActionDef): string {
113
+ return `---
114
+ description: ${action.description}
115
+ ---
116
+ ${userMessageSection}
117
+
118
+ # ${action.name.charAt(0).toUpperCase() + action.name.slice(1)}
119
+
120
+ ${action.body}`;
121
+ }
122
+
35
123
  const COMPACT_TEMPLATE = `---
36
124
  description: Flush thatch persistence, check for loose ends, then compact if clear
37
125
  ---
@@ -67,29 +155,40 @@ ${EXIT_READY_TOKEN}
67
155
 
68
156
  If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. opencode will only exit when the token is present.`;
69
157
 
70
- /** One wrap-up command: the command name (minus the thatch/ prefix) and its full markdown file content. */
158
+ /** One command: the command name (minus the thatch/ prefix) and its full markdown file content. */
71
159
  export interface CommandDef {
72
160
  name: string;
73
161
  content: string;
74
162
  }
75
163
 
76
- /** The wrap-up command set, as full command-file contents ready to write. */
164
+ /** The opencode command set: wrap-ups plus every action, as full file contents. */
77
165
  export function opencodeCommandDefs(): CommandDef[] {
78
166
  return [
79
167
  { name: "compact", content: COMPACT_TEMPLATE },
80
168
  { name: "exit", content: EXIT_TEMPLATE },
169
+ ...actionDefs(opencodeToolName).map((a) => ({ name: a.name, content: renderActionCommand(a) })),
81
170
  ];
82
171
  }
83
172
 
84
173
  /**
85
- * Sync the wrap-up commands into <configHome>/opencode/command/thatch/.
86
- * Idempotent: each file is written only when its on-disk content differs.
87
- * Returns the paths that were written (empty when everything was current).
174
+ * The Claude Code command set: the actions minus the opencode-only ones.
175
+ * The wrap-up commands are excluded too - their greenlight check needs the
176
+ * plugin's command.execute.before arming and TUI control routes.
88
177
  */
89
- export function installOpencodeCommands(configHome: string): string[] {
90
- const dir = join(configHome, "opencode", "command", "thatch");
178
+ export function claudeCommandDefs(): CommandDef[] {
179
+ return actionDefs(mcpToolName)
180
+ .filter((a) => !a.opencodeOnly)
181
+ .map((a) => ({ name: a.name, content: renderActionCommand(a) }));
182
+ }
183
+
184
+ /**
185
+ * Sync command files into dir. Idempotent: each file is written only when
186
+ * its on-disk content differs. Returns the paths that were written (empty
187
+ * when everything was current).
188
+ */
189
+ function syncCommandFiles(dir: string, defs: CommandDef[]): string[] {
91
190
  const written: string[] = [];
92
- for (const def of opencodeCommandDefs()) {
191
+ for (const def of defs) {
93
192
  const path = join(dir, `${def.name}.md`);
94
193
  let current: string | null = null;
95
194
  try {
@@ -105,3 +204,13 @@ export function installOpencodeCommands(configHome: string): string[] {
105
204
  }
106
205
  return written;
107
206
  }
207
+
208
+ /** Sync opencode's commands into <configHome>/opencode/command/thatch/. */
209
+ export function installOpencodeCommands(configHome: string): string[] {
210
+ return syncCommandFiles(join(configHome, "opencode", "command", "thatch"), opencodeCommandDefs());
211
+ }
212
+
213
+ /** Sync Claude Code's commands into <claudeDir>/commands/thatch/ (claudeDir is ~/.claude or <project>/.claude). */
214
+ export function installClaudeCommands(claudeDir: string): string[] {
215
+ return syncCommandFiles(join(claudeDir, "commands", "thatch"), claudeCommandDefs());
216
+ }
package/src/db.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
2
4
  import { blobToVector, cosineSimilarity } from "./vector-math";
3
5
  import {
4
6
  PredictionEngine,
@@ -58,6 +60,13 @@ export class ThatchDB {
58
60
  #chat: ChatStore;
59
61
 
60
62
  constructor(path: string) {
63
+ // bun:sqlite creates the file but not its parent directory. On a fresh
64
+ // machine the default path (~/.config/thatch/thatch.db) has no dir yet, so
65
+ // create it here - otherwise every command fails with SQLITE_CANTOPEN.
66
+ // `:memory:` and other special paths have no meaningful parent to create.
67
+ if (path !== ":memory:" && !path.startsWith("file:")) {
68
+ mkdirSync(dirname(path), { recursive: true });
69
+ }
61
70
  this.#db = new Database(path, { create: true });
62
71
  this.#db.run("PRAGMA journal_mode = WAL");
63
72
  this.#db.run("PRAGMA busy_timeout = 5000");
package/src/embeddings.ts CHANGED
@@ -1,9 +1,48 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
1
4
  // BGE-small-en-v1.5 requires a query prefix for asymmetric search.
2
5
  // Passage (memory content) gets no prefix - the model was trained
3
6
  // to encode passages without instruction.
4
7
  const QUERY_PREFIX =
5
8
  "Represent this sentence for searching relevant passages: ";
6
9
 
10
+ /**
11
+ * Writable directory for the downloaded model files.
12
+ *
13
+ * transformers.js defaults its filesystem cache to a `.cache` folder *inside*
14
+ * its own package directory (`node_modules/@huggingface/transformers/.cache`).
15
+ * That breaks any read-only install - a global `bun install -g`, and Nix store
16
+ * paths in particular - and gets wiped on every reinstall. Anchor the cache in
17
+ * the platform's per-user cache location instead so it survives upgrades and
18
+ * works from an immutable store path.
19
+ *
20
+ * THATCH_MODEL_CACHE explicit override (highest priority)
21
+ * XDG_CACHE_HOME explicit base dir -> $XDG_CACHE_HOME/thatch/models
22
+ * macOS ~/Library/Caches/thatch/models
23
+ * Windows %LOCALAPPDATA%\thatch\models
24
+ * Linux / other ~/.cache/thatch/models
25
+ *
26
+ * `platform` and `home` are injectable for testing; they default to the real
27
+ * runtime values.
28
+ */
29
+ export function modelCacheDir(
30
+ platform: NodeJS.Platform = process.platform,
31
+ home: string = homedir(),
32
+ ): string {
33
+ const env = process.env;
34
+ if (env.THATCH_MODEL_CACHE) return env.THATCH_MODEL_CACHE;
35
+ // Respect an explicit XDG override on any platform (some macOS users set it).
36
+ if (env.XDG_CACHE_HOME) return join(env.XDG_CACHE_HOME, "thatch", "models");
37
+ const base =
38
+ platform === "darwin"
39
+ ? join(home, "Library", "Caches")
40
+ : platform === "win32"
41
+ ? (env.LOCALAPPDATA ?? join(home, "AppData", "Local"))
42
+ : join(home, ".cache");
43
+ return join(base, "thatch", "models");
44
+ }
45
+
7
46
  /**
8
47
  * Interface for an embedding model so tests can supply a mock.
9
48
  * `name` is the tag stored alongside each entry so a future reader can tell
@@ -90,7 +129,9 @@ export function resetBackendForTests(): void {
90
129
 
91
130
  const defaultPipelineFactory: PipelineFactory = async (modelName) => {
92
131
  const mode = await configureBackend();
93
- const { pipeline } = await import("@huggingface/transformers");
132
+ const { pipeline, env } = await import("@huggingface/transformers");
133
+ // Redirect the model cache out of the (possibly read-only) package tree.
134
+ env.cacheDir = modelCacheDir();
94
135
  if (mode === "native") {
95
136
  return pipeline("feature-extraction", modelName);
96
137
  }