dsh-hypatia-auto-memory 0.1.0

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,477 @@
1
+ /**
2
+ * hypatia over MCP: one private `hypatia mcp` process on the subprocess
3
+ * service, spoken to over stdio.
4
+ *
5
+ * The connection is the plugin's own. It is not registered with
6
+ * `dsh-mcp-client`, so the model never sees these tools and the plugin's calls
7
+ * pass through no tool policy, hook or approval — the same property the argv
8
+ * CLI path has.
9
+ *
10
+ * `hypatia mcp` answers one request at a time, in order (hypatia
11
+ * docs/agent-interfaces.md §3), so requests are serialized here as well: a
12
+ * request's deadline then measures that request and not the queue in front of
13
+ * it. The process starts on the first call and is closed after a quiet spell,
14
+ * for two reasons from the same document. It keeps the embedding model's
15
+ * allocations after a semantic call (hundreds of MB resident), and it reads the
16
+ * shelf registry once, at start — a fresh process is what sees a shelf
17
+ * connected since.
18
+ *
19
+ * Results are mapped onto the CLI client's interface (hypatia-cli.js), so the
20
+ * writer, cascade, recall and housekeeping do not know which transport ran.
21
+ *
22
+ * @module dsh-hypatia-auto-memory/hypatia-mcp
23
+ */
24
+
25
+ import { homedir } from 'node:os'
26
+ import { createInterface } from 'node:readline'
27
+
28
+ import { trimLikeHypatia } from './content-policy.js'
29
+ import { HypatiaCliError } from './hypatia-cli.js'
30
+ import { DEFAULT_SHELF } from './shelf.js'
31
+
32
+ /** Handshake-era revision `hypatia mcp` speaks (it accepts four; this one is enough). */
33
+ const PROTOCOL_VERSION = '2025-06-18'
34
+ /** Per-call ceiling, as for one CLI invocation. */
35
+ const DEFAULT_TIMEOUT_MS = 30_000
36
+ /** Starting the process and listing its tools; opening a shelf is not part of it. */
37
+ const HANDSHAKE_TIMEOUT_MS = 10_000
38
+ /** Quiet time after which the process is closed. A consolidation's calls fall well inside it. */
39
+ const DEFAULT_IDLE_MS = 60_000
40
+ const GRACE_MS = 3000
41
+ const STDERR_MAX_BYTES = 64 * 1024
42
+
43
+ /** Tools the client calls. A server lacking any of them counts as unavailable. */
44
+ export const REQUIRED_TOOLS = Object.freeze([
45
+ 'knowledge_get',
46
+ 'knowledge_create',
47
+ 'statement_create',
48
+ 'search',
49
+ 'similar',
50
+ 'query',
51
+ ])
52
+
53
+ /**
54
+ * One request line. A lone UTF-16 surrogate — what a `.slice()` through an
55
+ * emoji leaves behind — is written by `JSON.stringify` as a bare `\ud83d`
56
+ * escape, which hypatia's JSON parser rejects, failing the whole request. The
57
+ * CLI path never met this: Node encodes argv as UTF-8, where it becomes U+FFFD.
58
+ * `toWellFormed()` makes the same substitution here.
59
+ */
60
+ function encode(message) {
61
+ return `${JSON.stringify(message, (_, value) => (typeof value === 'string' ? value.toWellFormed() : value))}\n`
62
+ }
63
+
64
+ /**
65
+ * Whether a failed handshake shows the binary serves no usable MCP, as opposed
66
+ * to one start that went wrong. Only the former is worth giving MCP up for.
67
+ */
68
+ function isUnavailable(error) {
69
+ if (!(error instanceof HypatiaCliError)) return false
70
+ // A required tool missing from `tools/list`.
71
+ if (error.code === 'MCP_UNAVAILABLE') return true
72
+ // A server that answers, but not `initialize` or `tools/list`.
73
+ if (error.code === 'MCP_ERROR') return true
74
+ // clap's usage error: a binary from before the `mcp` subcommand.
75
+ return error.code === 'MCP_EXITED' && error.exitCode === 2 && /unrecognized subcommand/.test(error.stderr ?? '')
76
+ }
77
+
78
+ /**
79
+ * One `hypatia mcp` process at a time, started on demand.
80
+ *
81
+ * Every failure is a `HypatiaCliError`. `MCP_UNAVAILABLE` means the binary
82
+ * ran but serves no usable MCP — no `mcp` subcommand, a required tool missing,
83
+ * or a server that answers `initialize` or `tools/list` with an error — and is the only
84
+ * code the transport switch in hypatia-client.js falls back on. Anything else
85
+ * fails the one call it happened to: a binary that cannot be started at all
86
+ * (`SPAWN_FAILED`, `NO_BINARY`, as on the CLI path), a start that timed out or
87
+ * died, or a process that dies or overruns during a call (`MCP_EXITED`,
88
+ * `TIMEOUT`). The next call starts a new process.
89
+ *
90
+ * @param {import('@deepseek-ai/cordis').Context} ctx - context injecting `subprocess`.
91
+ * @param {{
92
+ * binary: () => string | undefined,
93
+ * timeoutMs?: number,
94
+ * handshakeTimeoutMs?: number,
95
+ * idleMs?: number,
96
+ * }} options
97
+ */
98
+ export function createMcpConnection(ctx, options) {
99
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
100
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS
101
+ const idleMs = options.idleMs ?? DEFAULT_IDLE_MS
102
+
103
+ /** The live, handshaken process, if any. */
104
+ let current
105
+ let nextId = 1
106
+ let chain = Promise.resolve()
107
+ let idleTimer
108
+ let disposed = false
109
+ /** Set once a binary has shown it serves no usable MCP; it will not start to. */
110
+ let unavailable
111
+
112
+ /** Stderr written since `conn`'s current call was sent, trimmed to its tail. */
113
+ function stderrOf(conn) {
114
+ return (conn.handle.collected.stderr?.readFrom(conn.stderrMark).text ?? '').trim().slice(-500)
115
+ }
116
+
117
+ /** Reject whatever is waiting on `conn` and forget it. */
118
+ function fail(conn, error) {
119
+ conn.closed = true
120
+ if (current === conn) current = undefined
121
+ for (const waiter of conn.pending.values()) waiter.reject(error)
122
+ conn.pending.clear()
123
+ }
124
+
125
+ /** Close `conn`: EOF ends `hypatia mcp`'s read loop; terminate is the backstop. */
126
+ function close(conn) {
127
+ if (conn.closed) return
128
+ fail(conn, new HypatiaCliError('hypatia mcp connection closed', { code: 'MCP_CLOSED' }))
129
+ conn.handle.stdin.end()
130
+ const backstop = setTimeout(() => conn.handle.terminate(), GRACE_MS)
131
+ backstop.unref?.()
132
+ void conn.handle.done.finally(() => clearTimeout(backstop)).catch(() => {})
133
+ }
134
+
135
+ function kill(conn, error) {
136
+ fail(conn, error)
137
+ conn.handle.terminate()
138
+ }
139
+
140
+ function spawn() {
141
+ const binary = options.binary()
142
+ if (binary === undefined || binary === '') {
143
+ throw new HypatiaCliError('no hypatia binary configured', { code: 'NO_BINARY' })
144
+ }
145
+ let handle
146
+ try {
147
+ handle = ctx.subprocess.spawn({
148
+ argv: [binary, 'mcp'],
149
+ cwd: homedir(),
150
+ stdio: {
151
+ stdin: 'pipe',
152
+ stdout: 'pipe',
153
+ stderr: { maxBytes: STDERR_MAX_BYTES },
154
+ },
155
+ graceMs: GRACE_MS,
156
+ })
157
+ } catch (error) {
158
+ throw new HypatiaCliError(`failed to spawn hypatia mcp: ${String(error)}`, { code: 'SPAWN_FAILED' })
159
+ }
160
+ if (handle.stdin === undefined || handle.stdout === undefined) {
161
+ handle.terminate()
162
+ throw new HypatiaCliError('hypatia mcp spawned without piped stdio', { code: 'SPAWN_FAILED' })
163
+ }
164
+ const conn = { handle, pending: new Map(), closed: false, stderrMark: 0 }
165
+ // The exit path below reports a dead process; a write racing it must not
166
+ // surface as an unhandled stream error.
167
+ handle.stdin.on('error', () => {})
168
+ const lines = createInterface({ input: handle.stdout, crlfDelay: Infinity })
169
+ // readline re-emits a stdout stream error here; unheard, it would take the
170
+ // host down with it.
171
+ lines.on('error', (error) => {
172
+ kill(conn, new HypatiaCliError(`hypatia mcp stdout failed: ${String(error)}`, { code: 'MCP_EXITED' }))
173
+ })
174
+ lines.on('line', (line) => {
175
+ if (line.trim() === '') return
176
+ let message
177
+ try {
178
+ message = JSON.parse(line)
179
+ } catch {
180
+ return
181
+ }
182
+ // Replies only: this client offers no capabilities, so the server sends
183
+ // no requests or notifications worth answering.
184
+ let id = message?.id
185
+ // A request the server could not read is answered with id null. One
186
+ // request is outstanding at most, so it is that one; left unmatched, it
187
+ // would wait out its deadline and cost the process.
188
+ if (id === null && message.error !== undefined && conn.pending.size === 1) {
189
+ id = conn.pending.keys().next().value
190
+ }
191
+ const waiter = conn.pending.get(id)
192
+ if (waiter === undefined) return
193
+ conn.pending.delete(id)
194
+ if (message.error !== undefined) {
195
+ waiter.reject(new HypatiaCliError(`hypatia mcp: ${message.error?.message ?? 'error'}`, { code: 'MCP_ERROR' }))
196
+ } else {
197
+ waiter.resolve(message.result)
198
+ }
199
+ })
200
+ handle.done.then(
201
+ (outcome) => {
202
+ const how = outcome.exitCode !== null ? `exited ${outcome.exitCode}` : `killed by ${outcome.signal}`
203
+ const stderr = stderrOf(conn)
204
+ fail(conn, new HypatiaCliError(`hypatia mcp ${how}${stderr === '' ? '' : `: ${stderr}`}`, {
205
+ code: 'MCP_EXITED',
206
+ stderr,
207
+ exitCode: outcome.exitCode,
208
+ }))
209
+ },
210
+ (error) => {
211
+ fail(conn, new HypatiaCliError(`hypatia mcp failed to start: ${String(error)}`, { code: 'SPAWN_FAILED' }))
212
+ },
213
+ )
214
+ return conn
215
+ }
216
+
217
+ function request(conn, method, params, deadlineMs) {
218
+ return new Promise((resolve, reject) => {
219
+ if (conn.closed) {
220
+ reject(new HypatiaCliError('hypatia mcp connection closed', { code: 'MCP_CLOSED' }))
221
+ return
222
+ }
223
+ const id = nextId++
224
+ const timer = setTimeout(() => {
225
+ // `hypatia mcp` cannot cancel a request, and everything after this one
226
+ // would wait behind it: kill the process, as the CLI path kills a
227
+ // command that overruns.
228
+ kill(conn, new HypatiaCliError(`hypatia mcp timed out after ${deadlineMs}ms: ${method}`, {
229
+ code: 'TIMEOUT',
230
+ stderr: stderrOf(conn),
231
+ }))
232
+ }, deadlineMs)
233
+ conn.pending.set(id, {
234
+ resolve: (value) => {
235
+ clearTimeout(timer)
236
+ resolve(value)
237
+ },
238
+ reject: (error) => {
239
+ clearTimeout(timer)
240
+ reject(error)
241
+ },
242
+ })
243
+ conn.handle.stdin.write(encode({ jsonrpc: '2.0', id, method, params }))
244
+ })
245
+ }
246
+
247
+ /** Start a process and check it serves what the client calls. */
248
+ async function open() {
249
+ const conn = spawn()
250
+ try {
251
+ await request(conn, 'initialize', {
252
+ protocolVersion: PROTOCOL_VERSION,
253
+ capabilities: {},
254
+ clientInfo: { name: 'dsh-hypatia-auto-memory', version: '0' },
255
+ }, handshakeTimeoutMs)
256
+ conn.handle.stdin.write(encode({ jsonrpc: '2.0', method: 'notifications/initialized' }))
257
+ const listed = await request(conn, 'tools/list', {}, handshakeTimeoutMs)
258
+ const names = new Set((listed?.tools ?? []).map((tool) => tool?.name))
259
+ const missing = REQUIRED_TOOLS.filter((name) => !names.has(name))
260
+ if (missing.length > 0) {
261
+ throw new HypatiaCliError(`hypatia mcp lacks ${missing.join(', ')}`, { code: 'MCP_UNAVAILABLE' })
262
+ }
263
+ } catch (error) {
264
+ kill(conn, error)
265
+ if (!isUnavailable(error)) throw error
266
+ // Calls already queued get this answer instead of starting the binary
267
+ // again. The message carries clap's own words for an old binary.
268
+ unavailable = new HypatiaCliError(`hypatia mcp unavailable: ${error.message}`, {
269
+ code: 'MCP_UNAVAILABLE',
270
+ stderr: error.stderr,
271
+ })
272
+ throw unavailable
273
+ }
274
+ return conn
275
+ }
276
+
277
+ function armIdle() {
278
+ clearTimeout(idleTimer)
279
+ idleTimer = setTimeout(() => {
280
+ if (current !== undefined) close(current)
281
+ }, idleMs)
282
+ idleTimer.unref?.()
283
+ }
284
+
285
+ return {
286
+ /**
287
+ * One `tools/call`, after the calls before it.
288
+ * @param {string} name
289
+ * @param {Record<string, unknown>} args
290
+ * @returns {Promise<any>} the MCP `CallToolResult`, `isError` included.
291
+ */
292
+ call(name, args) {
293
+ const run = async () => {
294
+ if (disposed) throw new HypatiaCliError('hypatia mcp connection disposed', { code: 'MCP_CLOSED' })
295
+ if (unavailable !== undefined) throw unavailable
296
+ clearTimeout(idleTimer)
297
+ try {
298
+ if (current === undefined || current.closed) {
299
+ const conn = await open()
300
+ // Disposed during the handshake: nothing else would close it.
301
+ if (disposed) {
302
+ close(conn)
303
+ throw new HypatiaCliError('hypatia mcp connection disposed', { code: 'MCP_CLOSED' })
304
+ }
305
+ current = conn
306
+ }
307
+ // An error quotes what this call made the process write, not what
308
+ // earlier calls did. The handshake's errors quote everything.
309
+ current.stderrMark = current.handle.collected.stderr?.readFrom(0).nextOffset ?? 0
310
+ return await request(current, 'tools/call', { name, arguments: args }, timeoutMs)
311
+ } finally {
312
+ if (!disposed) armIdle()
313
+ }
314
+ }
315
+ const result = chain.then(run, run)
316
+ chain = result.catch(() => {})
317
+ return result
318
+ },
319
+
320
+ /** Drop the process; the next call starts a fresh one. */
321
+ reset() {
322
+ if (current !== undefined) close(current)
323
+ },
324
+
325
+ dispose() {
326
+ disposed = true
327
+ clearTimeout(idleTimer)
328
+ if (current !== undefined) close(current)
329
+ },
330
+ }
331
+ }
332
+
333
+ /** The text blocks of a tool result, joined. */
334
+ function toolText(result) {
335
+ return (result?.content ?? [])
336
+ .filter((block) => block?.type === 'text')
337
+ .map((block) => String(block.text ?? ''))
338
+ .join('\n')
339
+ }
340
+
341
+ /**
342
+ * A list as the CLI stores it when hypatia-cli.js passes `items.join(',')`:
343
+ * split on commas, trimmed, blanks dropped, and — for scopes — a trailing comma
344
+ * adding the global scope `""`. (The CLI keeps a blank tag where the server
345
+ * drops it; no caller sends one.) `hypatia mcp` trims each item the same way
346
+ * but never splits one or reads a trailing comma, so without this a list
347
+ * holding `a,b`, `""` or `foo,` would be stored differently depending on which
348
+ * transport wrote it, and scope queries would stop matching across the two.
349
+ * `projectScope` keeps the plugin's own project names clear of all three.
350
+ *
351
+ * @param {string[]} items
352
+ * @param {{scopes?: boolean}} [kind]
353
+ */
354
+ function asCliList(items, { scopes = false } = {}) {
355
+ const joined = items.join(',')
356
+ const list = joined.split(',').map(trimLikeHypatia).filter((item) => item !== '')
357
+ if (scopes && joined.endsWith(',') && !list.includes('')) list.push('')
358
+ return list
359
+ }
360
+
361
+ /**
362
+ * The CLI client's interface (hypatia-cli.js), served by `hypatia mcp`.
363
+ *
364
+ * Differences from the CLI, all at the transport level:
365
+ * - Content is a JSON string on stdin, not an argv element, so nothing is
366
+ * truncated to fit a command line.
367
+ * - Absence and collisions arrive as tool errors (`not found: knowledge …`,
368
+ * `knowledge '…' already exists`) and map onto the CLI client's results:
369
+ * `{found: false}` and a `false` create.
370
+ * - `statement_create` reports whether it created the triple.
371
+ *
372
+ * `listShelves` is not here: the shelf listing stays on the CLI (see
373
+ * hypatia-client.js).
374
+ *
375
+ * @param {ReturnType<typeof createMcpConnection>} connection
376
+ * @param {{shelf?: string}} config
377
+ */
378
+ export function createHypatiaMcp(connection, config) {
379
+ const shelfOf = () => config.shelf ?? DEFAULT_SHELF
380
+
381
+ /**
382
+ * Call a tool; a tool error becomes a `HypatiaCliError` whose code says what
383
+ * the CLI client would have made of it.
384
+ */
385
+ async function invoke(name, args) {
386
+ const result = await connection.call(name, args)
387
+ if (result?.isError === true) {
388
+ const message = toolText(result).trim()
389
+ // The server loaded the registry when it started. A shelf connected since
390
+ // is only visible to a new process, and the queue's retry will be one.
391
+ if (/is not connected/.test(message)) connection.reset()
392
+ let code = 'TOOL_ERROR'
393
+ if (/^not found:/.test(message)) code = 'NOT_FOUND'
394
+ // `knowledge_create` checks first and says so; the storage layer's own
395
+ // text — the same the CLI client keys on — covers a row that appeared
396
+ // between that check and the insert.
397
+ else if (/^knowledge '[\s\S]*' already exists;|UNIQUE constraint failed:|duplicate key/i.test(message)) code = 'DUPLICATE'
398
+ throw new HypatiaCliError(`hypatia ${name}: ${message.slice(0, 500)}`, { code, stderr: message.slice(0, 500) })
399
+ }
400
+ if (result?.structuredContent !== undefined) return result.structuredContent
401
+ try {
402
+ return JSON.parse(toolText(result))
403
+ } catch {
404
+ throw new HypatiaCliError(`unparseable ${name} result: ${toolText(result).slice(0, 200)}`, { code: 'BAD_JSON' })
405
+ }
406
+ }
407
+
408
+ /** A create that a replay repeats: a collision means the row is there. */
409
+ async function create(name, args) {
410
+ try {
411
+ return await invoke(name, args)
412
+ } catch (error) {
413
+ if (error instanceof HypatiaCliError && error.code === 'DUPLICATE') return undefined
414
+ throw error
415
+ }
416
+ }
417
+
418
+ /** `tags` and `scopes` as the CLI would have stored them; omitted when that is nothing. */
419
+ function lists(args, { tags, scopes }) {
420
+ const cleanTags = asCliList(tags ?? [])
421
+ if (cleanTags.length > 0) args.tags = cleanTags
422
+ const cleanScopes = asCliList(scopes ?? [], { scopes: true })
423
+ if (cleanScopes.length > 0) args.scopes = cleanScopes
424
+ return args
425
+ }
426
+
427
+ const rowsOf = (structured) => (Array.isArray(structured?.rows) ? structured.rows : [])
428
+
429
+ return {
430
+ /** @returns {Promise<{found: true, name: string, content: any} | {found: false}>} */
431
+ async knowledgeGet(name, shelf = shelfOf()) {
432
+ try {
433
+ const entry = await invoke('knowledge_get', { name, shelf })
434
+ return { found: true, name: entry.name, content: entry.content }
435
+ } catch (error) {
436
+ if (error instanceof HypatiaCliError && error.code === 'NOT_FOUND') return { found: false }
437
+ throw error
438
+ }
439
+ },
440
+
441
+ /**
442
+ * @param {{data: string, tags?: string[], scopes?: string[], shelf?: string}} entry
443
+ * @returns {Promise<boolean>} true when this call created the entry.
444
+ */
445
+ async knowledgeCreate(name, entry) {
446
+ const args = { name, shelf: entry.shelf ?? shelfOf() }
447
+ if (entry.data) args.data = entry.data
448
+ return (await create('knowledge_create', lists(args, entry))) !== undefined
449
+ },
450
+
451
+ /**
452
+ * @param {{data?: string, scopes?: string[], shelf?: string}} [entry]
453
+ * @returns {Promise<boolean>} true when this call created the triple.
454
+ */
455
+ async statementCreate(head, relation, tail, entry = {}) {
456
+ const args = { head, relation, tail, shelf: entry.shelf ?? shelfOf() }
457
+ if (entry.data) args.data = entry.data
458
+ const outcome = await create('statement_create', lists(args, { scopes: entry.scopes }))
459
+ return outcome !== undefined && outcome.created !== false
460
+ },
461
+
462
+ /** Keyword search; rows as the CLI prints them. */
463
+ async search(query, { catalog = 'knowledge', limit = 5, shelf = shelfOf() } = {}) {
464
+ return rowsOf(await invoke('search', { query, catalog, limit, shelf }))
465
+ },
466
+
467
+ /** Vector-similarity search; rows as the CLI prints them. */
468
+ async similar(query, { target = 'knowledge', limit = 5, shelf = shelfOf() } = {}) {
469
+ return rowsOf(await invoke('similar', { query, target, limit, shelf }))
470
+ },
471
+
472
+ /** Raw JSE query; `jse` is the JSON text the CLI would take as its argument. */
473
+ async query(jse, { shelf = shelfOf() } = {}) {
474
+ return rowsOf(await invoke('query', { jse, shelf }))
475
+ },
476
+ }
477
+ }