@jeffober/thatch 0.1.5 → 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/README.md +60 -9
- package/bin/thatch +167 -46
- package/docs/dev/README.md +67 -15
- package/docs/dev/gotchas.md +99 -0
- package/docs/dev/mcp-parity.md +113 -57
- package/docs/dev/setup-and-hooks.md +133 -0
- package/docs/dev/skills.md +109 -0
- package/docs/qa/README.md +9 -0
- package/docs/qa/use-cases/README.md +16 -0
- package/docs/qa/use-cases/UC-005-setup-install.md +34 -0
- package/docs/qa/use-cases/UC-006-cursor-hook-lifecycle.md +31 -0
- package/docs/qa/use-cases/UC-007-recall-nudge.md +31 -0
- package/docs/qa/use-cases/UC-008-hygiene-heartbeat.md +26 -0
- package/docs/qa/use-cases/UC-009-flush-tools-tiers.md +31 -0
- package/docs/qa/use-cases/UC-010-prime.md +25 -0
- package/docs/qa/use-cases/UC-011-write-time-similarity-warning.md +24 -0
- package/docs/qa/use-cases/UC-012-model-migration.md +28 -0
- package/docs/qa/use-cases/UC-013-compaction-context.md +25 -0
- package/docs/qa/use-cases/UC-014-skill-install-drift.md +28 -0
- package/docs/qa/use-cases/UC-015-env-override-matrix.md +28 -0
- package/docs/qa/use-cases/UC-016-concurrent-session-isolation.md +36 -0
- package/docs/qa/use-cases/UC-017-buffer-tool-vs-buffer-batch.md +27 -0
- package/docs/user/README.md +42 -7
- package/package.json +3 -2
- package/src/db.ts +19 -5
- package/src/git.ts +1 -1
- package/src/index.ts +65 -19
- package/src/mcp.ts +19 -0
- package/src/prompts.ts +111 -0
- package/src/setup.ts +171 -8
- package/src/sideband.ts +207 -0
- package/src/skills.ts +20 -8
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
|
-
|
|
96
|
-
|
|
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(
|
|
101
|
-
|
|
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
|
+
}
|
package/src/sideband.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createServer, connect, type Server, type Socket } from "node:net";
|
|
6
|
+
import type { ThatchDB } from "./db";
|
|
7
|
+
import type { EmbeddingModel } from "./embeddings";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Deterministic socket path from the DB path. Both the MCP server and hook
|
|
11
|
+
* processes resolve the same DB path (from THATCH_DB_PATH or the default
|
|
12
|
+
* under XDG_CONFIG_HOME), so they independently arrive at the same socket
|
|
13
|
+
* path without any out-of-band coordination.
|
|
14
|
+
*/
|
|
15
|
+
export function sidebandSocketPath(dbPath: string): string {
|
|
16
|
+
const hash = createHash("sha256").update(dbPath).digest("hex").slice(0, 16);
|
|
17
|
+
return join(tmpdir(), `thatch-${hash}.sock`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SidebandMatch {
|
|
21
|
+
label: string;
|
|
22
|
+
score: number;
|
|
23
|
+
store: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface MatchRequest {
|
|
27
|
+
method: "match";
|
|
28
|
+
text: string;
|
|
29
|
+
stores: string[];
|
|
30
|
+
threshold: number;
|
|
31
|
+
limit: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface MatchResponse {
|
|
35
|
+
ok: boolean;
|
|
36
|
+
matches?: SidebandMatch[];
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Serves embedding + semantic search requests over a Unix domain socket.
|
|
42
|
+
* The MCP server (long-lived, warm model) runs this so that one-shot hook
|
|
43
|
+
* processes can ask "does this prompt match any memories?" without loading
|
|
44
|
+
* the ~34 MB model themselves.
|
|
45
|
+
*
|
|
46
|
+
* The protocol is newline-delimited JSON: one request per connection, one
|
|
47
|
+
* response per connection. Requests are `{"method":"match","text":"...",
|
|
48
|
+
* "stores":[...],"threshold":N,"limit":N}`. Responses are `{"ok":true,
|
|
49
|
+
* "matches":[...]}` or `{"ok":false,"error":"..."}`.
|
|
50
|
+
*/
|
|
51
|
+
export class SidebandServer {
|
|
52
|
+
#server: Server | null = null;
|
|
53
|
+
#path: string;
|
|
54
|
+
#model: EmbeddingModel;
|
|
55
|
+
#db: ThatchDB;
|
|
56
|
+
|
|
57
|
+
constructor(path: string, model: EmbeddingModel, db: ThatchDB) {
|
|
58
|
+
this.#path = path;
|
|
59
|
+
this.#model = model;
|
|
60
|
+
this.#db = db;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Opens the socket. Cleans up any stale socket file from a crashed server. */
|
|
64
|
+
start(): void {
|
|
65
|
+
try {
|
|
66
|
+
unlinkSync(this.#path);
|
|
67
|
+
} catch {
|
|
68
|
+
// No stale socket — expected on first run.
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.#server = createServer((socket) => {
|
|
72
|
+
this.#handleConnection(socket);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.#server.listen(this.#path);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Closes the socket and removes the socket file. */
|
|
79
|
+
stop(): void {
|
|
80
|
+
this.#server?.close();
|
|
81
|
+
this.#server = null;
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(this.#path);
|
|
84
|
+
} catch {
|
|
85
|
+
// Already gone.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get path(): string {
|
|
90
|
+
return this.#path;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#handleConnection(socket: Socket): void {
|
|
94
|
+
let buf = "";
|
|
95
|
+
socket.on("data", (chunk: Buffer) => {
|
|
96
|
+
buf += chunk.toString();
|
|
97
|
+
const nl = buf.indexOf("\n");
|
|
98
|
+
if (nl < 0) return;
|
|
99
|
+
|
|
100
|
+
const line = buf.slice(0, nl).trim();
|
|
101
|
+
buf = buf.slice(nl + 1);
|
|
102
|
+
this.#handleRequest(line, socket);
|
|
103
|
+
});
|
|
104
|
+
socket.on("error", () => {
|
|
105
|
+
// Client disconnected abruptly — nothing to do.
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async #handleRequest(line: string, socket: Socket): Promise<void> {
|
|
110
|
+
let req: MatchRequest;
|
|
111
|
+
try {
|
|
112
|
+
req = JSON.parse(line);
|
|
113
|
+
} catch {
|
|
114
|
+
this.#respond(socket, { ok: false, error: "Invalid JSON" });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (req.method !== "match") {
|
|
119
|
+
this.#respond(socket, { ok: false, error: `Unknown method: ${req.method}` });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const embedding = await this.#model.queryEmbed(req.text);
|
|
125
|
+
const results = this.#db.search(req.stores, embedding, { limit: req.limit });
|
|
126
|
+
const matches: SidebandMatch[] = results
|
|
127
|
+
.filter((r) => r._score >= req.threshold)
|
|
128
|
+
.map((r) => ({
|
|
129
|
+
label: r.label,
|
|
130
|
+
score: Math.round(r._score * 1000) / 1000,
|
|
131
|
+
store: r.store,
|
|
132
|
+
}));
|
|
133
|
+
this.#respond(socket, { ok: true, matches });
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
this.#respond(socket, { ok: false, error: String(err?.message ?? err) });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#respond(socket: Socket, res: MatchResponse): void {
|
|
140
|
+
if (!socket.destroyed) {
|
|
141
|
+
socket.write(JSON.stringify(res) + "\n");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Connect to the sideband server and request semantic matches for a prompt.
|
|
148
|
+
* Returns null on any failure (server not running, stale socket, timeout) —
|
|
149
|
+
* callers must treat null as "skip the recall nudge" and fall back gracefully.
|
|
150
|
+
*/
|
|
151
|
+
export function sidebandMatch(
|
|
152
|
+
socketPath: string,
|
|
153
|
+
text: string,
|
|
154
|
+
stores: string[],
|
|
155
|
+
threshold: number,
|
|
156
|
+
limit: number,
|
|
157
|
+
timeoutMs = 2000,
|
|
158
|
+
): Promise<SidebandMatch[] | null> {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
let settled = false;
|
|
161
|
+
const done = (val: SidebandMatch[] | null) => {
|
|
162
|
+
if (settled) return;
|
|
163
|
+
settled = true;
|
|
164
|
+
resolve(val);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const socket = connect(socketPath);
|
|
168
|
+
const req = JSON.stringify({ method: "match", text, stores, threshold, limit }) + "\n";
|
|
169
|
+
let buf = "";
|
|
170
|
+
|
|
171
|
+
const timer = setTimeout(() => {
|
|
172
|
+
socket.destroy();
|
|
173
|
+
done(null);
|
|
174
|
+
}, timeoutMs);
|
|
175
|
+
|
|
176
|
+
socket.on("error", () => {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
// Stale socket file left by a crashed server — clean it up so the next
|
|
179
|
+
// connection attempt doesn't hit ECONNREFUSED again.
|
|
180
|
+
try {
|
|
181
|
+
unlinkSync(socketPath);
|
|
182
|
+
} catch {
|
|
183
|
+
// Already gone or never existed.
|
|
184
|
+
}
|
|
185
|
+
done(null);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
socket.on("connect", () => {
|
|
189
|
+
socket.write(req);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
socket.on("data", (chunk: Buffer) => {
|
|
193
|
+
buf += chunk.toString();
|
|
194
|
+
const nl = buf.indexOf("\n");
|
|
195
|
+
if (nl < 0) return;
|
|
196
|
+
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
try {
|
|
199
|
+
const res = JSON.parse(buf.slice(0, nl)) as MatchResponse;
|
|
200
|
+
done(res.ok ? (res.matches ?? []) : null);
|
|
201
|
+
} catch {
|
|
202
|
+
done(null);
|
|
203
|
+
}
|
|
204
|
+
socket.destroy();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
package/src/skills.ts
CHANGED
|
@@ -649,25 +649,33 @@ For each finding from the specialists:
|
|
|
649
649
|
|
|
650
650
|
3. **Determine provenance.** Is the finding a new issue introduced by this change, or a pre-existing problem? Note pre-existing bugs separately.
|
|
651
651
|
|
|
652
|
-
|
|
652
|
+
**Finding type determines which steps apply next.** Steps 4-6 apply to behavioral findings (from acceptance, state-flow). Mechanical findings (from pedantic, no-slop, breadcrumbs — docs, naming, style, comments, prose) skip steps 4-6 and use the mechanical verification criteria in step 4m instead.
|
|
653
653
|
|
|
654
|
-
|
|
654
|
+
4. **Check runtime model applicability (behavioral findings only).** Can the finding realistically manifest in normal usage given the application's runtime model? A finding that requires conditions impossible in the actual runtime context is not a real bug (e.g., state accumulation in a short-lived process, concurrency in single-threaded code).
|
|
655
|
+
|
|
656
|
+
5. **Prove the causal chain (behavioral findings only)** for any finding about state, data shape, cross-module contracts, or behavior:
|
|
655
657
|
- Identify the authoritative producer or source of the state/data.
|
|
656
658
|
- Identify the transforms between producer and consumer.
|
|
657
659
|
- Identify the consumer or branch where failure occurs.
|
|
658
660
|
- Identify the real entrypoint/workflow that exercises this chain.
|
|
659
661
|
If the only way to trigger the issue is by manually fabricating invalid data/state or bypassing the real producers/guards, reject the finding. If the citation is real but you cannot prove the producer chain, classify as UNVERIFIABLE rather than CONFIRMED.
|
|
660
662
|
|
|
661
|
-
6. **Verify intent.** If the specialist flagged behavior as a bug but the code appears to work as designed, check whether the behavior is intentional:
|
|
663
|
+
6. **Verify intent (behavioral findings only).** If the specialist flagged behavior as a bug but the code appears to work as designed, check whether the behavior is intentional:
|
|
662
664
|
- Read the callers of the cited code to see if the pattern makes sense in context.
|
|
663
665
|
- Check git log or git blame on the cited file for commit messages explaining the decision.
|
|
664
666
|
- Use thatch_memory_recall to search for documented rationale.
|
|
665
667
|
If the behavior is intentional or an accepted limitation, reject the finding.
|
|
666
668
|
|
|
669
|
+
4m. **Mechanical verification (pedantic, no-slop, breadcrumbs, docs, naming, style, comments).** For mechanical findings, verification means:
|
|
670
|
+
- The cited text exists at the cited location (already confirmed in step 2).
|
|
671
|
+
- The finding is branch-introduced or newly made relevant by the change. Pre-existing issues go in the pre-existing appendix, not rejected.
|
|
672
|
+
- The cited text violates the stated guideline, specialist taxonomy, or project norm. Identify the source of truth: the specific guideline, convention, or writing norm being violated.
|
|
673
|
+
Runtime reachability, producer chains, and intent verification do not apply. A mechanical finding is not a "bug" and does not need a "trigger scenario."
|
|
674
|
+
|
|
667
675
|
7. **Classify:**
|
|
668
|
-
- **CONFIRMED**:
|
|
669
|
-
- **REJECTED**:
|
|
670
|
-
- **UNVERIFIABLE**: The citation is correct but you cannot confirm the
|
|
676
|
+
- **CONFIRMED**: For behavioral findings: the cited code matches, the claim is accurate, the bug is reachable through realistic usage, you proved the workflow/producer chain where applicable, and the behavior is not intentional. For mechanical findings: the cited text exists, is branch-introduced or newly made relevant, and violates the stated guideline or specialist taxonomy.
|
|
677
|
+
- **REJECTED**: For behavioral findings: the citation is wrong, the claim is inaccurate, the bug cannot manifest in the actual runtime context, or the behavior is an intentional design decision (explain why briefly). Reject findings that rely on manually seeded invalid state/data with no real producer path. For mechanical findings: the cited text does not exist, does not violate the stated guideline, is unchanged legacy outside the touched scope, or the finding duplicates another confirmed finding.
|
|
678
|
+
- **UNVERIFIABLE**: The citation is correct but you cannot confirm the claim without deeper tracing. This is the default for plausible behavioral claims that lack a proven trigger path, producer chain, or authoritative source of truth. Mechanical findings should rarely be UNVERIFIABLE — if the text exists and violates the guideline, it is CONFIRMED.
|
|
671
679
|
|
|
672
680
|
## Deduplication
|
|
673
681
|
|
|
@@ -687,6 +695,8 @@ Assign final severity based on YOUR verification:
|
|
|
687
695
|
|
|
688
696
|
You MUST produce the full report structure below. Do not simplify or omit sections. Every confirmed finding must include all numbered fields. If a section has no entries, write "None" — do not skip the section.
|
|
689
697
|
|
|
698
|
+
Confirmed LOW findings are mandatory. Do not summarize them away or omit them for being non-functional. Pedantic, no-slop, breadcrumbs, docs, naming, style, and comment findings are first-class review findings when confirmed. Group them under LOW, but include every one.
|
|
699
|
+
|
|
690
700
|
### Scope
|
|
691
701
|
- Branch/range reviewed
|
|
692
702
|
- Design context (if provided)
|
|
@@ -858,13 +868,15 @@ For the integration review unit (5+ points), dispatch a sub-agent focused on:
|
|
|
858
868
|
|
|
859
869
|
After all sub-agents complete, load the thatch-review-synthesizer skill to verify findings, deduplicate across specialists, classify (CONFIRMED/REJECTED/UNVERIFIABLE), and produce the final severity-grouped report.
|
|
860
870
|
|
|
871
|
+
Confirmed LOW findings are mandatory in the final report, including mechanical findings (pedantic, no-slop, breadcrumbs, docs, naming, style, comments). Do not omit them for being non-functional.
|
|
872
|
+
|
|
861
873
|
Alternatively, perform the synthesis yourself:
|
|
862
874
|
1. Read each finding's cited location to verify evidence accuracy.
|
|
863
875
|
2. Deduplicate findings flagged by multiple specialists.
|
|
864
876
|
3. Group findings by root cause where multiple findings stem from the same issue.
|
|
865
|
-
4. Classify each as CONFIRMED, REJECTED, or UNVERIFIABLE
|
|
877
|
+
4. Classify each as CONFIRMED, REJECTED, or UNVERIFIABLE. For behavioral findings, apply citation verification, reachability, and intent verification. For mechanical findings, verify the cited text exists, is branch-introduced or newly made relevant, and violates the stated guideline or specialist taxonomy.
|
|
866
878
|
5. Calibrate severity (BLOCKING > HIGH > MEDIUM > LOW) based on your verification.
|
|
867
|
-
6. Produce a final report grouped by severity, with coverage gaps noted.
|
|
879
|
+
6. Produce a final report grouped by severity, with coverage gaps noted. Include every confirmed LOW finding.
|
|
868
880
|
|
|
869
881
|
## Specialist briefing template
|
|
870
882
|
|