@jeffober/thatch 0.1.4 → 0.1.6

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/index.ts CHANGED
@@ -8,15 +8,26 @@ import {
8
8
  systemPrompt,
9
9
  compactionContext,
10
10
  sessionStartReminder,
11
+ recallNudge,
12
+ type NudgeMatch,
11
13
  } from "./prompts";
12
14
  import { ExtractionPipeline } from "./extraction";
13
- import { installSkills } from "./skills";
15
+ import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
14
16
  import { hygieneReport } from "./hygiene";
15
17
 
16
18
  // ---------------------------------------------------------------------------
17
19
  // V1 server export — tools, prompt injection, session hooks
18
20
  // ---------------------------------------------------------------------------
19
21
 
22
+ // Minimum cosine score for the prompt-aware recall nudge. Lower than
23
+ // findDuplicates' 0.85 (near-dupes) because "relates to" is a weaker signal
24
+ // than "duplicate." Tunable via THATCH_RECALL_THRESHOLD.
25
+ const RECALL_THRESHOLD = parseFloat(process.env.THATCH_RECALL_THRESHOLD ?? "0.55");
26
+
27
+ // Prompts shorter than this skip the recall nudge — trivially short prompts
28
+ // like "yes" or "ok" match too broadly to be useful.
29
+ const MIN_PROMPT_LEN = 10;
30
+
20
31
  export const server: Plugin = async ({ client, worktree }) => {
21
32
  // The opencode server's cwd is wherever the server happened to start;
22
33
  // `worktree` is the project this plugin instance actually serves.
@@ -34,7 +45,10 @@ export const server: Plugin = async ({ client, worktree }) => {
34
45
  // worktree would mutate the user's repo (untracked files in git status).
35
46
  // A failed install degrades the nudge workflow but must not kill the plugin.
36
47
  try {
37
- installSkills(join(configHome, "opencode", "skills"));
48
+ installSkills(join(configHome, "opencode", "skills"), [
49
+ ...SHARED_SKILLS,
50
+ ...OPENCODE_ONLY_SKILLS,
51
+ ]);
38
52
  } catch (err) {
39
53
  console.error(`[thatch] skill install failed: ${err}`);
40
54
  }
@@ -69,27 +83,62 @@ export const server: Plugin = async ({ client, worktree }) => {
69
83
  });
70
84
  },
71
85
 
