@cerefox/memory 1.14.2 → 1.14.4

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.
@@ -4,6 +4,7 @@ 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 { reviewWorkflowEnabled } from "../../../_shared/mcp-tools/feature-flags.ts";
7
+ import { resolveByteBudget } from "../../../_shared/mcp-tools/_utils.ts";
7
8
 
8
9
  /**
9
10
  * cerefox-metadata-search -- Supabase Edge Function
@@ -103,8 +104,12 @@ Deno.serve(async (req: Request): Promise<Response> => {
103
104
  const include_content = body.include_content ?? false;
104
105
  const requested_max_bytes = body.max_bytes;
105
106
 
107
+ // One implementation of this arithmetic, shared with every other surface
108
+ // that takes a budget (#268): non-numeric and null both mean "unset" and
109
+ // fall back to the ceiling, while a real number of zero or less is
110
+ // honoured as "almost no budget".
106
111
  const max_bytes = include_content
107
- ? Math.min(requested_max_bytes ?? MAX_BYTES, MAX_BYTES)
112
+ ? resolveByteBudget(requested_max_bytes, MAX_BYTES)
108
113
  : null;
109
114
 
110
115
  const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
@@ -154,18 +159,90 @@ Deno.serve(async (req: Request): Promise<Response> => {
154
159
  });
155
160
  }
156
161
 
157
- // Fire-and-forget usage logging
162
+ let rows = (data ?? []) as Array<Record<string, unknown>>;
163
+
164
+ // Never answer with a shorter list than what matched (#268).
165
+ //
166
+ // The RPC applies `p_max_bytes` server-side by stopping at the first row
167
+ // whose content does not fit, so this list has two indistinguishable
168
+ // causes: fewer documents matched, or the budget cut it short. When the
169
+ // first row is the oversized one the array comes back EMPTY, and a caller
170
+ // reads `[]` as "this knowledge does not exist" and stops looking — the
171
+ // false negative #254 exists to prevent, reached here through a sibling
172
+ // that never got the guard.
173
+ //
174
+ // The response shape stays a bare array, because Custom GPTs are
175
+ // configured against it: every matching document is listed, and only
176
+ // CONTENT is negotiable. Rows the budget could not afford come back
177
+ // content-free and marked, so `results.length` is always the true count
178
+ // and nothing is held back silently.
179
+ //
180
+ // Cost: a second RPC round-trip whenever a content-bearing search returns
181
+ // less than a full page, which is the common case rather than a rare one.
182
+ // The RPC signals no total, so the only alternative to asking is guessing
183
+ // whether a short list was cut — and guessing wrong is the bug.
184
+ if (include_content && max_bytes !== null && rows.length < limit) {
185
+ const { data: headerData, error: probeError } = await supabase.rpc(
186
+ "cerefox_metadata_search",
187
+ { ...params, p_include_content: false, p_max_bytes: null },
188
+ );
189
+ // supabase-js RESOLVES with `{ data: null, error }` for PostgREST and
190
+ // network failures rather than throwing, so a probe failure must be read
191
+ // from the error, not inferred from an empty list — reading it as "no
192
+ // documents" is the very false empty this branch prevents (#261).
193
+ if (probeError && rows.length === 0) {
194
+ // Falling through here would ship exactly the false empty this block
195
+ // exists to prevent — `200 []`, which a caller reads as "no such
196
+ // knowledge". An error is the honest answer: it says the question was
197
+ // not resolved, rather than answering it wrongly.
198
+ return new Response(
199
+ JSON.stringify({
200
+ error:
201
+ `Nothing fit max_bytes=${max_bytes} with include_content, and the follow-up ` +
202
+ `query that lists what matched failed: ${probeError.message}. This is NOT a ` +
203
+ `confirmed empty result — retry with a larger max_bytes, or include_content: false.`,
204
+ }),
205
+ { status: 502, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
206
+ );
207
+ }
208
+ if (!probeError) {
209
+ const headers = (headerData ?? []) as Array<Record<string, unknown>>;
210
+ if (headers.length > rows.length) {
211
+ const withContent = new Map(rows.map((r) => [r.document_id as string, r]));
212
+ // The probe carries the full, correctly ordered match set; the
213
+ // content-bearing rows are folded into it by id so ordering is the
214
+ // RPC's, not an artefact of which rows happened to fit.
215
+ const merged = headers.map(
216
+ (h) => withContent.get(h.document_id as string) ?? { ...h, content_omitted: true },
217
+ );
218
+ // Two queries, two chances to disagree: the RPC orders by
219
+ // `updated_at DESC` with no tiebreaker under a LIMIT, and a
220
+ // concurrent write between the calls shifts the window. Anything the
221
+ // content query returned that the probe did not is APPENDED rather
222
+ // than dropped — losing a document we already hold, while fixing a
223
+ // bug about losing documents, would be its own joke.
224
+ const seen = new Set(merged.map((r) => r.document_id as string));
225
+ for (const r of rows) {
226
+ if (!seen.has(r.document_id as string)) merged.push(r);
227
+ }
228
+ rows = merged;
229
+ }
230
+ }
231
+ }
232
+
233
+ // Fire-and-forget usage logging. Counts what MATCHED, not what the budget
234
+ // allowed through: a budget-wiped search logging `0` misreports the store
235
+ // as empty in analytics (#259).
158
236
  Promise.resolve(supabase.rpc("cerefox_log_usage", {
159
237
  p_operation: "metadata_search",
160
238
  p_access_path: "edge-function",
161
239
  p_requestor: identityValue ?? null,
162
240
  p_query_text: JSON.stringify(metadata_filter),
163
- p_result_count: (data ?? []).length,
241
+ p_result_count: rows.length,
164
242
  p_project_id: project_id,
165
243
  })).catch(() => {});
166
244
 
167
245
  // Presentation only: the same shared reader every other surface uses.
168
- const rows = (data ?? []) as Array<Record<string, unknown>>;
169
246
  const showReview = await reviewWorkflowEnabled(supabase);
170
247
  const out = showReview
171
248
  ? rows
@@ -5,7 +5,7 @@ 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
7
  // One implementation of the byte budget, shared with the MCP tools (#254).
8
- import { applyByteBudget } from "../../../_shared/mcp-tools/_utils.ts";
8
+ import { applyByteBudget, resolveByteBudget } from "../../../_shared/mcp-tools/_utils.ts";
9
9
 
10
10
  /**
11
11
  * cerefox-search — Supabase Edge Function
@@ -36,7 +36,16 @@ import { applyByteBudget } from "../../../_shared/mcp-tools/_utils.ts";
36
36
  * `truncated` flag when results were dropped.
37
37
  *
38
38
  * Response: { results: [...], query, mode, match_count, project_name?,
39
- * 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).
40
49
  *
41
50
  * Example agent prompt:
42
51
  * "Invoke the cerefox-search edge function with query='knowledge management'
@@ -150,14 +159,6 @@ async function lookupProjectId(
150
159
  return data[0].id;
151
160
  }
152
161
 
153
- /**
154
- * Apply a byte budget to an array of result rows.
155
- *
156
- * Each row is serialised to JSON to measure its size. Rows are included in
157
- * order until the next row would push the running total over `maxBytes`.
158
- * Rows are always kept or dropped whole — content is never truncated
159
- * mid-document. Returns the accepted rows and a `truncated` flag.
160
- */
161
162
  const headers = {
162
163
  "Content-Type": "application/json",
163
164
  "Access-Control-Allow-Origin": "*",
@@ -214,7 +215,11 @@ Deno.serve(async (req: Request) => {
214
215
  } = body;
215
216
 
216
217
  // Enforce ceiling: agents may request less but never more than MAX_BYTES.
217
- const max_bytes = Math.min(requested_max_bytes ?? MAX_BYTES, MAX_BYTES);
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 max_bytes = resolveByteBudget(requested_max_bytes, MAX_BYTES);
218
223
  // Clamp match_count to [1, MAX_MATCH_COUNT] (bounds query work; see MAX_MATCH_COUNT).
219
224
  const match_count = Math.min(Math.max(1, Math.floor(Number(raw_match_count)) || 5), MAX_MATCH_COUNT);
220
225
 
@@ -362,10 +367,33 @@ Deno.serve(async (req: Request) => {
362
367
  // knowledge does not exist" to whatever is on the other end, so send the
363
368
  // rows WITHOUT their content instead: the caller learns what matched, how
364
369
  // big it is, and can re-ask with a larger budget or fetch one document.
370
+ //
371
+ // Both content columns have to go: `cerefox_search_docs` (mode "docs")
372
+ // returns `full_content`, while `cerefox_hybrid_search` and the FTS RPC
373
+ // return `content`. Stripping one of them shipped in v1.14.2 and returned
374
+ // 83 KB of chunk text against a 3 KB budget while the response claimed the
375
+ // content had been omitted (#257).
365
376
  const degraded = accepted.length === 0 && matched.length > 0;
366
- const results = degraded
367
- ? matched.map(({ full_content: _omitted, ...header }) => header)
368
- : accepted;
377
+ // And the headers themselves are held to the budget the caller asked for:
378
+ // a `match_count` of 200 makes even a content-free list large. At least one
379
+ // survives regardless: an empty `results` is the "nothing was found" shape
380
+ // #254 exists to prevent, and a header row can exceed a very small budget
381
+ // on its own (#259).
382
+ const listed = degraded
383
+ ? (() => {
384
+ const headerRows = matched.map(
385
+ ({ full_content: _full, content: _chunk, ...header }) => header,
386
+ );
387
+ const fitted = applyByteBudget(headerRows, max_bytes).accepted;
388
+ return fitted.length > 0 ? fitted : headerRows.slice(0, 1);
389
+ })()
390
+ : [];
391
+ const results = degraded ? listed : accepted;
392
+ // What is actually being returned, which on a degraded response is the
393
+ // header list rather than the (empty) content-bearing set.
394
+ const responseBytes = degraded
395
+ ? new TextEncoder().encode(JSON.stringify(results)).length
396
+ : usedBytes;
369
397
 
370
398
  // Fire-and-forget usage logging (never blocks the response)
371
399
  Promise.resolve(supabase.rpc("cerefox_log_usage", {
@@ -388,14 +416,20 @@ Deno.serve(async (req: Request) => {
388
416
  project_name: project_name ?? null,
389
417
  metadata_filter: metadata_filter ?? null,
390
418
  truncated,
391
- response_bytes: usedBytes,
419
+ response_bytes: responseBytes,
392
420
  matched: matched.length,
393
421
  degraded,
394
422
  ...(degraded
395
423
  ? {
396
424
  note:
397
425
  `${matched.length} result(s) matched but none fit max_bytes=${max_bytes}; ` +
398
- `content omitted. Raise max_bytes, or fetch one document with cerefox-get-document.`,
426
+ `content omitted${
427
+ listed.length < matched.length
428
+ ? `, and only ${listed.length} of them are listed here` +
429
+ " (the rest did not fit either)"
430
+ : ""
431
+ }. This is NOT an empty result. Raise max_bytes, or fetch one ` +
432
+ `document with cerefox-get-document.`,
399
433
  }
400
434
  : {}),
401
435
  }),
@@ -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.1.0
641
+ version: 4.3.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. Advanced; leave unset for the default.
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: >
@@ -713,9 +716,11 @@ paths:
713
716
  chunk_count, total_chars, best_score, is_partial.
714
717
  matched is how many results the query found, before the byte budget.
715
718
  When degraded is true, everything that matched was larger than max_bytes, so the
716
- items carry NO full_content — they name what exists so you can re-ask with a
717
- larger max_bytes or fetch one document. An empty results array with degraded
718
- true is never "nothing was found"; results is empty only when matched is 0.
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.
719
724
  is_partial is true when the document exceeded the small-to-big threshold — in that
720
725
  case full_content contains matched chunks plus their neighbours rather than the
721
726
  complete document, and total_chars still reflects the full document size.
@@ -1046,8 +1051,10 @@ paths:
1046
1051
  type: integer
1047
1052
  default: 200000
1048
1053
  description: >
1049
- Response size budget in bytes when include_content is true
1050
- (whole results dropped to fit). Advanced; leave unset for the default.
1054
+ Response size budget in bytes when include_content is true.
1055
+ Content is dropped whole, never truncated mid-document, and a
1056
+ document whose content is dropped is still listed with
1057
+ content_omitted: true. Advanced; leave unset for the default.
1051
1058
  author:
1052
1059
  type: string
1053
1060
  description: >
@@ -1062,6 +1069,12 @@ paths:
1062
1069
  version_count, content_hash, content }], plus review_status
1063
1070
  only while the store's review workflow is on (the key is absent
1064
1071
  when it is off).
1072
+ The array lists EVERY matching document, so its length is the true
1073
+ match count. When include_content is true and max_bytes cannot
1074
+ carry a document's text, that document is still listed, with its
1075
+ content omitted and "content_omitted": true set on the item — the
1076
+ list is never silently shortened, and an empty array always means
1077
+ nothing matched.
1065
1078
  content_hash is the concurrency token — pass it back as
1066
1079
  expected_content_hash when updating via ingestNote.
1067
1080
  ```
@@ -1130,7 +1143,7 @@ If the same content was already ingested (SHA-256 hash match), returns `"skipped
1130
1143
  | `mode` | string | `"docs"` | `"docs"` = full document results (recommended) |
1131
1144
  | `alpha` | number | 0.7 | Semantic weight (0 = FTS only, 1 = semantic only) |
1132
1145
  | `min_score` | number | 0.5 | Minimum cosine similarity threshold |
1133
- | `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. |
1146
+ | `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. |
1134
1147
 
1135
1148
  **Response envelope fields:**
1136
1149
 
@@ -8,11 +8,18 @@ explains how response size limits work and how to tune them.
8
8
 
9
9
  ## The key principle: opt-in limits, never truncate the web UI
10
10
 
11
- The web UI and CLI never truncate results. They have no size limit — the browser or terminal
12
- can handle arbitrarily large responses and there is no LLM context window to worry about.
11
+ The web UI never truncates results. It has no size limit — the browser can handle
12
+ arbitrarily large responses and there is no LLM context window to worry about.
13
13
 
14
- Limits are **opt-in per call**, used only on the MCP and Edge Function paths where an AI
15
- agent's context window matters. Callers always choose whether to apply a limit.
14
+ Limits apply on the MCP, Edge Function **and CLI** paths. On MCP and the Edge Functions
15
+ they exist because an AI agent's context window matters; the CLI applies the same default
16
+ so that one setting (`CEREFOX_MAX_RESPONSE_BYTES`) governs every non-browser path, and
17
+ raises or lowers it per call with `--max-bytes`.
18
+
19
+ > **Changed in v0.10.2.** The CLI originally returned everything, like the web UI. It now
20
+ > honours `CEREFOX_MAX_RESPONSE_BYTES` (200 000 default) and prints
21
+ > `(results truncated at N bytes; use --max-bytes to raise)` when results are dropped.
22
+ > This guide described the pre-v0.10.2 behaviour until v1.14.4.
16
23
 
17
24
  ---
18
25
 
@@ -21,7 +28,7 @@ agent's context window matters. Callers always choose whether to apply a limit.
21
28
  | Path | Limit behaviour |
22
29
  |------|----------------|
23
30
  | Web UI (`/search`) | **No limit** — all results returned |
24
- | CLI (`cerefox search`) | **No limit** all results returned |
31
+ | CLI (`cerefox search`) | Defaults to `CEREFOX_MAX_RESPONSE_BYTES` (200 000); raise or lower per call with `--max-bytes`. Announces truncation. |
25
32
  | Local MCP server (`cerefox mcp`) | Defaults to `CEREFOX_MAX_RESPONSE_BYTES` (200 000); agent can request less |
26
33
  | Edge Function (`cerefox-search`) | Defaults to 200 000 bytes; agent can request less via `max_bytes` body param |
27
34
  | Remote MCP (`cerefox-mcp` Edge Function) | Defaults to 200 000 bytes; agent can request less via `max_bytes` tool param |
@@ -30,13 +37,61 @@ agent's context window matters. Callers always choose whether to apply a limit.
30
37
 
31
38
  ## How limits are applied
32
39
 
33
- Truncation is always **whole-document**: results are dropped in full once adding the next
34
- document would exceed the budget. Cerefox never cuts a document mid-content.
40
+ Truncation is always **whole-document**: a result is returned in full or not at all.
41
+ Cerefox never cuts a document mid-content.
42
+
43
+ A result that does not fit is **skipped**, not treated as the end of the list, so the
44
+ returned set is not necessarily the top N by rank: one oversized document ranked first
45
+ does not hide the smaller results behind it (v1.14.3 on the MCP tool; v1.14.4 on the
46
+ `cerefox-search` Edge Function and the CLI, which both still stopped at the first
47
+ oversized row). Anything skipped is named in the footer, so what is missing is always
48
+ visible.
35
49
 
36
50
  When truncation occurs:
37
- - The local MCP server appends `[Results truncated at N bytes ...]` to the response text.
51
+ - The MCP tool appends a footer naming what was held back:
52
+ `[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
53
  - The Edge Function includes `"truncated": true` and `"response_bytes": N` in the JSON response.
39
54
 
55
+ **The reply as a whole stays inside the budget**, footer and warnings included:
56
+ they are measured in the same rendered bytes the caller receives (v1.14.3).
57
+ When the budget is tight the framing gives way before the results do.
58
+
59
+ **When nothing fits at all** — the smallest matching document is larger than
60
+ the whole budget — the reply is NOT "no results found", which an agent acts on
61
+ as "this knowledge does not exist". It is a header list naming what matched,
62
+ its size and its id, prefixed with a warning and the remedy. The same case on
63
+ the Edge Function sets `"degraded": true`, returns items with no content, and
64
+ reports `"matched"`: read that, not `results.length`, to know what the query
65
+ found. A reply may exceed `max_bytes` only by the framing that cannot be dropped
66
+ without misleading you: the notice that results were held back, or the
67
+ below-confidence advisory. Both are a few dozen bytes, and neither is ever
68
+ traded for content. Returning 1 of 5 results without saying so, or presenting
69
+ weak candidates as confident ones, would be worse than a small overrun.
70
+
71
+ ### Metadata search follows the same rules (v1.14.4)
72
+
73
+ `cerefox_metadata_search` and the `cerefox-metadata-search` Edge Function apply
74
+ `max_bytes` only when `include_content: true`, and the budget is applied by the
75
+ database, which stops at the first document whose content does not fit. That
76
+ made two silent failures possible until v1.14.4, and both are now closed:
77
+
78
+ - **The reply is never empty when documents matched.** If the first document is
79
+ the oversized one, the budget used to empty the result set — the MCP tool
80
+ returned "No documents match", the Edge Function returned `[]`. Both now say
81
+ what matched: the tool with a warning and a header list, the Edge Function by
82
+ listing every matching document with content omitted.
83
+ - **Documents are never held back silently.** The MCP tool appends
84
+ `[2 of 7 document(s) shown; 5 did not fit max_bytes=20000. …]`. The Edge
85
+ Function keeps its array shape and lists **every** matching document, marking
86
+ the ones whose content did not fit with `"content_omitted": true` — so
87
+ `results.length` is always the true match count and only content is dropped.
88
+
89
+ `max_bytes` is resolved the same way on every path: `null`, absent, empty or
90
+ non-numeric all mean **unset** and fall back to the server ceiling, and none of
91
+ them disables the limit. A real number of zero or less means "almost no
92
+ budget" and is honoured as such — a caller whose allowance has run out is not
93
+ handed the largest possible reply.
94
+
40
95
  ---
41
96
 
42
97
  ## The server ceiling — agents can request less, never more
@@ -142,7 +197,7 @@ threshold (it is a SQL DEFAULT in `rpcs.sql`, changed via `cerefox server deploy
142
197
  | Question | Answer |
143
198
  |----------|--------|
144
199
  | Does the web UI truncate results? | No — unlimited |
145
- | Does the CLI truncate results? | Nounlimited |
200
+ | Does the CLI truncate results? | Yesat `CEREFOX_MAX_RESPONSE_BYTES`, or `--max-bytes`. It says so when it does. |
146
201
  | What is the default MCP response limit? | 200 000 bytes |
147
202
  | Can an agent request a smaller limit? | Yes — `max_bytes` tool parameter |
148
203
  | Can an agent exceed the server ceiling? | No — always capped |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerefox/memory",
3
- "version": "1.14.2",
3
+ "version": "1.14.4",
4
4
  "description": "Cerefox — user-owned shared memory for AI agents. CLI + stdio MCP server + web UI + ingestion for a knowledge base on your own Supabase project (or fully self-hosted with Cerefox Local).",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/fstamatelopoulos/cerefox",
@@ -39,12 +39,12 @@
39
39
  "CHANGELOG.md"
40
40
  ],
41
41
  "dependencies": {
42
- "@hono/node-server": "^2.0.12",
42
+ "@hono/node-server": "^2.1.1",
43
43
  "@modelcontextprotocol/sdk": "^1.30.0",
44
44
  "@supabase/supabase-js": "^2.45.0",
45
45
  "cli-progress": "^3.12.0",
46
46
  "commander": "^14.0.3",
47
- "hono": "^4.12.34",
47
+ "hono": "^4.13.7",
48
48
  "mammoth": "^1.9.0",
49
49
  "ora": "^9.4.0",
50
50
  "picocolors": "^1.0.0",