@cerefox/memory 1.14.1 → 1.14.3
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/AGENT_GUIDE.md +12 -1
- package/AGENT_QUICK_REFERENCE.md +9 -8
- package/dist/bin/cerefox.js +1004 -667
- package/dist/frontend/assets/{index-BC_iqEHZ.js → index-DUY9wimN.js} +29 -29
- package/dist/frontend/assets/index-DUY9wimN.js.map +1 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
- package/dist/server-assets/_shared/mcp-tools/_utils.ts +8 -3
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +2 -2
- package/dist/server-assets/_shared/mcp-tools/metadata-search.ts +90 -19
- package/dist/server-assets/_shared/mcp-tools/search.ts +306 -50
- package/dist/server-assets/supabase/functions/cerefox-search/index.ts +75 -35
- package/docs/guides/connect-agents.md +15 -4
- package/docs/guides/response-limits.md +25 -3
- package/package.json +3 -3
- package/dist/frontend/assets/index-BC_iqEHZ.js.map +0 -1
|
@@ -18,12 +18,162 @@
|
|
|
18
18
|
import type { MCPSupabaseClient } from "./types.ts";
|
|
19
19
|
|
|
20
20
|
import { getEmbedding, resolveEmbedderKind } from "../embeddings/index.ts";
|
|
21
|
-
import {
|
|
21
|
+
import { getConfiguredMinSearchScore, getConfiguredSearchAlpha,
|
|
22
22
|
getMaxResponseBytes, getMinTermCoverage, logUsage } from "./_utils.ts";
|
|
23
23
|
import { lookupProjectId } from "./_projects.ts";
|
|
24
24
|
import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
|
|
25
25
|
import { AUTHOR_PARAM_READ, callerIdentity } from "./identity.ts";
|
|
26
26
|
|
|
27
|
+
interface SearchRow {
|
|
28
|
+
document_id?: string;
|
|
29
|
+
doc_title?: string;
|
|
30
|
+
/** Document-level modes (`docs`) return this… */
|
|
31
|
+
full_content?: string;
|
|
32
|
+
/** …while `hybrid` and `fts` return the chunk text under this name. */
|
|
33
|
+
content?: string;
|
|
34
|
+
/** Chunk-mode identity: the RPCs return several chunks OF THE SAME document. */
|
|
35
|
+
chunk_id?: string;
|
|
36
|
+
chunk_index?: number;
|
|
37
|
+
/** The chunk's own heading, and its full path from the document root. */
|
|
38
|
+
title?: string;
|
|
39
|
+
heading_path?: string[];
|
|
40
|
+
best_score?: number;
|
|
41
|
+
score?: number;
|
|
42
|
+
is_partial?: boolean;
|
|
43
|
+
chunk_count?: number;
|
|
44
|
+
total_chars?: number;
|
|
45
|
+
content_hash?: string;
|
|
46
|
+
below_confidence?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The row's text, under whichever column name this search mode returns. */
|
|
50
|
+
export function rowContent(row: SearchRow): string {
|
|
51
|
+
return row.full_content ?? row.content ?? "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `Document Title › Section` for a chunk row, `Document Title` for a document
|
|
56
|
+
* row, plus the ids that identify it.
|
|
57
|
+
*
|
|
58
|
+
* The chunk RPCs return several chunks OF THE SAME document, so without the
|
|
59
|
+
* section and the index those results are indistinguishable (#261). Shared by
|
|
60
|
+
* the rendered path and the degraded one, where identical headings would be
|
|
61
|
+
* the entire answer.
|
|
62
|
+
*/
|
|
63
|
+
function rowHeading(row: SearchRow): string {
|
|
64
|
+
const doc = row.doc_title ?? "Untitled";
|
|
65
|
+
// `heading_path` normally opens with the document's own H1, so the FIRST
|
|
66
|
+
// element is dropped when it repeats the title. Only the first: a section
|
|
67
|
+
// legitimately named after the document must still appear (#261).
|
|
68
|
+
const path = [...(row.heading_path ?? [])];
|
|
69
|
+
if (path.length > 0 && path[0] === doc) path.shift();
|
|
70
|
+
const section = path.length
|
|
71
|
+
? path.filter(Boolean).join(" › ")
|
|
72
|
+
: row.title && row.title !== doc
|
|
73
|
+
? row.title
|
|
74
|
+
: "";
|
|
75
|
+
const docId = row.document_id ? ` [id: ${row.document_id}]` : "";
|
|
76
|
+
const chunk = row.chunk_index != null ? ` (chunk ${row.chunk_index})` : "";
|
|
77
|
+
return `${doc}${section ? ` › ${section}` : ""}${docId}${chunk}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A short label for a row in the truncation footer: the leaf section, the
|
|
82
|
+
* chunk index, and the document id.
|
|
83
|
+
*
|
|
84
|
+
* Shorter than the rendered heading, which carries the whole breadcrumb — the
|
|
85
|
+
* footer names up to five rows, and full headings pushed the reply over the
|
|
86
|
+
* budget it was reporting on (#263). The id stays, because chunk modes span
|
|
87
|
+
* documents too and two documents sharing a title and a section name are
|
|
88
|
+
* otherwise indistinguishable, with no way to fetch either (#265). The footer
|
|
89
|
+
* is inside the budget and drops names it cannot afford, so it is accounted
|
|
90
|
+
* for either way.
|
|
91
|
+
*/
|
|
92
|
+
function shortLabel(row: SearchRow): string {
|
|
93
|
+
const doc = row.doc_title ?? "Untitled";
|
|
94
|
+
const path = [...(row.heading_path ?? [])];
|
|
95
|
+
if (path.length > 0 && path[0] === doc) path.shift();
|
|
96
|
+
const leaf = path.filter(Boolean).at(-1) ?? (row.title && row.title !== doc ? row.title : "");
|
|
97
|
+
const chunk = row.chunk_index != null ? ` (chunk ${row.chunk_index})` : "";
|
|
98
|
+
// Always the id. Chunk modes are not single-document either, so two
|
|
99
|
+
// documents sharing a title and a section name would otherwise produce
|
|
100
|
+
// byte-identical entries with no way to fetch either (#265). The footer is
|
|
101
|
+
// inside the budget now and drops names it cannot afford, so carrying the
|
|
102
|
+
// id costs nothing that is not accounted for.
|
|
103
|
+
const id = row.document_id ? ` [id: ${row.document_id}]` : "";
|
|
104
|
+
return `${leaf ? `${doc} › ${leaf}` : doc}${chunk}${id}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One rendered result: heading, score, then the row's text. */
|
|
108
|
+
function renderRow(row: SearchRow): string {
|
|
109
|
+
const raw = row.best_score ?? row.score;
|
|
110
|
+
const score = raw != null ? ` (score: ${raw.toFixed(3)})` : "";
|
|
111
|
+
const partial = row.is_partial
|
|
112
|
+
? ` -- partial (${row.chunk_count} of ${(row.total_chars ?? 0).toLocaleString()} chars)`
|
|
113
|
+
: "";
|
|
114
|
+
// content_hash = the concurrency token for cerefox_ingest updates (iter-32).
|
|
115
|
+
const hash = row.content_hash ? `\nhash: ${row.content_hash}` : "";
|
|
116
|
+
// Whichever column this mode returns (#259): `cerefox_search_docs` gives
|
|
117
|
+
// `full_content`, the chunk RPCs give `content`, and reading only the first
|
|
118
|
+
// rendered every hybrid/fts result as a title with an empty body.
|
|
119
|
+
return `## ${rowHeading(row)}${score}${partial}${hash}\n\n${rowContent(row)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `## Title [id: …] (score: …) -- 20,297 chars` — everything but the content. */
|
|
123
|
+
function headerLine(row: SearchRow): string {
|
|
124
|
+
const raw = row.best_score ?? row.score;
|
|
125
|
+
const score = raw != null ? ` (score: ${raw.toFixed(3)})` : "";
|
|
126
|
+
const size = row.total_chars != null ? ` -- ${row.total_chars.toLocaleString()} chars` : "";
|
|
127
|
+
const hash = row.content_hash ? `\nhash: ${row.content_hash}` : "";
|
|
128
|
+
return `## ${rowHeading(row)}${score}${size}${hash}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What to say when results matched but none fit `max_bytes` (#254).
|
|
133
|
+
*
|
|
134
|
+
* Never "no results": that is the one answer an agent acts on irreversibly.
|
|
135
|
+
* The headers are listed while they fit the same budget, so the response
|
|
136
|
+
* still honours the limit the caller asked for; if even one header does not
|
|
137
|
+
* fit, the count and the remedy alone still beat silence.
|
|
138
|
+
*/
|
|
139
|
+
function degradedToHeaders(
|
|
140
|
+
matched: SearchRow[],
|
|
141
|
+
rendered: string[],
|
|
142
|
+
maxBytes: number,
|
|
143
|
+
belowConfidence: boolean,
|
|
144
|
+
): string {
|
|
145
|
+
// Rendered bytes, not JSON: the fit decision that reaches here is made in
|
|
146
|
+
// rendered bytes, and quoting a JSON size produced messages saying the
|
|
147
|
+
// largest result was 490 bytes and did not fit a 600-byte budget (#265).
|
|
148
|
+
// Sizes come from the render the caller already paid for — re-rendering
|
|
149
|
+
// whole documents here just to measure them tripled peak memory.
|
|
150
|
+
const biggest = Math.max(...rendered.map((t) => new TextEncoder().encode(t).length));
|
|
151
|
+
// A degraded response must not silently promote 28I's weak-signal
|
|
152
|
+
// candidates into real matches: an agent told "N result(s) matched" about
|
|
153
|
+
// rows that cleared no threshold would trust them.
|
|
154
|
+
const confidence = belowConfidence
|
|
155
|
+
? "None of these cleared the confidence threshold — they are the closest " +
|
|
156
|
+
"candidates, so judge relevance from the scores. "
|
|
157
|
+
: "";
|
|
158
|
+
const lead =
|
|
159
|
+
`⚠ ${matched.length} result(s) matched, but none fit max_bytes=${maxBytes} ` +
|
|
160
|
+
`(the largest is ${biggest.toLocaleString()} bytes). ${confidence}This is NOT an ` +
|
|
161
|
+
`empty knowledge base. Listing what matched, without content — raise max_bytes ` +
|
|
162
|
+
`to read it, or read one document with cerefox_get_document (outline: true for ` +
|
|
163
|
+
`structure, or section: "## Heading" for one part).`;
|
|
164
|
+
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
let used = new TextEncoder().encode(lead).length;
|
|
167
|
+
for (const row of matched) {
|
|
168
|
+
const line = headerLine(row);
|
|
169
|
+
const size = new TextEncoder().encode(line).length + 2;
|
|
170
|
+
if (used + size > maxBytes) break;
|
|
171
|
+
lines.push(line);
|
|
172
|
+
used += size;
|
|
173
|
+
}
|
|
174
|
+
return lines.length > 0 ? `${lead}\n\n${lines.join("\n\n")}` : lead;
|
|
175
|
+
}
|
|
176
|
+
|
|
27
177
|
async function handler(
|
|
28
178
|
supabase: MCPSupabaseClient,
|
|
29
179
|
args: Record<string, unknown>,
|
|
@@ -31,7 +181,12 @@ async function handler(
|
|
|
31
181
|
): Promise<string> {
|
|
32
182
|
const query = args.query as string;
|
|
33
183
|
const project_name = args.project_name as string | undefined;
|
|
34
|
-
|
|
184
|
+
// Sanitised, then clamped, exactly as the Edge Function does it. Clamping
|
|
185
|
+
// alone left `NaN` for a non-numeric value, which serialises to JSON null,
|
|
186
|
+
// and `LIMIT NULL` in Postgres means NO limit — the unbounded work the
|
|
187
|
+
// clamp exists to prevent, reachable because the local MCP server passes
|
|
188
|
+
// tool arguments through unvalidated (#265).
|
|
189
|
+
const match_count = Math.min(Math.max(1, Math.floor(Number(args.match_count)) || 5), 200);
|
|
35
190
|
const mode = (args.mode as string | undefined) ?? "docs";
|
|
36
191
|
// #133: omit unconfigured tunables so the server resolves them from
|
|
37
192
|
// cerefox_config (one setting governs every access path).
|
|
@@ -52,7 +207,18 @@ async function handler(
|
|
|
52
207
|
const requested_max_bytes = args.max_bytes as number | undefined;
|
|
53
208
|
|
|
54
209
|
const ceiling = getMaxResponseBytes();
|
|
55
|
-
|
|
210
|
+
// Sanitised before clamping, for the reason `match_count` is: `NaN` makes
|
|
211
|
+
// every `> max_bytes` comparison below false, so a non-numeric value emitted
|
|
212
|
+
// every row unbounded — the ceiling bypassed by passing it a word (#266).
|
|
213
|
+
// A NUMBER of 0 or less still means "almost no budget", as it always did:
|
|
214
|
+
// falling back to the ceiling there would hand a caller whose remaining
|
|
215
|
+
// allowance ran out the largest possible reply (#267). Only a missing or
|
|
216
|
+
// non-numeric value defaults to the ceiling.
|
|
217
|
+
const requestedBytes = Math.floor(Number(requested_max_bytes));
|
|
218
|
+
const max_bytes = Math.min(
|
|
219
|
+
Number.isFinite(requestedBytes) ? Math.max(requestedBytes, 1) : ceiling,
|
|
220
|
+
ceiling,
|
|
221
|
+
);
|
|
56
222
|
|
|
57
223
|
if (
|
|
58
224
|
metadata_filter !== null &&
|
|
@@ -131,7 +297,138 @@ async function handler(
|
|
|
131
297
|
|
|
132
298
|
if (error) throw new Error(`RPC error: ${error.message}`);
|
|
133
299
|
|
|
134
|
-
const
|
|
300
|
+
const matched = (data ?? []) as SearchRow[];
|
|
301
|
+
|
|
302
|
+
// 28I: nothing cleared the relevance threshold, so the server returned its
|
|
303
|
+
// best-effort candidates flagged below_confidence rather than an empty set
|
|
304
|
+
// (which agents misread as "this knowledge does not exist"). The flag is
|
|
305
|
+
// all-or-nothing per response, so it is read from what MATCHED.
|
|
306
|
+
const belowConfidence = matched.length > 0 && matched.every((r) => r.below_confidence === true);
|
|
307
|
+
|
|
308
|
+
if (matched.length === 0) {
|
|
309
|
+
logUsage(supabase, {
|
|
310
|
+
operation: "search",
|
|
311
|
+
accessPath: ctx.accessPath,
|
|
312
|
+
requestor: callerIdentity(args),
|
|
313
|
+
query_text: query,
|
|
314
|
+
project_id: projectId,
|
|
315
|
+
result_count: 0,
|
|
316
|
+
});
|
|
317
|
+
return "No results found.";
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── Fit the reply to max_bytes, in the units the caller receives ──────────
|
|
321
|
+
//
|
|
322
|
+
// The budget is spent on RENDERED text, not on the JSON the RPC returned:
|
|
323
|
+
// `applyByteBudget` measures `JSON.stringify(row)`, which is the right unit
|
|
324
|
+
// for the Edge Function (it ships JSON) and the wrong one here (this returns
|
|
325
|
+
// markdown). Reserving rendered bytes out of a JSON-measured budget
|
|
326
|
+
// guaranteed nothing, and neither the below-confidence preamble (~185 bytes)
|
|
327
|
+
// nor the footer was counted by anything (#265).
|
|
328
|
+
//
|
|
329
|
+
// So: render, then take as many rows as the whole assembled reply can carry.
|
|
330
|
+
// Assembling and measuring is the only way to be sure, because the preamble
|
|
331
|
+
// and the footer both depend on how many rows were kept.
|
|
332
|
+
const rendered = matched.map(renderRow);
|
|
333
|
+
const size = (t: string) => new TextEncoder().encode(t).length;
|
|
334
|
+
const SEP = "\n\n---\n\n";
|
|
335
|
+
|
|
336
|
+
// The 28I advisory, long and short. It is charged to the budget like
|
|
337
|
+
// everything else, but it must never displace the answer it is advising
|
|
338
|
+
// about: a ~190-byte banner that pushes the only result out of a small reply
|
|
339
|
+
// leaves the caller with a warning and no content (#265). The short form is
|
|
340
|
+
// the floor — the contract is that weak candidates are never presented as
|
|
341
|
+
// confident ones, and that survives in six words.
|
|
342
|
+
const banner = (take: number, short: boolean) =>
|
|
343
|
+
!belowConfidence
|
|
344
|
+
? ""
|
|
345
|
+
: short
|
|
346
|
+
? `⚠ Below the confidence threshold — judge relevance from the scores.\n\n`
|
|
347
|
+
: `⚠ No results cleared the confidence threshold. Showing the closest ${take} ` +
|
|
348
|
+
`candidate(s) with scores — judge relevance yourself; a low score means weak ` +
|
|
349
|
+
`signal, not necessarily absent knowledge.\n\n`;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The whole reply for a set of kept rows.
|
|
353
|
+
*
|
|
354
|
+
* Everything but the results is optional, in this order: the long advisory
|
|
355
|
+
* gives way first, then the named list of what was dropped, then the bare
|
|
356
|
+
* footer. What is NOT optional is saying that results were dropped — a
|
|
357
|
+
* reply that quietly returns 1 of 5 hits leaves the caller believing they
|
|
358
|
+
* saw everything, which is the failure this whole file is about (#266).
|
|
359
|
+
*/
|
|
360
|
+
const assemble = (keptCount: number, short: boolean): string => {
|
|
361
|
+
const kept = keptIdx.slice(0, keptCount);
|
|
362
|
+
const body = banner(keptCount, short) + kept.map((i) => rendered[i]!).join(SEP);
|
|
363
|
+
if (kept.length === matched.length) return body;
|
|
364
|
+
|
|
365
|
+
const droppedRows = matched.filter((_, i) => !kept.includes(i));
|
|
366
|
+
const room = max_bytes - size(body);
|
|
367
|
+
const footer = (named: number) => {
|
|
368
|
+
const labels = droppedRows.slice(0, named).map(shortLabel);
|
|
369
|
+
const rest = droppedRows.length - labels.length;
|
|
370
|
+
const naming = labels.length
|
|
371
|
+
? `: ${labels.join(", ")}${rest > 0 ? ` and ${rest} more` : ""}`
|
|
372
|
+
: "";
|
|
373
|
+
return (
|
|
374
|
+
`\n\n[${kept.length} of ${matched.length} result(s) shown; ${droppedRows.length} did ` +
|
|
375
|
+
`not fit max_bytes=${max_bytes}${naming}. Raise max_bytes, narrow the query, or ` +
|
|
376
|
+
`lower match_count.]`
|
|
377
|
+
);
|
|
378
|
+
};
|
|
379
|
+
for (let named = Math.min(5, droppedRows.length); named >= 1; named--) {
|
|
380
|
+
const candidate = footer(named);
|
|
381
|
+
if (size(candidate) <= room) return body + candidate;
|
|
382
|
+
}
|
|
383
|
+
const bare = footer(0);
|
|
384
|
+
if (size(bare) <= room) return body + bare;
|
|
385
|
+
// The floor, ~25 bytes, never dropped: the caller must know there is more.
|
|
386
|
+
return body + `\n\n[${kept.length} of ${matched.length} shown; raise max_bytes]`;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Which rows the budget can carry, in rank order. Rows that do not fit are
|
|
390
|
+
// SKIPPED rather than ending the scan: one oversized top hit used to suppress
|
|
391
|
+
// every smaller result behind it, and the reply then claimed nothing fit when
|
|
392
|
+
// rows two and three would have fitted comfortably (#266).
|
|
393
|
+
const sepBytes = size(SEP);
|
|
394
|
+
const keptIdx: number[] = [];
|
|
395
|
+
let acc = 0;
|
|
396
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
397
|
+
const add = size(rendered[i]!) + (keptIdx.length > 0 ? sepBytes : 0);
|
|
398
|
+
if (acc + add > max_bytes) continue;
|
|
399
|
+
acc += add;
|
|
400
|
+
keptIdx.push(i);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Not one result fits (#254) — the only case that answers with no content,
|
|
404
|
+
// and now the only case that can truthfully say so.
|
|
405
|
+
if (keptIdx.length === 0) {
|
|
406
|
+
logUsage(supabase, {
|
|
407
|
+
operation: "search",
|
|
408
|
+
accessPath: ctx.accessPath,
|
|
409
|
+
requestor: callerIdentity(args),
|
|
410
|
+
query_text: query,
|
|
411
|
+
project_id: projectId,
|
|
412
|
+
result_count: matched.length,
|
|
413
|
+
extra: { returned: 0, truncated: true, degraded: true },
|
|
414
|
+
});
|
|
415
|
+
return degradedToHeaders(matched, rendered, max_bytes, belowConfidence);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Give up the framing before the content, but never the fact of truncation:
|
|
419
|
+
// long advisory, then a result, and the "N of M shown" notice always stays.
|
|
420
|
+
let keptCount = keptIdx.length;
|
|
421
|
+
let short = false;
|
|
422
|
+
let output = assemble(keptCount, short);
|
|
423
|
+
while (size(output) > max_bytes) {
|
|
424
|
+
if (belowConfidence && !short) short = true;
|
|
425
|
+
else if (keptCount > 1) {
|
|
426
|
+
keptCount -= 1;
|
|
427
|
+
short = belowConfidence;
|
|
428
|
+
} else break; // one result plus the shortest possible framing: the floor
|
|
429
|
+
output = assemble(keptCount, short);
|
|
430
|
+
}
|
|
431
|
+
const take = keptCount;
|
|
135
432
|
|
|
136
433
|
logUsage(supabase, {
|
|
137
434
|
operation: "search",
|
|
@@ -139,53 +436,12 @@ async function handler(
|
|
|
139
436
|
requestor: callerIdentity(args),
|
|
140
437
|
query_text: query,
|
|
141
438
|
project_id: projectId,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const rows = accepted as Array<{
|
|
148
|
-
document_id?: string;
|
|
149
|
-
doc_title?: string;
|
|
150
|
-
full_content?: string;
|
|
151
|
-
best_score?: number;
|
|
152
|
-
score?: number;
|
|
153
|
-
is_partial?: boolean;
|
|
154
|
-
chunk_count?: number;
|
|
155
|
-
total_chars?: number;
|
|
156
|
-
content_hash?: string;
|
|
157
|
-
below_confidence?: boolean;
|
|
158
|
-
}>;
|
|
159
|
-
|
|
160
|
-
// 28I: nothing cleared the relevance threshold, so the server returned its
|
|
161
|
-
// best-effort top candidates flagged below_confidence instead of an empty
|
|
162
|
-
// set (which agents misread as "this knowledge does not exist").
|
|
163
|
-
const belowConfidence = rows.length > 0 && rows.every((r) => r.below_confidence === true);
|
|
164
|
-
|
|
165
|
-
const parts: string[] = rows.map((row) => {
|
|
166
|
-
const title = row.doc_title ?? "Untitled";
|
|
167
|
-
const docId = row.document_id ? ` [id: ${row.document_id}]` : "";
|
|
168
|
-
const rawScore = row.best_score ?? row.score;
|
|
169
|
-
const score = rawScore != null ? ` (score: ${rawScore.toFixed(3)})` : "";
|
|
170
|
-
const partial = row.is_partial
|
|
171
|
-
? ` -- partial (${row.chunk_count} of ${(row.total_chars ?? 0).toLocaleString()} chars)`
|
|
172
|
-
: "";
|
|
173
|
-
// content_hash = the concurrency token for cerefox_ingest updates (iter-32).
|
|
174
|
-
const hash = row.content_hash ? `\nhash: ${row.content_hash}` : "";
|
|
175
|
-
return `## ${title}${docId}${score}${partial}${hash}\n\n${row.full_content ?? ""}`;
|
|
439
|
+
// What the QUERY matched, not what survived the budget: recording the
|
|
440
|
+
// latter made a budget-wiped search look like an empty store in analytics.
|
|
441
|
+
result_count: matched.length,
|
|
442
|
+
...(take < matched.length ? { extra: { returned: take, truncated: true } } : {}),
|
|
176
443
|
});
|
|
177
444
|
|
|
178
|
-
let output = parts.join("\n\n---\n\n");
|
|
179
|
-
if (belowConfidence) {
|
|
180
|
-
output =
|
|
181
|
-
`⚠ No results cleared the confidence threshold. Showing the closest ${rows.length} ` +
|
|
182
|
-
`candidate(s) with scores — judge relevance yourself; a low score means weak signal, ` +
|
|
183
|
-
`not necessarily absent knowledge.\n\n` + output;
|
|
184
|
-
}
|
|
185
|
-
if (truncated) {
|
|
186
|
-
output +=
|
|
187
|
-
`\n\n[Results truncated at ${usedBytes} bytes. Use a more specific query or a smaller match_count to see more.]`;
|
|
188
|
-
}
|
|
189
445
|
return output;
|
|
190
446
|
}
|
|
191
447
|
|
|
@@ -207,7 +463,7 @@ export const searchTool: ToolDefinition = {
|
|
|
207
463
|
query: { type: "string", description: "Natural-language search query" },
|
|
208
464
|
match_count: {
|
|
209
465
|
type: "integer",
|
|
210
|
-
description: "Maximum number of documents to return (default: 5)",
|
|
466
|
+
description: "Maximum number of documents to return (default: 5, maximum: 200)",
|
|
211
467
|
},
|
|
212
468
|
project_name: {
|
|
213
469
|
type: "string",
|
|
@@ -4,6 +4,8 @@ import { isVersionRequest, versionResponse } from "../../../_shared/ef-meta/inde
|
|
|
4
4
|
import { efAuthGate } from "../../../_shared/ef-auth/index.ts";
|
|
5
5
|
import { callerIdentity } from "../../../_shared/mcp-tools/identity.ts";
|
|
6
6
|
import { capEmbeddingInput } from "../../../_shared/embeddings/index.ts";
|
|
7
|
+
// One implementation of the byte budget, shared with the MCP tools (#254).
|
|
8
|
+
import { applyByteBudget } from "../../../_shared/mcp-tools/_utils.ts";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* cerefox-search — Supabase Edge Function
|
|
@@ -34,7 +36,16 @@ import { capEmbeddingInput } from "../../../_shared/embeddings/index.ts";
|
|
|
34
36
|
* `truncated` flag when results were dropped.
|
|
35
37
|
*
|
|
36
38
|
* Response: { results: [...], query, mode, match_count, project_name?,
|
|
37
|
-
* truncated: boolean, response_bytes: number
|
|
39
|
+
* truncated: boolean, response_bytes: number,
|
|
40
|
+
* matched: number, degraded: boolean, note?: string }
|
|
41
|
+
*
|
|
42
|
+
* `matched` is how many results the query found, before the byte budget.
|
|
43
|
+
* `degraded` is true when everything that matched was larger than max_bytes:
|
|
44
|
+
* the items then carry NO content, they name what exists so the caller can
|
|
45
|
+
* re-ask with a larger budget. That header list is capped to max_bytes and so
|
|
46
|
+
* may be a SUBSET of `matched`, except that one item is always returned even
|
|
47
|
+
* if it exceeds the budget — an empty `results` would read as "nothing was
|
|
48
|
+
* found", which is `matched: 0` and nothing else (#254, #257, #261).
|
|
38
49
|
*
|
|
39
50
|
* Example agent prompt:
|
|
40
51
|
* "Invoke the cerefox-search edge function with query='knowledge management'
|
|
@@ -148,35 +159,6 @@ async function lookupProjectId(
|
|
|
148
159
|
return data[0].id;
|
|
149
160
|
}
|
|
150
161
|
|
|
151
|
-
/**
|
|
152
|
-
* Apply a byte budget to an array of result rows.
|
|
153
|
-
*
|
|
154
|
-
* Each row is serialised to JSON to measure its size. Rows are included in
|
|
155
|
-
* order until the next row would push the running total over `maxBytes`.
|
|
156
|
-
* Rows are always kept or dropped whole — content is never truncated
|
|
157
|
-
* mid-document. Returns the accepted rows and a `truncated` flag.
|
|
158
|
-
*/
|
|
159
|
-
function applyByteBudget(
|
|
160
|
-
rows: unknown[],
|
|
161
|
-
maxBytes: number,
|
|
162
|
-
): { accepted: unknown[]; truncated: boolean; usedBytes: number } {
|
|
163
|
-
const accepted: unknown[] = [];
|
|
164
|
-
let usedBytes = 0;
|
|
165
|
-
let truncated = false;
|
|
166
|
-
|
|
167
|
-
for (const row of rows) {
|
|
168
|
-
const rowBytes = new TextEncoder().encode(JSON.stringify(row)).length;
|
|
169
|
-
if (usedBytes + rowBytes > maxBytes) {
|
|
170
|
-
truncated = true;
|
|
171
|
-
break;
|
|
172
|
-
}
|
|
173
|
-
accepted.push(row);
|
|
174
|
-
usedBytes += rowBytes;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return { accepted, truncated, usedBytes };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
162
|
const headers = {
|
|
181
163
|
"Content-Type": "application/json",
|
|
182
164
|
"Access-Control-Allow-Origin": "*",
|
|
@@ -233,7 +215,15 @@ Deno.serve(async (req: Request) => {
|
|
|
233
215
|
} = body;
|
|
234
216
|
|
|
235
217
|
// Enforce ceiling: agents may request less but never more than MAX_BYTES.
|
|
236
|
-
|
|
218
|
+
// Sanitised before clamping: `NaN` compares false against every budget
|
|
219
|
+
// check, so a non-numeric value bypassed the ceiling entirely (#266).
|
|
220
|
+
// A number of 0 or less still means "almost no budget"; only a missing or
|
|
221
|
+
// non-numeric value falls back to the ceiling (#267).
|
|
222
|
+
const requestedBytes = Math.floor(Number(requested_max_bytes));
|
|
223
|
+
const max_bytes = Math.min(
|
|
224
|
+
Number.isFinite(requestedBytes) ? Math.max(requestedBytes, 1) : MAX_BYTES,
|
|
225
|
+
MAX_BYTES,
|
|
226
|
+
);
|
|
237
227
|
// Clamp match_count to [1, MAX_MATCH_COUNT] (bounds query work; see MAX_MATCH_COUNT).
|
|
238
228
|
const match_count = Math.min(Math.max(1, Math.floor(Number(raw_match_count)) || 5), MAX_MATCH_COUNT);
|
|
239
229
|
|
|
@@ -374,7 +364,40 @@ Deno.serve(async (req: Request) => {
|
|
|
374
364
|
|
|
375
365
|
// Apply byte budget — drop whole results (never truncate mid-doc) to stay
|
|
376
366
|
// under the limit. This mirrors the local MCP server's truncation behaviour.
|
|
377
|
-
const
|
|
367
|
+
const matched = (data ?? []) as Array<Record<string, unknown>>;
|
|
368
|
+
const { accepted, truncated, usedBytes } = applyByteBudget(matched, max_bytes);
|
|
369
|
+
|
|
370
|
+
// Nothing fit the budget (#254). Returning an empty `results` reads as "this
|
|
371
|
+
// knowledge does not exist" to whatever is on the other end, so send the
|
|
372
|
+
// rows WITHOUT their content instead: the caller learns what matched, how
|
|
373
|
+
// big it is, and can re-ask with a larger budget or fetch one document.
|
|
374
|
+
//
|
|
375
|
+
// Both content columns have to go: `cerefox_search_docs` (mode "docs")
|
|
376
|
+
// returns `full_content`, while `cerefox_hybrid_search` and the FTS RPC
|
|
377
|
+
// return `content`. Stripping one of them shipped in v1.14.2 and returned
|
|
378
|
+
// 83 KB of chunk text against a 3 KB budget while the response claimed the
|
|
379
|
+
// content had been omitted (#257).
|
|
380
|
+
const degraded = accepted.length === 0 && matched.length > 0;
|
|
381
|
+
// And the headers themselves are held to the budget the caller asked for:
|
|
382
|
+
// a `match_count` of 200 makes even a content-free list large. At least one
|
|
383
|
+
// survives regardless: an empty `results` is the "nothing was found" shape
|
|
384
|
+
// #254 exists to prevent, and a header row can exceed a very small budget
|
|
385
|
+
// on its own (#259).
|
|
386
|
+
const listed = degraded
|
|
387
|
+
? (() => {
|
|
388
|
+
const headerRows = matched.map(
|
|
389
|
+
({ full_content: _full, content: _chunk, ...header }) => header,
|
|
390
|
+
);
|
|
391
|
+
const fitted = applyByteBudget(headerRows, max_bytes).accepted;
|
|
392
|
+
return fitted.length > 0 ? fitted : headerRows.slice(0, 1);
|
|
393
|
+
})()
|
|
394
|
+
: [];
|
|
395
|
+
const results = degraded ? listed : accepted;
|
|
396
|
+
// What is actually being returned, which on a degraded response is the
|
|
397
|
+
// header list rather than the (empty) content-bearing set.
|
|
398
|
+
const responseBytes = degraded
|
|
399
|
+
? new TextEncoder().encode(JSON.stringify(results)).length
|
|
400
|
+
: usedBytes;
|
|
378
401
|
|
|
379
402
|
// Fire-and-forget usage logging (never blocks the response)
|
|
380
403
|
Promise.resolve(supabase.rpc("cerefox_log_usage", {
|
|
@@ -382,20 +405,37 @@ Deno.serve(async (req: Request) => {
|
|
|
382
405
|
p_access_path: "edge-function",
|
|
383
406
|
p_requestor: identityValue ?? null,
|
|
384
407
|
p_query_text: query,
|
|
385
|
-
|
|
408
|
+
// What the query matched, not what survived the budget: recording 0 for a
|
|
409
|
+
// budget-wiped search made it look like an empty knowledge base here too.
|
|
410
|
+
p_result_count: matched.length,
|
|
386
411
|
p_project_id: projectId,
|
|
387
412
|
})).catch(() => {});
|
|
388
413
|
|
|
389
414
|
return new Response(
|
|
390
415
|
JSON.stringify({
|
|
391
|
-
results
|
|
416
|
+
results,
|
|
392
417
|
query,
|
|
393
418
|
mode,
|
|
394
419
|
match_count,
|
|
395
420
|
project_name: project_name ?? null,
|
|
396
421
|
metadata_filter: metadata_filter ?? null,
|
|
397
422
|
truncated,
|
|
398
|
-
response_bytes:
|
|
423
|
+
response_bytes: responseBytes,
|
|
424
|
+
matched: matched.length,
|
|
425
|
+
degraded,
|
|
426
|
+
...(degraded
|
|
427
|
+
? {
|
|
428
|
+
note:
|
|
429
|
+
`${matched.length} result(s) matched but none fit max_bytes=${max_bytes}; ` +
|
|
430
|
+
`content omitted${
|
|
431
|
+
listed.length < matched.length
|
|
432
|
+
? `, and only ${listed.length} of them are listed here` +
|
|
433
|
+
" (the rest did not fit either)"
|
|
434
|
+
: ""
|
|
435
|
+
}. This is NOT an empty result. Raise max_bytes, or fetch one ` +
|
|
436
|
+
`document with cerefox-get-document.`,
|
|
437
|
+
}
|
|
438
|
+
: {}),
|
|
399
439
|
}),
|
|
400
440
|
{ headers },
|
|
401
441
|
);
|
|
@@ -638,7 +638,7 @@ In the action editor, paste this schema (replace `<your-project-ref>`):
|
|
|
638
638
|
openapi: 3.1.0
|
|
639
639
|
info:
|
|
640
640
|
title: Cerefox Knowledge Base
|
|
641
|
-
version: 4.
|
|
641
|
+
version: 4.2.0
|
|
642
642
|
servers:
|
|
643
643
|
- url: https://<your-project-ref>.supabase.co/functions/v1
|
|
644
644
|
paths:
|
|
@@ -697,7 +697,10 @@ paths:
|
|
|
697
697
|
Response size budget in bytes (server hard ceiling 200000).
|
|
698
698
|
Whole results are dropped (never truncated mid-document) until
|
|
699
699
|
the budget is met; the response sets `truncated: true` when this
|
|
700
|
-
happens.
|
|
700
|
+
happens. One exception: when nothing fits at all, a single
|
|
701
|
+
content-free header is returned even if it exceeds the budget,
|
|
702
|
+
because an empty results array would read as "nothing was found"
|
|
703
|
+
(see degraded). Advanced; leave unset for the default.
|
|
701
704
|
author:
|
|
702
705
|
type: string
|
|
703
706
|
description: >
|
|
@@ -707,9 +710,17 @@ paths:
|
|
|
707
710
|
responses:
|
|
708
711
|
'200':
|
|
709
712
|
description: >
|
|
710
|
-
{ results, query, mode, match_count, project_name, metadata_filter, truncated,
|
|
713
|
+
{ results, query, mode, match_count, project_name, metadata_filter, truncated,
|
|
714
|
+
response_bytes, matched, degraded }.
|
|
711
715
|
Each item in results (docs mode) contains: document_id, doc_title, full_content,
|
|
712
716
|
chunk_count, total_chars, best_score, is_partial.
|
|
717
|
+
matched is how many results the query found, before the byte budget.
|
|
718
|
+
When degraded is true, everything that matched was larger than max_bytes, so the
|
|
719
|
+
items carry NO content of any kind — they name what exists so you can re-ask with
|
|
720
|
+
a larger max_bytes or fetch one document. That header list is itself capped to
|
|
721
|
+
max_bytes, so it may be a SUBSET of matched (at least one item is always
|
|
722
|
+
returned). Read matched, never results.length, to know how much the query found:
|
|
723
|
+
"nothing was found" is matched == 0.
|
|
713
724
|
is_partial is true when the document exceeded the small-to-big threshold — in that
|
|
714
725
|
case full_content contains matched chunks plus their neighbours rather than the
|
|
715
726
|
complete document, and total_chars still reflects the full document size.
|
|
@@ -1124,7 +1135,7 @@ If the same content was already ingested (SHA-256 hash match), returns `"skipped
|
|
|
1124
1135
|
| `mode` | string | `"docs"` | `"docs"` = full document results (recommended) |
|
|
1125
1136
|
| `alpha` | number | 0.7 | Semantic weight (0 = FTS only, 1 = semantic only) |
|
|
1126
1137
|
| `min_score` | number | 0.5 | Minimum cosine similarity threshold |
|
|
1127
|
-
| `max_bytes` | number | 200000 | Response size budget in bytes. Results are dropped whole (never truncated mid-document) once the budget is reached. The response includes `truncated: true` and `response_bytes` when the limit was hit. See "Response size limit" below. |
|
|
1138
|
+
| `max_bytes` | number | 200000 | Response size budget in bytes. Results are dropped whole (never truncated mid-document) once the budget is reached. The response includes `truncated: true` and `response_bytes` when the limit was hit. When nothing fits, `degraded: true` and a content-free header list come back instead of an empty `results` — always at least one item, even if it exceeds the budget. See "Response size limit" below. |
|
|
1128
1139
|
|
|
1129
1140
|
**Response envelope fields:**
|
|
1130
1141
|
|
|
@@ -30,13 +30,35 @@ agent's context window matters. Callers always choose whether to apply a limit.
|
|
|
30
30
|
|
|
31
31
|
## How limits are applied
|
|
32
32
|
|
|
33
|
-
Truncation is always **whole-document**:
|
|
34
|
-
|
|
33
|
+
Truncation is always **whole-document**: a result is returned in full or not at all.
|
|
34
|
+
Cerefox never cuts a document mid-content.
|
|
35
|
+
|
|
36
|
+
A result that does not fit is **skipped**, not treated as the end of the list, so the
|
|
37
|
+
returned set is not necessarily the top N by rank: one oversized document ranked first
|
|
38
|
+
does not hide the smaller results behind it (v1.14.3). Anything skipped is named in the
|
|
39
|
+
footer, so what is missing is always visible.
|
|
35
40
|
|
|
36
41
|
When truncation occurs:
|
|
37
|
-
- The
|
|
42
|
+
- The MCP tool appends a footer naming what was held back:
|
|
43
|
+
`[3 of 12 result(s) shown; 9 did not fit max_bytes=8000: Plan › Rollout (chunk 4) [id: …] and 8 more. Raise max_bytes, narrow the query, or lower match_count.]`
|
|
38
44
|
- The Edge Function includes `"truncated": true` and `"response_bytes": N` in the JSON response.
|
|
39
45
|
|
|
46
|
+
**The reply as a whole stays inside the budget**, footer and warnings included:
|
|
47
|
+
they are measured in the same rendered bytes the caller receives (v1.14.3).
|
|
48
|
+
When the budget is tight the framing gives way before the results do.
|
|
49
|
+
|
|
50
|
+
**When nothing fits at all** — the smallest matching document is larger than
|
|
51
|
+
the whole budget — the reply is NOT "no results found", which an agent acts on
|
|
52
|
+
as "this knowledge does not exist". It is a header list naming what matched,
|
|
53
|
+
its size and its id, prefixed with a warning and the remedy. The same case on
|
|
54
|
+
the Edge Function sets `"degraded": true`, returns items with no content, and
|
|
55
|
+
reports `"matched"`: read that, not `results.length`, to know what the query
|
|
56
|
+
found. A reply may exceed `max_bytes` only by the framing that cannot be dropped
|
|
57
|
+
without misleading you: the notice that results were held back, or the
|
|
58
|
+
below-confidence advisory. Both are a few dozen bytes, and neither is ever
|
|
59
|
+
traded for content. Returning 1 of 5 results without saying so, or presenting
|
|
60
|
+
weak candidates as confident ones, would be worse than a small overrun.
|
|
61
|
+
|
|
40
62
|
---
|
|
41
63
|
|
|
42
64
|
## The server ceiling — agents can request less, never more
|