@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.
@@ -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
+ }