@cerefox/memory 1.17.0 → 1.17.2

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.
@@ -5318,7 +5318,7 @@ var init_cli_core = __esm(() => {
5318
5318
  });
5319
5319
 
5320
5320
  // src/meta.ts
5321
- var PKG_VERSION = "1.17.0";
5321
+ var PKG_VERSION = "1.17.2";
5322
5322
  var init_meta = () => {};
5323
5323
 
5324
5324
  // ../../_shared/config/paths.ts
@@ -26230,7 +26230,7 @@ var init_bundled_docs = __esm(() => {
26230
26230
  });
26231
26231
 
26232
26232
  // ../../_shared/ef-meta/index.ts
26233
- var EF_VERSION = "1.17.0", CEREFOX_VERSION = "1.17.0", EF_LAST_CHANGED = "1.17.0";
26233
+ var EF_VERSION = "1.17.2", CEREFOX_VERSION = "1.17.2", EF_LAST_CHANGED = "1.17.2";
26234
26234
  var init_ef_meta = () => {};
26235
26235
 
26236
26236
  // ../../_shared/compatibility/index.ts
@@ -26369,20 +26369,22 @@ var init_compatibility = __esm(() => {
26369
26369
  // ../../_shared/embeddings/onnx-embedder.ts
26370
26370
  var exports_onnx_embedder = {};
26371
26371
  __export(exports_onnx_embedder, {
26372
+ EmbedderDownloadError: () => EmbedderDownloadError,
26372
26373
  ONNX_MODEL_APPROX_MB: () => ONNX_MODEL_APPROX_MB,
26373
26374
  ONNX_MODEL_DIM: () => ONNX_MODEL_DIM,
26374
26375
  ONNX_MODEL_DTYPE: () => ONNX_MODEL_DTYPE,
26375
26376
  ONNX_MODEL_ID: () => ONNX_MODEL_ID,
26376
26377
  ONNX_MODEL_NAME: () => ONNX_MODEL_NAME,
26377
26378
  buildPrefixedInputs: () => buildPrefixedInputs,
26379
+ downloadModelFiles: () => downloadModelFiles,
26378
26380
  isModelCached: () => isModelCached,
26379
26381
  nomicPrefix: () => nomicPrefix,
26380
26382
  onnxEmbed: () => onnxEmbed,
26381
26383
  warmup: () => warmup
26382
26384
  });
26383
- import { existsSync as existsSync10, mkdirSync as mkdirSync4 } from "node:fs";
26385
+ import { existsSync as existsSync10, mkdirSync as mkdirSync4, renameSync, statSync as statSync2, writeFileSync as writeFileSync5, appendFileSync } from "node:fs";
26384
26386
  import { homedir as homedir6 } from "node:os";
26385
- import { join as join9 } from "node:path";
26387
+ import { dirname as dirname4, join as join9 } from "node:path";
26386
26388
  function nomicPrefix(role) {
26387
26389
  return role === "query" ? "search_query: " : "search_document: ";
26388
26390
  }
@@ -26390,6 +26392,86 @@ function buildPrefixedInputs(texts, role) {
26390
26392
  const p = nomicPrefix(role);
26391
26393
  return texts.map((t) => p + t);
26392
26394
  }
26395
+ async function fetchRange(url, start, end, timeoutMs) {
26396
+ const res = await fetch(url, { headers: { Range: `bytes=${start}-${end}` }, signal: AbortSignal.timeout(timeoutMs) });
26397
+ if (res.status === 200) {
26398
+ await res.body?.cancel();
26399
+ return "no-range";
26400
+ }
26401
+ const total = Number(/\/(\d+)\s*$/.exec(res.headers.get("content-range") ?? "")?.[1] ?? 0);
26402
+ if (res.status === 416 && total > 0) {
26403
+ await res.body?.cancel();
26404
+ return { bytes: new Uint8Array(0), total };
26405
+ }
26406
+ if (res.status !== 206)
26407
+ throw new Error(`HTTP ${res.status}`);
26408
+ if (total === 0)
26409
+ throw new Error("partial response without a total size (Content-Range)");
26410
+ const bytes = new Uint8Array(await res.arrayBuffer());
26411
+ const want = Math.min(end, total > 0 ? total - 1 : end) - start + 1;
26412
+ if (bytes.byteLength !== want)
26413
+ throw new Error(`segment ${start}-${end}: got ${bytes.byteLength} bytes, expected ${want}`);
26414
+ return { bytes, total };
26415
+ }
26416
+ async function downloadModelFiles(remoteHost, onProgress, onDone, opts = {}) {
26417
+ const attempts = opts.attempts ?? DOWNLOAD_ATTEMPTS;
26418
+ const backoffMs = opts.backoffMs ?? ((attempt) => Math.min(30, 2 ** attempt) * 1000);
26419
+ const segment = opts.segmentBytes ?? SEGMENT_BYTES;
26420
+ const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
26421
+ const dir = getCacheDir();
26422
+ const base = remoteHost.endsWith("/") ? remoteHost : `${remoteHost}/`;
26423
+ for (const rel of MODEL_REPO_FILES) {
26424
+ const dest = join9(dir, ONNX_MODEL_ID, rel);
26425
+ if (existsSync10(dest))
26426
+ continue;
26427
+ mkdirSync4(dirname4(dest), { recursive: true });
26428
+ const part = `${dest}.part`;
26429
+ const url = `${base}${ONNX_MODEL_ID}/resolve/main/${rel}`;
26430
+ let failures = 0;
26431
+ let lastError = "";
26432
+ let total = 0;
26433
+ for (;; ) {
26434
+ const have = existsSync10(part) ? statSync2(part).size : 0;
26435
+ if (total > 0 && have >= total)
26436
+ break;
26437
+ try {
26438
+ const got = await fetchRange(url, have, have + segment - 1, timeoutMs);
26439
+ if (got === "no-range") {
26440
+ const res = await fetch(url, { signal: AbortSignal.timeout(Math.max(timeoutMs, ONNX_MODEL_APPROX_MB * 1e5)) });
26441
+ if (!res.ok)
26442
+ throw new Error(`HTTP ${res.status}`);
26443
+ const declared = Number(res.headers.get("content-length") ?? 0);
26444
+ const bytes = new Uint8Array(await res.arrayBuffer());
26445
+ if (declared > 0 && bytes.byteLength !== declared) {
26446
+ throw new Error(`got ${bytes.byteLength} bytes, expected ${declared}`);
26447
+ }
26448
+ writeFileSync5(part, bytes);
26449
+ total = bytes.byteLength;
26450
+ } else {
26451
+ total = got.total;
26452
+ if (have > total) {
26453
+ writeFileSync5(part, new Uint8Array(0));
26454
+ continue;
26455
+ }
26456
+ appendFileSync(part, got.bytes);
26457
+ }
26458
+ failures = 0;
26459
+ onProgress(rel, statSync2(part).size, total);
26460
+ } catch (err) {
26461
+ lastError = err instanceof Error ? err.message : String(err);
26462
+ failures++;
26463
+ if (failures >= attempts)
26464
+ throw new EmbedderDownloadError(`${rel}: ${lastError}`);
26465
+ const wait = backoffMs(failures);
26466
+ process.stderr.write(`[cerefox-embed] ${rel}: ${lastError}; retrying in ${Math.round(wait / 1000)}s (${failures}/${attempts - 1})
26467
+ `);
26468
+ await new Promise((r) => setTimeout(r, wait));
26469
+ }
26470
+ }
26471
+ renameSync(part, dest);
26472
+ onDone(rel, statSync2(dest).size);
26473
+ }
26474
+ }
26393
26475
  function isModelCached(dir = getCacheDir()) {
26394
26476
  return MODEL_FILES.every((f) => existsSync10(join9(dir, f)));
26395
26477
  }
@@ -26518,9 +26600,9 @@ async function ensurePipeline() {
26518
26600
  progressState.set(file, { ...prior, done: true });
26519
26601
  }
26520
26602
  };
26603
+ await downloadModelFiles(String(transformers.env.remoteHost ?? "https://huggingface.co/"), (file, loaded, total) => progressCallback({ status: "progress", file, loaded, total }), (file, size) => progressCallback({ status: "done", file, total: size }));
26521
26604
  const pipe = await transformers.pipeline("feature-extraction", ONNX_MODEL_ID, {
26522
- dtype: ONNX_MODEL_DTYPE,
26523
- progress_callback: progressCallback
26605
+ dtype: ONNX_MODEL_DTYPE
26524
26606
  });
26525
26607
  process.stderr.write(`[cerefox-embed] embedder ready.
26526
26608
  `);
@@ -26564,14 +26646,23 @@ async function onnxEmbed(texts, role) {
26564
26646
  }
26565
26647
  return vectors;
26566
26648
  }
