@jeffober/thatch 0.1.43 → 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/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. */
@@ -24,19 +47,92 @@ export const EXIT_READY_TOKEN = "THATCH_EXIT_READY";
24
47
  */
25
48
 
26
49
  const sharedChecklist = `1. Flush pending persistence. Call thatch_get_extraction_payload; if it returns buffered tool interactions, process them now (write any memories worth keeping), then call thatch_extraction_done to mark them complete. Complete any memory writes you promised earlier but have not made.
27
- 2. Check for loose ends. Review the conversation for todos, follow-ups, or open questions raised earlier that were never addressed, and surface anything the user should address before the wrap-up completes.
50
+ 2. Check for loose ends. Review the conversation for todos, follow-ups, or open questions raised earlier that were never addressed, and surface anything the user should address before the wrap-up completes.`;
28
51
 
29
- Do not start new work beyond this checklist. If every item is handled and nothing needs the user's attention first, end your final response with exactly this token as the very last line, with no formatting around it:`;
52
+ const userMessageSection = `# User Message
53
+
54
+ $ARGUMENTS
55
+
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.)`;
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
+ }
30
122
 
31
123
  const COMPACT_TEMPLATE = `---
32
124
  description: Flush thatch persistence, check for loose ends, then compact if clear
33
125
  ---
34
- $ARGUMENTS
126
+ ${userMessageSection}
127
+
128
+ # Pre-compact wrap-up
35
129
 
36
- Pre-compaction wrap-up. Work through this checklist before responding:
130
+ Work through this checklist before responding:
37
131
 
38
132
  ${sharedChecklist}
39
133
 
134
+ Do not start new work beyond this checklist. If every item is handled and nothing needs the user's attention first, end your final response with exactly this token as the very last line, with no formatting around it:
135
+
40
136
  ${COMPACT_READY_TOKEN}
41
137
 
42
138
  If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. The session will only be compacted when the token is present.`;
@@ -44,9 +140,11 @@ If anything is outstanding, list the items concisely so the user can address the
44
140
  const EXIT_TEMPLATE = `---
45
141
  description: Flush thatch persistence, check for loose ends, then exit opencode if clear
46
142
  ---
47
- $ARGUMENTS
143
+ ${userMessageSection}
144
+
145
+ # Pre-exit wrap-up
48
146
 
49
- Pre-exit wrap-up. Work through this checklist before responding:
147
+ Work through this checklist before responding:
50
148
 
51
149
  ${sharedChecklist}
52
150
  3. Leave the chat directory. Call thatch_chat_unregister for this session - the session is exiting, so other sessions must stop addressing mail to it.
@@ -57,29 +155,40 @@ ${EXIT_READY_TOKEN}
57
155
 
58
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.`;
59
157
 
60
- /** 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. */
61
159
  export interface CommandDef {
62
160
  name: string;
63
161
  content: string;
64
162
  }
65
163
 
66
- /** 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. */
67
165
  export function opencodeCommandDefs(): CommandDef[] {
68
166
  return [
69
167
  { name: "compact", content: COMPACT_TEMPLATE },
70
168
  { name: "exit", content: EXIT_TEMPLATE },
169
+ ...actionDefs(opencodeToolName).map((a) => ({ name: a.name, content: renderActionCommand(a) })),
71
170
  ];
72
171
  }
73
172
 
74
173
  /**
75
- * Sync the wrap-up commands into <configHome>/opencode/command/thatch/.
76
- * Idempotent: each file is written only when its on-disk content differs.
77
- * 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.
78
177
  */
