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,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
|
+
}
|