@cerefox/memory 1.1.0-beta.1 → 1.1.0-beta.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.
@@ -7438,7 +7438,7 @@ var exports_meta = {};
7438
7438
  __export(exports_meta, {
7439
7439
  PKG_VERSION: () => PKG_VERSION
7440
7440
  });
7441
- var PKG_VERSION = "1.1.0-beta.1";
7441
+ var PKG_VERSION = "1.1.0-beta.2";
7442
7442
  var init_meta = () => {};
7443
7443
 
7444
7444
  // ../../node_modules/.bun/tslib@2.8.1/node_modules/tslib/tslib.js
@@ -23362,9 +23362,12 @@ function loadEnv(opts = {}) {
23362
23362
  } catch {
23363
23363
  return { path: envPath, vars: 0 };
23364
23364
  }
23365
+ const configDirNamed = (env2.CEREFOX_CONFIG_DIR ?? "").trim() !== "";
23365
23366
  let count = 0;
23366
23367
  for (const [k, v] of Object.entries(parseDotenv(content))) {
23367
- if (env2[k] === undefined) {
23368
+ if (k === "CEREFOX_CONFIG_DIR")
23369
+ continue;
23370
+ if (env2[k] === undefined || configDirNamed) {
23368
23371
  env2[k] = v;
23369
23372
  count++;
23370
23373
  }
@@ -55186,6 +55189,36 @@ var init_audit_log = __esm(() => {
55186
55189
  };
55187
55190
  });
55188
55191
 
55192
+ // ../../_shared/mcp-tools/feature-flags.ts
55193
+ async function relationsEnabled(supabase) {
55194
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS)
55195
+ return cached.value;
55196
+ try {
55197
+ const { data, error: error2 } = await supabase.rpc("cerefox_get_config", {
55198
+ p_key: "relations_enabled"
55199
+ });
55200
+ if (error2)
55201
+ throw new Error(error2.message);
55202
+ const value = String(data ?? "").trim().toLowerCase() === "true";
55203
+ cached = { value, at: Date.now() };
55204
+ return value;
55205
+ } catch {
55206
+ return false;
55207
+ }
55208
+ }
55209
+ function disabledToolMessage(name) {
55210
+ return `${name} is part of the document-relations feature, which is off by default. ` + `Enable it with: cerefox config set relations_enabled true ` + `(deployment-wide; every access path picks it up).`;
55211
+ }
55212
+ var RELATION_TOOL_NAMES, CACHE_TTL_MS = 60000, cached = null;
55213
+ var init_feature_flags = __esm(() => {
55214
+ RELATION_TOOL_NAMES = new Set([
55215
+ "cerefox_set_relation",
55216
+ "cerefox_delete_relation",
55217
+ "cerefox_get_relations",
55218
+ "cerefox_get_neighbors"
55219
+ ]);
55220
+ });
55221
+
55189
55222
  // ../../_shared/mcp-tools/types.ts
55190
55223
  var McpInvalidParams;