79
- export function installOpencodeCommands(configHome: string): string[] {
80
- 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[] {
81
190
  const written: string[] = [];
82
- for (const def of opencodeCommandDefs()) {
191
+ for (const def of defs) {
83
192
  const path = join(dir, `${def.name}.md`);
84
193
  let current: string | null = null;
85
194
  try {
@@ -95,3 +204,13 @@ export function installOpencodeCommands(configHome: string): string[] {
95
204
  }
96
205
  return written;
97
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");
@@ -284,6 +293,20 @@ export class ThatchDB {
284
293
  )
285
294
  `);
286
295
 
296
+ // Hook-parent-process -> session mapping (Claude Code identity anchor):
297
+ // hooks learn the true session id from the host's hook payload and run
298
+ // as children of the same process that spawned the MCP server, so the
299
+ // server resolves its own parent pid against this table - an identity
300
+ // the model cannot claim. Rows are timestamped; stale rows are ignored
301
+ // (pid reuse) rather than deleted.
302
+ this.#db.run(`
303
+ CREATE TABLE IF NOT EXISTS chat_host_pids (
304
+ ppid INTEGER PRIMARY KEY,
305
+ session_id TEXT NOT NULL,
306
+ seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
307
+ )
308
+ `);
309
+
287
310
  this.#migrateColumns();
288
311
  this.#migrateChatNameCollation();
289
312
  this.#migrateChatTopic();
@@ -291,6 +314,7 @@ export class ThatchDB {
291
314
  this.#migrateChatBroadcastFlag();
292
315
  this.#migrateChatAuto();
293
316
  this.#migrateChatWorktree();
317
+ this.#migrateChatHostPid();
294
318
  }
295
319
 
296
320
  // chat_sessions tables created before the auto column lack it; the ALTER
@@ -322,6 +346,16 @@ export class ThatchDB {
322
346
  }
323
347
  }
324
348
 
349
+ // The released schema has no host_pid column (liveness is heartbeat age;
350
+ // see chatLiveness). A development database may carry one; drop it so
351
+ // every database matches a fresh install.
352
+ #migrateChatHostPid(): void {
353
+ const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
354
+ if (cols.includes("host_pid")) {
355
+ this.#db.run("ALTER TABLE chat_sessions DROP COLUMN host_pid");
356
+ }
357
+ }
358
+
325
359
  // chat_messages tables created before the via_broadcast column lack it;
326
360
  // the ALTER adds it, and existing rows read as direct sends (which they
327
361
  // were).
@@ -935,6 +969,18 @@ export class ThatchDB {
935
969
  return this.#chat.unreadCount(sessionID);
936
970
  }
937
971
 
972
+ recordChatHostPid(ppid: number, sessionID: string) {
973
+ return this.#chat.recordHostPid(ppid, sessionID);
974
+ }
975
+
976
+ findChatSessionByHostPid(ppid: number, maxAgeSeconds: number): string | null {
977
+ return this.#chat.findSessionByHostPid(ppid, maxAgeSeconds);
978
+ }
979
+
980
+ continueChatSession(oldSessionID: string, newSessionID: string) {
981
+ return this.#chat.continueSession(oldSessionID, newSessionID);
982
+ }
983
+
938
984
  heartbeatChatSessions(sessionIDs: string[]) {
939
985
  return this.#chat.heartbeat(sessionIDs);
940
986
  }
package/src/debug.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ // Opt-in diagnostic logging for questions a live harness cannot otherwise
5
+ // answer ("what did the plugin actually see at init?"). Lines go to
6
+ // debug.log beside the database, never to the host's stdout or stderr:
7
+ // the plugin runs inside opencode's TUI process, where console output is
8
+ // invisible or garbles the screen, and test sandboxes point the database at
9
+ // a tmp dir so their debug output never reaches the quality gate.
10
+ //
11
+ // Off unless THATCH_DEBUG is set. THATCH_DEBUG=1 (or "all" / "*") logs every
12
+ // tag. Otherwise it is a comma-separated list of tag filters: a filter
13
+ // matches a tag when it equals the whole tag ("chat:startup") or the
14
+ // feature part before the colon ("chat" matches every chat:* tag).
15
+ //
16
+ // Tags are `feature:aspect`, rendered in square brackets, so a grep on
17
+ // "[chat:" pulls one feature's story out of a shared file.
18
+
19
+ export type DebugLog = (tag: string, msg: string) => void;
20
+
21
+ // Decide, from a THATCH_DEBUG value, whether a tag is enabled. Exported for
22
+ // tests; callers use createDebugLog.
23
+ export function debugEnabled(spec: string | undefined, tag: string): boolean {
24
+ if (!spec) return false;
25
+ const s = spec.trim();
26
+ if (s === "1" || s === "all" || s === "*") return true;
27
+ const feature = tag.split(":")[0];
28
+ return s.split(",").map((f) => f.trim()).filter(Boolean).some((f) => f === tag || f === feature);
29
+ }
30
+
31
+ // Build the logger for a database location. Every line: ISO timestamp,
32
+ // bracketed tag, message. Writes never throw: diagnostics must not break
33
+ // the host, so a missing directory or a full disk simply drops the line.
34
+ export function createDebugLog(dbPath: string, spec: string | undefined = process.env.THATCH_DEBUG): DebugLog {
35
+ const file = join(dirname(dbPath), "debug.log");
36
+ return (tag, msg) => {
37
+ if (!debugEnabled(spec, tag)) return;
38
+ try {
39
+ appendFileSync(file, `${new Date().toISOString()} [${tag}] ${msg}\n`);
40
+ } catch {
41
+ // Intentionally silent; see above.
42
+ }
43
+ };
44
+ }
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
  }