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,294 @@
1
+ /**
2
+ * Typed wrapper over the `hypatia` CLI via the DSH subprocess service.
3
+ *
4
+ * Every invocation uses an argv array with NO shell interpretation, so the
5
+ * plugin never needs sandbox escalation and never parses shell syntax — the
6
+ * auto-approve layer that dsh-hypatia needs for bash-string calls simply does
7
+ * not exist here.
8
+ *
9
+ * @module dsh-hypatia-auto-memory/hypatia-cli
10
+ */
11
+
12
+ import { homedir } from 'node:os'
13
+
14
+ import { DEFAULT_SHELF, parseShelfList } from './shelf.js'
15
+
16
+ /** Per-call ceiling for one hypatia CLI invocation. */
17
+ const DEFAULT_TIMEOUT_MS = 30_000
18
+ const GRACE_MS = 3000
19
+ const STDOUT_MAX_BYTES = 512 * 1024
20
+ const STDERR_MAX_BYTES = 64 * 1024
21
+
22
+ /**
23
+ * Byte ceiling for one `--data=` argument.
24
+ *
25
+ * Content travels as a single argv element. Linux rejects any one argument over
26
+ * 128 KiB (`MAX_ARG_STRLEN`) with E2BIG, and macOS caps the whole argv near
27
+ * 1 MiB — so an unbounded payload (a pasted log, a huge tool result folded into
28
+ * a message) failed the spawn outright, retried, and ended as a failed task.
29
+ * 96 KiB leaves headroom for the rest of the command line.
30
+ */
31
+ export const MAX_DATA_BYTES = 96 * 1024
32
+
33
+ /**
34
+ * Trim `data` to fit one argv element, cutting on a UTF-8 boundary and saying
35
+ * so. Returns the input unchanged when it already fits.
36
+ * @param {string} data
37
+ * @returns {string}
38
+ */
39
+ export function fitDataArgument(data) {
40
+ const bytes = Buffer.byteLength(data, 'utf8')
41
+ if (bytes <= MAX_DATA_BYTES) return data
42
+ const marker = `\n\n[...truncated ${bytes - MAX_DATA_BYTES} bytes to fit the command line]`
43
+ const budget = MAX_DATA_BYTES - Buffer.byteLength(marker, 'utf8')
44
+ // Decoding a prefix that ends mid-character yields U+FFFD; drop it rather
45
+ // than store a mangled final character.
46
+ const head = Buffer.from(data, 'utf8').subarray(0, budget).toString('utf8').replace(/\uFFFD+$/, '')
47
+ return head + marker
48
+ }
49
+
50
+ export class HypatiaCliError extends Error {
51
+ /**
52
+ * @param {string} message
53
+ * @param {{code?: string, stderr?: string, exitCode?: number|null}} [facts]
54
+ */
55
+ constructor(message, facts = {}) {
56
+ super(message)
57
+ this.name = 'HypatiaCliError'
58
+ this.code = facts.code ?? 'HYPATIA_CLI'
59
+ this.stderr = facts.stderr
60
+ this.exitCode = facts.exitCode ?? null
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Create the CLI runner bound to one subprocess service and config source.
66
+ *
67
+ * @param {import('@deepseek-ai/cordis').Context} ctx - context injecting `subprocess`.
68
+ * @param {{binaries: string[], shelf?: string, timeoutMs?: number}} config -
69
+ * `shelf` is where every call goes unless the call names another.
70
+ */
71
+ export function createHypatiaCli(ctx, config) {
72
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS
73
+ const shelfOf = () => config.shelf ?? DEFAULT_SHELF
74
+
75
+ /**
76
+ * Run one argv, collect bounded output, enforce the deadline.
77
+ * @param {string[]} argv
78
+ * @returns {Promise<{exitCode: number|null, stdout: string, stderr: string}>}
79
+ */
80
+ async function run(argv) {
81
+ const binary = config.binaries[0]
82
+ if (binary === undefined || binary === '') {
83
+ throw new HypatiaCliError('no hypatia binary configured', { code: 'NO_BINARY' })
84
+ }
85
+ const controller = new AbortController()
86
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
87
+ let handle
88
+ try {
89
+ handle = ctx.subprocess.spawn({
90
+ argv: [binary, ...argv],
91
+ cwd: homedir(),
92
+ stdio: {
93
+ stdin: 'ignore',
94
+ stdout: { maxBytes: STDOUT_MAX_BYTES },
95
+ stderr: { maxBytes: STDERR_MAX_BYTES },
96
+ },
97
+ graceMs: GRACE_MS,
98
+ signal: controller.signal,
99
+ })
100
+ } catch (error) {
101
+ clearTimeout(timer)
102
+ throw new HypatiaCliError(`failed to spawn hypatia: ${String(error)}`, { code: 'SPAWN_FAILED' })
103
+ }
104
+ let outcome
105
+ try {
106
+ outcome = await handle.done
107
+ } catch (error) {
108
+ clearTimeout(timer)
109
+ throw new HypatiaCliError(`hypatia process failed: ${String(error)}`, { code: 'PROCESS_FAILED' })
110
+ } finally {
111
+ clearTimeout(timer)
112
+ }
113
+ const stdout = handle.collected.stdout?.readFrom(0).text ?? ''
114
+ const stderr = handle.collected.stderr?.readFrom(0).text ?? ''
115
+ if (controller.signal.aborted) {
116
+ throw new HypatiaCliError(`hypatia timed out after ${timeoutMs}ms: ${argv.join(' ')}`, {
117
+ code: 'TIMEOUT',
118
+ stderr,
119
+ exitCode: outcome.exitCode,
120
+ })
121
+ }
122
+ return { exitCode: outcome.exitCode, stdout, stderr }
123
+ }
124
+
125
+ /**
126
+ * Run and require exit 0; throw with stderr context otherwise.
127
+ *
128
+ * A primary-key collision gets its own `DUPLICATE` code so the write helpers
129
+ * can treat it as an idempotent no-op. `knowledge-create` has no upsert and
130
+ * no `--if-not-exists` — a plain `INSERT` with no `ON CONFLICT`
131
+ * (`src/storage/sqlite_store.rs`) — so recognising the error text is the only
132
+ * way a replay can finish a half-written multi-step graph write.
133
+ * `statement-create` became idempotent in hypatia #20 (a repeat exits 0 with
134
+ * `Statement already exists`), but older binaries still reject a duplicate
135
+ * triple the same way, so this classification serves both. Matching on a
136
+ * message is unlovely; it is confined to this one function so that a
137
+ * knowledge upsert upstream is a single deletion here. Both backends are
138
+ * covered: SQLite reports `UNIQUE constraint failed:`, PostgreSQL
139
+ * `duplicate key`.
140
+ *
141
+ * @param {string[]} argv
142
+ */
143
+ async function runOk(argv) {
144
+ const result = await run(argv)
145
+ if (result.exitCode !== 0) {
146
+ const stderr = result.stderr.trim().slice(0, 500)
147
+ const duplicate = /UNIQUE constraint failed:|duplicate key/i.test(stderr)
148
+ throw new HypatiaCliError(`hypatia exited ${result.exitCode}: ${argv.join(' ')}`, {
149
+ code: duplicate ? 'DUPLICATE' : 'NONZERO_EXIT',
150
+ stderr,
151
+ exitCode: result.exitCode,
152
+ })
153
+ }
154
+ return result
155
+ }
156
+
157
+ /**
158
+ * Run a create that is expected to be replayed: a collision means the row is
159
+ * already there, which is success for our purposes.
160
+ * @returns {Promise<boolean>} true when this call created the row. Since
161
+ * hypatia #20 a repeated `statement-create` also exits 0, so for a
162
+ * statement `true` only means the triple exists now; no caller relies on
163
+ * the difference.
164
+ */
165
+ async function runCreate(argv) {
166
+ try {
167
+ await runOk(argv)
168
+ return true
169
+ } catch (error) {
170
+ if (error instanceof HypatiaCliError && error.code === 'DUPLICATE') return false
171
+ throw error
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Whether stdout is hypatia's empty-result sentinel.
177
+ *
178
+ * The sentinel must be the WHOLE output. Testing for the phrase anywhere
179
+ * discarded entire result sets whenever one returned entry merely contained
180
+ * it — and stored tool output did: a logged search that found nothing reads
181
+ * "No results found." in the middle of an otherwise normal row.
182
+ */
183
+ function isEmptyResult(stdout) {
184
+ return stdout.trim() === 'No results found.'
185
+ }
186
+
187
+ /** Parse a pretty-JSON stdout payload, tolerating trailing whitespace. */
188
+ function parseJson(stdout, what) {
189
+ try {
190
+ return JSON.parse(stdout)
191
+ } catch {
192
+ throw new HypatiaCliError(`unparseable ${what} output: ${stdout.slice(0, 200)}`, { code: 'BAD_JSON' })
193
+ }
194
+ }
195
+
196
+ return {
197
+ run,
198
+ runOk,
199
+
200
+ /**
201
+ * Read one knowledge entry. Missing entries are a normal result, not an
202
+ * error: the CLI prints `Knowledge '<name>' not found.` with exit 0.
203
+ * @returns {Promise<{found: true, name: string, content: any} | {found: false}>}
204
+ */
205
+ async knowledgeGet(name, shelf = shelfOf()) {
206
+ const { stdout } = await runOk(['knowledge-get', name, '--shelf', shelf])
207
+ const trimmed = stdout.trim()
208
+ if (/^Knowledge '.*' not found\.$/.test(trimmed)) return { found: false }
209
+ const parsed = parseJson(trimmed, 'knowledge-get')
210
+ return { found: true, name: parsed.name, content: parsed.content }
211
+ },
212
+
213
+ /**
214
+ * @param {{data: string, tags?: string[], scopes?: string[], shelf?: string}} entry
215
+ * @returns {Promise<boolean>} true when this call created the entry.
216
+ */
217
+ async knowledgeCreate(name, entry) {
218
+ const argv = ['knowledge-create', name, '--shelf', entry.shelf ?? shelfOf()]
219
+ // Use the `--data=<value>` form: consolidated content routinely begins
220
+ // with a markdown bullet (`- …`), and a space-separated `-d <value>`
221
+ // makes clap parse the leading `-` as a new flag (exit 2).
222
+ if (entry.data) argv.push(`--data=${fitDataArgument(entry.data)}`)
223
+ if (entry.tags && entry.tags.length > 0) argv.push('--tags', entry.tags.join(','))
224
+ if (entry.scopes && entry.scopes.length > 0) argv.push('--scopes', entry.scopes.join(','))
225
+ return runCreate(argv)
226
+ },
227
+
228
+ /**
229
+ * @param {{data?: string, scopes?: string[], shelf?: string}} [entry]
230
+ * @returns {Promise<boolean>} true when this call created the triple.
231
+ */
232
+ async statementCreate(head, relation, tail, entry = {}) {
233
+ const argv = [
234
+ 'statement-create', head, relation, tail,
235
+ '--shelf', entry.shelf ?? shelfOf(),
236
+ ]
237
+ // Same `--data=` rationale as knowledgeCreate: statement payloads can
238
+ // start with `-` (markdown bullets) which a space-separated value would
239
+ // expose to clap's flag parsing.
240
+ if (entry.data) argv.push(`--data=${fitDataArgument(entry.data)}`)
241
+ if (entry.scopes && entry.scopes.length > 0) argv.push('--scopes', entry.scopes.join(','))
242
+ return runCreate(argv)
243
+ },
244
+
245
+ /**
246
+ * Keyword search. Returns the raw row array (shape owned by hypatia —
247
+ * rows carry `key`, not `name`); callers filter defensively. An empty
248
+ * result prints `No results found.` on stdout with exit 0.
249
+ * @returns {Promise<any[]>}
250
+ */
251
+ async search(query, { catalog = 'knowledge', limit = 5, shelf = shelfOf() } = {}) {
252
+ const { stdout } = await runOk(['search', query, '-c', catalog, '--limit', String(limit), '--shelf', shelf])
253
+ if (isEmptyResult(stdout)) return []
254
+ const parsed = parseJson(stdout, 'search')
255
+ return Array.isArray(parsed) ? parsed : []
256
+ },
257
+
258
+ /**
259
+ * Vector-similarity search — the recall path's primary ranking tool.
260
+ * NOTE the flag is `-t/--target` (unlike keyword search's `-c`).
261
+ * @returns {Promise<any[]>}
262
+ */
263
+ async similar(query, { target = 'knowledge', limit = 5, shelf = shelfOf() } = {}) {
264
+ const { stdout } = await runOk(['similar', query, '-t', target, '--limit', String(limit), '--shelf', shelf])
265
+ if (isEmptyResult(stdout)) return []
266
+ const parsed = parseJson(stdout, 'similar')
267
+ return Array.isArray(parsed) ? parsed : []
268
+ },
269
+
270
+ /**
271
+ * Raw JSE query (rules/taboos preload, not-summarized checks).
272
+ * @returns {Promise<any[]>} result rows.
273
+ */
274
+ async query(jse, { shelf = shelfOf() } = {}) {
275
+ const { stdout } = await runOk(['query', jse, '--shelf', shelf])
276
+ // An empty query prints the same sentinel as search; without this check it
277
+ // surfaced as an "unparseable query output" error.
278
+ if (isEmptyResult(stdout)) return []
279
+ const parsed = parseJson(stdout, 'query')
280
+ if (Array.isArray(parsed)) return parsed
281
+ if (Array.isArray(parsed?.rows)) return parsed.rows
282
+ return []
283
+ },
284
+
285
+ /**
286
+ * Registered shelves, as `hypatia list` reports them.
287
+ * @returns {Promise<{name: string, path: string, connected: boolean}[]>}
288
+ */
289
+ async listShelves() {
290
+ const { stdout } = await runOk(['list'])
291
+ return parseShelfList(stdout)
292
+ },
293
+ }
294
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The hypatia client the plugin uses: MCP by default, the CLI as fallback.
3
+ *
4
+ * `hypatia-auto-memory.transport` picks the transport and is read on every
5
+ * call. With `mcp`, a binary that serves no usable MCP (one from before the
6
+ * `mcp` subcommand) sends every call to the CLI for the rest of the run, with
7
+ * one warning; a profile reload tries MCP again. Any other failure is the
8
+ * call's own and propagates, as it would on the CLI.
9
+ *
10
+ * The shelf listing always comes from the CLI. `hypatia mcp` reads the
11
+ * registry once, at start, so a shelf connected while it runs would stay
12
+ * missing from the settings card; the listing is the one call whose point is
13
+ * to notice that.
14
+ *
15
+ * @module dsh-hypatia-auto-memory/hypatia-client
16
+ */
17
+
18
+ import { HypatiaCliError, createHypatiaCli } from './hypatia-cli.js'
19
+ import { createHypatiaMcp, createMcpConnection } from './hypatia-mcp.js'
20
+
21
+ /**
22
+ * @param {import('@deepseek-ai/cordis').Context} ctx - context injecting `subprocess`.
23
+ * @param {{binaries: string[], transport?: 'mcp' | 'cli', shelf?: string}} config -
24
+ * read live; `shelf` is where every call goes unless the call names another.
25
+ * @param {{status: {warn: (message: string) => void}, connection?: ReturnType<typeof createMcpConnection>}} deps -
26
+ * `connection` is for tests.
27
+ */
28
+ export function createHypatiaClient(ctx, config, { status, connection }) {
29
+ const cli = createHypatiaCli(ctx, config)
30
+ const conn = connection ?? createMcpConnection(ctx, { binary: () => config.binaries[0] })
31
+ const mcp = createHypatiaMcp(conn, config)
32
+ /** Why MCP was given up for this run; empty while it is in use. */
33
+ let unavailable = ''
34
+
35
+ const usesMcp = () => (config.transport ?? 'mcp') === 'mcp' && unavailable === ''
36
+
37
+ async function via(method, args) {
38
+ if (!usesMcp()) return cli[method](...args)
39
+ try {
40
+ return await mcp[method](...args)
41
+ } catch (error) {
42
+ if (!(error instanceof HypatiaCliError)) throw error
43
+ if (error.code === 'MCP_UNAVAILABLE') {
44
+ if (unavailable === '') {
45
+ unavailable = error.message
46
+ conn.dispose()
47
+ status.warn(`${error.message}; using the hypatia CLI until the profile reloads`)
48
+ }
49
+ return cli[method](...args)
50
+ }
51
+ // Queued behind the call that gave MCP up, so it never reached a server.
52
+ if (error.code === 'MCP_CLOSED' && unavailable !== '') return cli[method](...args)
53
+ throw error
54
+ }
55
+ }
56
+
57
+ return {
58
+ knowledgeGet: (...args) => via('knowledgeGet', args),
59
+ knowledgeCreate: (...args) => via('knowledgeCreate', args),
60
+ statementCreate: (...args) => via('statementCreate', args),
61
+ search: (...args) => via('search', args),
62
+ similar: (...args) => via('similar', args),
63
+ query: (...args) => via('query', args),
64
+ listShelves: () => cli.listShelves(),
65
+
66
+ /** The transport the next call will use. */
67
+ transport: () => (usesMcp() ? 'mcp' : 'cli'),
68
+
69
+ /** Close the MCP process, if one is running. */
70
+ dispose: () => conn.dispose(),
71
+ }
72
+ }