26567
- var ONNX_MODEL_ID = "nomic-ai/nomic-embed-text-v1.5", ONNX_MODEL_NAME = "nomic-embed-text-v1.5", ONNX_MODEL_DTYPE = "q8", ONNX_MODEL_DIM = 768, ONNX_MODEL_APPROX_MB = 130, MODEL_FILES, transformersModule = null, pipelinePromise = null, DEFAULT_ONNX_BATCH = 4;
26649
+ var ONNX_MODEL_ID = "nomic-ai/nomic-embed-text-v1.5", ONNX_MODEL_NAME = "nomic-embed-text-v1.5", ONNX_MODEL_DTYPE = "q8", ONNX_MODEL_DIM = 768, ONNX_MODEL_APPROX_MB = 130, MODEL_REPO_FILES, MODEL_FILES, EmbedderDownloadError, DOWNLOAD_ATTEMPTS = 6, SEGMENT_BYTES, REQUEST_TIMEOUT_MS = 120000, transformersModule = null, pipelinePromise = null, DEFAULT_ONNX_BATCH = 4;
26568
26650
  var init_onnx_embedder = __esm(() => {
26569
- MODEL_FILES = [
26651
+ MODEL_REPO_FILES = [
26570
26652
  "config.json",
26571
26653
  "tokenizer.json",
26572
26654
  "tokenizer_config.json",
26573
26655
  `onnx/model_quantized.onnx`
26574
- ].map((f) => join9(ONNX_MODEL_ID, f));
26656
+ ];
26657
+ MODEL_FILES = MODEL_REPO_FILES.map((f) => join9(ONNX_MODEL_ID, f));
26658
+ EmbedderDownloadError = class EmbedderDownloadError extends Error {
26659
+ status = 503;
26660
+ constructor(detail) {
26661
+ super(`The local embedding model (${ONNX_MODEL_NAME}, ~${ONNX_MODEL_APPROX_MB} MB) could not be downloaded: ${detail}. ` + "What was downloaded is kept, and the next request resumes it. Check the network and try again.");
26662
+ this.name = "EmbedderDownloadError";
26663
+ }
26664
+ };
26665
+ SEGMENT_BYTES = 4 * 1024 * 1024;
26575
26666
  });
26576
26667
 
26577
26668
  // ../../_shared/embeddings/index.ts
