@hydradb/mcp 1.1.1 → 1.2.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/CHANGELOG.md +167 -0
- package/README.md +92 -34
- package/dist/adapters.d.ts +27 -16
- package/dist/adapters.js +58 -54
- package/dist/adapters.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +27 -1
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +43 -3
- package/dist/context.js +374 -34
- package/dist/context.js.map +1 -1
- package/dist/descriptions.d.ts +55 -28
- package/dist/descriptions.js +198 -52
- package/dist/descriptions.js.map +1 -1
- package/dist/hydra/client.d.ts +62 -7
- package/dist/hydra/client.js +102 -20
- package/dist/hydra/client.js.map +1 -1
- package/dist/hydra/errors.js +91 -6
- package/dist/hydra/errors.js.map +1 -1
- package/dist/hydra/index.d.ts +2 -2
- package/dist/hydra/index.js +1 -1
- package/dist/hydra/index.js.map +1 -1
- package/dist/index.js +97 -10
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +18 -1
- package/dist/server.js +973 -103
- package/dist/server.js.map +1 -1
- package/dist/tool-names.d.ts +2 -1
- package/dist/tool-names.js +2 -0
- package/dist/tool-names.js.map +1 -1
- package/dist/types.d.ts +8 -51
- package/dist/types.js +6 -5
- package/dist/types.js.map +1 -1
- package/package.json +11 -6
package/dist/server.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { createRequire } from "node:module";
|
|
2
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
4
|
import { z } from "zod";
|
|
4
|
-
import { toAddMemoryResponse, toMemoryList,
|
|
5
|
+
import { toAddMemoryResponse, toMemoryList, toSourceList } from "./adapters.js";
|
|
5
6
|
import { resolveConfig } from "./config.js";
|
|
6
|
-
import {
|
|
7
|
+
import { renderRecalledContext } from "./context.js";
|
|
7
8
|
import { SERVER_INSTRUCTIONS, TOOL_DESCRIPTIONS } from "./descriptions.js";
|
|
8
9
|
import { HydraDB } from "./hydra/index.js";
|
|
9
10
|
import { logger } from "./logger.js";
|
|
@@ -20,9 +21,80 @@ const INGEST_INSTRUCTIONS = "Focus on extracting user preferences, habits, opini
|
|
|
20
21
|
// both `src/` (tsx) and `dist/` (published build).
|
|
21
22
|
const require = createRequire(import.meta.url);
|
|
22
23
|
const { version: SERVER_VERSION } = require("../package.json");
|
|
24
|
+
/**
|
|
25
|
+
* A usable title for an entry the caller did not name.
|
|
26
|
+
*
|
|
27
|
+
* The default was the constant "MCP Memory". Since `title` is the ONLY per-chunk
|
|
28
|
+
* label `buildRecalledContext` renders, fifty untitled saves produced fifty
|
|
29
|
+
* recall results all reading `Source: MCP Memory` — the caller could not cite
|
|
30
|
+
* where a fact came from, or tell whether two chunks were the same memory. It
|
|
31
|
+
* also defeats any future filter on title, since every row shares one value.
|
|
32
|
+
*
|
|
33
|
+
* Deriving from the first line is a safety net, not the fix. The fix is the
|
|
34
|
+
* description telling the model to set one; this keeps the failure from being
|
|
35
|
+
* total when it does not.
|
|
36
|
+
*/
|
|
37
|
+
function defaultTitle(text) {
|
|
38
|
+
const firstLine = (text.trim().split("\n", 1)[0] ?? "").trim();
|
|
39
|
+
if (firstLine === "")
|
|
40
|
+
return "Untitled note";
|
|
41
|
+
// Ingested documents commonly start with a markdown heading, and the hashes
|
|
42
|
+
// are noise in a label.
|
|
43
|
+
const cleaned = firstLine.replace(/^#+\s*/, "").trim() || firstLine;
|
|
44
|
+
return cleaned.length <= 60 ? cleaned : `${cleaned.slice(0, 57).trimEnd()}…`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A source id for a conversation the caller did not name.
|
|
48
|
+
*
|
|
49
|
+
* This was `mcp-conversation-${Date.now()}` — millisecond resolution, no
|
|
50
|
+
* randomness, no process or session identity. Two ingests landing in the same
|
|
51
|
+
* millisecond produced the same id, and because `upsert` is true the second
|
|
52
|
+
* silently REPLACED the first (see the upsert regression test) while reporting
|
|
53
|
+
* "success: 1, failed: 0". Nothing surfaced the loss.
|
|
54
|
+
*
|
|
55
|
+
* The collision window is wider than one agent racing itself: HYDRADB_COLLECTION
|
|
56
|
+
* defaults to the shared literal `hydra-db-mcp`, so every user who does not set
|
|
57
|
+
* it shares one namespace with a low-entropy id.
|
|
58
|
+
*
|
|
59
|
+
* The timestamp prefix is kept because it sorts and reads well; the suffix is
|
|
60
|
+
* what makes it unique.
|
|
61
|
+
*/
|
|
62
|
+
function generatedSourceId() {
|
|
63
|
+
return `mcp-conversation-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
64
|
+
}
|
|
23
65
|
function textResult(text) {
|
|
24
66
|
return { content: [{ type: "text", text }] };
|
|
25
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* A result the caller can read OR parse.
|
|
70
|
+
*
|
|
71
|
+
* Every handler returned prose only, so a caller wanting an id had to pull it
|
|
72
|
+
* out of a sentence. `structuredContent` hands over the same facts already
|
|
73
|
+
* parsed — ids it can pass straight to the next tool, counts it can branch on.
|
|
74
|
+
*
|
|
75
|
+
* `content` stays populated alongside it. The MCP spec requires that for hosts
|
|
76
|
+
* that ignore structured output, and dropping it would break every client that
|
|
77
|
+
* renders the text.
|
|
78
|
+
*/
|
|
79
|
+
function structuredResult(text, structuredContent) {
|
|
80
|
+
return { content: [{ type: "text", text }], structuredContent };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A failure the caller should treat as a failure, not a result.
|
|
84
|
+
*
|
|
85
|
+
* Three different contracts for "it didn\'t work" used to coexist here: thrown
|
|
86
|
+
* errors became `isError: true`, while a failed inspect and a server-REFUSED
|
|
87
|
+
* delete returned plain text with `isError` absent. A client branching on
|
|
88
|
+
* `isError` therefore read "Could NOT delete X — the server refused" as a
|
|
89
|
+
* success.
|
|
90
|
+
*
|
|
91
|
+
* These stay soft text rather than throws, deliberately — the message is
|
|
92
|
+
* carefully worded and a thrown error would replace it with a generic one — but
|
|
93
|
+
* they are now flagged.
|
|
94
|
+
*/
|
|
95
|
+
function errorResult(text) {
|
|
96
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
97
|
+
}
|
|
26
98
|
/**
|
|
27
99
|
* What a query actually searched, for the result strings. A query over `all`
|
|
28
100
|
* must not report "memories" — that phrasing is what taught callers the MCP
|
|
@@ -36,9 +108,33 @@ function resultNoun(kind, count) {
|
|
|
36
108
|
return one ? "knowledge source" : "knowledge sources";
|
|
37
109
|
return one ? "context item" : "context items";
|
|
38
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Input ceilings.
|
|
113
|
+
*
|
|
114
|
+
* `text` and `turns` were unbounded. The whole payload is materialised —
|
|
115
|
+
* `JSON.stringify` on the memory path, a Buffer on the knowledge path — so an
|
|
116
|
+
* oversized body is best case a 413 after uploading all of it, worst case an
|
|
117
|
+
* out-of-memory in this process. Rejecting locally is instant, costs no
|
|
118
|
+
* bandwidth, and names the limit.
|
|
119
|
+
*
|
|
120
|
+
* Sized well above any realistic memory or document this tool is asked to store.
|
|
121
|
+
*/
|
|
122
|
+
const MAX_TEXT_CHARS = 1000000;
|
|
123
|
+
const MAX_TURNS = 500;
|
|
124
|
+
const MAX_TURN_CHARS = 100000;
|
|
39
125
|
const turnSchema = z.object({
|
|
40
|
-
user: z
|
|
41
|
-
|
|
126
|
+
user: z
|
|
127
|
+
.string()
|
|
128
|
+
.max(MAX_TURN_CHARS, {
|
|
129
|
+
message: `each turn's user message must be at most ${MAX_TURN_CHARS} characters`,
|
|
130
|
+
})
|
|
131
|
+
.describe("The user's message"),
|
|
132
|
+
assistant: z
|
|
133
|
+
.string()
|
|
134
|
+
.max(MAX_TURN_CHARS, {
|
|
135
|
+
message: `each turn's assistant message must be at most ${MAX_TURN_CHARS} characters`,
|
|
136
|
+
})
|
|
137
|
+
.describe("The assistant's response"),
|
|
42
138
|
});
|
|
43
139
|
// Deprecated aliases emit exactly one stderr warning PER PROCESS naming the
|
|
44
140
|
// canonical replacement (CONTRACT §3). The dedupe state is module-scoped so the
|
|
@@ -53,6 +149,74 @@ function warnDeprecatedAlias(name) {
|
|
|
53
149
|
const replacement = ALIAS_REPLACEMENTS[name] ?? "a canonical tool";
|
|
54
150
|
console.error(`[hydradb-mcp] Tool "${name}" is deprecated and will be removed in a future major version; use "${replacement}" instead.`);
|
|
55
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* In-flight tool calls, so shutdown can wait for them.
|
|
154
|
+
*
|
|
155
|
+
* `server.close()` tears down the transport; it does not wait for handlers that
|
|
156
|
+
* are already running. Without this, SIGTERM during an ingest kills the process
|
|
157
|
+
* mid-write and the caller never learns whether it committed — which, since a
|
|
158
|
+
* reused source_id replaces, is not a question they can answer by retrying.
|
|
159
|
+
*
|
|
160
|
+
* Module-scoped so it spans every server instance in the process, matching how
|
|
161
|
+
* the alias-warning dedupe is scoped.
|
|
162
|
+
*/
|
|
163
|
+
let inFlight = 0;
|
|
164
|
+
const idleWaiters = [];
|
|
165
|
+
/**
|
|
166
|
+
* Set once shutdown begins, so no NEW call is accepted after that point.
|
|
167
|
+
*
|
|
168
|
+
* Draining alone is not enough: a call arriving after the counter reaches zero
|
|
169
|
+
* but before the transport closes would be accepted, then aborted by the close —
|
|
170
|
+
* leaving an ingest caller unable to tell whether the write committed, which is
|
|
171
|
+
* the exact outcome draining exists to prevent.
|
|
172
|
+
*/
|
|
173
|
+
let shuttingDown = false;
|
|
174
|
+
/** Stop accepting tool calls. Idempotent. */
|
|
175
|
+
export function beginShutdown() {
|
|
176
|
+
shuttingDown = true;
|
|
177
|
+
}
|
|
178
|
+
/** Test-only: allow a fresh server in the same process after a shutdown test. */
|
|
179
|
+
export function __resetShutdown() {
|
|
180
|
+
shuttingDown = false;
|
|
181
|
+
}
|
|
182
|
+
function trackInFlight(work) {
|
|
183
|
+
if (shuttingDown) {
|
|
184
|
+
return Promise.reject(new Error("Hydra DB MCP server is shutting down and is not accepting new requests. " +
|
|
185
|
+
"Retry once it has restarted."));
|
|
186
|
+
}
|
|
187
|
+
inFlight++;
|
|
188
|
+
return work().finally(() => {
|
|
189
|
+
inFlight--;
|
|
190
|
+
if (inFlight === 0) {
|
|
191
|
+
while (idleWaiters.length > 0)
|
|
192
|
+
idleWaiters.pop()?.();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/** Resolves once no tool call is running, or immediately if none is. */
|
|
197
|
+
export function awaitInFlight() {
|
|
198
|
+
if (inFlight === 0)
|
|
199
|
+
return Promise.resolve();
|
|
200
|
+
return new Promise((resolve) => idleWaiters.push(resolve));
|
|
201
|
+
}
|
|
202
|
+
/** How many tool calls are currently running. Exported for tests and logging. */
|
|
203
|
+
export function inFlightCount() {
|
|
204
|
+
return inFlight;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Whether the deprecated tool aliases are registered.
|
|
208
|
+
*
|
|
209
|
+
* Off by default as of 1.2.0. Anyone whose mcp.json still calls the old names
|
|
210
|
+
* sets HYDRADB_MCP_LEGACY_TOOLS=1 to restore them — one env var, no code change,
|
|
211
|
+
* and the opt-in is itself the adoption signal that a later removal needs and
|
|
212
|
+
* that nothing here could previously collect.
|
|
213
|
+
*/
|
|
214
|
+
export function legacyToolsEnabled(env = process.env) {
|
|
215
|
+
const raw = env.HYDRADB_MCP_LEGACY_TOOLS;
|
|
216
|
+
if (raw == null)
|
|
217
|
+
return false;
|
|
218
|
+
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
|
|
219
|
+
}
|
|
56
220
|
/** Test-only: reset the once-per-process alias warning dedupe. */
|
|
57
221
|
export function __resetAliasWarnings() {
|
|
58
222
|
warnedAliases.clear();
|
|
@@ -75,59 +239,189 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
75
239
|
database: config.database,
|
|
76
240
|
collection: config.collection,
|
|
77
241
|
...(config.baseUrl != null ? { baseUrl: config.baseUrl } : {}),
|
|
242
|
+
...(config.timeoutSeconds != null
|
|
243
|
+
? { timeoutSeconds: config.timeoutSeconds }
|
|
244
|
+
: {}),
|
|
245
|
+
...(config.maxRetries != null ? { maxRetries: config.maxRetries } : {}),
|
|
78
246
|
});
|
|
79
247
|
logger.info(`Hydra DB connected (database=${config.database}, collection=${config.collection})`);
|
|
80
248
|
}
|
|
81
249
|
// --- Handlers (shared by canonical tools and their deprecated aliases) ---
|
|
82
|
-
async function runQuery(args) {
|
|
250
|
+
async function runQuery(args, signal) {
|
|
83
251
|
// Host-owned default (CONTRACT §2 rule 5): search BOTH families. This tool
|
|
84
252
|
// used to pin `kind: "memory"`, which made every ingested knowledge source
|
|
85
253
|
// unreachable from the MCP — `hydradb_list`/`hydradb_inspect` could browse
|
|
86
254
|
// knowledge but nothing could search it.
|
|
87
255
|
const kind = args.kind ?? "all";
|
|
88
256
|
logger.debug(`${TOOL_NAMES.QUERY}: "${args.query}" (kind=${kind})`);
|
|
257
|
+
const maxResults = args.max_results ?? 10;
|
|
89
258
|
const raw = await hydra.context.query({
|
|
90
259
|
query: args.query,
|
|
91
260
|
kind,
|
|
92
|
-
maxResults
|
|
261
|
+
maxResults,
|
|
93
262
|
mode: args.mode ?? "thinking",
|
|
263
|
+
operator: args.operator,
|
|
264
|
+
ids: args.source_ids,
|
|
265
|
+
metadataFilters: args.metadata_filters,
|
|
266
|
+
numRelatedChunks: args.num_related_chunks,
|
|
94
267
|
graphContext: args.graph_context ?? true,
|
|
95
268
|
alpha: 0.8,
|
|
96
269
|
recencyBias: 0,
|
|
97
|
-
});
|
|
98
|
-
|
|
270
|
+
}, { signal });
|
|
271
|
+
// The renderer reads the SDK payload directly; there is no longer a
|
|
272
|
+
// snake_case mirror to convert into.
|
|
273
|
+
const res = raw;
|
|
274
|
+
// The server can return more chunks than were asked for — a live call with
|
|
275
|
+
// max_results=10 came back with 15, and all 15 were rendered. Honour the
|
|
276
|
+
// parameter here so it means what its description says.
|
|
277
|
+
if (res.chunks != null && res.chunks.length > maxResults) {
|
|
278
|
+
res.chunks = res.chunks.slice(0, maxResults);
|
|
279
|
+
}
|
|
99
280
|
if (!res.chunks || res.chunks.length === 0) {
|
|
100
281
|
return textResult(`No relevant ${resultNoun(kind)} found in Hydra DB.`);
|
|
101
282
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
283
|
+
// No separate summary block. It listed the first 10 chunks truncated to 150
|
|
284
|
+
// characters each — text that is a verbatim prefix of what the context
|
|
285
|
+
// block below already renders in full. Every chunk body went to the caller
|
|
286
|
+
// twice, and the only field the summary carried that the context block did
|
|
287
|
+
// not was the score, which now rides in the chunk header.
|
|
288
|
+
//
|
|
289
|
+
// It also disagreed with its own header: `Found ${length}` counted every
|
|
290
|
+
// chunk while the list stopped at 10, so a 15-chunk result announced 15 and
|
|
291
|
+
// showed 10.
|
|
292
|
+
const compact = (args.detail ?? "compact") === "compact";
|
|
293
|
+
// The header and the legend are part of the response the caller pays for,
|
|
294
|
+
// so the renderer gets a budget with room already reserved for them.
|
|
295
|
+
// Adding framing after the ceiling had been applied put the finished
|
|
296
|
+
// response over the documented limit — the same mistake as leaving the
|
|
297
|
+
// entity-path prefix out of the accounting, one layer up.
|
|
298
|
+
const legend = `\n\n---\nEach [id: …] is a source id: pass one to ${TOOL_NAMES.INSPECT} for that ` +
|
|
299
|
+
`source's full content, or to ${TOOL_NAMES.DELETE} to remove it.`;
|
|
300
|
+
const headerAllowance = 120;
|
|
301
|
+
const { text: contextStr, shown } = renderRecalledContext(res, {
|
|
302
|
+
// Compact keeps every chunk but trims each body and drops the
|
|
303
|
+
// extra-context blocks; `full` is the unchanged rendering.
|
|
304
|
+
...(compact
|
|
305
|
+
? { maxChunkChars: COMPACT_CHUNK_CHARS, includeExtraContext: false }
|
|
306
|
+
: {}),
|
|
307
|
+
maxTotalChars: QUERY_CHAR_BUDGET - legend.length - headerAllowance,
|
|
111
308
|
});
|
|
112
|
-
return textResult(`Found ${
|
|
309
|
+
return textResult(`Found ${shown} ${resultNoun(kind, shown)}:\n\n${contextStr}${legend}`);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The id the server assigned to the item it just stored.
|
|
313
|
+
*
|
|
314
|
+
* On the memory path the caller may supply `source_id`, but when it does not
|
|
315
|
+
* the server assigns one — and that value appeared nowhere in the tool result,
|
|
316
|
+
* so the caller could not later inspect, correct or delete what it had
|
|
317
|
+
* written. Reads the first successful item; ingest here is always one item.
|
|
318
|
+
*/
|
|
319
|
+
function createdId(res) {
|
|
320
|
+
for (const item of res.results) {
|
|
321
|
+
if (item.source_id && !item.error)
|
|
322
|
+
return item.source_id;
|
|
323
|
+
}
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Ingestion is asynchronous, and the caller has no way to know that.
|
|
328
|
+
*
|
|
329
|
+
* The upload returns as soon as the source is queued; indexing then runs
|
|
330
|
+
* through graph extraction and takes seconds. A caller that saves and
|
|
331
|
+
* immediately queries to confirm gets "No relevant context items found" and
|
|
332
|
+
* reasonably concludes the save failed — then re-saves, which under upsert
|
|
333
|
+
* replaces what it just wrote.
|
|
334
|
+
*
|
|
335
|
+
* The server already says this in its 202 body; the adapter kept the message
|
|
336
|
+
* so we can pass the server's own words through rather than invent our own.
|
|
337
|
+
*/
|
|
338
|
+
function indexingNote(res) {
|
|
339
|
+
const said = res.message.trim();
|
|
340
|
+
const mentionsAsync = /asynchron|queued|still processing|not.*indexed/i.test(said);
|
|
341
|
+
return (`\n\nIndexing is asynchronous — the content is not searchable until it ` +
|
|
342
|
+
`completes. Use ${TOOL_NAMES.STATUS} to check.` +
|
|
343
|
+
(mentionsAsync ? "" : said ? `\nServer: ${briefly(said)}` : ""));
|
|
344
|
+
}
|
|
345
|
+
/** Keep one server-supplied message from crowding out the rest of the result. */
|
|
346
|
+
function briefly(message) {
|
|
347
|
+
return message.length > 200 ? `${message.slice(0, 200)}…` : message;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Per-item detail for an ingest that did not fully succeed.
|
|
351
|
+
*
|
|
352
|
+
* Two distinct outcomes are worth reporting and neither is visible in the
|
|
353
|
+
* counts alone:
|
|
354
|
+
*
|
|
355
|
+
* - a failed item, where the caller needs the id and the reason to retry
|
|
356
|
+
* just that one rather than re-ingesting everything;
|
|
357
|
+
* - an item the server stored but could not extract relations from, which
|
|
358
|
+
* is a partial success. It is findable by text and unreachable by graph
|
|
359
|
+
* traversal, and `failed_count` stays 0 — so without this line it looks
|
|
360
|
+
* identical to a clean ingest.
|
|
361
|
+
*
|
|
362
|
+
* Returns "" when there is nothing to say, so the success path stays quiet.
|
|
363
|
+
*/
|
|
364
|
+
function ingestIssues(res) {
|
|
365
|
+
const lines = [];
|
|
366
|
+
for (const item of res.results) {
|
|
367
|
+
const label = item.source_id || item.title || "(unnamed item)";
|
|
368
|
+
const failure = item.error ?? (item.status === "failed" ? "ingestion failed" : null);
|
|
369
|
+
if (failure) {
|
|
370
|
+
const code = item.error_code ? ` [${item.error_code}]` : "";
|
|
371
|
+
lines.push(` - ${label}: ${briefly(failure)}${code}`);
|
|
372
|
+
}
|
|
373
|
+
else if (item.relations_error) {
|
|
374
|
+
lines.push(` - ${label}: stored, but graph extraction failed — ${briefly(item.relations_error)}. ` +
|
|
375
|
+
`It is searchable by text but will not be reached by graph traversal.`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return lines.length > 0 ? `\n\nIssues:\n${lines.join("\n")}` : "";
|
|
113
379
|
}
|
|
114
|
-
async function runStore(args) {
|
|
115
|
-
|
|
380
|
+
async function runStore(args, signal) {
|
|
381
|
+
const kind = args.kind ?? "memory";
|
|
382
|
+
logger.debug(`${TOOL_NAMES.INGEST}: "${args.text.slice(0, 50)}..." (kind=${kind})`);
|
|
383
|
+
// The memory item shape has no counterpart on the knowledge path, which
|
|
384
|
+
// carries only a document and its filename — so those fields are sent only
|
|
385
|
+
// where they mean something. The wrapper rejects them on the knowledge
|
|
386
|
+
// branch rather than dropping them, and passing them here unconditionally
|
|
387
|
+
// would make every knowledge write fail.
|
|
388
|
+
const memoryOnly = kind === "memory"
|
|
389
|
+
? {
|
|
390
|
+
sourceId: args.source_id,
|
|
391
|
+
infer: args.infer ?? true,
|
|
392
|
+
isMarkdown: args.is_markdown ?? false,
|
|
393
|
+
customInstructions: INGEST_INSTRUCTIONS,
|
|
394
|
+
metadata: args.metadata,
|
|
395
|
+
observationDate: args.observation_date,
|
|
396
|
+
}
|
|
397
|
+
: {};
|
|
116
398
|
const raw = await hydra.context.ingest({
|
|
117
|
-
kind
|
|
399
|
+
kind,
|
|
118
400
|
text: args.text,
|
|
119
|
-
title: args.title ??
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
upsert: true,
|
|
125
|
-
});
|
|
401
|
+
title: args.title ?? defaultTitle(args.text),
|
|
402
|
+
...memoryOnly,
|
|
403
|
+
// Default stays true. The SDK retries POSTs, so upsert is what keeps a
|
|
404
|
+
// retried ingest from duplicating — flipping this default would trade a
|
|
405
|
+
// silent overwrite for a silent duplicate.
|
|
406
|
+
upsert: args.overwrite ?? true,
|
|
407
|
+
}, { signal });
|
|
126
408
|
const res = toAddMemoryResponse(raw);
|
|
127
|
-
|
|
128
|
-
|
|
409
|
+
// Was an 80-char echo of the text the caller had just sent — zero
|
|
410
|
+
// information back to them. The id is the thing they do not have and
|
|
411
|
+
// cannot derive, and it is what makes correcting this memory later
|
|
412
|
+
// possible at all.
|
|
413
|
+
const id = createdId(res) ?? args.source_id;
|
|
414
|
+
return structuredResult(`Saved to Hydra DB${id ? ` (id: ${id})` : ""} ` +
|
|
415
|
+
`(${res.success_count} success, ${res.failed_count} failed).` +
|
|
416
|
+
indexingNote(res) +
|
|
417
|
+
ingestIssues(res), {
|
|
418
|
+
...(id != null ? { id } : {}),
|
|
419
|
+
success_count: res.success_count,
|
|
420
|
+
failed_count: res.failed_count,
|
|
421
|
+
indexing_pending: true,
|
|
422
|
+
});
|
|
129
423
|
}
|
|
130
|
-
async function runIngestConversation(turns, sourceId, opts) {
|
|
424
|
+
async function runIngestConversation(turns, sourceId, opts, signal) {
|
|
131
425
|
logger.debug(`${TOOL_NAMES.INGEST}: ${turns.length} turns -> ${sourceId}`);
|
|
132
426
|
const raw = await hydra.context.ingest({
|
|
133
427
|
kind: "memory",
|
|
@@ -138,50 +432,297 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
138
432
|
title: opts?.title,
|
|
139
433
|
isMarkdown: opts?.isMarkdown,
|
|
140
434
|
customInstructions: INGEST_INSTRUCTIONS,
|
|
141
|
-
upsert: true,
|
|
142
|
-
});
|
|
435
|
+
upsert: opts?.overwrite ?? true,
|
|
436
|
+
}, { signal });
|
|
143
437
|
const res = toAddMemoryResponse(raw);
|
|
144
|
-
|
|
438
|
+
const conversationId = createdId(res) ?? sourceId;
|
|
439
|
+
return structuredResult(`Ingested ${turns.length} conversation turn(s) into Hydra DB ` +
|
|
440
|
+
`(id: ${conversationId}, success: ${res.success_count}, failed: ${res.failed_count})` +
|
|
441
|
+
indexingNote(res) +
|
|
442
|
+
ingestIssues(res), {
|
|
443
|
+
id: conversationId,
|
|
444
|
+
success_count: res.success_count,
|
|
445
|
+
failed_count: res.failed_count,
|
|
446
|
+
indexing_pending: true,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* How much of the corpus this page covered, stated plainly.
|
|
451
|
+
*
|
|
452
|
+
* A listing that shows 50 of 4,000 rows and says "50 memories:" is not a
|
|
453
|
+
* truncated answer, it is a wrong one — the caller reports it as the complete
|
|
454
|
+
* inventory. Say what was shown, out of what, and how to reach the rest.
|
|
455
|
+
*/
|
|
456
|
+
/**
|
|
457
|
+
* Whether another page exists.
|
|
458
|
+
*
|
|
459
|
+
* `total > shown` is NOT a usable test on its own: on the last page of a large
|
|
460
|
+
* corpus it is still true (12 shown of 412) and would point the caller at a
|
|
461
|
+
* page that does not exist. Prefer what the server stated, then the page
|
|
462
|
+
* arithmetic, and only then the row comparison — which is correct on page 1,
|
|
463
|
+
* the only place it is reached.
|
|
464
|
+
*/
|
|
465
|
+
function hasMore(shown, page, requestedPage) {
|
|
466
|
+
const total = page.total ?? shown;
|
|
467
|
+
const current = page.page ?? requestedPage ?? 1;
|
|
468
|
+
const seen = (current - 1) * (page.page_size ?? shown) + shown;
|
|
469
|
+
return (page.has_next ??
|
|
470
|
+
(page.total_pages != null ? current < page.total_pages : seen < total));
|
|
145
471
|
}
|
|
146
|
-
|
|
472
|
+
function coverage(shown, page, requestedPage) {
|
|
473
|
+
const total = page.total ?? shown;
|
|
474
|
+
const current = page.page ?? requestedPage ?? 1;
|
|
475
|
+
const more = hasMore(shown, page, requestedPage);
|
|
476
|
+
if (!more && current === 1)
|
|
477
|
+
return `${shown}`;
|
|
478
|
+
return `${shown} of ${total} (page ${current})${more ? ` — pass page=${current + 1} for more` : ""}`;
|
|
479
|
+
}
|
|
480
|
+
async function runListMemories(args = {}, signal) {
|
|
147
481
|
logger.debug(TOOL_NAMES.LIST);
|
|
148
|
-
const raw = await hydra.context.list({
|
|
149
|
-
|
|
482
|
+
const raw = await hydra.context.list({
|
|
483
|
+
kind: "memory",
|
|
484
|
+
ids: args.source_ids,
|
|
485
|
+
page: args.page,
|
|
486
|
+
pageSize: args.page_size,
|
|
487
|
+
}, { signal });
|
|
488
|
+
const { memories, page } = toMemoryList(raw);
|
|
150
489
|
if (memories.length === 0) {
|
|
151
|
-
return
|
|
490
|
+
// Declaring an outputSchema obliges EVERY return path to carry structured
|
|
491
|
+
// content, including this one — a caller branching on `items` should not
|
|
492
|
+
// have to special-case the empty result.
|
|
493
|
+
return structuredResult(args.page != null && args.page > 1
|
|
494
|
+
? `No memories on page ${args.page}.`
|
|
495
|
+
: "No memories stored yet.", {
|
|
496
|
+
kind: "memory",
|
|
497
|
+
items: [],
|
|
498
|
+
shown: 0,
|
|
499
|
+
total: page.total ?? 0,
|
|
500
|
+
page: args.page ?? 1,
|
|
501
|
+
has_more: false,
|
|
502
|
+
});
|
|
152
503
|
}
|
|
153
|
-
const lines = memories.map((m, i) =>
|
|
154
|
-
|
|
504
|
+
const lines = memories.map((m, i) => {
|
|
505
|
+
// The query path appends "..." when it truncates; this one did not, so a
|
|
506
|
+
// half sentence read as a complete fact.
|
|
507
|
+
const content = m.memory_content;
|
|
508
|
+
const snippet = content.length > 150 ? `${content.slice(0, 150)}...` : content;
|
|
509
|
+
return `${i + 1}. [${m.memory_id}] ${snippet}`;
|
|
510
|
+
});
|
|
511
|
+
return structuredResult(`${coverage(memories.length, page, args.page)} memories:\n\n${lines.join("\n")}`, {
|
|
512
|
+
kind: "memory",
|
|
513
|
+
// Bounded like the text preview. The structured payload previously
|
|
514
|
+
// carried every memory_content in full, so a host consuming it got
|
|
515
|
+
// megabytes from a routine inventory call while the prose beside it
|
|
516
|
+
// showed 150 characters per row. Structured output is a different
|
|
517
|
+
// encoding of the same answer, not a bypass of its limits.
|
|
518
|
+
items: memories.map((m) => ({
|
|
519
|
+
id: m.memory_id,
|
|
520
|
+
content: clampPreview(m.memory_content),
|
|
521
|
+
})),
|
|
522
|
+
shown: memories.length,
|
|
523
|
+
total: page.total ?? memories.length,
|
|
524
|
+
page: page.page ?? args.page ?? 1,
|
|
525
|
+
has_more: hasMore(memories.length, page, args.page),
|
|
526
|
+
});
|
|
155
527
|
}
|
|
156
|
-
async function runListSources(args) {
|
|
528
|
+
async function runListSources(args, signal) {
|
|
157
529
|
logger.debug(TOOL_NAMES.LIST);
|
|
158
530
|
const raw = await hydra.context.list({
|
|
159
531
|
kind: "knowledge",
|
|
160
532
|
ids: args.source_ids,
|
|
161
|
-
|
|
162
|
-
|
|
533
|
+
page: args.page,
|
|
534
|
+
pageSize: args.page_size,
|
|
535
|
+
}, { signal });
|
|
536
|
+
const { sources, page } = toSourceList(raw);
|
|
163
537
|
if (sources.length === 0) {
|
|
164
|
-
return
|
|
538
|
+
return structuredResult(args.page != null && args.page > 1
|
|
539
|
+
? `No sources on page ${args.page}.`
|
|
540
|
+
: "No sources found.", {
|
|
541
|
+
kind: "knowledge",
|
|
542
|
+
items: [],
|
|
543
|
+
shown: 0,
|
|
544
|
+
total: page.total ?? 0,
|
|
545
|
+
page: args.page ?? 1,
|
|
546
|
+
has_more: false,
|
|
547
|
+
});
|
|
165
548
|
}
|
|
166
549
|
const lines = sources.map((s, i) => {
|
|
167
550
|
const title = s.title ? ` — ${s.title}` : "";
|
|
168
551
|
const type = s.type ? ` (${s.type})` : "";
|
|
169
552
|
return `${i + 1}. [${s.id}]${title}${type}`;
|
|
170
553
|
});
|
|
171
|
-
|
|
554
|
+
// Was `${total} sources:` — the corpus-wide total printed above a single
|
|
555
|
+
// page of rows, so "412 sources:" sat over 50 lines with no marker and no
|
|
556
|
+
// way to reach the other 362.
|
|
557
|
+
return structuredResult(`${coverage(sources.length, page, args.page)} sources:\n\n${lines.join("\n")}`, {
|
|
558
|
+
kind: "knowledge",
|
|
559
|
+
items: sources.map((src) => ({
|
|
560
|
+
id: src.id,
|
|
561
|
+
...(src.title != null ? { title: src.title } : {}),
|
|
562
|
+
...(src.type != null ? { type: src.type } : {}),
|
|
563
|
+
})),
|
|
564
|
+
shown: sources.length,
|
|
565
|
+
total: page.total ?? sources.length,
|
|
566
|
+
page: page.page ?? args.page ?? 1,
|
|
567
|
+
has_more: hasMore(sources.length, page, args.page),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* How much source text one inspect call may put into the caller's context.
|
|
572
|
+
*
|
|
573
|
+
* Roughly 5k tokens. Large enough that ordinary documents come back whole,
|
|
574
|
+
* small enough that no single call can dominate a conversation.
|
|
575
|
+
*/
|
|
576
|
+
const INSPECT_CHAR_BUDGET = 20000;
|
|
577
|
+
/**
|
|
578
|
+
* The per-row preview length shared by the text and structured listings.
|
|
579
|
+
*
|
|
580
|
+
* They must agree: a caller reading `structuredContent` and a caller reading
|
|
581
|
+
* the prose should get the same answer, not two different ones.
|
|
582
|
+
*/
|
|
583
|
+
const LIST_PREVIEW_CHARS = 150;
|
|
584
|
+
function clampPreview(text) {
|
|
585
|
+
return text.length > LIST_PREVIEW_CHARS
|
|
586
|
+
? `${text.slice(0, LIST_PREVIEW_CHARS)}...`
|
|
587
|
+
: text;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Query output ceilings.
|
|
591
|
+
*
|
|
592
|
+
* Chunk bodies were rendered at full length with no cap of any kind, so one
|
|
593
|
+
* query over a corpus of long documents could dominate the caller's context.
|
|
594
|
+
* `compact` trims each body and drops the extra-context blocks; `full`
|
|
595
|
+
* restores the previous rendering. The total budget applies either way,
|
|
596
|
+
* because fifty capped chunks still add up.
|
|
597
|
+
*/
|
|
598
|
+
const COMPACT_CHUNK_CHARS = 600;
|
|
599
|
+
const QUERY_CHAR_BUDGET = 40000;
|
|
600
|
+
/** Bound any one server-supplied string, marking it when it is shortened. */
|
|
601
|
+
function clamp(text, budget) {
|
|
602
|
+
if (text.length <= budget)
|
|
603
|
+
return text;
|
|
604
|
+
return `${text.slice(0, budget)}\n\n[truncated: ${text.length} chars total]`;
|
|
172
605
|
}
|
|
173
|
-
|
|
606
|
+
/**
|
|
607
|
+
* The readable part of an inspect response, bounded.
|
|
608
|
+
*
|
|
609
|
+
* This was `res.content ?? res.contentBase64 ?? "(no text content)"`, with no
|
|
610
|
+
* cap anywhere between the API and the tool result. Two problems:
|
|
611
|
+
*
|
|
612
|
+
* - unbounded text. A large ingested document arrives whole, and the caller
|
|
613
|
+
* cannot un-read it or tell in advance how big it is. The tool is
|
|
614
|
+
* annotated readOnlyHint, so clients call it speculatively.
|
|
615
|
+
* - base64. `contentBase64` is the binary fallback and base64 inflates 4/3,
|
|
616
|
+
* so a 1 MB scanned PDF becomes ~1.4M characters — a whole context window
|
|
617
|
+
* in one call. It only fires when text extraction yielded nothing, which
|
|
618
|
+
* is precisely the case a user retries by hand when the first call looks
|
|
619
|
+
* empty.
|
|
620
|
+
*
|
|
621
|
+
* Binary is never inlined. The caller is told what it is, how big, and how to
|
|
622
|
+
* get it — `mode: "url"` already returns a download link.
|
|
623
|
+
*/
|
|
624
|
+
function inspectBody(res, offset, limit) {
|
|
625
|
+
if (res.content == null || res.content === "") {
|
|
626
|
+
if (res.contentBase64) {
|
|
627
|
+
const size = res.sizeBytes != null ? `${res.sizeBytes} bytes` : "unknown size";
|
|
628
|
+
// The summary is server-generated and unbounded, so it has to obey
|
|
629
|
+
// the same budget as the content it stands in for — otherwise the
|
|
630
|
+
// binary branch, which exists to keep this response small, becomes
|
|
631
|
+
// its own way of blowing past it.
|
|
632
|
+
const summary = res.inferredContent
|
|
633
|
+
? `\n\nSummary of the content:\n${clamp(res.inferredContent, INSPECT_CHAR_BUDGET)}`
|
|
634
|
+
: "";
|
|
635
|
+
return (`(binary ${res.contentType ?? "content"}, ${size} — not shown. ` +
|
|
636
|
+
`Call again with mode:"url" for a download link.)${summary}`);
|
|
637
|
+
}
|
|
638
|
+
return "(no text content)";
|
|
639
|
+
}
|
|
640
|
+
const start = Math.max(0, offset ?? 0);
|
|
641
|
+
const budget = Math.min(limit ?? INSPECT_CHAR_BUDGET, INSPECT_CHAR_BUDGET);
|
|
642
|
+
const total = res.content.length;
|
|
643
|
+
if (start === 0 && total <= budget)
|
|
644
|
+
return res.content;
|
|
645
|
+
const slice = res.content.slice(start, start + budget);
|
|
646
|
+
const end = start + slice.length;
|
|
647
|
+
const more = end < total
|
|
648
|
+
? ` Call again with offset=${end} for the next ${Math.min(budget, total - end)}.`
|
|
649
|
+
: "";
|
|
650
|
+
return (`${slice}\n\n[truncated: showing characters ${start}-${end} of ${total}.${more}]`);
|
|
651
|
+
}
|
|
652
|
+
/** Accept either spelling, and say so when neither is present. */
|
|
653
|
+
function toInspectArgs(args) {
|
|
654
|
+
const a = args;
|
|
655
|
+
// Reject a conflict rather than picking one. This server rejects `text`
|
|
656
|
+
// AND `turns` on ingest for the same reason: silently choosing between two
|
|
657
|
+
// values the caller deliberately supplied means acting on a target they
|
|
658
|
+
// did not ask for, and here that target can be a DELETE.
|
|
659
|
+
if (a.id != null && a.source_id != null && a.id !== a.source_id) {
|
|
660
|
+
throw new Error(`${TOOL_NAMES.INSPECT} received different values for \`id\` (${a.id}) and its ` +
|
|
661
|
+
`deprecated alias \`source_id\` (${a.source_id}). Pass only \`id\`.`);
|
|
662
|
+
}
|
|
663
|
+
const id = a.id ?? a.source_id;
|
|
664
|
+
if (!id) {
|
|
665
|
+
throw new Error(`${TOOL_NAMES.INSPECT} requires \`id\` — the value shown as [id: …] in ` +
|
|
666
|
+
`${TOOL_NAMES.QUERY} results or in [brackets] in ${TOOL_NAMES.LIST} output.`);
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
source_id: id,
|
|
670
|
+
mode: a.mode,
|
|
671
|
+
offset: a.offset,
|
|
672
|
+
limit: a.limit,
|
|
673
|
+
expiry_seconds: a.expiry_seconds,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
async function runInspect(args, signal) {
|
|
174
677
|
logger.debug(`${TOOL_NAMES.INSPECT}: ${args.source_id}`);
|
|
175
678
|
const res = await hydra.context.inspect({
|
|
176
679
|
id: args.source_id,
|
|
177
680
|
mode: args.mode ?? "content",
|
|
178
|
-
|
|
681
|
+
expirySeconds: args.expiry_seconds,
|
|
682
|
+
}, { signal });
|
|
179
683
|
// Soft failure: return a normal (non-error) text result, matching v1.
|
|
180
684
|
if (!res.success || res.error) {
|
|
181
|
-
return
|
|
685
|
+
return errorResult(`Could not fetch source ${args.source_id}: ${res.error ?? "unknown error"}`);
|
|
182
686
|
}
|
|
183
|
-
const
|
|
184
|
-
|
|
687
|
+
const mode = args.mode ?? "content";
|
|
688
|
+
const parts = [`Source: ${args.source_id}`];
|
|
689
|
+
// `presignedUrl` was never read, so `mode: "url"` — documented in the
|
|
690
|
+
// schema and the README — returned "(no text content)" and nothing else.
|
|
691
|
+
// The one mode whose entire purpose is the link never emitted the link.
|
|
692
|
+
if (mode === "url" || mode === "both") {
|
|
693
|
+
parts.push(res.presignedUrl
|
|
694
|
+
? `Download URL (time-limited): ${res.presignedUrl}`
|
|
695
|
+
: "No download URL available for this source.");
|
|
696
|
+
}
|
|
697
|
+
if (mode === "content" || mode === "both") {
|
|
698
|
+
parts.push(inspectBody(res, args.offset, args.limit));
|
|
699
|
+
}
|
|
700
|
+
return textResult(parts.join("\n\n"));
|
|
701
|
+
}
|
|
702
|
+
async function runStatus(args, signal) {
|
|
703
|
+
logger.debug(`${TOOL_NAMES.STATUS}: ${args.ids.join(", ")}`);
|
|
704
|
+
const res = await hydra.context.ingestionStatus({ ids: args.ids }, { signal });
|
|
705
|
+
const statuses = res.statuses ?? [];
|
|
706
|
+
if (statuses.length === 0) {
|
|
707
|
+
return textResult(`No indexing status found for: ${args.ids.join(", ")}. ` +
|
|
708
|
+
`Either the ids are wrong or the sources were never queued.`);
|
|
709
|
+
}
|
|
710
|
+
const lines = statuses.map((s) => {
|
|
711
|
+
const state = s.indexingStatus ?? "unknown";
|
|
712
|
+
const reason = s.errorMessage
|
|
713
|
+
? ` — ${briefly(s.errorMessage)}${s.errorCode ? ` [${s.errorCode}]` : ""}`
|
|
714
|
+
: "";
|
|
715
|
+
return ` - ${s.id ?? "(unknown id)"}: ${state}${reason}`;
|
|
716
|
+
});
|
|
717
|
+
// `completed` and `failed` are terminal; everything else means keep
|
|
718
|
+
// waiting. Deliberately not switched over the SDK's status enum
|
|
719
|
+
// (queued/processing/completed/failed) — a live run returned
|
|
720
|
+
// `graph_creation`, which that enum does not declare.
|
|
721
|
+
const pending = statuses.filter((s) => !["completed", "failed"].includes(String(s.indexingStatus).toLowerCase()));
|
|
722
|
+
const note = pending.length > 0
|
|
723
|
+
? `\n\n${pending.length} still indexing — not yet searchable. Check again in a few seconds.`
|
|
724
|
+
: `\n\nAll sources have reached a terminal state.`;
|
|
725
|
+
return textResult(`Indexing status:\n${lines.join("\n")}${note}`);
|
|
185
726
|
}
|
|
186
727
|
/**
|
|
187
728
|
* Three outcomes, not two. A delete that removed nothing is either the
|
|
@@ -191,19 +732,65 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
191
732
|
* observe, and it is the reassuring one: the caller is told their data is
|
|
192
733
|
* gone when the server just declined to remove it.
|
|
193
734
|
*/
|
|
194
|
-
function deleteReport(kind,
|
|
735
|
+
function deleteReport(kind, ids, res, removed,
|
|
736
|
+
/** How many were removed, when the server said. `undefined` means unknown. */
|
|
737
|
+
removedCount) {
|
|
195
738
|
const noun = kind === "knowledge" ? "source" : "memory";
|
|
739
|
+
const id = ids.join(", ");
|
|
196
740
|
if (removed) {
|
|
197
|
-
|
|
741
|
+
// Three outcomes, and the third is "we were not told".
|
|
742
|
+
const partial = removedCount != null && ids.length > 1 && removedCount < ids.length;
|
|
743
|
+
const unknownCount = removedCount == null && ids.length > 1;
|
|
744
|
+
let text;
|
|
745
|
+
if (partial) {
|
|
746
|
+
text =
|
|
747
|
+
`Deleted ${removedCount} of ${ids.length} ${noun}s (requested: ${id}). ` +
|
|
748
|
+
`The rest were not found or could not be removed.`;
|
|
749
|
+
}
|
|
750
|
+
else if (unknownCount) {
|
|
751
|
+
// Do not claim all of them went. The server confirmed a removal and
|
|
752
|
+
// gave no count, so that is exactly what gets reported.
|
|
753
|
+
text =
|
|
754
|
+
`Deleted from Hydra DB (requested: ${id}). The server confirmed a removal ` +
|
|
755
|
+
`but did not say how many of the ${ids.length} ids were removed — ` +
|
|
756
|
+
`use ${TOOL_NAMES.LIST} to confirm which remain.`;
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
text = `Deleted ${noun}${ids.length > 1 ? "s" : ""}: ${id}`;
|
|
760
|
+
}
|
|
761
|
+
return structuredResult(text, {
|
|
762
|
+
ids,
|
|
763
|
+
kind,
|
|
764
|
+
deleted: true,
|
|
765
|
+
// Omitted rather than guessed when the server did not report it.
|
|
766
|
+
...(removedCount != null ? { deleted_count: removedCount } : {}),
|
|
767
|
+
...(partial ? { partial: true } : {}),
|
|
768
|
+
...(unknownCount ? { deleted_count_known: false } : {}),
|
|
769
|
+
});
|
|
198
770
|
}
|
|
199
771
|
if (res.success === false) {
|
|
200
772
|
const reason = deleteFailureReason(res);
|
|
201
|
-
return
|
|
202
|
-
|
|
203
|
-
|
|
773
|
+
return {
|
|
774
|
+
...structuredResult(`Could NOT delete ${noun} ${id} — the server refused the request` +
|
|
775
|
+
`${reason ? `: ${reason}` : " and gave no reason"}. ` +
|
|
776
|
+
`The ${noun} has not been removed.`, {
|
|
777
|
+
ids,
|
|
778
|
+
kind,
|
|
779
|
+
deleted: false,
|
|
780
|
+
deleted_count: 0,
|
|
781
|
+
...(reason ? { reason } : {}),
|
|
782
|
+
}),
|
|
783
|
+
isError: true,
|
|
784
|
+
};
|
|
204
785
|
}
|
|
205
|
-
|
|
206
|
-
|
|
786
|
+
// The server succeeded and removed nothing, so no such id exists in this
|
|
787
|
+
// database. "or already deleted" was the same mistake the refusal branch
|
|
788
|
+
// above was written to fix: it offers a cause we did not observe, and the
|
|
789
|
+
// reassuring one. A caller that invented an id — the likely case, since
|
|
790
|
+
// until recently nothing emitted one — reads it as confirmation and tells
|
|
791
|
+
// the user their data is gone.
|
|
792
|
+
return structuredResult(`No ${noun} with id ${id} exists in this database — nothing was deleted. ` +
|
|
793
|
+
`Ids come from ${TOOL_NAMES.QUERY} or ${TOOL_NAMES.LIST}; check the id rather than retrying.`, { ids, kind, deleted: false, deleted_count: 0, reason: "not found" });
|
|
207
794
|
}
|
|
208
795
|
/** The server's own explanation, preferring the per-item error over the summary. */
|
|
209
796
|
function deleteFailureReason(res) {
|
|
@@ -217,30 +804,61 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
217
804
|
}
|
|
218
805
|
return res.message !== "" ? res.message : undefined;
|
|
219
806
|
}
|
|
220
|
-
|
|
807
|
+
/** Accept `ids` or the singular `id`, and say where a real id comes from. */
|
|
808
|
+
function toDeleteArgs(args) {
|
|
809
|
+
const a = args;
|
|
810
|
+
const ids = a.ids ?? (a.id != null ? [a.id] : []);
|
|
811
|
+
if (ids.length === 0) {
|
|
812
|
+
throw new Error(`${TOOL_NAMES.DELETE} requires \`ids\` (or \`id\`). Ids come from ` +
|
|
813
|
+
`${TOOL_NAMES.QUERY} or ${TOOL_NAMES.LIST} — do not guess one.`);
|
|
814
|
+
}
|
|
815
|
+
return { ids, kind: a.kind };
|
|
816
|
+
}
|
|
817
|
+
async function runDelete(args, signal) {
|
|
221
818
|
const kind = args.kind ?? "memory";
|
|
222
|
-
logger.debug(`${TOOL_NAMES.DELETE}: ${kind} ${args.
|
|
223
|
-
const res = await hydra.context.delete({ ids:
|
|
224
|
-
|
|
819
|
+
logger.debug(`${TOOL_NAMES.DELETE}: ${kind} ${args.ids.join(", ")}`);
|
|
820
|
+
const res = await hydra.context.delete({ ids: args.ids, kind }, { signal });
|
|
821
|
+
// `userMemoryDeleted` is a COUNT on the v2 wire — a live delete returned
|
|
822
|
+
// `{"deletedCount":1,"userMemoryDeleted":1}` — and the SDK types it as a
|
|
823
|
+
// number. The v1 memory-delete handler returns a boolean for the same
|
|
824
|
+
// concept, so both are handled: a boolean answers "did anything go?" and
|
|
825
|
+
// only a number answers "how many?".
|
|
826
|
+
//
|
|
827
|
+
// The distinction matters for bulk delete. Reading a bare `true` as 1
|
|
828
|
+
// would report a successful 3-id removal as "Deleted 1 of 3 … partial",
|
|
829
|
+
// inventing a failure that did not happen — the mirror image of claiming
|
|
830
|
+
// success over a genuine partial.
|
|
831
|
+
const rawMemoryDeleted = res.userMemoryDeleted;
|
|
832
|
+
const memoryDeletedCount = typeof rawMemoryDeleted === "number" ? rawMemoryDeleted : undefined;
|
|
833
|
+
const reportedCount = res.deletedCount ?? memoryDeletedCount;
|
|
834
|
+
const removed = (reportedCount ?? 0) > 0 || rawMemoryDeleted === true;
|
|
835
|
+
// With no count at all, we do not know how many went — and inventing one
|
|
836
|
+
// is wrong in both directions. Reading the flag as 1 understated a full
|
|
837
|
+
// removal ("Deleted 1 of 3"); substituting ids.length overstates a partial
|
|
838
|
+
// one as complete success. So the count stays UNKNOWN and the report says
|
|
839
|
+
// so, which is the only thing actually observed.
|
|
840
|
+
const removedCount = reportedCount;
|
|
225
841
|
if (!removed) {
|
|
226
|
-
logger.warn(`${TOOL_NAMES.DELETE}: removed nothing for ${kind} ${args.
|
|
842
|
+
logger.warn(`${TOOL_NAMES.DELETE}: removed nothing for ${kind} ${args.ids.join(", ")}`, { success: res.success, message: res.message, results: res.results });
|
|
227
843
|
}
|
|
228
|
-
return deleteReport(kind, args.
|
|
844
|
+
return deleteReport(kind, args.ids, res, removed, removedCount);
|
|
229
845
|
}
|
|
230
846
|
// --- Registration helper ---
|
|
231
|
-
function register(name, inputSchema, handler, annotations) {
|
|
847
|
+
function register(name, inputSchema, handler, annotations, outputSchema) {
|
|
232
848
|
const desc = TOOL_DESCRIPTIONS[name];
|
|
233
849
|
const isDeprecated = DEPRECATED_TOOL_NAMES.includes(name);
|
|
850
|
+
const counted = (args, extra) => trackInFlight(() => handler(args, extra));
|
|
234
851
|
const wrapped = isDeprecated
|
|
235
|
-
? (args) => {
|
|
852
|
+
? (args, extra) => {
|
|
236
853
|
warnDeprecatedAlias(name);
|
|
237
|
-
return
|
|
854
|
+
return counted(args, extra);
|
|
238
855
|
}
|
|
239
|
-
:
|
|
856
|
+
: counted;
|
|
240
857
|
server.registerTool(name, {
|
|
241
858
|
title: desc.title,
|
|
242
859
|
description: desc.description,
|
|
243
860
|
inputSchema: inputSchema,
|
|
861
|
+
...(outputSchema ? { outputSchema: outputSchema } : {}),
|
|
244
862
|
...(annotations ? { annotations } : {}),
|
|
245
863
|
}, wrapped);
|
|
246
864
|
}
|
|
@@ -258,16 +876,45 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
258
876
|
.optional()
|
|
259
877
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.max_results),
|
|
260
878
|
mode: z
|
|
261
|
-
.enum(["fast", "thinking"])
|
|
879
|
+
.enum(["fast", "thinking", "auto"])
|
|
262
880
|
.optional()
|
|
263
881
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.mode),
|
|
264
882
|
graph_context: z
|
|
265
883
|
.boolean()
|
|
266
884
|
.optional()
|
|
267
885
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.graph_context),
|
|
886
|
+
detail: z
|
|
887
|
+
.enum(["compact", "full"])
|
|
888
|
+
.optional()
|
|
889
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.detail),
|
|
890
|
+
operator: z
|
|
891
|
+
.enum(["or", "and", "phrase"])
|
|
892
|
+
.optional()
|
|
893
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.operator),
|
|
894
|
+
source_ids: z
|
|
895
|
+
.array(z.string())
|
|
896
|
+
.min(1)
|
|
897
|
+
.optional()
|
|
898
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.source_ids),
|
|
899
|
+
metadata_filters: z
|
|
900
|
+
.record(z.unknown())
|
|
901
|
+
.optional()
|
|
902
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.metadata_filters),
|
|
903
|
+
num_related_chunks: z
|
|
904
|
+
.number()
|
|
905
|
+
.int()
|
|
906
|
+
.min(0)
|
|
907
|
+
.max(5)
|
|
908
|
+
.optional()
|
|
909
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.num_related_chunks),
|
|
268
910
|
};
|
|
269
911
|
const storeSchema = {
|
|
270
|
-
text: z
|
|
912
|
+
text: z
|
|
913
|
+
.string()
|
|
914
|
+
.max(MAX_TEXT_CHARS, {
|
|
915
|
+
message: `text must be at most ${MAX_TEXT_CHARS} characters`,
|
|
916
|
+
})
|
|
917
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.text),
|
|
271
918
|
title: z
|
|
272
919
|
.string()
|
|
273
920
|
.optional()
|
|
@@ -284,20 +931,48 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
284
931
|
.boolean()
|
|
285
932
|
.optional()
|
|
286
933
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.is_markdown),
|
|
934
|
+
overwrite: z
|
|
935
|
+
.boolean()
|
|
936
|
+
.optional()
|
|
937
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.overwrite),
|
|
938
|
+
};
|
|
939
|
+
const ingestMetadataSchema = {
|
|
940
|
+
metadata: z
|
|
941
|
+
.record(z.unknown())
|
|
942
|
+
.optional()
|
|
943
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.metadata),
|
|
944
|
+
observation_date: z
|
|
945
|
+
.string()
|
|
946
|
+
.optional()
|
|
947
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.observation_date),
|
|
287
948
|
};
|
|
288
949
|
const ingestSchema = {
|
|
289
950
|
...storeSchema,
|
|
951
|
+
...ingestMetadataSchema,
|
|
290
952
|
// Canonical ingest accepts EITHER `text` or `turns`, so `text` is optional
|
|
291
953
|
// here (the `hydra_db_store` alias keeps it required).
|
|
292
954
|
text: z
|
|
293
955
|
.string()
|
|
956
|
+
.max(MAX_TEXT_CHARS, {
|
|
957
|
+
message: `text must be at most ${MAX_TEXT_CHARS} characters`,
|
|
958
|
+
})
|
|
294
959
|
.optional()
|
|
295
960
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.text),
|
|
961
|
+
kind: z
|
|
962
|
+
.enum(["memory", "knowledge"])
|
|
963
|
+
.optional()
|
|
964
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.kind),
|
|
296
965
|
turns: z
|
|
297
966
|
.array(turnSchema)
|
|
298
967
|
.min(1)
|
|
968
|
+
.max(MAX_TURNS, { message: `at most ${MAX_TURNS} turns per ingest` })
|
|
299
969
|
.optional()
|
|
300
970
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.turns),
|
|
971
|
+
// `text` and `turns` are mutually exclusive and exactly one is required.
|
|
972
|
+
// JSON Schema cannot express that, so the rule lives in three places: the
|
|
973
|
+
// tool description, these two param descriptions, and the handler check
|
|
974
|
+
// below. They must agree — a model that reads "provide turns rather than
|
|
975
|
+
// text" concludes both are allowed and discovers otherwise at runtime.
|
|
301
976
|
user_name: z
|
|
302
977
|
.string()
|
|
303
978
|
.optional()
|
|
@@ -307,6 +982,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
307
982
|
turns: z
|
|
308
983
|
.array(turnSchema)
|
|
309
984
|
.min(1)
|
|
985
|
+
.max(MAX_TURNS, { message: `at most ${MAX_TURNS} turns per ingest` })
|
|
310
986
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.turns),
|
|
311
987
|
source_id: z
|
|
312
988
|
.string()
|
|
@@ -317,14 +993,34 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
317
993
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.user_name),
|
|
318
994
|
};
|
|
319
995
|
const listSchema = {
|
|
996
|
+
// Required, not defaulted. `hydradb_list({})` used to return memories only
|
|
997
|
+
// and read as the complete inventory, so a caller asking "what does Hydra
|
|
998
|
+
// DB have?" never saw the knowledge corpus — which hydradb_query searches
|
|
999
|
+
// by default. Same class of bug as the query `kind` pin, on the list path.
|
|
320
1000
|
kind: z
|
|
321
1001
|
.enum(["memory", "knowledge"])
|
|
322
|
-
.optional()
|
|
323
1002
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.kind),
|
|
324
|
-
|
|
1003
|
+
ids: z
|
|
325
1004
|
.array(z.string())
|
|
326
1005
|
.optional()
|
|
327
1006
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.source_ids),
|
|
1007
|
+
source_ids: z
|
|
1008
|
+
.array(z.string())
|
|
1009
|
+
.optional()
|
|
1010
|
+
.describe("Deprecated alias for `ids`."),
|
|
1011
|
+
page: z
|
|
1012
|
+
.number()
|
|
1013
|
+
.int()
|
|
1014
|
+
.min(1)
|
|
1015
|
+
.optional()
|
|
1016
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.page),
|
|
1017
|
+
page_size: z
|
|
1018
|
+
.number()
|
|
1019
|
+
.int()
|
|
1020
|
+
.min(1)
|
|
1021
|
+
.max(100)
|
|
1022
|
+
.optional()
|
|
1023
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.page_size),
|
|
328
1024
|
};
|
|
329
1025
|
const listSourcesSchema = {
|
|
330
1026
|
source_ids: z
|
|
@@ -333,44 +1029,170 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
333
1029
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST_SOURCES].params.source_ids),
|
|
334
1030
|
};
|
|
335
1031
|
const inspectSchema = {
|
|
336
|
-
|
|
1032
|
+
// CONTRACT §1 says a source's identifier field is `id`, but this surface
|
|
1033
|
+
// spelled one concept three ways across tools meant to chain: inspect took
|
|
1034
|
+
// `source_id`, list took `source_ids`, delete took `id`. `id` is canonical
|
|
1035
|
+
// here; the old spelling stays accepted so nothing breaks.
|
|
1036
|
+
id: z
|
|
337
1037
|
.string()
|
|
1038
|
+
.optional()
|
|
338
1039
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.source_id),
|
|
1040
|
+
source_id: z
|
|
1041
|
+
.string()
|
|
1042
|
+
.optional()
|
|
1043
|
+
.describe("Deprecated alias for `id`."),
|
|
339
1044
|
mode: z
|
|
340
1045
|
.enum(["content", "url", "both"])
|
|
341
1046
|
.optional()
|
|
342
1047
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.mode),
|
|
1048
|
+
offset: z
|
|
1049
|
+
.number()
|
|
1050
|
+
.int()
|
|
1051
|
+
.min(0)
|
|
1052
|
+
.optional()
|
|
1053
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.offset),
|
|
1054
|
+
limit: z
|
|
1055
|
+
.number()
|
|
1056
|
+
.int()
|
|
1057
|
+
.min(1)
|
|
1058
|
+
.max(INSPECT_CHAR_BUDGET)
|
|
1059
|
+
.optional()
|
|
1060
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.limit),
|
|
1061
|
+
expiry_seconds: z
|
|
1062
|
+
.number()
|
|
1063
|
+
.int()
|
|
1064
|
+
.min(1)
|
|
1065
|
+
.optional()
|
|
1066
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.expiry_seconds),
|
|
343
1067
|
};
|
|
344
1068
|
const deleteSchema = {
|
|
345
|
-
|
|
1069
|
+
ids: z
|
|
1070
|
+
.array(z.string())
|
|
1071
|
+
.min(1)
|
|
1072
|
+
.optional()
|
|
1073
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.ids),
|
|
1074
|
+
id: z
|
|
1075
|
+
.string()
|
|
1076
|
+
.optional()
|
|
1077
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.id),
|
|
346
1078
|
kind: z
|
|
347
1079
|
.enum(["memory", "knowledge"])
|
|
348
1080
|
.optional()
|
|
349
1081
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.kind),
|
|
350
1082
|
};
|
|
1083
|
+
// Output schemas, declared only where the result is genuinely structured.
|
|
1084
|
+
// Query stays prose: its payload IS text, and forcing it into fields would
|
|
1085
|
+
// duplicate the rendered context rather than replace it.
|
|
1086
|
+
const listOutputSchema = {
|
|
1087
|
+
kind: z.enum(["memory", "knowledge"]),
|
|
1088
|
+
items: z.array(z.object({
|
|
1089
|
+
id: z.string(),
|
|
1090
|
+
title: z.string().optional(),
|
|
1091
|
+
type: z.string().optional(),
|
|
1092
|
+
content: z.string().optional(),
|
|
1093
|
+
})),
|
|
1094
|
+
shown: z.number(),
|
|
1095
|
+
total: z.number(),
|
|
1096
|
+
page: z.number(),
|
|
1097
|
+
has_more: z.boolean(),
|
|
1098
|
+
};
|
|
1099
|
+
const ingestOutputSchema = {
|
|
1100
|
+
id: z.string().optional(),
|
|
1101
|
+
success_count: z.number(),
|
|
1102
|
+
failed_count: z.number(),
|
|
1103
|
+
indexing_pending: z.boolean(),
|
|
1104
|
+
};
|
|
1105
|
+
const deleteOutputSchema = {
|
|
1106
|
+
ids: z.array(z.string()),
|
|
1107
|
+
kind: z.enum(["memory", "knowledge"]),
|
|
1108
|
+
deleted: z.boolean(),
|
|
1109
|
+
/** Absent when the server confirmed a removal without saying how many. */
|
|
1110
|
+
deleted_count: z.number().optional(),
|
|
1111
|
+
deleted_count_known: z.boolean().optional(),
|
|
1112
|
+
partial: z.boolean().optional(),
|
|
1113
|
+
reason: z.string().optional(),
|
|
1114
|
+
};
|
|
1115
|
+
const statusSchema = {
|
|
1116
|
+
ids: z
|
|
1117
|
+
.array(z.string())
|
|
1118
|
+
.min(1)
|
|
1119
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STATUS].params.ids),
|
|
1120
|
+
};
|
|
351
1121
|
const deleteMemorySchema = {
|
|
352
1122
|
memory_id: z
|
|
353
1123
|
.string()
|
|
354
1124
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE_MEMORY].params.memory_id),
|
|
355
1125
|
};
|
|
356
|
-
|
|
357
|
-
|
|
1126
|
+
// `destructiveHint` was missing from the annotations type, so no tool could
|
|
1127
|
+
// declare it — and the MCP spec defaults it to TRUE for any non-readonly
|
|
1128
|
+
// tool. A spec-following host therefore read hydradb_ingest as destructive
|
|
1129
|
+
// and could prompt the user before every proactive save, which is exactly the
|
|
1130
|
+
// behaviour the instructions now ask for. Meanwhile hydradb_delete, which IS
|
|
1131
|
+
// destructive, was landing there only by absence — one refactor adding an
|
|
1132
|
+
// explicit `readOnlyHint: false` would have flipped it.
|
|
1133
|
+
//
|
|
1134
|
+
// All four are stated on every tool so none of them depends on a default.
|
|
1135
|
+
const readOnly = {
|
|
358
1136
|
readOnlyHint: true,
|
|
1137
|
+
destructiveHint: false,
|
|
1138
|
+
idempotentHint: true,
|
|
359
1139
|
openWorldHint: true,
|
|
1140
|
+
};
|
|
1141
|
+
const searchAnnotations = readOnly;
|
|
1142
|
+
/** Adds context; never removes any. Repeating it is not a no-op. */
|
|
1143
|
+
const additiveWrite = {
|
|
1144
|
+
readOnlyHint: false,
|
|
1145
|
+
destructiveHint: false,
|
|
1146
|
+
idempotentHint: false,
|
|
1147
|
+
openWorldHint: true,
|
|
1148
|
+
};
|
|
1149
|
+
/** Removes context irreversibly. Repeating it is harmless once it is gone. */
|
|
1150
|
+
const destructive = {
|
|
1151
|
+
readOnlyHint: false,
|
|
1152
|
+
destructiveHint: true,
|
|
360
1153
|
idempotentHint: true,
|
|
1154
|
+
openWorldHint: true,
|
|
361
1155
|
};
|
|
362
1156
|
// --- Canonical tools ---
|
|
363
|
-
register(TOOL_NAMES.QUERY, querySchema, (args) => runQuery(args), searchAnnotations);
|
|
364
|
-
register(TOOL_NAMES.INGEST, ingestSchema, async (args) => {
|
|
1157
|
+
register(TOOL_NAMES.QUERY, querySchema, (args, extra) => runQuery(args, extra?.signal), searchAnnotations);
|
|
1158
|
+
register(TOOL_NAMES.INGEST, ingestSchema, async (args, extra) => {
|
|
365
1159
|
const a = args;
|
|
366
1160
|
const hasTurns = a.turns != null && a.turns.length > 0;
|
|
1161
|
+
// A conversation is a memory by definition; there is no knowledge document
|
|
1162
|
+
// made of user/assistant pairs. Reject rather than quietly ingesting it as
|
|
1163
|
+
// the wrong family.
|
|
1164
|
+
if (hasTurns && a.kind === "knowledge") {
|
|
1165
|
+
throw new Error(`${TOOL_NAMES.INGEST} cannot ingest \`turns\` as knowledge — conversations are memories. ` +
|
|
1166
|
+
`Use \`text\` for a knowledge document, or drop \`kind\`.`);
|
|
1167
|
+
}
|
|
1168
|
+
// The handler strips memory-only fields before calling the wrapper on the
|
|
1169
|
+
// knowledge path, which means the wrapper's own guard never sees them —
|
|
1170
|
+
// so without this they would be dropped in silence, which is the exact
|
|
1171
|
+
// behaviour that guard exists to prevent.
|
|
1172
|
+
if (a.kind === "knowledge") {
|
|
1173
|
+
const memoryOnlyGiven = [
|
|
1174
|
+
["source_id", a.source_id],
|
|
1175
|
+
["infer", a.infer],
|
|
1176
|
+
["is_markdown", a.is_markdown],
|
|
1177
|
+
["user_name", a.user_name],
|
|
1178
|
+
["metadata", a.metadata],
|
|
1179
|
+
["observation_date", a.observation_date],
|
|
1180
|
+
]
|
|
1181
|
+
.filter(([, value]) => value != null)
|
|
1182
|
+
.map(([name]) => name);
|
|
1183
|
+
if (memoryOnlyGiven.length > 0) {
|
|
1184
|
+
throw new Error(`${TOOL_NAMES.INGEST} does not support ${memoryOnlyGiven.join(", ")} for ` +
|
|
1185
|
+
`kind "knowledge" — those apply to memory ingestion only. Drop them, or ` +
|
|
1186
|
+
`ingest this as a memory.`);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
367
1189
|
// `text` and `turns` are mutually exclusive — reject rather than silently
|
|
368
1190
|
// dropping one (the documented "exactly one" contract).
|
|
369
1191
|
if (hasTurns && a.text != null) {
|
|
370
1192
|
throw new Error(`${TOOL_NAMES.INGEST} accepts either \`text\` (a note) or \`turns\` (a conversation), not both.`);
|
|
371
1193
|
}
|
|
372
1194
|
if (a.turns != null && a.turns.length > 0) {
|
|
373
|
-
const sourceId = a.source_id ??
|
|
1195
|
+
const sourceId = a.source_id ?? generatedSourceId();
|
|
374
1196
|
// Forward every option the canonical schema accepts so none is
|
|
375
1197
|
// silently dropped on the conversation path.
|
|
376
1198
|
return runIngestConversation(a.turns, sourceId, {
|
|
@@ -378,44 +1200,92 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
378
1200
|
infer: a.infer,
|
|
379
1201
|
title: a.title,
|
|
380
1202
|
isMarkdown: a.is_markdown,
|
|
381
|
-
|
|
1203
|
+
overwrite: a.overwrite,
|
|
1204
|
+
}, extra?.signal);
|
|
382
1205
|
}
|
|
383
1206
|
if (a.text != null) {
|
|
384
1207
|
return runStore({
|
|
385
1208
|
text: a.text,
|
|
1209
|
+
kind: a.kind,
|
|
386
1210
|
title: a.title,
|
|
387
1211
|
source_id: a.source_id,
|
|
388
1212
|
infer: a.infer,
|
|
389
1213
|
is_markdown: a.is_markdown,
|
|
390
|
-
|
|
1214
|
+
overwrite: a.overwrite,
|
|
1215
|
+
metadata: a.metadata,
|
|
1216
|
+
observation_date: a.observation_date,
|
|
1217
|
+
}, extra?.signal);
|
|
391
1218
|
}
|
|
392
1219
|
throw new Error(`${TOOL_NAMES.INGEST} requires either \`text\` (a note) or \`turns\` (a conversation).`);
|
|
393
|
-
});
|
|
394
|
-
register(TOOL_NAMES.LIST, listSchema, (args) => {
|
|
1220
|
+
}, additiveWrite, ingestOutputSchema);
|
|
1221
|
+
register(TOOL_NAMES.LIST, listSchema, (args, extra) => {
|
|
395
1222
|
const a = args;
|
|
396
|
-
|
|
397
|
-
|
|
1223
|
+
// Compare as SETS. These are filters, so order carries no meaning —
|
|
1224
|
+
// rejecting ["a","b"] against ["b","a"] refuses a request that asked
|
|
1225
|
+
// for exactly one thing, which is worse than the ambiguity the check
|
|
1226
|
+
// exists to catch.
|
|
1227
|
+
// Compare DISTINCT members. An earlier version compared lengths and
|
|
1228
|
+
// union size, which called ["a","b"] and ["a","a"] equivalent — same
|
|
1229
|
+
// length, same union size — and then silently listed records the
|
|
1230
|
+
// deprecated filter had excluded.
|
|
1231
|
+
const sameIds = (x, y) => {
|
|
1232
|
+
const left = new Set(x);
|
|
1233
|
+
const right = new Set(y);
|
|
1234
|
+
return left.size === right.size && [...left].every((v) => right.has(v));
|
|
1235
|
+
};
|
|
1236
|
+
if (a.ids != null &&
|
|
1237
|
+
a.source_ids != null &&
|
|
1238
|
+
!sameIds(a.ids, a.source_ids)) {
|
|
1239
|
+
throw new Error(`${TOOL_NAMES.LIST} received different values for \`ids\` and its deprecated ` +
|
|
1240
|
+
`alias \`source_ids\`. Pass only \`ids\`.`);
|
|
398
1241
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
1242
|
+
const ids = a.ids ?? a.source_ids;
|
|
1243
|
+
if (a.kind === "knowledge") {
|
|
1244
|
+
return runListSources({ source_ids: ids, page: a.page, page_size: a.page_size }, extra?.signal);
|
|
1245
|
+
}
|
|
1246
|
+
return runListMemories({ source_ids: ids, page: a.page, page_size: a.page_size }, extra?.signal);
|
|
1247
|
+
}, readOnly, listOutputSchema);
|
|
1248
|
+
register(TOOL_NAMES.INSPECT, inspectSchema, (args, extra) => runInspect(toInspectArgs(args), extra?.signal), readOnly);
|
|
1249
|
+
register(TOOL_NAMES.DELETE, deleteSchema, (args, extra) => runDelete(toDeleteArgs(args), extra?.signal), destructive, deleteOutputSchema);
|
|
1250
|
+
register(TOOL_NAMES.STATUS, statusSchema, (args, extra) => runStatus(args, extra?.signal), readOnly);
|
|
403
1251
|
// --- Deprecated aliases ---
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
1252
|
+
//
|
|
1253
|
+
// Registered only when HYDRADB_MCP_LEGACY_TOOLS is set. Off by default.
|
|
1254
|
+
//
|
|
1255
|
+
// Twelve tools is not the problem; adversarial naming is. The alias names are
|
|
1256
|
+
// systematically better literal matches for how users phrase requests than
|
|
1257
|
+
// the canonical ones — "search my memory" matches hydra_db_search exactly
|
|
1258
|
+
// while hydradb_query needs a synonym step, "list my memories" matches
|
|
1259
|
+
// hydra_db_list_memories verbatim while hydradb_list additionally needs
|
|
1260
|
+
// `kind` inferred. Every canonical tool has a competitor that wins on surface
|
|
1261
|
+
// form AND requires fewer inferential steps to parameterise, against nothing
|
|
1262
|
+
// but a "DEPRECATED" prefix — a negative instruction losing to a positive
|
|
1263
|
+
// lexical match.
|
|
1264
|
+
//
|
|
1265
|
+
// The cost of losing that contest is real capability, not just a warning:
|
|
1266
|
+
// hydra_db_ingest_conversation cannot set kind, overwrite, title, infer or
|
|
1267
|
+
// is_markdown, and hydra_db_store has no path to `turns`. A model that picks
|
|
1268
|
+
// the alias because the name matched silently gets the lesser tool.
|
|
1269
|
+
//
|
|
1270
|
+
// They also cost every conversation ~1,800 tokens of manifest — 55% of it —
|
|
1271
|
+
// before a single call is made.
|
|
1272
|
+
if (legacyToolsEnabled()) {
|
|
1273
|
+
register(TOOL_NAMES.SEARCH, querySchema, (args, extra) => runQuery(args, extra?.signal), searchAnnotations);
|
|
1274
|
+
register(TOOL_NAMES.STORE, storeSchema, (args, extra) => runStore(args, extra?.signal), additiveWrite);
|
|
1275
|
+
register(TOOL_NAMES.INGEST_CONVERSATION, conversationSchema, (args, extra) => {
|
|
1276
|
+
const a = args;
|
|
1277
|
+
// The deprecated alias keeps its historical shape (user_name only; infer
|
|
1278
|
+
// on, no title/markdown). The canonical hydradb_ingest forwards the rest.
|
|
1279
|
+
return runIngestConversation(a.turns, a.source_id, { userName: a.user_name }, extra?.signal);
|
|
1280
|
+
}, additiveWrite);
|
|
1281
|
+
register(TOOL_NAMES.LIST_MEMORIES, {}, (_args, extra) => runListMemories({}, extra?.signal), readOnly);
|
|
1282
|
+
register(TOOL_NAMES.LIST_SOURCES, listSourcesSchema, (args, extra) => runListSources(args, extra?.signal), readOnly);
|
|
1283
|
+
register(TOOL_NAMES.FETCH_CONTENT, inspectSchema, (args, extra) => runInspect(toInspectArgs(args), extra?.signal), readOnly);
|
|
1284
|
+
register(TOOL_NAMES.DELETE_MEMORY, deleteMemorySchema, (args, extra) => {
|
|
1285
|
+
const { memory_id } = args;
|
|
1286
|
+
return runDelete({ ids: [memory_id], kind: "memory" }, extra?.signal);
|
|
1287
|
+
}, destructive);
|
|
1288
|
+
}
|
|
419
1289
|
return server.server;
|
|
420
1290
|
}
|
|
421
1291
|
//# sourceMappingURL=server.js.map
|