@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/README.md +60 -9
- package/bin/thatch +167 -46
- package/docs/dev/README.md +73 -18
- 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 +101 -4
- package/package.json +3 -2
- package/src/db.ts +19 -5
- package/src/git.ts +1 -1
- package/src/index.ts +70 -21
- 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 +732 -9
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
|
+
}
|