@@ -52519,7 +52610,7 @@ var init_search = __esm(() => {
52519
52610
  },
52520
52611
  min_term_coverage: {
52521
52612
  type: "number",
52522
- description: "Keyword OR-fallback confidence bar 0–1 (default: server-configured, else 0.67): fraction of the query's meaningful terms a result must match to count as a confident hit; weaker matches return flagged below-confidence. 0 = any matching term. Needs schema ≥ 0.9.1."
52613
+ description: "Keyword OR-fallback confidence bar 0–1 (default: server-configured, else 0.66): fraction of the query's meaningful terms a result must match to count as a confident hit; weaker matches return flagged below-confidence. 0 = any matching term. Needs schema ≥ 0.9.1."
52523
52614
  },
52524
52615
  max_bytes: {
52525
52616
  type: "integer",
@@ -65711,7 +65802,7 @@ var CONFIG_CATALOG = [
65711
65802
  key: "min_term_coverage",
65712
65803
  description: "Fraction of a query's meaningful terms a keyword OR-fallback match must cover to count as confident.",
65713
65804
  kind: "number",
65714
- defaultValue: "0.67",
65805
+ defaultValue: "0.66",
65715
65806
  min: 0,
65716
65807
  max: 1,
65717
65808
  group: "Retrieval"
@@ -71773,7 +71864,7 @@ init_config();
71773
71864
  init_config();
71774
71865
  init_compatibility();
71775
71866
  init_server_assets();
71776
- import { existsSync as existsSync11, readFileSync as readFileSync9, realpathSync, statSync as statSync2 } from "node:fs";
71867
+ import { existsSync as existsSync11, readFileSync as readFileSync9, realpathSync, statSync as statSync3 } from "node:fs";
71777
71868
  import { homedir as homedir7 } from "node:os";
71778
71869
  import { join as join10 } from "node:path";
71779
71870
 
@@ -72014,7 +72105,7 @@ function checkConfig() {
72014
72105
  let modeDetail = "";
72015
72106
  if (process.platform !== "win32") {
72016
72107
  try {
72017
- const mode = statSync2(envPath).mode & 511;
72108
+ const mode = statSync3(envPath).mode & 511;
72018
72109
  if (mode & 63) {
72019
72110
  return {
72020
72111
  name: "config",
@@ -72371,6 +72462,17 @@ async function checkEmbedderMismatch() {
72371
72462
  const recorded = [...new Set(rows.map((r) => r.embedder_primary))];
72372
72463
  const stale = recorded.filter((r) => r !== active);
72373
72464
  if (stale.length === 0) {
72465
+ if (resolveEmbedderKind() === "local") {
72466
+ await Promise.resolve().then(() => init_onnx_embedder());
72467
+ if (!isModelCached()) {
72468
+ return {
72469
+ name: EMBEDDER_CHECK_NAME,
72470
+ status: "warn",
72471
+ detail: `configured "${active}", but the model is not downloaded yet; the first search or write will fetch it (~130 MB).`,
72472
+ hint: "Fetch it now with `cerefox embedder-warmup` (Cerefox Local: `cerefox-local embedder-warmup`); it resumes if the connection drops."
72473
+ };
72474
+ }
72475
+ }
72374
72476
  return {
72375
72477
  name: EMBEDDER_CHECK_NAME,
72376
72478
  status: "ok",
@@ -74030,7 +74132,7 @@ init_dist4();
74030
74132
  init_cli_core();
74031
74133
  init_config();
74032
74134
  var import_cli_progress = __toESM(require_cli_progress(), 1);
74033
- import { readdirSync as readdirSync4, statSync as statSync3 } from "node:fs";
74135
+ import { readdirSync as readdirSync4, statSync as statSync4 } from "node:fs";
74034
74136
  import { basename as basename3, extname as extname4, join as join11 } from "node:path";
74035
74137
  function walk(dir, extensions) {
74036
74138
  let entries;
@@ -74044,7 +74146,7 @@ function walk(dir, extensions) {
74044
74146
  const full = join11(dir, name);
74045
74147
  let stat;
74046
74148
  try {
74047
- stat = statSync3(full);
74149
+ stat = statSync4(full);
74048
74150
  } catch {
74049
74151
  continue;
74050
74152
  }
@@ -74147,10 +74249,10 @@ import {
74147
74249
  existsSync as existsSync12,
74148
74250
  mkdirSync as mkdirSync5,
74149
74251
  readFileSync as readFileSync14,
74150
- writeFileSync as writeFileSync5
74252
+ writeFileSync as writeFileSync6
74151
74253
  } from "node:fs";
74152
74254
  import { homedir as homedir8 } from "node:os";
74153
- import { dirname as dirname4, join as join12 } from "node:path";
74255
+ import { dirname as dirname5, join as join12 } from "node:path";
74154
74256
  async function readConfigFile(path) {
74155
74257
  if (!existsSync12(path)) {
74156
74258
  throw userError(`--config file not found: ${path}`);
@@ -74454,8 +74556,8 @@ async function postWriteLifecycle(envPath, options) {
74454
74556
  println(c.dim(` Config in effect: ${envPath}`));
74455
74557
  }
74456
74558
  function writeAnswersTo(target, answers) {
74457
- mkdirSync5(dirname4(target), { recursive: true });
74458
- writeFileSync5(target, buildEnvFile(answers), "utf8");
74559
+ mkdirSync5(dirname5(target), { recursive: true });
74560
+ writeFileSync6(target, buildEnvFile(answers), "utf8");
74459
74561
  if (process.platform !== "win32") {
74460
74562
  try {
74461
74563
  chmodSync(target, 384);
@@ -74518,7 +74620,7 @@ async function action23(options) {
74518
74620
  const ch = await promptMigrationChoice();
74519
74621
  println("");
74520
74622
  if (ch === "c") {
74521
- mkdirSync5(dirname4(homeEnv), { recursive: true });
74623
+ mkdirSync5(dirname5(homeEnv), { recursive: true });
74522
74624
  copyFileSync2(cwdEnv, homeEnv);
74523
74625
  if (process.platform !== "win32") {
74524
74626
  try {
@@ -75135,7 +75237,7 @@ function registerMigrateFormat(program) {
75135
75237
  // src/cli/commands/restore.ts
75136
75238
  init_cli_core();
75137
75239
  init_client();
75138
- import { existsSync as existsSync13, readFileSync as readFileSync15, readdirSync as readdirSync5, statSync as statSync4 } from "node:fs";
75240
+ import { existsSync as existsSync13, readFileSync as readFileSync15, readdirSync as readdirSync5, statSync as statSync5 } from "node:fs";
75139
75241
  import { homedir as homedir9 } from "node:os";
75140
75242
  import { join as join13, resolve as resolve4 } from "node:path";
75141
75243
  function expandHome2(path) {
@@ -75150,10 +75252,10 @@ function resolveBackupFile(target) {
75150
75252
  if (!existsSync13(path)) {
75151
75253
  throw userError(`Backup path not found: ${target}`);
75152
75254
  }
75153
- const stat = statSync4(path);
75255
+ const stat = statSync5(path);
75154
75256
  if (stat.isFile())
75155
75257
  return path;
75156
- const candidates = readdirSync5(path).filter((n) => n.endsWith(".json") && n.startsWith("cerefox-")).map((n) => ({ name: n, mtime: statSync4(join13(path, n)).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
75258
+ const candidates = readdirSync5(path).filter((n) => n.endsWith(".json") && n.startsWith("cerefox-")).map((n) => ({ name: n, mtime: statSync5(join13(path, n)).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
75157
75259
  if (candidates.length === 0) {
75158
75260
  throw userError(`No cerefox-*.json files in ${path}`);
75159
75261
  }
@@ -75487,7 +75589,7 @@ async function action32(query, options) {
75487
75589
  }
75488
75590
  }
75489
75591
  function registerSearch(program) {
75490
- program.command("search").description("Search the knowledge base (hybrid FTS + semantic).").argument("<query>", "Natural-language search query.").option("-c, --match-count <n>", "Maximum number of documents to return.", "5").option("-p, --project-name <name>", "Filter results to a specific project.").option("-f, --metadata-filter <json>", "JSON containment filter; only docs whose metadata contains ALL pairs are returned.").option("--mode <mode>", "Search mode: docs (default), hybrid, fts.", "docs").option("--alpha <float>", "Semantic weight 0..1 (default: CEREFOX_SEARCH_ALPHA; else 0.7).").option("--min-score <float>", "Minimum cosine similarity threshold (default: the store's min_search_score; else 0.5, or 0.6 on a local-embedder store).").option("--min-term-coverage <float>", "OR-fallback keyword matches must cover at least this fraction of the query's meaningful terms to count as confident hits (default: the store's min_term_coverage; else 0.67; needs schema ≥ 0.9.1).").option("--max-bytes <n>", "Response size budget in bytes (default: CEREFOX_MAX_RESPONSE_BYTES or 200000).").addOption(authorOption("read")).addOption(requestorAliasOption()).option("--json", "Emit machine-readable JSON instead of the default text.").option("--only-metadata", "List matching docs (id, score, chunks, chars, partial/full) WITHOUT their content — like the web UI's collapsed result list. Grab a [id:…] then `cerefox document get <id>`.").action(action32);
75592
+ program.command("search").description("Search the knowledge base (hybrid FTS + semantic).").argument("<query>", "Natural-language search query.").option("-c, --match-count <n>", "Maximum number of documents to return.", "5").option("-p, --project-name <name>", "Filter results to a specific project.").option("-f, --metadata-filter <json>", "JSON containment filter; only docs whose metadata contains ALL pairs are returned.").option("--mode <mode>", "Search mode: docs (default), hybrid, fts.", "docs").option("--alpha <float>", "Semantic weight 0..1 (default: CEREFOX_SEARCH_ALPHA; else 0.7).").option("--min-score <float>", "Minimum cosine similarity threshold (default: the store's min_search_score; else 0.5, or 0.6 on a local-embedder store).").option("--min-term-coverage <float>", "OR-fallback keyword matches must cover at least this fraction of the query's meaningful terms to count as confident hits (default: the store's min_term_coverage; else 0.66; needs schema ≥ 0.9.1).").option("--max-bytes <n>", "Response size budget in bytes (default: CEREFOX_MAX_RESPONSE_BYTES or 200000).").addOption(authorOption("read")).addOption(requestorAliasOption()).option("--json", "Emit machine-readable JSON instead of the default text.").option("--only-metadata", "List matching docs (id, score, chunks, chars, partial/full) WITHOUT their content — like the web UI's collapsed result list. Grab a [id:…] then `cerefox document get <id>`.").action(action32);
75491
75593
  }
75492
75594
 
75493
75595
  // src/cli/commands/self-update.ts
@@ -75638,8 +75740,8 @@ init_config();
75638
75740
  import { randomBytes } from "node:crypto";
75639
75741
 
75640
75742
  // src/cli/util/env-file.ts
75641
- import { copyFileSync as copyFileSync3, existsSync as existsSync14, readFileSync as readFileSync16, writeFileSync as writeFileSync6 } from "node:fs";
75642
- import { dirname as dirname5 } from "node:path";
75743
+ import { copyFileSync as copyFileSync3, existsSync as existsSync14, readFileSync as readFileSync16, writeFileSync as writeFileSync7 } from "node:fs";
75744
+ import { dirname as dirname6 } from "node:path";
75643
75745
  import { spawnSync as spawnSync6 } from "node:child_process";
75644
75746
  function escapeRegExp(s) {
75645
75747
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -75649,7 +75751,7 @@ function upsertEnvVar(path, key, value, opts = {}) {
75649
75751
  const header = opts.comment ? `# ${opts.comment}
75650
75752
  ` : "";
75651
75753
  if (!existsSync14(path)) {
75652
- writeFileSync6(path, `${header}${line}
75754
+ writeFileSync7(path, `${header}${line}
75653
75755
  `, { mode: 384 });
75654
75756
  return { path, action: "created" };
75655
75757
  }
@@ -75674,7 +75776,7 @@ ${header}${line}
75674
75776
  `;
75675
75777
  action = "added";
75676
75778
  }
75677
- writeFileSync6(path, next);
75779
+ writeFileSync7(path, next);
75678
75780
  return { path, action, backupPath };
75679
75781
  }
75680
75782
  function readEnvVar(path, key) {
@@ -75685,7 +75787,7 @@ function readEnvVar(path, key) {
75685
75787
  }
75686
75788
  function envGitignoreWarning(path) {
75687
75789
  try {
75688
- const dir = dirname5(path);
75790
+ const dir = dirname6(path);
75689
75791
  const inTree = spawnSync6("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
75690
75792
  encoding: "utf8"
75691
75793
  });
@@ -77645,7 +77747,7 @@ var _baseMimes = {
77645
77747
  var baseMimes = _baseMimes;
77646
77748
 
77647
77749
  // ../../node_modules/.bun/@hono+node-server@2.1.1+2ac783cc5e75a70c/node_modules/@hono/node-server/dist/serve-static.mjs
77648
- import { createReadStream, existsSync as existsSync16, statSync as statSync5 } from "node:fs";
77750
+ import { createReadStream, existsSync as existsSync16, statSync as statSync6 } from "node:fs";
77649
77751
  import { join as join14 } from "node:path";
77650
77752
  var COMPRESSIBLE_CONTENT_TYPE_REGEX = /^\s*(?:text\/[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i;
77651
77753
  var ENCODINGS = {
@@ -77657,7 +77759,7 @@ var ENCODINGS_ORDERED_KEYS = Object.keys(ENCODINGS);
77657
77759
  var getStats = (path) => {
77658
77760
  let stats;
77659
77761
  try {
77660
- stats = statSync5(path);
77762
+ stats = statSync6(path);
77661
77763
  } catch {}
77662
77764
  return stats;
77663
77765
  };
@@ -77803,7 +77905,7 @@ var serveStatic = (options = { root: "" }) => {
77803
77905
 
77804
77906
  // src/web/server.ts
77805
77907
  import { existsSync as existsSync20 } from "node:fs";
77806
- import { readFileSync as readFileSync19, statSync as statSync8 } from "node:fs";
77908
+ import { readFileSync as readFileSync19, statSync as statSync9 } from "node:fs";
77807
77909
  import { join as join18 } from "node:path";
77808
77910
 
77809
77911
  // ../../node_modules/.bun/hono@4.13.7/node_modules/hono/dist/compose.js
@@ -79993,6 +80095,9 @@ function registerDiscoveryRoutes(app, ctx) {
79993
80095
  if (err instanceof HttpError) {
79994
80096
  return c.json({ detail: err.message }, err.status);
79995
80097
  }
80098
+ if (err instanceof Error && err.name === "EmbedderDownloadError") {
80099
+ return c.json({ detail: err.message }, 503);
80100
+ }
79996
80101
  throw err;
79997
80102
  }
79998
80103
  const who = resolveCallerIdentity(c);
@@ -80792,6 +80897,8 @@ function notReady(error) {
80792
80897
  return { success: false, error, detail: error };
80793
80898
  }
80794
80899
  function ingestFailure(err) {
80900
+ if (err instanceof Error && err.name === "EmbedderDownloadError")
80901
+ return [notReady(err.message), 503];
80795
80902
  if (err instanceof ConcurrencyConflictError) {
80796
80903
  return [
80797
80904
  {
@@ -81058,7 +81165,7 @@ function registerIngestRoutes(app, ctx) {
81058
81165
  init_server_assets();
81059
81166
  init_meta();
81060
81167
  import { execFileSync } from "node:child_process";
81061
- import { dirname as dirname7 } from "node:path";
81168
+ import { dirname as dirname8 } from "node:path";
81062
81169
  import { fileURLToPath as fileURLToPath4 } from "node:url";
81063
81170
 
81064
81171
  // src/web/docs.ts
@@ -81066,9 +81173,9 @@ import {
81066
81173
  existsSync as existsSync17,
81067
81174
  readFileSync as readFileSync17,
81068
81175
  readdirSync as readdirSync6,
81069
- statSync as statSync6
81176
+ statSync as statSync7
81070
81177
  } from "node:fs";
81071
- import { basename as basename5, dirname as dirname6, join as join15, resolve as resolve5 } from "node:path";
81178
+ import { basename as basename5, dirname as dirname7, join as join15, resolve as resolve5 } from "node:path";
81072
81179
  import { fileURLToPath as fileURLToPath3 } from "node:url";
81073
81180
  var TOP_LEVEL_DOCS = [
81074
81181
  { filename: "README.md", path: "README.md", category: "readme" },
@@ -81084,7 +81191,7 @@ var TOP_LEVEL_DOCS = [
81084
81191
  }
81085
81192
  ];
81086
81193
  function moduleDir2() {
81087
- return dirname6(fileURLToPath3(import.meta.url));
81194
+ return dirname7(fileURLToPath3(import.meta.url));
81088
81195
  }
81089
81196
  function resolveDocsRoots() {
81090
81197
  const here = moduleDir2();
@@ -81096,7 +81203,7 @@ function resolveDocsRoots() {
81096
81203
  let pkgTopLevel = null;
81097
81204
  for (const pkg of pkgRootCandidates) {
81098
81205
  const guides = join15(pkg, "docs", "guides");
81099
- if (existsSync17(guides) && statSync6(guides).isDirectory()) {
81206
+ if (existsSync17(guides) && statSync7(guides).isDirectory()) {
81100
81207
  pkgGuides = guides;
81101
81208
  pkgTopLevel = pkg;
81102
81209
  break;
@@ -81162,7 +81269,7 @@ function readDoc(docPath) {
81162
81269
  if (!candidate.startsWith(rootResolved + "/") && candidate !== rootResolved) {
81163
81270
  return null;
81164
81271
  }
81165
- if (existsSync17(candidate) && statSync6(candidate).isFile()) {
81272
+ if (existsSync17(candidate) && statSync7(candidate).isFile()) {
81166
81273
  try {
81167
81274
  return readFileSync17(candidate, "utf8");
81168
81275
  } catch {
@@ -81188,7 +81295,7 @@ function readOpenApiDocument() {
81188
81295
 
81189
81296
  // src/web/routes/meta.ts
81190
81297
  init_compatibility();
81191
- function resolveGitCommitShort(dir = dirname7(fileURLToPath4(import.meta.url))) {
81298
+ function resolveGitCommitShort(dir = dirname8(fileURLToPath4(import.meta.url))) {
81192
81299
  const env = process.env.CEREFOX_GIT_COMMIT;
81193
81300
  if (env)
81194
81301
  return env.slice(0, 7);
@@ -81320,7 +81427,7 @@ function registerPostgrestProxy(app) {
81320
81427
 
81321
81428
  // src/web/routes/preferences.ts
81322
81429
  init_config();
81323
- import { existsSync as existsSync18, mkdirSync as mkdirSync6, readFileSync as readFileSync18, writeFileSync as writeFileSync7 } from "node:fs";
81430
+ import { existsSync as existsSync18, mkdirSync as mkdirSync6, readFileSync as readFileSync18, writeFileSync as writeFileSync8 } from "node:fs";
81324
81431
  import { join as join16 } from "node:path";
81325
81432
  function isTheme(v) {
81326
81433
  return v === "auto" || v === "light" || v === "dark";
@@ -81348,7 +81455,7 @@ function registerPreferencesRoutes(app) {
81348
81455
  const dir = userStateDir();
81349
81456
  if (!existsSync18(dir))
81350
81457
  mkdirSync6(dir, { recursive: true });
81351
- writeFileSync7(prefsFile(), `${JSON.stringify(next, null, 2)}
81458
+ writeFileSync8(prefsFile(), `${JSON.stringify(next, null, 2)}
81352
81459
  `);
81353
81460
  } catch (err) {
81354
81461
  return c.json({ detail: err instanceof Error ? err.message : String(err) }, 500);
@@ -81483,14 +81590,14 @@ function registerProjectsRoutes(app, ctx) {
81483
81590
  }
81484
81591
 
81485
81592
  // src/web/static.ts
81486
- import { existsSync as existsSync19, statSync as statSync7 } from "node:fs";
81487
- import { dirname as dirname8, join as join17 } from "node:path";
81593
+ import { existsSync as existsSync19, statSync as statSync8 } from "node:fs";
81594
+ import { dirname as dirname9, join as join17 } from "node:path";
81488
81595
  import { fileURLToPath as fileURLToPath5 } from "node:url";
81489
81596
  function moduleDir3() {
81490
- return dirname8(fileURLToPath5(import.meta.url));
81597
+ return dirname9(fileURLToPath5(import.meta.url));
81491
81598
  }
81492
81599
  function isUsableSpaDir(dir) {
81493
- return existsSync19(dir) && statSync7(dir).isDirectory() && existsSync19(join17(dir, "index.html"));
81600
+ return existsSync19(dir) && statSync8(dir).isDirectory() && existsSync19(join17(dir, "index.html"));
81494
81601
  }
81495
81602
  function resolveSpaDist() {
81496
81603
  const here = moduleDir3();
@@ -81512,7 +81619,7 @@ function resolveStaticDir() {
81512
81619
  join17(here, "..", "..", "..", "..", "web", "static")
81513
81620
  ];
81514
81621
  for (const c of candidates) {
81515
- if (existsSync19(c) && statSync7(c).isDirectory())
81622
+ if (existsSync19(c) && statSync8(c).isDirectory())
81516
81623
  return c;
81517
81624
  }
81518
81625
  return null;
@@ -81613,7 +81720,7 @@ function buildApp(ctx = buildWebContext()) {
81613
81720
  let cached = null;
81614
81721
  const readIndex = () => {
81615
81722
  try {
81616
- const { mtimeMs, size } = statSync8(indexPath);
81723
+ const { mtimeMs, size } = statSync9(indexPath);
81617
81724
  if (!cached || cached.mtimeMs !== mtimeMs || cached.size !== size) {
81618
81725
  cached = { mtimeMs, size, html: readFileSync19(indexPath, "utf8") };
81619
81726
  }
@@ -18,7 +18,7 @@
18
18
  * doesn't touch `supabase/functions/` leaves it alone).
19
19
  */
20
20
 
21
- export const EF_VERSION = "1.17.0";
21
+ export const EF_VERSION = "1.17.2";
22
22
 
23
23
  /**
24
24
  * The Cerefox RELEASE version — what `cerefox --version` reports and what npm
@@ -36,7 +36,7 @@ export const EF_VERSION = "1.17.0";
36
36
  * is imported by the Deno Edge Functions, which cannot reach into the npm
37
37
  * package.
38
38
  */
39
- export const CEREFOX_VERSION = "1.17.0";
39
+ export const CEREFOX_VERSION = "1.17.2";
40
40
 
41
41
  /**
42
42
  * The most recent version whose EF-side SOURCE actually changed (#127).
@@ -46,7 +46,7 @@ export const CEREFOX_VERSION = "1.17.0";
46
46
  * `cut_release.ts` ONLY when EF source changed since the last tag; doctor
47
47
  * uses it to stay silent on label-only drift.
48
48
  */
49
- export const EF_LAST_CHANGED = "1.17.0";
49
+ export const EF_LAST_CHANGED = "1.17.2";
50
50
 
51
51
  /**
52
52
  * The 8 peer EFs the cerefox-mcp aggregator probes (excludes cerefox-mcp
@@ -21,9 +21,9 @@
21
21
  * volume so models survive container recreate/upgrade); default `~/.cerefox/models`.
22
22
  */
23
23
 
24
- import { existsSync, mkdirSync } from "node:fs";
24
+ import { existsSync, mkdirSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
- import { join } from "node:path";
26
+ import { dirname, join } from "node:path";
27
27
 
28
28
  export const ONNX_MODEL_ID = "nomic-ai/nomic-embed-text-v1.5";
29
29
  export const ONNX_MODEL_NAME = "nomic-embed-text-v1.5";
@@ -46,14 +46,149 @@ export function buildPrefixedInputs(texts: string[], role: EmbedRole): string[]
46
46
  return texts.map((t) => p + t);
47
47
  }
48
48
 
49
- /** What the q8 pipeline reads, relative to the cache dir. Same layout under
49
+ /** What the q8 pipeline reads, relative to the model dir. Same layout under
50
50
  * transformers.js 3.x (the Local image) and 4.x (npm installs). */
51
- const MODEL_FILES = [
51
+ const MODEL_REPO_FILES = [
52
52
  "config.json",
53
53
  "tokenizer.json",
54
54
  "tokenizer_config.json",
55
55
  `onnx/model_quantized.onnx`,
56
- ].map((f) => join(ONNX_MODEL_ID, f));
56
+ ];
57
+ const MODEL_FILES = MODEL_REPO_FILES.map((f) => join(ONNX_MODEL_ID, f));
58
+
59
+ /**
60
+ * The model could not be fetched (#314). Carries a status so HTTP surfaces
61
+ * answer 503 (try again) rather than 500, and a message that says what was
62
+ * happening instead of the transport error underneath.
63
+ */
64
+ export class EmbedderDownloadError extends Error {
65
+ readonly status = 503;
66
+ constructor(detail: string) {
67
+ super(
68
+ `The local embedding model (${ONNX_MODEL_NAME}, ~${ONNX_MODEL_APPROX_MB} MB) could not be downloaded: ${detail}. ` +
69
+ "What was downloaded is kept, and the next request resumes it. Check the network and try again.",
70
+ );
71
+ this.name = "EmbedderDownloadError";
72
+ }
73
+ }
74
+
75
+ /** Consecutive failed requests per file before giving up. */
76
+ const DOWNLOAD_ATTEMPTS = 6;
77
+ /** Bytes per Range request. Small enough that a failure loses little. */
78
+ const SEGMENT_BYTES = 4 * 1024 * 1024;
79
+ /**
80
+ * Per-request limit. A connection that dies without a reset (a network that
81
+ * disappears rather than refuses) otherwise leaves fetch waiting forever. 4 MB
82
+ * in 2 minutes is ~35 KB/s, far below any link that could finish the model.
83
+ */
84
+ const REQUEST_TIMEOUT_MS = 120_000;
85
+
86
+ /**
87
+ * One exact byte range, or an error. The length check is the point: when a
88
+ * connection drops, Bun's fetch can silently re-issue the request and splice
89
+ * the new response onto the old stream (observed: one fetch, two server
90
+ * requests, the bytes of both). Trusting the stream would write a corrupt model;
91
+ * a segment of the wrong length is retried instead.
92
+ */
93
+ async function fetchRange(
94
+ url: string,
95
+ start: number,
96
+ end: number,
97
+ timeoutMs: number,
98
+ ): Promise<{ bytes: Uint8Array; total: number } | "no-range"> {
99
+ const res = await fetch(url, { headers: { Range: `bytes=${start}-${end}` }, signal: AbortSignal.timeout(timeoutMs) });
100
+ if (res.status === 200) {
101
+ await res.body?.cancel();
102
+ return "no-range";
103
+ }
104
+ const total = Number(/\/(\d+)\s*$/.exec(res.headers.get("content-range") ?? "")?.[1] ?? 0);
105
+ if (res.status === 416 && total > 0) {
106
+ // Nothing at or past `start`: the part file already holds the whole file.
107
+ await res.body?.cancel();
108
+ return { bytes: new Uint8Array(0), total };
109
+ }
110
+ if (res.status !== 206) throw new Error(`HTTP ${res.status}`);
111
+ if (total === 0) throw new Error("partial response without a total size (Content-Range)");
112
+ const bytes = new Uint8Array(await res.arrayBuffer());
113
+ const want = Math.min(end, total > 0 ? total - 1 : end) - start + 1;
114
+ if (bytes.byteLength !== want) throw new Error(`segment ${start}-${end}: got ${bytes.byteLength} bytes, expected ${want}`);
115
+ return { bytes, total };
116
+ }
117
+
118
+ /**
119
+ * Fetch any missing model file into the cache path transformers.js reads,
120
+ * resumably and with retries (#314). Before this the download was one stream
121
+ * inside transformers.js: on a slow link the connection dropped minutes in, the
122
+ * request that triggered it failed with a raw socket error, and the next attempt
123
+ * refetched the 131 MB from zero.
124
+ *
125
+ * Works in verified Range segments appended to `<file>.part`, renamed into
126
+ * place only when the declared size is reached, so neither a partial nor a
127
+ * spliced file is ever mistaken for the model. A host that ignores Range gets
128
+ * one whole-file request, checked against Content-Length.
129
+ */
130
+ export async function downloadModelFiles(
131
+ remoteHost: string,
132
+ onProgress: (file: string, loaded: number, total: number) => void,
133
+ onDone: (file: string, size: number) => void,
134
+ opts: { attempts?: number; backoffMs?: (attempt: number) => number; segmentBytes?: number; timeoutMs?: number } = {},
135
+ ): Promise<void> {
136
+ const attempts = opts.attempts ?? DOWNLOAD_ATTEMPTS;
137
+ const backoffMs = opts.backoffMs ?? ((attempt: number) => Math.min(30, 2 ** attempt) * 1000);
138
+ const segment = opts.segmentBytes ?? SEGMENT_BYTES;
139
+ const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
140
+ const dir = getCacheDir();
141
+ const base = remoteHost.endsWith("/") ? remoteHost : `${remoteHost}/`;
142
+ for (const rel of MODEL_REPO_FILES) {
143
+ const dest = join(dir, ONNX_MODEL_ID, rel);
144
+ if (existsSync(dest)) continue;
145
+ mkdirSync(dirname(dest), { recursive: true });
146
+ const part = `${dest}.part`;
147
+ const url = `${base}${ONNX_MODEL_ID}/resolve/main/${rel}`;
148
+ let failures = 0;
149
+ let lastError = "";
150
+ let total = 0;
151
+ for (;;) {
152
+ const have = existsSync(part) ? statSync(part).size : 0;
153
+ if (total > 0 && have >= total) break;
154
+ try {
155
+ const got = await fetchRange(url, have, have + segment - 1, timeoutMs);
156
+ if (got === "no-range") {
157
+ // Whole file in one request; only a length-checked body is kept.
158
+ // No segment to bound it, so a generous whole-file limit (10 KB/s).
159
+ const res = await fetch(url, { signal: AbortSignal.timeout(Math.max(timeoutMs, ONNX_MODEL_APPROX_MB * 100_000)) });
160
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
161
+ const declared = Number(res.headers.get("content-length") ?? 0);
162
+ const bytes = new Uint8Array(await res.arrayBuffer());
163
+ if (declared > 0 && bytes.byteLength !== declared) {
164
+ throw new Error(`got ${bytes.byteLength} bytes, expected ${declared}`);
165
+ }
166
+ writeFileSync(part, bytes);
167
+ total = bytes.byteLength;
168
+ } else {
169
+ total = got.total;
170
+ if (have > total) {
171
+ // A part file longer than the file itself is not ours to trust.
172
+ writeFileSync(part, new Uint8Array(0));
173
+ continue;
174
+ }
175
+ appendFileSync(part, got.bytes);
176
+ }
177
+ failures = 0;
178
+ onProgress(rel, statSync(part).size, total);
179
+ } catch (err) {
180
+ lastError = err instanceof Error ? err.message : String(err);
181
+ failures++;
182
+ if (failures >= attempts) throw new EmbedderDownloadError(`${rel}: ${lastError}`);
183
+ const wait = backoffMs(failures);
184
+ process.stderr.write(`[cerefox-embed] ${rel}: ${lastError}; retrying in ${Math.round(wait / 1000)}s (${failures}/${attempts - 1})\n`);
185
+ await new Promise((r) => setTimeout(r, wait));
186
+ }
187
+ }
188
+ renameSync(part, dest);
189
+ onDone(rel, statSync(dest).size);
190
+ }
191
+ }
57
192
 
58
193
  /**
59
194
  * Is every file the pipeline needs already on disk? Decides whether loading is
@@ -223,9 +358,17 @@ async function ensurePipeline(): Promise<FeaturePipeline> {
223
358
  }
224
359
  };
225
360
 
361
+ // Our own resumable, retried fetch first (#314); transformers.js then
362
+ // finds every file in its cache and loads locally.
363
+ await downloadModelFiles(
364
+ String(transformers.env.remoteHost ?? "https://huggingface.co/"),
365
+ (file, loaded, total) => progressCallback({ status: "progress", file, loaded, total }),
366
+ (file, size) => progressCallback({ status: "done", file, total: size }),
367
+ );
368
+ // Every file is on disk now, so this is a local load: no progress callback,
369
+ // which would only report each file "done" a second time.
226
370
  const pipe = await transformers.pipeline("feature-extraction", ONNX_MODEL_ID, {
227
371
  dtype: ONNX_MODEL_DTYPE,
228
- progress_callback: progressCallback,
229
372
  });
230
373
  process.stderr.write(`[cerefox-embed] embedder ready.\n`);
231
374
  return pipe as unknown as FeaturePipeline;
@@ -490,7 +490,7 @@ export const searchTool: ToolDefinition = {
490
490
  min_term_coverage: {
491
491
  type: "number",
492
492
  description:
493
- "Keyword OR-fallback confidence bar 0–1 (default: server-configured, else 0.67): fraction of the query's meaningful terms a result must match to count as a confident hit; weaker matches return flagged below-confidence. 0 = any matching term. Needs schema ≥ 0.9.1.",
493
+ "Keyword OR-fallback confidence bar 0–1 (default: server-configured, else 0.66): fraction of the query's meaningful terms a result must match to count as a confident hit; weaker matches return flagged below-confidence. 0 = any matching term. Needs schema ≥ 0.9.1.",
494
494
  },
495
495
  max_bytes: {
496
496
  type: "integer",
@@ -184,10 +184,16 @@ DECLARE
184
184
  cerefox_default_min_search_score()));
185
185
  v_alpha FLOAT := COALESCE(p_alpha,
186
186
  cerefox_config_float('search_alpha', 0.7));
187
- -- 0.18.0: 0.67 (was 0.5), so a confident OR-fallback match needs two of
187
+ -- 0.18.2: 0.66, so a confident OR-fallback match needs two of three
188
+ -- terms. 0.18.0 shipped 0.67, which for three terms is 2.01, i.e. all
189
+ -- three, which on real data made multi-word keyword queries rank markedly
190
+ -- worse
191
+ -- (docs/specs/search-calibration.md). The bar is coverage >= v * terms, so
192
+ -- the value must sit at or below k/n for the k-of-n rule it intends.
193
+ -- (0.18.0 note:) a confident OR-fallback match needs two of
188
194
  -- three terms, not one of two (docs/specs/search-calibration.md).
189
195
  v_min_coverage FLOAT := COALESCE(p_min_term_coverage,
190
- cerefox_config_float('min_term_coverage', 0.67));
196
+ cerefox_config_float('min_term_coverage', 0.66));
191
197
  BEGIN
192
198
  -- Build the OR-composed query: plainto each whitespace token (so tokens get
193
199
  -- the same normalization/stemming as the AND path), skip stopword-only
@@ -237,6 +243,15 @@ BEGIN
237
243
  -- scored 1.2 or 0.3 depending on which phrasing found it
238
244
  -- (docs/specs/search-calibration.md).
239
245
  ts_rank_cd(c.fts, query_fts, 32)::FLOAT AS fts_score,
246
+ -- 0.18.1: the exact cosine of every keyword match, so a chunk
247
+ -- that matched by keyword is never fused as if its meaning
248
+ -- scored 0 merely because it fell outside vec_results.
249
+ CASE
250
+ WHEN p_use_upgrade AND c.embedding_upgrade IS NOT NULL
251
+ THEN (1.0 - (c.embedding_upgrade <=> p_query_embedding))::FLOAT
252
+ ELSE
253
+ (1.0 - (c.embedding_primary <=> p_query_embedding))::FLOAT
254
+ END AS fts_vec_score,
240
255
  -- v1.0.4 coverage gate: in AND mode a match means 100% of the
241
256
  -- query's terms are present, so the unconditional pass is
242
257
  -- earned by construction. In OR-fallback mode, earn it only
@@ -283,21 +298,24 @@ BEGIN
283
298
  ))
284
299
  AND (p_metadata_filter IS NULL OR d.metadata @> p_metadata_filter)
285
300
  AND (p_review_status IS NULL OR d.review_status = p_review_status)
286
- ORDER BY
287
- CASE
288
- WHEN p_use_upgrade AND c.embedding_upgrade IS NOT NULL
289
- THEN c.embedding_upgrade <=> p_query_embedding
290
- ELSE c.embedding_primary <=> p_query_embedding
291
- END
301
+ -- 0.18.1: ordered by the computed similarity, NOT by the bare
302
+ -- `embedding <=> query` distance. The bare form (which the CASE
303
+ -- folds to once p_use_upgrade is known) lets the planner serve this
304
+ -- from the HNSW index, which is approximate and returns at most
305
+ -- hnsw.ef_search (40) rows: on a store with heavy version churn it
306
+ -- returned far fewer candidates than asked for, mostly not the
307
+ -- nearest ones. An exact scan is also faster at knowledge-base
308
+ -- scale (thousands of chunks).
309
+ ORDER BY vec_score DESC
292
310
  LIMIT candidate_count
293
311
  ),
294
312
  combined AS (
295
313
  SELECT
296
314
  COALESCE(f.id, v.id) AS id,
297
- ( v_alpha * COALESCE(v.vec_score, 0.0) +
315
+ ( v_alpha * COALESCE(v.vec_score, f.fts_vec_score, 0.0) +
298
316
  (1.0 - v_alpha) * COALESCE(f.fts_score, 0.0)
299
317
  ) AS score,
300
- COALESCE(v.vec_score, 0.0) AS vec_score,
318
+ COALESCE(v.vec_score, f.fts_vec_score, 0.0) AS vec_score,
301
319
  -- TRUE when the chunk matched the @@ FTS operator WITH enough
302
320
  -- term coverage to earn the unconditional pass (v1.0.4; always
303
321
  -- true for AND-mode matches). We use this flag rather than
@@ -417,7 +435,7 @@ DECLARE
417
435
  seen_tokens TEXT[] := '{}';
418
436
  total_tokens INT;
419
437
  v_min_coverage FLOAT := COALESCE(p_min_term_coverage,
420
- cerefox_config_float('min_term_coverage', 0.67));
438
+ cerefox_config_float('min_term_coverage', 0.66));
421
439
  BEGIN
422
440
  FOR tok IN SELECT unnest(regexp_split_to_array(trim(p_query_text), '\s+')) LOOP
423
441
  tok_q := plainto_tsquery('english', tok);
@@ -565,12 +583,10 @@ BEGIN
565
583
  THEN (1.0 - (c.embedding_upgrade <=> p_query_embedding))::FLOAT
566
584
  ELSE (1.0 - (c.embedding_primary <=> p_query_embedding))::FLOAT
567
585
  END >= p_min_score
568
- ORDER BY
569
- CASE
570
- WHEN p_use_upgrade AND c.embedding_upgrade IS NOT NULL
571
- THEN c.embedding_upgrade <=> p_query_embedding
572
- ELSE c.embedding_primary <=> p_query_embedding
573
- END
586
+ -- 0.18.1: by the computed similarity, so the approximate HNSW index cannot
587
+ -- serve (and truncate) it; see vec_results in cerefox_hybrid_search.
588
+ -- By position: `score` would be ambiguous with the OUT column here.
589
+ ORDER BY 8 DESC
574
590
  LIMIT p_match_count;
575
591
  END;
576
592
  $$;
@@ -3219,6 +3235,13 @@ SET search_path = public, pg_catalog
3219
3235
  AS $$
3220
3236
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
3221
3237
  -- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
3238
+ -- 0.18.2: min_term_coverage built-in default 0.66 (two of three terms).
3239
+ -- 0.18.0's 0.67 required all three for a three-term query. RPC-only.
3240
+ -- 0.18.1: exact vector candidates. Hybrid and semantic search ordered by
3241
+ -- the bare distance, which let the planner use the approximate HNSW index
3242
+ -- (at most ef_search = 40 rows, poor recall after heavy version churn);
3243
+ -- they now order by the computed similarity (exact scan). Keyword matches
3244
+ -- carry their exact cosine into fusion. RPC-only.
3222
3245
  -- 0.18.0 (iteration 48): search calibration, RPC-only. Bounded keyword
3223
3246
  -- score in hybrid fusion (ts_rank_cd normalisation 32); min_term_coverage
3224
3247
  -- default 0.67; built-in min_search_score follows the store's embedder
@@ -3264,7 +3287,7 @@ AS $$
3264
3287
  -- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
3265
3288
  -- the partial-edit surface, and both migrations (0019, 0020) are in the
3266
3289
  -- sequence, so a store deploying this gets everything from both lines.
3267
- SELECT '0.18.0'::TEXT;
3290
+ SELECT '0.18.2'::TEXT;
3268
3291
  $$;
3269
3292
 
3270
3293
  -- ── cerefox_find_dead_links ──────────────────────────────────────────────────
@@ -5,7 +5,7 @@
5
5
  -- Requires extensions: vector (pgvector), uuid-ossp
6
6
  -- These are enabled at the top of db_deploy.py before this file is applied.
7
7
  --
8
- -- @version: 0.18.0
8
+ -- @version: 0.18.2
9
9
  -- The `@version` marker above is read by the schema-version-mismatch banner
10
10
  -- (see /api/v1/schema-version). Bump it whenever schema.sql OR rpcs.sql
11
11
  -- changes in a way that requires `cerefox server deploy` to be re-run —
@@ -156,7 +156,7 @@ This handles intermittent OpenAI API errors (500s) that would otherwise cause se
156
156
  > | Key | Default | Meaning |
157
157
  > |---|---|---|
158
158
  > | `min_search_score` | **0.5** on an OpenAI store, **0.6** on a local-embedder (nomic) store, derived from the store's own embeddings | A result with no confident keyword match must be at least this similar to the query. Nomic scores unrelated text around 0.4 to 0.55, so 0.5 would let it through. |
159
- > | `min_term_coverage` | **0.67** (was 0.5) | When no document contains every query word, a keyword match counts as confident only if it covers this share of the words: two of three, not one of two. |
159
+ > | `min_term_coverage` | **0.66** (0.5 before v1.17.0; 0.67 in v1.17.0 and v1.17.1, which made a three-word query need all three) | When no document contains every query word, a keyword match counts as confident only if it covers this share of the words: two of three, not one of two. |
160
160
  > | `search_alpha` | **0.7** | Weight of meaning against keywords. Both sides are on a 0 to 1 scale since v1.17.0, so the weight now means what it says. |
161
161
  >
162
162
  > Tune only with evidence: `bun scripts/search_benchmark.ts` measures any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerefox/memory",
3
- "version": "1.17.0",
3
+ "version": "1.17.2",
4
4
  "description": "Cerefox \u2014 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",