@jeffober/thatch 0.1.44 → 0.1.45

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/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.45",
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
  }