@hydradb/mcp 1.1.0 → 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 +57 -28
- package/dist/descriptions.js +203 -49
- package/dist/descriptions.js.map +1 -1
- package/dist/hydra/client.d.ts +69 -8
- 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 +998 -106
- 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,12 +21,120 @@ 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
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* What a query actually searched, for the result strings. A query over `all`
|
|
100
|
+
* must not report "memories" — that phrasing is what taught callers the MCP
|
|
101
|
+
* was memory-only in the first place.
|
|
102
|
+
*/
|
|
103
|
+
function resultNoun(kind, count) {
|
|
104
|
+
const one = count === 1;
|
|
105
|
+
if (kind === "memory")
|
|
106
|
+
return one ? "memory" : "memories";
|
|
107
|
+
if (kind === "knowledge")
|
|
108
|
+
return one ? "knowledge source" : "knowledge sources";
|
|
109
|
+
return one ? "context item" : "context items";
|
|
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;
|
|
26
125
|
const turnSchema = z.object({
|
|
27
|
-
user: z
|
|
28
|
-
|
|
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"),
|
|
29
138
|
});
|
|
30
139
|
// Deprecated aliases emit exactly one stderr warning PER PROCESS naming the
|
|
31
140
|
// canonical replacement (CONTRACT §3). The dedupe state is module-scoped so the
|
|
@@ -40,6 +149,74 @@ function warnDeprecatedAlias(name) {
|
|
|
40
149
|
const replacement = ALIAS_REPLACEMENTS[name] ?? "a canonical tool";
|
|
41
150
|
console.error(`[hydradb-mcp] Tool "${name}" is deprecated and will be removed in a future major version; use "${replacement}" instead.`);
|
|
42
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
|
+
}
|
|
43
220
|
/** Test-only: reset the once-per-process alias warning dedupe. */
|
|
44
221
|
export function __resetAliasWarnings() {
|
|
45
222
|
warnedAliases.clear();
|
|
@@ -62,54 +239,189 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
62
239
|
database: config.database,
|
|
63
240
|
collection: config.collection,
|
|
64
241
|
...(config.baseUrl != null ? { baseUrl: config.baseUrl } : {}),
|
|
242
|
+
...(config.timeoutSeconds != null
|
|
243
|
+
? { timeoutSeconds: config.timeoutSeconds }
|
|
244
|
+
: {}),
|
|
245
|
+
...(config.maxRetries != null ? { maxRetries: config.maxRetries } : {}),
|
|
65
246
|
});
|
|
66
247
|
logger.info(`Hydra DB connected (database=${config.database}, collection=${config.collection})`);
|
|
67
248
|
}
|
|
68
249
|
// --- Handlers (shared by canonical tools and their deprecated aliases) ---
|
|
69
|
-
async function runQuery(args) {
|
|
70
|
-
|
|
250
|
+
async function runQuery(args, signal) {
|
|
251
|
+
// Host-owned default (CONTRACT §2 rule 5): search BOTH families. This tool
|
|
252
|
+
// used to pin `kind: "memory"`, which made every ingested knowledge source
|
|
253
|
+
// unreachable from the MCP — `hydradb_list`/`hydradb_inspect` could browse
|
|
254
|
+
// knowledge but nothing could search it.
|
|
255
|
+
const kind = args.kind ?? "all";
|
|
256
|
+
logger.debug(`${TOOL_NAMES.QUERY}: "${args.query}" (kind=${kind})`);
|
|
257
|
+
const maxResults = args.max_results ?? 10;
|
|
71
258
|
const raw = await hydra.context.query({
|
|
72
259
|
query: args.query,
|
|
73
|
-
kind
|
|
74
|
-
maxResults
|
|
260
|
+
kind,
|
|
261
|
+
maxResults,
|
|
75
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,
|
|
76
267
|
graphContext: args.graph_context ?? true,
|
|
77
268
|
alpha: 0.8,
|
|
78
269
|
recencyBias: 0,
|
|
79
|
-
});
|
|
80
|
-
|
|
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
|
+
}
|
|
81
280
|
if (!res.chunks || res.chunks.length === 0) {
|
|
82
|
-
return textResult(
|
|
281
|
+
return textResult(`No relevant ${resultNoun(kind)} found in Hydra DB.`);
|
|
83
282
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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,
|
|
93
308
|
});
|
|
94
|
-
return textResult(`Found ${
|
|
309
|
+
return textResult(`Found ${shown} ${resultNoun(kind, shown)}:\n\n${contextStr}${legend}`);
|
|
95
310
|
}
|
|
96
|
-
|
|
97
|
-
|
|
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")}` : "";
|
|
379
|
+
}
|
|
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
|
+
: {};
|
|
98
398
|
const raw = await hydra.context.ingest({
|
|
99
|
-
kind
|
|
399
|
+
kind,
|
|
100
400
|
text: args.text,
|
|
101
|
-
title: args.title ??
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
upsert: true,
|
|
107
|
-
});
|
|
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 });
|
|
108
408
|
const res = toAddMemoryResponse(raw);
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
});
|
|
111
423
|
}
|
|
112
|
-
async function runIngestConversation(turns, sourceId, opts) {
|
|
424
|
+
async function runIngestConversation(turns, sourceId, opts, signal) {
|
|
113
425
|
logger.debug(`${TOOL_NAMES.INGEST}: ${turns.length} turns -> ${sourceId}`);
|
|
114
426
|
const raw = await hydra.context.ingest({
|
|
115
427
|
kind: "memory",
|
|
@@ -120,50 +432,297 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
120
432
|
title: opts?.title,
|
|
121
433
|
isMarkdown: opts?.isMarkdown,
|
|
122
434
|
customInstructions: INGEST_INSTRUCTIONS,
|
|
123
|
-
upsert: true,
|
|
124
|
-
});
|
|
435
|
+
upsert: opts?.overwrite ?? true,
|
|
436
|
+
}, { signal });
|
|
125
437
|
const res = toAddMemoryResponse(raw);
|
|
126
|
-
|
|
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
|
+
});
|
|
127
448
|
}
|
|
128
|
-
|
|
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));
|
|
471
|
+
}
|
|
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) {
|
|
129
481
|
logger.debug(TOOL_NAMES.LIST);
|
|
130
|
-
const raw = await hydra.context.list({
|
|
131
|
-
|
|
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);
|
|
132
489
|
if (memories.length === 0) {
|
|
133
|
-
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
|
+
});
|
|
134
503
|
}
|
|
135
|
-
const lines = memories.map((m, i) =>
|
|
136
|
-
|
|
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
|
+
});
|
|
137
527
|
}
|
|
138
|
-
async function runListSources(args) {
|
|
528
|
+
async function runListSources(args, signal) {
|
|
139
529
|
logger.debug(TOOL_NAMES.LIST);
|
|
140
530
|
const raw = await hydra.context.list({
|
|
141
531
|
kind: "knowledge",
|
|
142
532
|
ids: args.source_ids,
|
|
143
|
-
|
|
144
|
-
|
|
533
|
+
page: args.page,
|
|
534
|
+
pageSize: args.page_size,
|
|
535
|
+
}, { signal });
|
|
536
|
+
const { sources, page } = toSourceList(raw);
|
|
145
537
|
if (sources.length === 0) {
|
|
146
|
-
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
|
+
});
|
|
147
548
|
}
|
|
148
549
|
const lines = sources.map((s, i) => {
|
|
149
550
|
const title = s.title ? ` — ${s.title}` : "";
|
|
150
551
|
const type = s.type ? ` (${s.type})` : "";
|
|
151
552
|
return `${i + 1}. [${s.id}]${title}${type}`;
|
|
152
553
|
});
|
|
153
|
-
|
|
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]`;
|
|
605
|
+
}
|
|
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}]`);
|
|
154
651
|
}
|
|
155
|
-
|
|
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) {
|
|
156
677
|
logger.debug(`${TOOL_NAMES.INSPECT}: ${args.source_id}`);
|
|
157
678
|
const res = await hydra.context.inspect({
|
|
158
679
|
id: args.source_id,
|
|
159
680
|
mode: args.mode ?? "content",
|
|
160
|
-
|
|
681
|
+
expirySeconds: args.expiry_seconds,
|
|
682
|
+
}, { signal });
|
|
161
683
|
// Soft failure: return a normal (non-error) text result, matching v1.
|
|
162
684
|
if (!res.success || res.error) {
|
|
163
|
-
return
|
|
685
|
+
return errorResult(`Could not fetch source ${args.source_id}: ${res.error ?? "unknown error"}`);
|
|
164
686
|
}
|
|
165
|
-
const
|
|
166
|
-
|
|
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}`);
|
|
167
726
|
}
|
|
168
727
|
/**
|
|
169
728
|
* Three outcomes, not two. A delete that removed nothing is either the
|
|
@@ -173,19 +732,65 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
173
732
|
* observe, and it is the reassuring one: the caller is told their data is
|
|
174
733
|
* gone when the server just declined to remove it.
|
|
175
734
|
*/
|
|
176
|
-
function deleteReport(kind,
|
|
735
|
+
function deleteReport(kind, ids, res, removed,
|
|
736
|
+
/** How many were removed, when the server said. `undefined` means unknown. */
|
|
737
|
+
removedCount) {
|
|
177
738
|
const noun = kind === "knowledge" ? "source" : "memory";
|
|
739
|
+
const id = ids.join(", ");
|
|
178
740
|
if (removed) {
|
|
179
|
-
|
|
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
|
+
});
|
|
180
770
|
}
|
|
181
771
|
if (res.success === false) {
|
|
182
772
|
const reason = deleteFailureReason(res);
|
|
183
|
-
return
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
};
|
|
186
785
|
}
|
|
187
|
-
|
|
188
|
-
|
|
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" });
|
|
189
794
|
}
|
|
190
795
|
/** The server's own explanation, preferring the per-item error over the summary. */
|
|
191
796
|
function deleteFailureReason(res) {
|
|
@@ -199,36 +804,71 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
199
804
|
}
|
|
200
805
|
return res.message !== "" ? res.message : undefined;
|
|
201
806
|
}
|
|
202
|
-
|
|
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) {
|
|
203
818
|
const kind = args.kind ?? "memory";
|
|
204
|
-
logger.debug(`${TOOL_NAMES.DELETE}: ${kind} ${args.
|
|
205
|
-
const res = await hydra.context.delete({ ids:
|
|
206
|
-
|
|
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;
|
|
207
841
|
if (!removed) {
|
|
208
|
-
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 });
|
|
209
843
|
}
|
|
210
|
-
return deleteReport(kind, args.
|
|
844
|
+
return deleteReport(kind, args.ids, res, removed, removedCount);
|
|
211
845
|
}
|
|
212
846
|
// --- Registration helper ---
|
|
213
|
-
function register(name, inputSchema, handler, annotations) {
|
|
847
|
+
function register(name, inputSchema, handler, annotations, outputSchema) {
|
|
214
848
|
const desc = TOOL_DESCRIPTIONS[name];
|
|
215
849
|
const isDeprecated = DEPRECATED_TOOL_NAMES.includes(name);
|
|
850
|
+
const counted = (args, extra) => trackInFlight(() => handler(args, extra));
|
|
216
851
|
const wrapped = isDeprecated
|
|
217
|
-
? (args) => {
|
|
852
|
+
? (args, extra) => {
|
|
218
853
|
warnDeprecatedAlias(name);
|
|
219
|
-
return
|
|
854
|
+
return counted(args, extra);
|
|
220
855
|
}
|
|
221
|
-
:
|
|
856
|
+
: counted;
|
|
222
857
|
server.registerTool(name, {
|
|
223
858
|
title: desc.title,
|
|
224
859
|
description: desc.description,
|
|
225
860
|
inputSchema: inputSchema,
|
|
861
|
+
...(outputSchema ? { outputSchema: outputSchema } : {}),
|
|
226
862
|
...(annotations ? { annotations } : {}),
|
|
227
863
|
}, wrapped);
|
|
228
864
|
}
|
|
229
865
|
// --- Input schemas ---
|
|
230
866
|
const querySchema = {
|
|
231
867
|
query: z.string().describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.query),
|
|
868
|
+
kind: z
|
|
869
|
+
.enum(["memory", "knowledge", "all"])
|
|
870
|
+
.optional()
|
|
871
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.kind),
|
|
232
872
|
max_results: z
|
|
233
873
|
.number()
|
|
234
874
|
.min(1)
|
|
@@ -236,16 +876,45 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
236
876
|
.optional()
|
|
237
877
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.max_results),
|
|
238
878
|
mode: z
|
|
239
|
-
.enum(["fast", "thinking"])
|
|
879
|
+
.enum(["fast", "thinking", "auto"])
|
|
240
880
|
.optional()
|
|
241
881
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.mode),
|
|
242
882
|
graph_context: z
|
|
243
883
|
.boolean()
|
|
244
884
|
.optional()
|
|
245
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),
|
|
246
910
|
};
|
|
247
911
|
const storeSchema = {
|
|
248
|
-
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),
|
|
249
918
|
title: z
|
|
250
919
|
.string()
|
|
251
920
|
.optional()
|
|
@@ -262,20 +931,48 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
262
931
|
.boolean()
|
|
263
932
|
.optional()
|
|
264
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),
|
|
265
948
|
};
|
|
266
949
|
const ingestSchema = {
|
|
267
950
|
...storeSchema,
|
|
951
|
+
...ingestMetadataSchema,
|
|
268
952
|
// Canonical ingest accepts EITHER `text` or `turns`, so `text` is optional
|
|
269
953
|
// here (the `hydra_db_store` alias keeps it required).
|
|
270
954
|
text: z
|
|
271
955
|
.string()
|
|
956
|
+
.max(MAX_TEXT_CHARS, {
|
|
957
|
+
message: `text must be at most ${MAX_TEXT_CHARS} characters`,
|
|
958
|
+
})
|
|
272
959
|
.optional()
|
|
273
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),
|
|
274
965
|
turns: z
|
|
275
966
|
.array(turnSchema)
|
|
276
967
|
.min(1)
|
|
968
|
+
.max(MAX_TURNS, { message: `at most ${MAX_TURNS} turns per ingest` })
|
|
277
969
|
.optional()
|
|
278
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.
|
|
279
976
|
user_name: z
|
|
280
977
|
.string()
|
|
281
978
|
.optional()
|
|
@@ -285,6 +982,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
285
982
|
turns: z
|
|
286
983
|
.array(turnSchema)
|
|
287
984
|
.min(1)
|
|
985
|
+
.max(MAX_TURNS, { message: `at most ${MAX_TURNS} turns per ingest` })
|
|
288
986
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.turns),
|
|
289
987
|
source_id: z
|
|
290
988
|
.string()
|
|
@@ -295,14 +993,34 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
295
993
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.user_name),
|
|
296
994
|
};
|
|
297
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.
|
|
298
1000
|
kind: z
|
|
299
1001
|
.enum(["memory", "knowledge"])
|
|
300
|
-
.optional()
|
|
301
1002
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.kind),
|
|
302
|
-
|
|
1003
|
+
ids: z
|
|
303
1004
|
.array(z.string())
|
|
304
1005
|
.optional()
|
|
305
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),
|
|
306
1024
|
};
|
|
307
1025
|
const listSourcesSchema = {
|
|
308
1026
|
source_ids: z
|
|
@@ -311,44 +1029,170 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
311
1029
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST_SOURCES].params.source_ids),
|
|
312
1030
|
};
|
|
313
1031
|
const inspectSchema = {
|
|
314
|
-
|
|
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
|
|
315
1037
|
.string()
|
|
1038
|
+
.optional()
|
|
316
1039
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.source_id),
|
|
1040
|
+
source_id: z
|
|
1041
|
+
.string()
|
|
1042
|
+
.optional()
|
|
1043
|
+
.describe("Deprecated alias for `id`."),
|
|
317
1044
|
mode: z
|
|
318
1045
|
.enum(["content", "url", "both"])
|
|
319
1046
|
.optional()
|
|
320
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),
|
|
321
1067
|
};
|
|
322
1068
|
const deleteSchema = {
|
|
323
|
-
|
|
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),
|
|
324
1078
|
kind: z
|
|
325
1079
|
.enum(["memory", "knowledge"])
|
|
326
1080
|
.optional()
|
|
327
1081
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.kind),
|
|
328
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
|
+
};
|
|
329
1121
|
const deleteMemorySchema = {
|
|
330
1122
|
memory_id: z
|
|
331
1123
|
.string()
|
|
332
1124
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE_MEMORY].params.memory_id),
|
|
333
1125
|
};
|
|
334
|
-
|
|
335
|
-
|
|
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 = {
|
|
336
1136
|
readOnlyHint: true,
|
|
1137
|
+
destructiveHint: false,
|
|
1138
|
+
idempotentHint: true,
|
|
337
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,
|
|
338
1153
|
idempotentHint: true,
|
|
1154
|
+
openWorldHint: true,
|
|
339
1155
|
};
|
|
340
1156
|
// --- Canonical tools ---
|
|
341
|
-
register(TOOL_NAMES.QUERY, querySchema, (args) => runQuery(args), searchAnnotations);
|
|
342
|
-
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) => {
|
|
343
1159
|
const a = args;
|
|
344
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
|
+
}
|
|
345
1189
|
// `text` and `turns` are mutually exclusive — reject rather than silently
|
|
346
1190
|
// dropping one (the documented "exactly one" contract).
|
|
347
1191
|
if (hasTurns && a.text != null) {
|
|
348
1192
|
throw new Error(`${TOOL_NAMES.INGEST} accepts either \`text\` (a note) or \`turns\` (a conversation), not both.`);
|
|
349
1193
|
}
|
|
350
1194
|
if (a.turns != null && a.turns.length > 0) {
|
|
351
|
-
const sourceId = a.source_id ??
|
|
1195
|
+
const sourceId = a.source_id ?? generatedSourceId();
|
|
352
1196
|
// Forward every option the canonical schema accepts so none is
|
|
353
1197
|
// silently dropped on the conversation path.
|
|
354
1198
|
return runIngestConversation(a.turns, sourceId, {
|
|
@@ -356,44 +1200,92 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
356
1200
|
infer: a.infer,
|
|
357
1201
|
title: a.title,
|
|
358
1202
|
isMarkdown: a.is_markdown,
|
|
359
|
-
|
|
1203
|
+
overwrite: a.overwrite,
|
|
1204
|
+
}, extra?.signal);
|
|
360
1205
|
}
|
|
361
1206
|
if (a.text != null) {
|
|
362
1207
|
return runStore({
|
|
363
1208
|
text: a.text,
|
|
1209
|
+
kind: a.kind,
|
|
364
1210
|
title: a.title,
|
|
365
1211
|
source_id: a.source_id,
|
|
366
1212
|
infer: a.infer,
|
|
367
1213
|
is_markdown: a.is_markdown,
|
|
368
|
-
|
|
1214
|
+
overwrite: a.overwrite,
|
|
1215
|
+
metadata: a.metadata,
|
|
1216
|
+
observation_date: a.observation_date,
|
|
1217
|
+
}, extra?.signal);
|
|
369
1218
|
}
|
|
370
1219
|
throw new Error(`${TOOL_NAMES.INGEST} requires either \`text\` (a note) or \`turns\` (a conversation).`);
|
|
371
|
-
});
|
|
372
|
-
register(TOOL_NAMES.LIST, listSchema, (args) => {
|
|
1220
|
+
}, additiveWrite, ingestOutputSchema);
|
|
1221
|
+
register(TOOL_NAMES.LIST, listSchema, (args, extra) => {
|
|
373
1222
|
const a = args;
|
|
374
|
-
|
|
375
|
-
|
|
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\`.`);
|
|
376
1241
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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);
|
|
381
1251
|
// --- Deprecated aliases ---
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
+
}
|
|
397
1289
|
return server.server;
|
|
398
1290
|
}
|
|
399
1291
|
//# sourceMappingURL=server.js.map
|