55191
55224
  var init_types3 = __esm(() => {
@@ -56281,9 +56314,21 @@ var init_set_document_projects = __esm(() => {
56281
56314
  });
56282
56315
 
56283
56316
  // ../../_shared/mcp-tools/index.ts
56317
+ async function listEnabledTools(supabase) {
56318
+ const relations = await relationsEnabled(supabase);
56319
+ return ALL_TOOLS.filter((t) => relations || !RELATION_TOOL_NAMES.has(t.name));
56320
+ }
56321
+ async function assertToolEnabled(supabase, name) {
56322
+ if (!RELATION_TOOL_NAMES.has(name))
56323
+ return;
56324
+ if (await relationsEnabled(supabase))
56325
+ return;
56326
+ throw new McpInvalidParams(disabledToolMessage(name));
56327
+ }
56284
56328
  var ALL_TOOLS, TOOLS_BY_NAME;
56285
56329
  var init_mcp_tools = __esm(() => {
56286
56330
  init_audit_log();
56331
+ init_feature_flags();
56287
56332
  init_relations();
56288
56333
  init_get_document();
56289
56334
  init_get_help();
@@ -56295,6 +56340,7 @@ var init_mcp_tools = __esm(() => {
56295
56340
  init_search();
56296
56341
  init_set_document_projects();
56297
56342
  init_types3();
56343
+ init_types3();
56298
56344
  ALL_TOOLS = [
56299
56345
  searchTool,
56300
56346
  ingestTool,
@@ -56500,7 +56546,7 @@ __export(exports_util, {
56500
56546
  cleanRegex: () => cleanRegex,
56501
56547
  cleanEnum: () => cleanEnum,
56502
56548
  captureStackTrace: () => captureStackTrace,
56503
- cached: () => cached,
56549
+ cached: () => cached2,
56504
56550
  assignProp: () => assignProp,
56505
56551
  assertNotEqual: () => assertNotEqual,
56506
56552
  assertNever: () => assertNever,
@@ -56537,7 +56583,7 @@ function jsonStringifyReplacer(_, value) {
56537
56583
  return value.toString();
56538
56584
  return value;
56539
56585
  }
56540
- function cached(getter) {
56586
+ function cached2(getter) {
56541
56587
  const set = false;
56542
56588
  return {
56543
56589
  get value() {
@@ -56949,7 +56995,7 @@ var captureStackTrace, allowsEval, getParsedType2 = (data) => {
56949
56995
  }, propertyKeyTypes, primitiveTypes, NUMBER_FORMAT_RANGES, BIGINT_FORMAT_RANGES;
56950
56996
  var init_util2 = __esm(() => {
56951
56997
  captureStackTrace = Error.captureStackTrace ? Error.captureStackTrace : (..._args) => {};
56952
- allowsEval = cached(() => {
56998
+ allowsEval = cached2(() => {
56953
56999
  if (typeof navigator !== "undefined" && navigator?.userAgent?.includes("Cloudflare")) {
56954
57000
  return false;
56955
57001
  }
@@ -58254,7 +58300,7 @@ var init_schemas = __esm(() => {
58254
58300
  });
58255
58301
  $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
58256
58302
  $ZodType.init(inst, def);
58257
- const _normalized = cached(() => {
58303
+ const _normalized = cached2(() => {
58258
58304
  const keys = Object.keys(def.shape);
58259
58305
  for (const k of keys) {
58260
58306
  if (!(def.shape[k] instanceof $ZodType)) {
@@ -58474,7 +58520,7 @@ var init_schemas = __esm(() => {
58474
58520
  }
58475
58521
  return propValues;
58476
58522
  });
58477
- const disc = cached(() => {
58523
+ const disc = cached2(() => {
58478
58524
  const opts = def.options;
58479
58525
  const map = new Map;
58480
58526
  for (const o of opts) {
@@ -69455,7 +69501,7 @@ function buildServer() {
69455
69501
  };
69456
69502
  const server = new Server({ name: SERVER_NAME, version: PKG_VERSION }, { capabilities: { tools: {} } });
69457
69503
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
69458
- tools: ALL_TOOLS.map((t) => ({
69504
+ tools: (await listEnabledTools(supabase)).map((t) => ({
69459
69505
  name: t.name,
69460
69506
  description: t.description,
69461
69507
  inputSchema: t.inputSchema
@@ -69468,6 +69514,7 @@ function buildServer() {
69468
69514
  throw new McpInvalidParams(`Unknown tool: ${name}`);
69469
69515
  }
69470
69516
  try {
69517
+ await assertToolEnabled(supabase, name);
69471
69518
  const text = await tool.handler(supabase, args, ctx);
69472
69519
  return { content: [{ type: "text", text }] };
69473
69520
  } catch (err) {
@@ -69574,6 +69621,7 @@ async function fetchAllPages(makeQuery, batchSize = 200) {
69574
69621
 
69575
69622
  // src/cli/commands/backup.ts
69576
69623
  init_client();
69624
+ init_meta();
69577
69625
  function expandHome(path) {
69578
69626
  if (path === "~")
69579
69627
  return homedir2();
@@ -69587,18 +69635,67 @@ function utcStamp() {
69587
69635
  return d.getUTCFullYear().toString() + pad(d.getUTCMonth() + 1) + pad(d.getUTCDate()) + "T" + pad(d.getUTCHours()) + pad(d.getUTCMinutes()) + pad(d.getUTCSeconds()) + "Z";
69588
69636
  }
69589
69637
  async function action(options) {
69590
- const outDir = resolve(expandHome(options.outputDir ?? process.env.CEREFOX_BACKUP_DIR ?? "~/.cerefox/backups"));
69638
+ const configuredDir = options.outputDir ?? process.env.CEREFOX_BACKUP_DIR;
69639
+ const outDir = resolve(expandHome(configuredDir ?? "~/.cerefox/backups"));
69640
+ if (configuredDir !== undefined && !configuredDir.startsWith("/") && !configuredDir.startsWith("~")) {
69641
+ println(c.yellow("⚠ ") + `Backup directory "${configuredDir}" is relative — it resolves against the ` + "current working directory, so backups land in different places depending " + "on where you run this from.");
69642
+ println(c.dim(` Writing to: ${outDir}`));
69643
+ println(c.dim(" Set an absolute path (e.g. ~/.cerefox/backups) to keep them together."));
69644
+ }
69591
69645
  if (!existsSync2(outDir))
69592
69646
  mkdirSync(outDir, { recursive: true });
69593
69647
  const stamp = utcStamp();
69594
69648
  const filename = `cerefox-${stamp}${options.label ? "-" + options.label : ""}.json`;
69595
69649
  const dest = join2(outDir, filename);
69596
69650
  const client = getClient();
69651
+ let schemaVersion = "unknown";
69652
+ try {
69653
+ schemaVersion = await client.rpc("cerefox_schema_version", {}) ?? "unknown";
69654
+ } catch {}
69655
+ const includeTrash = options.trash !== false;
69656
+ const BASE_COLUMNS = "id, title, content_hash, source, metadata, total_chars, chunk_count, " + "review_status, created_at, updated_at, deleted_at";
69657
+ const fetchDocs = (columns) => fetchAllPages((from, to) => {
69658
+ const q = client.raw.from("cerefox_documents").select(columns);
69659
+ return (includeTrash ? q : q.is("deleted_at", null)).order("created_at", { ascending: true }).order("id", { ascending: true }).range(from, to);
69660
+ });
69597
69661
  let docs;
69662
+ let lifecycleCaptured = true;
69663
+ try {
69664
+ docs = await fetchDocs(`${BASE_COLUMNS}, lifecycle_status`);
69665
+ } catch (err) {
69666
+ const message = err instanceof Error ? err.message : String(err);
69667
+ if (!/lifecycle_status/.test(message)) {
69668
+ throw systemError(`Document fetch failed: ${message}`);
69669
+ }
69670
+ lifecycleCaptured = false;
69671
+ try {
69672
+ docs = await fetchDocs(BASE_COLUMNS);
69673
+ } catch (retryErr) {
69674
+ throw systemError(`Document fetch failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
69675
+ }
69676
+ println(c.dim(" (server predates lifecycle_status — captured without it)"));
69677
+ }
69678
+ const trashedCount = docs.filter((d) => d.deleted_at != null).length;
69679
+ let projects = [];
69680
+ let memberships = [];
69598
69681
  try {
69599
- docs = await fetchAllPages((from, to) => client.raw.from("cerefox_documents").select("id, title, content_hash, source, metadata, total_chars, chunk_count, " + "review_status, created_at, updated_at, deleted_at").is("deleted_at", null).order("created_at", { ascending: true }).order("id", { ascending: true }).range(from, to));
69682
+ projects = await fetchAllPages((from, to) => client.raw.from("cerefox_projects").select("id, name, description, created_at, updated_at").order("id", { ascending: true }).range(from, to));
69683
+ memberships = await fetchAllPages((from, to) => client.raw.from("cerefox_document_projects").select("document_id, project_id").order("document_id", { ascending: true }).order("project_id", { ascending: true }).range(from, to));
69600
69684
  } catch (err) {
69601
- throw systemError(`Document fetch failed: ${err instanceof Error ? err.message : String(err)}`);
69685
+ throw systemError(`Project/membership fetch failed: ${err instanceof Error ? err.message : String(err)}`);
69686
+ }
69687
+ let relations = [];
69688
+ try {
69689
+ relations = await fetchAllPages((from, to) => client.raw.from("cerefox_document_relations").select("source_id, target_id, rel_type, metadata, author, author_type, created_at").order("source_id", { ascending: true }).order("target_id", { ascending: true }).order("rel_type", { ascending: true }).range(from, to));
69690
+ } catch {}
69691
+ {
69692
+ const captured = new Set(docs.map((d) => d.id));
69693
+ const before = memberships.length;
69694
+ memberships = memberships.filter((m) => captured.has(m.document_id));
69695
+ const dropped = before - memberships.length;
69696
+ if (dropped > 0) {
69697
+ println(c.dim(` (skipped ${dropped} membership(s) belonging to trashed documents)`));
69698
+ }
69602
69699
  }
69603
69700
  let chunkTotal = 0;
69604
69701
  const enriched = [];
@@ -69622,22 +69719,38 @@ async function action(options) {
69622
69719
  `);
69623
69720
  const payload = {
69624
69721
  created_at: new Date().toISOString(),
69625
- cerefox_version: process.env.npm_package_version ?? "unknown",
69722
+ cerefox_version: PKG_VERSION,
69723
+ backup_format: 4,
69724
+ schema_version: schemaVersion,
69626
69725
  document_count: docs.length,
69726
+ trashed_count: trashedCount,
69727
+ includes_trash: includeTrash,
69728
+ includes_lifecycle_status: lifecycleCaptured,
69627
69729
  chunk_count: chunkTotal,
69730
+ project_count: projects.length,
69731
+ membership_count: memberships.length,
69732
+ relation_count: relations.length,
69733
+ projects,
69734
+ memberships,
69735
+ relations,
69628
69736
  documents: enriched
69629
69737
  };
69630
69738
  writeFileSync(dest, JSON.stringify(payload, null, 2), "utf8");
69631
69739
  println("");
69632
69740
  println(c.green("✓ ") + `Backup written: ${dest}`);
69633
- println(c.dim(` documents: ${docs.length} · chunks: ${chunkTotal}`));
69741
+ println(c.dim(` documents: ${docs.length} · chunks: ${chunkTotal} · ` + `projects: ${projects.length} · memberships: ${memberships.length}` + (relations.length > 0 ? ` · relations: ${relations.length}` : "")));
69742
+ if (!includeTrash) {
69743
+ println(c.dim(" trashed documents: excluded (--no-trash)"));
69744
+ } else if (trashedCount > 0) {
69745
+ println(c.dim(` of which trashed: ${trashedCount} (restored as trash, not resurrected)`));
69746
+ }
69634
69747
  if (options.git) {
69635
69748
  println(c.yellow("⚠ ") + "--git commit is not implemented; the snapshot was written without a git checkpoint.");
69636
69749
  println(c.dim(" Commit the backup directory yourself if you want it version-controlled."));
69637
69750
  }
69638
69751
  }
69639
69752
  function registerBackup(program2) {
69640
- program2.command("backup").description("Write a JSON snapshot of the knowledge base.").option("-o, --output-dir <dir>", "Snapshot output directory (default: CEREFOX_BACKUP_DIR or ~/.cerefox/backups).").option("-l, --label <label>", "Optional suffix added to the filename.").option("--include-versions", "Include archived versions in the snapshot. (v0.5: ignored — current chunks only.)").option("--git", "Commit the snapshot to the output dir as a git checkpoint. (v0.5: ignored.)").action(action);
69753
+ program2.command("backup").description("Write a JSON snapshot of the knowledge base.").option("-o, --output-dir <dir>", "Snapshot output directory (default: CEREFOX_BACKUP_DIR or ~/.cerefox/backups).").option("-l, --label <label>", "Optional suffix added to the filename.").option("--include-versions", "Include archived versions in the snapshot. (v0.5: ignored — current chunks only.)").option("--git", "Commit the snapshot to the output dir as a git checkpoint. (v0.5: ignored.)").option("--no-trash", "Exclude soft-deleted documents. Default: they are captured and restored as trash.").action(action);
69641
69754
  }
69642
69755
 
69643
69756
  // src/cli/commands/completion.ts
@@ -69957,6 +70070,10 @@ var CONFIG_KEYS = [
69957
70070
  key: "min_term_coverage",
69958
70071
  description: "0–1 — fraction of a query's meaningful terms a keyword OR-fallback match must cover to count as confident. Default 0.5."
69959
70072
  },
70073
+ {
70074
+ key: "relations_enabled",
70075
+ description: "'true'/'false' — expose the document-relation tools to agents. Off by default; the feature is dormant until enabled (iteration 29)."
70076
+ },
69960
70077
  {
69961
70078
  key: "search_alpha",
69962
70079
  description: "0–1 — hybrid fusion weight: 1 = pure semantic, 0 = pure keyword. Default 0.7."
@@ -75502,8 +75619,8 @@ import { homedir as homedir6 } from "node:os";
75502
75619
  import { join as join9 } from "node:path";
75503
75620
 
75504
75621
  // ../../_shared/ef-meta/index.ts
75505
- var EF_VERSION = "1.1.0-beta.1";
75506
- var EF_LAST_CHANGED = "1.1.0-beta.1";
75622
+ var EF_VERSION = "1.1.0-beta.2";
75623
+ var EF_LAST_CHANGED = "1.1.0-beta.2";
75507
75624
 
75508
75625
  // src/cli/util/checks.ts
75509
75626
  init_config();
@@ -75852,7 +75969,7 @@ async function checkContentFormat() {
75852
75969
  name: CONTENT_FORMAT_CHECK_NAME,
75853
75970
  status: "skipped",
75854
75971
  detail: `${legacy} of ${total} document(s) use the legacy reconstruction format (format 1).`,
75855
- hint: "They auto-convert on next edit; run `cerefox server reindex` to convert all now. What this means: `cerefox guides show content-format`."
75972
+ hint: "Harmless — they auto-convert on next edit. To convert them all now: `cerefox server migrate-format` (re-embeds, so try `--dry-run` first). To read what chunk formats are: `cerefox guides show content-format`."
75856
75973
  };
75857
75974
  } catch (err) {
75858
75975
  return {
@@ -77997,6 +78114,114 @@ function registerReindex(program2) {
77997
78114
  program2.command("reindex").description("Re-embed existing document chunks (v0.7+).").option("--all", "Reindex every chunk regardless of embedder.").option("--batch <n>", "Chunks per OpenAI batch call. Capped at 96 internally.", "32").option("--dry-run", "Show counts without re-embedding.").option("-i, --document-id <uuid>", "Limit reindex to a single document.").action(action27);
77998
78115
  }
77999
78116
 
78117
+ // src/cli/commands/migrate-format.ts
78118
+ init_cli_core();
78119
+ init_config();
78120
+ init_client();
78121
+ var CURRENT_FORMAT = 2;
78122
+ async function action28(options) {
78123
+ const settings = loadSettings();
78124
+ const client = getClient();
78125
+ const supabase = client.raw;
78126
+ let legacyChunkRows;
78127
+ try {
78128
+ legacyChunkRows = await fetchAllPages((from, to) => {
78129
+ let q = supabase.from("cerefox_chunks").select("document_id").is("version_id", null).lt("content_format", CURRENT_FORMAT);
78130
+ if (options.documentId)
78131
+ q = q.eq("document_id", options.documentId);
78132
+ return q.order("document_id", { ascending: true }).range(from, to);
78133
+ }, 1000);
78134
+ } catch (err) {
78135
+ throw systemError(`Could not list legacy chunks: ${err instanceof Error ? err.message : String(err)}`);
78136
+ }
78137
+ const docIds = [...new Set(legacyChunkRows.map((r) => r.document_id))];
78138
+ const limit = options.limit ? parsePositiveInt(options.limit, "--limit", docIds.length) : docIds.length;
78139
+ const targets = docIds.slice(0, limit);
78140
+ if (targets.length === 0) {
78141
+ println(c.green("✓ Nothing to migrate — every document already uses the current format."));
78142
+ return;
78143
+ }
78144
+ println(c.bold(`${docIds.length} document(s) on the legacy format` + (targets.length < docIds.length ? `; converting ${targets.length} (--limit)` : "")));
78145
+ if (options.dryRun) {
78146
+ println(c.yellow("⚠ --dry-run: nothing was written."));
78147
+ println(c.dim(" Each document would be re-chunked and RE-EMBEDDED (embedding spend)."));
78148
+ return;
78149
+ }
78150
+ println(c.dim("Each document is re-chunked and re-embedded — this costs embedding spend."));
78151
+ println("");
78152
+ const author = resolveAuthor(options.author);
78153
+ const authorType = resolveAuthorType(undefined);
78154
+ const pipeline2 = new IngestionPipeline({
78155
+ supabase,
78156
+ openAiApiKey: settings.openaiApiKey
78157
+ });
78158
+ let converted = 0;
78159
+ let skipped = 0;
78160
+ const duplicates = [];
78161
+ const failures = [];
78162
+ for (let i = 0;i < targets.length; i++) {
78163
+ const id = targets[i];
78164
+ if (process.stdout.isTTY) {
78165
+ process.stderr.write(`\r Converting ${i + 1}/${targets.length}…`);
78166
+ }
78167
+ let doc2 = null;
78168
+ try {
78169
+ const rows = await client.rpc("cerefox_get_document", { p_document_id: id, p_version_id: null });
78170
+ doc2 = rows?.[0] ?? null;
78171
+ } catch (err) {
78172
+ failures.push({ document: id, reason: `read: ${err instanceof Error ? err.message : String(err)}` });
78173
+ continue;
78174
+ }
78175
+ if (!doc2) {
78176
+ failures.push({ document: id, reason: "read: document not found" });
78177
+ continue;
78178
+ }
78179
+ try {
78180
+ await pipeline2.ingestText({
78181
+ text: doc2.full_content,
78182
+ title: doc2.doc_title,
78183
+ documentId: id,
78184
+ source: "migrate-format",
78185
+ author,
78186
+ authorType,
78187
+ expectedContentHash: doc2.content_hash
78188
+ });
78189
+ converted++;
78190
+ } catch (err) {
78191
+ const message = err instanceof Error ? err.message : String(err);
78192
+ if (/conflict/i.test(message)) {
78193
+ skipped++;
78194
+ } else if (/identical content already exists/i.test(message)) {
78195
+ duplicates.push({ document: `${doc2.doc_title} (${id})`, reason: message });
78196
+ } else {
78197
+ failures.push({ document: `${doc2.doc_title} (${id})`, reason: message });
78198
+ }
78199
+ }
78200
+ }
78201
+ if (process.stdout.isTTY)
78202
+ process.stderr.write(`
78203
+ `);
78204
+ println("");
78205
+ println(c.bold(`Converted ${converted} · skipped ${skipped} (changed mid-run) · failed ${failures.length}`));
78206
+ if (targets.length < docIds.length) {
78207
+ println(c.dim(` ${docIds.length - targets.length} document(s) still pending — re-run to continue.`));
78208
+ }
78209
+ if (duplicates.length > 0) {
78210
+ println("");
78211
+ println(c.yellow(`⚠ ${duplicates.length} document(s) could not be converted because their content is ` + "byte-identical to another document."));
78212
+ println(c.dim(" Re-ingesting them would collide with the content-hash dedup check. They keep working " + "on the legacy format; de-duplicate them if you want them converted."));
78213
+ printTable(duplicates.map((d) => ({ document: d.document })));
78214
+ }
78215
+ if (failures.length > 0) {
78216
+ println("");
78217
+ printTable(failures);
78218
+ throw systemError(`${failures.length} document(s) failed to convert.`);
78219
+ }
78220
+ }
78221
+ function registerMigrateFormat(program2) {
78222
+ program2.command("migrate-format").description("Convert legacy-format documents to the current chunk format (re-chunks + re-embeds).").option("--dry-run", "Report how many documents would be converted; write nothing.").option("-l, --limit <n>", "Convert at most N documents (re-run to continue).").option("--document-id <uuid>", "Convert a single document.").option("--author <name>", "Recorded in the audit log for each conversion.").action(action28);
78223
+ }
78224
+
78000
78225
  // src/cli/commands/restore.ts
78001
78226
  init_cli_core();
78002
78227
  init_client();
@@ -78024,7 +78249,7 @@ function resolveBackupFile(target) {
78024
78249
  }
78025
78250
  return join12(path, candidates[0].name);
78026
78251
  }
78027
- async function action28(target, options) {
78252
+ async function action29(target, options) {
78028
78253
  const file = resolveBackupFile(target);
78029
78254
  let payload;
78030
78255
  try {
@@ -78036,7 +78261,17 @@ async function action28(target, options) {
78036
78261
  throw userError(`Backup file is missing "documents" array: ${file}`);
78037
78262
  }
78038
78263
  println(c.bold(`Restoring from ${file}`));
78039
- println(c.dim(` cerefox_version: ${payload.cerefox_version ?? "?"} · ` + `documents in file: ${payload.documents.length} · chunks in file: ${payload.chunk_count ?? "?"}`));
78264
+ const hasMemberships = Array.isArray(payload.memberships);
78265
+ println(c.dim(` cerefox_version: ${payload.cerefox_version ?? "?"} · ` + `schema: ${payload.schema_version ?? "?"} · ` + `documents in file: ${payload.documents.length} · chunks in file: ${payload.chunk_count ?? "?"}`));
78266
+ if (hasMemberships) {
78267
+ println(c.dim(` projects: ${payload.projects?.length ?? 0} · memberships: ${payload.memberships?.length ?? 0}`));
78268
+ } else {
78269
+ warn("This backup predates project-membership capture (format 1) — documents " + "will be restored WITHOUT their project assignments.");
78270
+ }
78271
+ const trashedInFile = payload.documents.filter((d) => d.deleted_at != null).length;
78272
+ if (trashedInFile > 0) {
78273
+ println(c.dim(` trashed documents in file: ${trashedInFile} — restored as trash ` + "(still deleted; recover with `cerefox document restore`)"));
78274
+ }
78040
78275
  println("");
78041
78276
  const client = getClient();
78042
78277
  let restored = 0;
@@ -78044,7 +78279,7 @@ async function action28(target, options) {
78044
78279
  let errors4 = 0;
78045
78280
  const errorDetails = [];
78046
78281
  for (const doc2 of payload.documents) {
78047
- const { data: existing } = await client.raw.from("cerefox_documents").select("id").eq("content_hash", doc2.content_hash).maybeSingle();
78282
+ const { data: existing } = await client.raw.from("cerefox_documents").select("id").or(`id.eq.${doc2.id},content_hash.eq.${doc2.content_hash}`).limit(1).maybeSingle();
78048
78283
  if (existing) {
78049
78284
  skipped++;
78050
78285
  continue;
@@ -78071,8 +78306,70 @@ async function action28(target, options) {
78071
78306
  }
78072
78307
  restored++;
78073
78308
  }
78309
+ let projectsRestored = 0;
78310
+ let membershipsRestored = 0;
78311
+ let relationsRestored = 0;
78312
+ if (!options.dryRun && hasMemberships) {
78313
+ const projects = payload.projects ?? [];
78314
+ if (projects.length > 0) {
78315
+ const { error: projErr } = await client.raw.from("cerefox_projects").upsert(projects, { onConflict: "id", ignoreDuplicates: true });
78316
+ if (projErr) {
78317
+ errors4++;
78318
+ errorDetails.push({ title: "(projects)", error: projErr.message });
78319
+ } else {
78320
+ projectsRestored = projects.length;
78321
+ }
78322
+ }
78323
+ const presentDocIds = new Set;
78324
+ {
78325
+ const ids = (payload.documents ?? []).map((d) => d.id);
78326
+ for (let i = 0;i < ids.length; i += 200) {
78327
+ const { data } = await client.raw.from("cerefox_documents").select("id").in("id", ids.slice(i, i + 200));
78328
+ for (const row of data ?? [])
78329
+ presentDocIds.add(row.id);
78330
+ }
78331
+ }
78332
+ const links = (payload.memberships ?? []).filter((m) => presentDocIds.has(m.document_id));
78333
+ for (let i = 0;i < links.length; i += 500) {
78334
+ const { error: linkErr } = await client.raw.from("cerefox_document_projects").upsert(links.slice(i, i + 500), {
78335
+ onConflict: "document_id,project_id",
78336
+ ignoreDuplicates: true
78337
+ });
78338
+ if (linkErr) {
78339
+ errors4++;
78340
+ errorDetails.push({ title: "(memberships)", error: linkErr.message });
78341
+ break;
78342
+ }
78343
+ membershipsRestored += links.slice(i, i + 500).length;
78344
+ }
78345
+ const relations = (payload.relations ?? []).filter((r) => presentDocIds.has(r.source_id) && presentDocIds.has(r.target_id));
78346
+ if (relations.length > 0) {
78347
+ for (let i = 0;i < relations.length; i += 500) {
78348
+ const { error: relErr } = await client.raw.from("cerefox_document_relations").upsert(relations.slice(i, i + 500), {
78349
+ onConflict: "source_id,target_id,rel_type",
78350
+ ignoreDuplicates: true
78351
+ });
78352
+ if (relErr) {
78353
+ errorDetails.push({ title: "(relations)", error: relErr.message });
78354
+ errors4++;
78355
+ break;
78356
+ }
78357
+ relationsRestored += relations.slice(i, i + 500).length;
78358
+ }
78359
+ const dropped = (payload.relations?.length ?? 0) - relations.length;
78360
+ if (dropped > 0) {
78361
+ warn(`${dropped} relation(s) skipped — one or both documents were not restored.`);
78362
+ }
78363
+ }
78364
+ }
78074
78365
  println("");
78075
78366
  println((options.dryRun ? c.yellow("(dry-run) ") : "") + c.bold(`Summary: ${restored} restored · ${skipped} skipped · ${errors4} errors`));
78367
+ if (hasMemberships && !options.dryRun) {
78368
+ println(c.dim(` projects: ${projectsRestored} · memberships: ${membershipsRestored}` + (relationsRestored > 0 ? ` · relations: ${relationsRestored}` : "")));
78369
+ }
78370
+ if (trashedInFile > 0) {
78371
+ println(c.dim(` ${trashedInFile} of those are trashed and stay trashed.`));
78372
+ }
78076
78373
  if (errors4 > 0) {
78077
78374
  println("");
78078
78375
  printTable(errorDetails);
@@ -78080,7 +78377,7 @@ async function action28(target, options) {
78080
78377
  }
78081
78378
  }
78082
78379
  function registerRestore(program2) {
78083
- program2.command("restore").description("Restore a JSON-snapshot backup into the knowledge base.").argument("<snapshot>", "Backup file (or directory; most recent is picked) produced by `cerefox backup`.").option("--dry-run", "Print what would be restored without writing.").option("-p, --project-name <name>", "Reserved for future use; currently ignored (project memberships ride along with each doc's metadata).").action(action28);
78380
+ program2.command("restore").description("Restore a JSON-snapshot backup into the knowledge base.").argument("<snapshot>", "Backup file (or directory; most recent is picked) produced by `cerefox backup`.").option("--dry-run", "Print what would be restored without writing.").option("-p, --project-name <name>", "Reserved for future use; currently ignored. Project memberships are restored from the backup itself (format 2+).").action(action29);
78084
78381
  }
78085
78382
 
78086
78383
  // src/cli/commands/search.ts
@@ -78105,7 +78402,7 @@ async function embedQuery(query) {
78105
78402
  }
78106
78403
 
78107
78404
  // src/cli/commands/search.ts
78108
- async function action29(query, options) {
78405
+ async function action30(query, options) {
78109
78406
  if (!query || query.trim() === "") {
78110
78407
  throw userError("Empty query.");
78111
78408
  }
@@ -78271,7 +78568,7 @@ async function action29(query, options) {
78271
78568
  }
78272
78569
  }
78273
78570
  function registerSearch(program2) {
78274
- program2.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: CEREFOX_MIN_SEARCH_SCORE; else 0.5, or 0.6 with the local embedder).").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: CEREFOX_MIN_TERM_COVERAGE; else the server default 0.5; needs schema ≥ 0.9.1).").option("--max-bytes <n>", "Response size budget in bytes (default: CEREFOX_MAX_RESPONSE_BYTES or 200000).").option("-r, --requestor <name>", "Agent / user name (recorded in usage log).").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(action29);
78571
+ program2.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: CEREFOX_MIN_SEARCH_SCORE; else 0.5, or 0.6 with the local embedder).").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: CEREFOX_MIN_TERM_COVERAGE; else the server default 0.5; needs schema ≥ 0.9.1).").option("--max-bytes <n>", "Response size budget in bytes (default: CEREFOX_MAX_RESPONSE_BYTES or 200000).").option("-r, --requestor <name>", "Agent / user name (recorded in usage log).").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(action30);
78275
78572
  }
78276
78573
 
78277
78574
  // src/cli/commands/self-update.ts
@@ -78318,7 +78615,7 @@ async function fetchLatestVersion() {
78318
78615
  }
78319
78616
  return body.version;
78320
78617
  }
78321
- async function action30(options) {
78618
+ async function action31(options) {
78322
78619
  let target;
78323
78620
  try {
78324
78621
  target = options.version ?? await fetchLatestVersion();
@@ -78371,7 +78668,7 @@ async function action30(options) {
78371
78668
  }
78372
78669
  function registerSelfUpdate(program2) {
78373
78670
  const desc = "Upgrade Cerefox in place. Alias: `cerefox upgrade`.";
78374
- const declaration = (cmd) => cmd.description(desc).option("--check", "Print current vs latest; do nothing.").option("--yes", "Non-interactive (skip confirmation).").option("--version <version>", "Pin a specific version (e.g. 0.5.1 or 0.6.0-rc.1).").action(action30);
78671
+ const declaration = (cmd) => cmd.description(desc).option("--check", "Print current vs latest; do nothing.").option("--yes", "Non-interactive (skip confirmation).").option("--version <version>", "Pin a specific version (e.g. 0.5.1 or 0.6.0-rc.1).").action(action31);
78375
78672
  declaration(program2.command("self-update"));
78376
78673
  declaration(program2.command("upgrade"));
78377
78674
  }
@@ -78390,7 +78687,7 @@ function symbol2(status) {
78390
78687
  return cErr.dim("ℹ");
78391
78688
  }
78392
78689
  }
78393
- async function action31(options) {
78690
+ async function action32(options) {
78394
78691
  const useSpinner = !options.json && process.stderr.isTTY;
78395
78692
  const spinner = useSpinner ? ora({ text: "Starting checks…", spinner: "dots", stream: process.stderr }).start() : null;
78396
78693
  const results = await runFastChecks({
@@ -78411,7 +78708,7 @@ async function action31(options) {
78411
78708
  }
78412
78709
  }
78413
78710
  function registerStatus(program2) {
78414
- program2.command("status").description("Quick sanity check (fast subset of `cerefox doctor`).").option("--json", "Emit machine-readable JSON.").action(action31);
78711
+ program2.command("status").description("Quick sanity check (fast subset of `cerefox doctor`).").option("--json", "Emit machine-readable JSON.").action(action32);
78415
78712
  }
78416
78713
 
78417
78714
  // src/cli/commands/token.ts
@@ -78444,10 +78741,10 @@ function upsertEnvVar(path, key, value, opts = {}) {
78444
78741
  }
78445
78742
  const re = new RegExp(`^(\\s*)${escapeRegExp(key)}=.*$`, "m");
78446
78743
  let next;
78447
- let action32;
78744
+ let action33;
78448
78745
  if (re.test(original)) {
78449
78746
  next = original.replace(re, `$1${line}`);
78450
- action32 = "updated";
78747
+ action33 = "updated";
78451
78748
  } else {
78452
78749
  const base = original.endsWith(`
78453
78750
  `) ? original : `${original}
@@ -78455,10 +78752,10 @@ function upsertEnvVar(path, key, value, opts = {}) {
78455
78752
  next = `${base}
78456
78753
  ${header}${line}
78457
78754
  `;
78458
- action32 = "added";
78755
+ action33 = "added";
78459
78756
  }
78460
78757
  writeFileSync5(path, next);
78461
- return { path, action: action32, backupPath };
78758
+ return { path, action: action33, backupPath };
78462
78759
  }
78463
78760
  function readEnvVar(path, key) {
78464
78761
  if (!existsSync14(path))
@@ -83707,7 +84004,13 @@ import {
83707
84004
  } from "node:fs";
83708
84005
  import { homedir as homedir9 } from "node:os";
83709
84006
  import { join as join18 } from "node:path";
83710
- var STATE_DIR = join18(homedir9(), ".cerefox");
84007
+ function resolveStateDir(override = process.env.CEREFOX_CONFIG_DIR, home = homedir9()) {
84008
+ override = (override ?? "").trim();
84009
+ if (!override)
84010
+ return join18(home, ".cerefox");
84011
+ return override === "~" || override.startsWith("~/") ? join18(home, override.slice(2)) : override;
84012
+ }
84013
+ var STATE_DIR = resolveStateDir();
83711
84014
  var PID_FILE = join18(STATE_DIR, "web.pid");
83712
84015
  var LOG_FILE = join18(STATE_DIR, "web.log");
83713
84016
  var daemonPaths = { stateDir: STATE_DIR, pidFile: PID_FILE, logFile: LOG_FILE };
@@ -84075,6 +84378,7 @@ Learn more:
84075
84378
  const server = program2.command("server").description("Server side: deploy, reindex.");
84076
84379
  moveInto(server, registerDeployServer, "deploy");
84077
84380
  moveInto(server, registerReindex, "reindex");
84381
+ registerMigrateFormat(server);
84078
84382
  const guides = program2.command("guides").description("Bundled docs: list, open, show, ingest (into the KB).");
84079
84383
  registerGuides(guides);
84080
84384
  moveInto(guides, registerSyncSelfDocs, "ingest");