72
- // 4. Per-message nudge — when interactions are queued, hand the agent the
73
- // actual payload; the thatch-fact-extractor skill's contract is "you will
74
- // be given a JSON payload", so the nudge must carry it.
86
+ // 4. Per-message nudge — two priority tiers:
87
+ // a. Extraction nudge: prior tool interactions are queued for fact
88
+ // extraction (carries the JSON payload for thatch-fact-extractor).
89
+ // b. Recall nudge: the user's prompt semantically matches existing
90
+ // memories — surface that prior knowledge exists before the agent
91
+ // responds. Uses the in-process warm model (no sideband needed).
75
92
  "chat.message": async (input, output) => {
76
- if (!extraction.pending(input.sessionID)) return;
77
-
78
- const batch = extraction.flush(input.sessionID);
79
- const payload = extraction.buildPayload(batch, repo);
80
- const text =
81
- `[thatch] ${batch.length} recent tool interactions are queued for fact extraction. ` +
82
- `Use the skill tool to load thatch-fact-extractor, then use thatch_memory_remember ` +
83
- `to save any new durable facts from this payload:\n${payload}`;
93
+ if (extraction.pending(input.sessionID)) {
94
+ const batch = extraction.flush(input.sessionID);
95
+ const payload = extraction.buildPayload(batch, repo);
96
+ const text =
97
+ `[thatch] ${batch.length} recent tool interactions are queued for fact extraction. ` +
98
+ `Use the skill tool to load thatch-fact-extractor, then use thatch_memory_remember ` +
99
+ `to save any new durable facts from this payload:\n${payload}`;
100
+
101
+ output.parts.push({
102
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
103
+ sessionID: input.sessionID,
104
+ messageID: input.messageID ?? output.message.id,
105
+ type: "text",
106
+ text,
107
+ synthetic: true,
108
+ });
109
+ return;
110
+ }
84
111
 
85
- output.parts.push({
86
- id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
87
- sessionID: input.sessionID,
88
- messageID: input.messageID ?? output.message.id,
89
- type: "text",
90
- text,
91
- synthetic: true,
92
- });
112
+ // No extraction pending — try the prompt-aware recall nudge. Extract
113
+ // the user's prompt text from the message parts, embed it with the
114
+ // warm in-process model, and search for matches. Best-effort: any
115
+ // failure (no text, model not loaded, empty store) silently skips.
116
+ try {
117
+ const promptText = (output.parts as any[])
118
+ .filter((p) => p.type === "text" && !p.synthetic)
119
+ .map((p) => p.text)
120
+ .join(" ");
121
+ if (promptText.length < MIN_PROMPT_LEN) return;
122
+
123
+ const embedding = await model.queryEmbed(promptText);
124
+ const results = db.search([repo, "global"], embedding, { limit: 5 });
125
+ const matches: NudgeMatch[] = results
126
+ .filter((r) => r._score >= RECALL_THRESHOLD)
127
+ .map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
128
+
129
+ if (matches.length === 0) return;
130
+
131
+ output.parts.push({
132
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
133
+ sessionID: input.sessionID,
134
+ messageID: input.messageID ?? output.message.id,
135
+ type: "text",
136
+ text: recallNudge(matches),
137
+ synthetic: true,
138
+ });
139
+ } catch (err) {
140
+ console.error(`[thatch] recall nudge failed: ${err}`);
141
+ }
93
142
  },
94
143
 
95
144
  // 5. Session-start reminder, carrying the hygiene heartbeat. Hygiene is
package/src/mcp.ts CHANGED
@@ -3,6 +3,7 @@ import { ThatchDB } from "./db";
3
3
  import { BgeEmbeddingModel } from "./embeddings";
4
4
  import { detectRepo } from "./git";
5
5
  import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
6
+ import { SidebandServer, sidebandSocketPath } from "./sideband";
6
7
 
7
8
  // ---------------------------------------------------------------------------
8
9
  // JSON-RPC 2.0 types
