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.
- package/LICENSE +21 -0
- package/README.md +454 -0
- package/cordis.patch.yml +20 -0
- package/lib/client.js +1081 -0
- package/lib/client.js.map +1 -0
- package/package.json +90 -0
- package/skills/hypatia/SKILL.md +585 -0
- package/skills/hypatia-dream/SKILL.md +246 -0
- package/skills/hypatia-dream/evals/evals.json +92 -0
- package/skills/hypatia-memory/SKILL.md +175 -0
- package/src/auto-approve.js +133 -0
- package/src/cascade.js +190 -0
- package/src/client/SettingsCard.module.css +420 -0
- package/src/client/SettingsCard.tsx +700 -0
- package/src/client/card-form.ts +450 -0
- package/src/client/consolidation-models.ts +69 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.tsx +106 -0
- package/src/client/locales.ts +111 -0
- package/src/client/shelves.ts +88 -0
- package/src/client/slot-contract.ts +17 -0
- package/src/collector.js +596 -0
- package/src/config.js +225 -0
- package/src/consolidator.js +592 -0
- package/src/content-policy.js +301 -0
- package/src/housekeeping.js +197 -0
- package/src/hypatia-cli.js +294 -0
- package/src/hypatia-client.js +72 -0
- package/src/hypatia-mcp.js +477 -0
- package/src/index.js +418 -0
- package/src/persisted-session.js +99 -0
- package/src/progress.js +56 -0
- package/src/queue.js +466 -0
- package/src/recall.js +148 -0
- package/src/shelf.js +180 -0
- package/src/skills.js +166 -0
- package/src/state.js +87 -0
- package/src/status.js +53 -0
- package/src/writer.js +320 -0
|
@@ -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
|
+
}
|