@@ -79,6 +80,10 @@ function compileTools(): Map<string, CompiledTool> {
79
80
  * model loads lazily on the first tool call that needs it, so the server
80
81
  * starts instantly and only pays the ~34 MB download cost when a memory is
81
82
  * actually written or recalled.
83
+ *
84
+ * A Unix domain socket sideband is opened at startup so that one-shot hook
85
+ * processes (thatch flush-tools) can ask the warm MCP server to embed a
86
+ * prompt and search for matches without loading the model themselves.
82
87
  */
83
88
  export async function runMcpServer(): Promise<void> {
84
89
  const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
@@ -94,6 +99,19 @@ export async function runMcpServer(): Promise<void> {
94
99
  const ctx: CoreContext = { db, model, defaultStore: repo };
95
100
  const tools = compileTools();
96
101
 
102
+ // The sideband socket lets one-shot hook processes (flush-tools) ask the
103
+ // warm MCP server to embed a prompt and search for matches — without
104
+ // loading the ~34 MB model themselves. If the socket can't be opened
105
+ // (permissions, tmpdir issues), the MCP server still works; only the
106
+ // prompt-aware recall nudge degrades.
107
+ const sockPath = sidebandSocketPath(dbPath);
108
+ const sideband = new SidebandServer(sockPath, model, db);
109
+ try {
110
+ sideband.start();
111
+ } catch (err) {
112
+ console.error(`[thatch] sideband socket failed: ${err}`);
113
+ }
114
+
97
115
  // Read stdin line by line. Each line is a complete JSON-RPC message.
98
116
  const decoder = new TextDecoder();
99
117
  let buf = "";
@@ -123,6 +141,7 @@ export async function runMcpServer(): Promise<void> {
123
141
  }
124
142
  }
125
143
 
144
+ sideband.stop();
126
145
  db.close();
127
146
  }
128
147
 
package/src/prompts.ts CHANGED
@@ -202,3 +202,114 @@ export function claudeExtractionNudge(count: number, payload: string): string {
202
202
  `Load the thatch-fact-extractor skill, then use mcp__thatch__memory_remember ` +
203
203
  `to save any new durable facts from this payload:\n${payload}`;
204
204
  }
205
+
206
+ /**
207
+ * Static instructions for Cursor's AGENTS.md. Same contract as
208
+ * claudeInstructions() — the host loads this at every session start, and the
209
+ * repo store name is auto-detected at runtime by the MCP server. Tool names
210
+ * use the same mcp__thatch__ prefix since Cursor discovers MCP tools via the
211
+ * same protocol.
212
+ */
213
+ export function cursorInstructions(): string {
214
+ return `# Persistence
215
+
216
+ Thatch provides persistent memory across Cursor sessions. Use it to persist
217
+ knowledge so future sessions can build on what you've already learned.
218
+ Tools are prefixed in Cursor: \`mcp__thatch__memory_remember\`,
219
+ \`mcp__thatch__memory_recall\`, \`mcp__thatch__memory_list\`,
220
+ \`mcp__thatch__memory_show\`, \`mcp__thatch__memory_forget\`,
221
+ \`mcp__thatch__store_list\`, \`mcp__thatch__find_duplicates\`,
222
+ \`mcp__thatch__dedup_mark_checked\`.
223
+ Bare names used below for readability.
224
+
225
+ ## Stores
226
+
227
+ - \`global\`: user preferences, personality, system environment (shared across projects)
228
+ - Per-project stores: project-specific knowledge (auto-detected from git remote)
229
+ - Use \`mcp__thatch__store_list\` to see active stores
230
+
231
+ Memories can be scoped to a git branch via the \`branch\` param (feature design,
232
+ WIP, PR status). Unscoped memories are project-wide and always included in search.
233
+
234
+ ## Session Startup
235
+
236
+ 1. \`memory_recall\` "user preferences and personality" (global store)
237
+ 2. \`memory_recall\` "project architecture and conventions" (project store)
238
+ 3. If on a non-main branch, \`memory_recall\` with the branch name
239
+ 4. \`memory_list\` and \`store_list\` to see what's available
240
+
241
+ Before diving into code, \`memory_recall\` with a query relevant to the area
242
+ you're working in. Prior sessions may have already investigated it.
243
+
244
+ ## When to Write
245
+
246
+ **One signal is enough.** Do not wait for confirmation.
247
+ Save immediately on: feedback, preferences, corrections, emotional signals,
248
+ new project knowledge, conventions, architectural decisions, investigation results.
249
+
250
+ ## What to Store
251
+
252
+ - **Global store**: user observations, agent personality, system environment
253
+ - **Project store**: repo organization, infrastructure, languages/frameworks,
254
+ component relationships, conventions, operational playbooks
255
+ - **Branch-scoped**: branch purpose, feature design decisions, PR status,
256
+ temporary workarounds
257
+ - **Insights**: ALWAYS save non-obvious gotchas, patterns, and architectural
258
+ lessons discovered during implementation. Never prompt the user; just do it.
259
+
260
+ ## How to Write
261
+
262
+ - \`memory_recall\` first to check for duplicates. Use overwrite: true to
263
+ update rather than creating a new entry.
264
+ - One topic per memory. Write for a future instance with zero current context.
265
+ - Confidence 1-2: single signal. 5-6: moderate. 9: explicitly stated.
266
+ 10: hard constraint.
267
+
268
+ ## What NOT to Store
269
+
270
+ Session-specific context, incomplete/unverified info, anything already in
271
+ AGENTS.md, speculative conclusions.
272
+
273
+ ## Explicit Requests
274
+
275
+ "Remember X" — save immediately.
276
+ "Forget X" — \`memory_recall\` to find it, then \`memory_forget\`.`;
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Prompt-aware recall nudge — fires when the user's prompt semantically
281
+ // matches existing memories. Injected via opencode's chat.message hook
282
+ // (in-process, warm model) or via the sideband socket for Claude Code/Cursor
283
+ // (flush-tools CLI subcommand).
284
+ // ---------------------------------------------------------------------------
285
+
286
+ export interface NudgeMatch {
287
+ label: string;
288
+ score: number;
289
+ }
290
+
291
+ /**
292
+ * The recall nudge for the opencode plugin path. Uses the thatch_ tool prefix
293
+ * since opencode discovers tools directly from the plugin registration.
294
+ */
295
+ export function recallNudge(matches: NudgeMatch[]): string {
296
+ return formatRecallNudge(matches, "thatch_memory_recall");
297
+ }
298
+
299
+ /**
300
+ * The recall nudge for the Claude Code / Cursor hook path. Uses the bare tool
301
+ * name (memory_recall) since the mcp__thatch__ prefix is verbose and the
302
+ * CLAUDE.md / AGENTS.md instructions already establish the bare-name convention.
303
+ */
304
+ export function claudeRecallNudge(matches: NudgeMatch[]): string {
305
+ return formatRecallNudge(matches, "memory_recall");
306
+ }
307
+
308
+ function formatRecallNudge(matches: NudgeMatch[], toolName: string): string {
309
+ const n = matches.length;
310
+ const word = n === 1 ? "memory" : "memories";
311
+ const verb = n === 1 ? "relates" : "relate";
312
+ const labels = matches.slice(0, 2).map((m) => `"${m.label}"`).join(", ");
313
+ const etc = n > 2 ? ", etc." : "";
314
+ return `[thatch] ${n} ${word} ${verb} to this prompt (${labels}${etc}). Use ${toolName} before responding.`;
315
+ }
package/src/setup.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { claudeInstructions } from "./prompts";
3
+ import { claudeInstructions, cursorInstructions } from "./prompts";
4
4
  import { installSkills, type SkillFile } from "./skills";
5
5
 
6
6
  // ---------------------------------------------------------------------------
@@ -91,17 +91,26 @@ function writeMcpConfig(path: string, thatchBin: string): void {
91
91
  // ---------------------------------------------------------------------------
92
92
 
93
93
  const THATCH_MARKER = "# Persistence\n\nThatch provides persistent memory across Claude Code sessions.";
94
+ const THATCH_END_MARKER = '"Forget X" — `memory_recall` to find it, then `memory_forget`.';
95
+ const CURSOR_MARKER = "# Persistence\n\nThatch provides persistent memory across Cursor sessions.";
96
+ const CURSOR_END_MARKER = THATCH_END_MARKER;
94
97
 
95
- function appendInstructions(path: string): void {
96
- const instructions = claudeInstructions() + "\n";
97
-
98
+ /**
99
+ * Idempotently append (or replace) a block of instructions in a markdown file.
100
+ * The block is delimited by startMarker and endMarker so re-running setup
101
+ * updates drifted content without clobbering surrounding text.
102
+ */
103
+ function appendBlock(
104
+ path: string,
105
+ instructions: string,
106
+ startMarker: string,
107
+ endMarker: string,
108
+ ): void {
98
109
  if (existsSync(path)) {
99
110
  const existing = readFileSync(path, "utf8");
100
- if (existing.includes(THATCH_MARKER)) {
101
- // Already has thatch instructions — replace the block.
102
- const startIdx = existing.indexOf("# Persistence\n\nThatch provides persistent memory across Claude Code sessions.");
111
+ if (existing.includes(startMarker)) {
112
+ const startIdx = existing.indexOf(startMarker);
103
113
  if (startIdx >= 0) {
104
- const endMarker = '"Forget X" — `memory_recall` to find it, then `memory_forget`.';
105
114
  const endIdx = existing.indexOf(endMarker, startIdx);
106
115
  if (endIdx >= 0) {
107
116
  const afterEnd = endIdx + endMarker.length;
@@ -122,6 +131,14 @@ function appendInstructions(path: string): void {
122
131
  }
123
132
  }
124
133
 
134
+ function appendInstructions(path: string): void {
135
+ appendBlock(path, claudeInstructions() + "\n", THATCH_MARKER, THATCH_END_MARKER);
136
+ }
137
+
138
+ function appendCursorInstructions(path: string): void {
139
+ appendBlock(path, cursorInstructions() + "\n", CURSOR_MARKER, CURSOR_END_MARKER);
140
+ }
141
+
125
142
  // ---------------------------------------------------------------------------
126
143
  // settings.json — hook installation
127
144
  // ---------------------------------------------------------------------------
@@ -257,3 +274,149 @@ export function setupClaudeCode(
257
274
  : null,
258
275
  };
259
276
  }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Cursor setup — .cursor/mcp.json, AGENTS.md, .cursor/hooks.json, skills
280
+ // ---------------------------------------------------------------------------
281
+
282
+ /**
283
+ * Resolve the Cursor config directory. Cursor stores config under `~/.cursor`
284
+ * by default. No documented env override exists (unlike Claude Code's
285
+ * CLAUDE_CONFIG_DIR), but we honor a hypothetical CURSOR_CONFIG_DIR for
286
+ * symmetry and forward-compatibility.
287
+ */
288
+ function cursorConfigDir(homeDir: string): string {
289
+ return process.env.CURSOR_CONFIG_DIR ?? join(homeDir, ".cursor");
290
+ }
291
+
292
+ interface CursorSetupPaths {
293
+ mcpConfigPath: string;
294
+ agentsMdPath: string;
295
+ hooksPath: string;
296
+ skillsDir: string;
297
+ global: boolean;
298
+ }
299
+
300
+ function resolveCursorPaths(global: boolean, projectDir: string, homeDir: string): CursorSetupPaths {
301
+ const configDir = cursorConfigDir(homeDir);
302
+ const skillsDir = join(configDir, "skills");
303
+
304
+ if (global) {
305
+ return {
306
+ // Cursor's global MCP config is a simple file — no equivalent of
307
+ // `claude mcp add --scope user`. Writing ~/.cursor/mcp.json is enough.
308
+ mcpConfigPath: join(configDir, "mcp.json"),
309
+ agentsMdPath: join(configDir, "AGENTS.md"),
310
+ hooksPath: join(configDir, "hooks.json"),
311
+ skillsDir,
312
+ global: true,
313
+ };
314
+ }
315
+
316
+ return {
317
+ mcpConfigPath: join(projectDir, ".cursor", "mcp.json"),
318
+ agentsMdPath: join(projectDir, "AGENTS.md"),
319
+ hooksPath: join(projectDir, ".cursor", "hooks.json"),
320
+ skillsDir,
321
+ global: false,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Cursor hooks use a flat format (no nesting, no `type` field):
327
+ * { "version": 1, "hooks": { "event": [{ "command": "..." }] } }
328
+ */
329
+ interface CursorHooksJson {
330
+ version: number;
331
+ hooks: {
332
+ [event: string]: { command: string }[];
333
+ };
334
+ [key: string]: unknown;
335
+ }
336
+
337
+ function writeCursorHooks(path: string, thatchBin: string): void {
338
+ let config: CursorHooksJson = { version: 1, hooks: {} };
339
+ if (existsSync(path)) {
340
+ try {
341
+ config = JSON.parse(readFileSync(path, "utf8"));
342
+ } catch {
343
+ // Corrupt — start fresh.
344
+ }
345
+ }
346
+ if (!config.version) config.version = 1;
347
+ if (!config.hooks) config.hooks = {};
348
+
349
+ // --json makes reminder output { additional_context: "..." } which Cursor
350
+ // injects into the session. Without --json, stdout is plain text (Claude
351
+ // Code style) and Cursor would not parse it.
352
+ const sessionStartCmd = `${thatchBin} reminder --json`;
353
+ // postToolUse fires per-tool (Cursor has no PostToolBatch equivalent).
354
+ // Silent — no stdout — so the agent loop is not delayed.
355
+ const bufferCmd = `${thatchBin} buffer-tool`;
356
+ // beforeSubmitPrompt is Cursor's UserPromptSubmit equivalent. Drains the
357
+ // queue and prints the extraction nudge as JSON additional_context.
358
+ const flushCmd = `${thatchBin} flush-tools --json`;
359
+
360
+ config.hooks.sessionStart = replaceCursorThatchHooks(config.hooks.sessionStart ?? [], sessionStartCmd);
361
+ config.hooks.postToolUse = replaceCursorThatchHooks(config.hooks.postToolUse ?? [], bufferCmd);
362
+ config.hooks.beforeSubmitPrompt = replaceCursorThatchHooks(config.hooks.beforeSubmitPrompt ?? [], flushCmd);
363
+
364
+ mkdirSync(join(path, ".."), { recursive: true });
365
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
366
+ }
367
+
368
+ function replaceCursorThatchHooks(
369
+ entries: { command: string }[],
370
+ command: string,
371
+ ): { command: string }[] {
372
+ const filtered = entries.filter((e) => !e.command?.includes("thatch"));
373
+ filtered.push({ command });
374
+ return filtered;
375
+ }
376
+
377
+ export interface CursorSetupResult {
378
+ mcpConfig: string;
379
+ agentsMd: string;
380
+ hooks: string;
381
+ skills: SkillFile[];
382
+ global: boolean;
383
+ }
384
+
385
+ /**
386
+ * Installs thatch into Cursor. Writes .cursor/mcp.json (or ~/.cursor/mcp.json
387
+ * for global), appends instructions to AGENTS.md, installs sessionStart +
388
+ * postToolUse + beforeSubmitPrompt hooks in .cursor/hooks.json, and installs
389
+ * skill files. All operations are idempotent.
390
+ *
391
+ * Cursor's global MCP config is a simple file write (unlike Claude Code which
392
+ * needs `claude mcp add --scope user`). Skills install to ~/.cursor/skills/
393
+ * (auto-discovered by Cursor, which also reads ~/.claude/skills/ for compat).
394
+ *
395
+ * @param thatchBin Absolute path to the thatch binary
396
+ * @param global Whether to install globally or project-locally
397
+ * @param projectDir The project directory (used for project-local installs)
398
+ * @param homeDir Home directory (defaults to os.homedir())
399
+ */
400
+ export function setupCursor(
401
+ thatchBin: string,
402
+ global: boolean,
403
+ projectDir: string,
404
+ homeDir?: string,
405
+ ): CursorSetupResult {
406
+ const { homedir } = require("node:os");
407
+ const home = homeDir ?? homedir();
408
+ const paths = resolveCursorPaths(global, projectDir, home);
409
+
410
+ writeMcpConfig(paths.mcpConfigPath, thatchBin);
411
+ appendCursorInstructions(paths.agentsMdPath);
412
+ writeCursorHooks(paths.hooksPath, thatchBin);
413
+ const skills = installClaudeSkills(paths.skillsDir);
414
+
415
+ return {
416
+ mcpConfig: paths.mcpConfigPath,
417
+ agentsMd: paths.agentsMdPath,
418
+ hooks: paths.hooksPath,
419
+ skills,
420
+ global,
421
+ };
422
+ }