@cerefox/memory 1.0.5 → 1.1.0-beta.1

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.
@@ -15,8 +15,8 @@
15
15
  href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap"
16
16
  />
17
17
  <title>Cerefox</title>
18
- <script type="module" crossorigin src="/app/assets/index-Csj-6UHY.js"></script>
19
- <link rel="stylesheet" crossorigin href="/app/assets/index-Asx5wD7g.css">
18
+ <script type="module" crossorigin src="/app/assets/index-BMFGsK0D.js"></script>
19
+ <link rel="stylesheet" crossorigin href="/app/assets/index-C1JXZA9m.css">
20
20
  </head>
21
21
  <body>
22
22
  <div id="root"></div>
@@ -18,7 +18,7 @@
18
18
  * doesn't touch `supabase/functions/` leaves it alone).
19
19
  */
20
20
 
21
- export const EF_VERSION = "1.0.5";
21
+ export const EF_VERSION = "1.1.0-beta.1";
22
22
 
23
23
  /**
24
24
  * The most recent version whose EF-side SOURCE actually changed (#127).
@@ -28,7 +28,7 @@ export const EF_VERSION = "1.0.5";
28
28
  * `cut_release.ts` ONLY when EF source changed since the last tag; doctor
29
29
  * uses it to stay silent on label-only drift.
30
30
  */
31
- export const EF_LAST_CHANGED = "1.0.4";
31
+ export const EF_LAST_CHANGED = "1.1.0-beta.1";
32
32
 
33
33
  /**
34
34
  * The 8 peer EFs the cerefox-mcp aggregator probes (excludes cerefox-mcp
@@ -44,6 +44,77 @@ export const DEFAULT_MIN_SEARCH_SCORE = 0.5;
44
44
  */
45
45
  export const DEFAULT_MIN_SEARCH_SCORE_LOCAL = 0.6;
46
46
 
47
+ /**
48
+ * Read an env var in any of Cerefox's three runtimes.
49
+ *
50
+ * Node/Bun expose `process.env` (populated from the user's `.env` by
51
+ * `_shared/config`). Supabase Edge Functions run Deno, where `process` may be
52
+ * absent but **Function secrets are readable via `Deno.env`** — so reading
53
+ * both means a secret set on the project configures the remote MCP / EF path
54
+ * the same way `.env` configures the local one. (Before this, the retrieval
55
+ * tunables silently fell back to built-in defaults on the remote path.)
56
+ */
57
+ function readEnv(name: string): string | undefined {
58
+ const g = globalThis as {
59
+ process?: { env?: Record<string, string | undefined> };
60
+ Deno?: { env?: { get(k: string): string | undefined } };
61
+ };
62
+ const fromProcess = g.process?.env?.[name];
63
+ if (fromProcess !== undefined && fromProcess !== "") return fromProcess;
64
+ try {
65
+ const fromDeno = g.Deno?.env?.get(name);
66
+ return fromDeno === "" ? undefined : fromDeno;
67
+ } catch {
68
+ // Deno without --allow-env: treat as unset.
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ /** Parse a 0–1 env value; undefined when unset or out of range. */
74
+ function readUnitInterval(name: string): number | undefined {
75
+ const raw = readEnv(name);
76
+ if (raw === undefined) return undefined;
77
+ const n = Number.parseFloat(raw);
78
+ return Number.isNaN(n) || n < 0 || n > 1 ? undefined : n;
79
+ }
80
+
81
+ /**
82
+ * Default hybrid fusion weight: 1.0 = pure semantic, 0.0 = pure keyword.
83
+ * Overridable via `CEREFOX_SEARCH_ALPHA` (parity with the other retrieval
84
+ * tunables; previously alpha was per-call only).
85
+ */
86
+ export const DEFAULT_SEARCH_ALPHA = 0.7;
87
+
88
+ export function getSearchAlpha(): number {
89
+ return readUnitInterval("CEREFOX_SEARCH_ALPHA") ?? DEFAULT_SEARCH_ALPHA;
90
+ }
91
+
92
+ /**
93
+ * The retrieval tunables a client should actually SEND to the RPCs (#133).
94
+ *
95
+ * Returns undefined when the operator has expressed no preference, so the
96
+ * parameter is omitted and the server resolves it: `cerefox_config` first,
97
+ * then the built-in default. That is what lets one `cerefox config set` govern
98
+ * every access path. When a value IS configured here it wins, preserving the
99
+ * chain: per-call argument > client env > deployment config > built-in.
100
+ *
101
+ * `min_search_score` has one subtlety: the local embedder needs a higher floor
102
+ * (nomic scores unrelated text ~0.4–0.55), so an explicitly local embedder
103
+ * counts as "configured" even when CEREFOX_MIN_SEARCH_SCORE is unset —
104
+ * otherwise omitting the parameter would silently apply the OpenAI-calibrated
105
+ * default to a local deployment.
106
+ */
107
+ export function getConfiguredMinSearchScore(): number | undefined {
108
+ const explicit = readUnitInterval("CEREFOX_MIN_SEARCH_SCORE");
109
+ if (explicit !== undefined) return explicit;
110
+ if (readEnv("CEREFOX_EMBEDDER") === "local") return DEFAULT_MIN_SEARCH_SCORE_LOCAL;
111
+ return undefined;
112
+ }
113
+
114
+ export function getConfiguredSearchAlpha(): number | undefined {
115
+ return readUnitInterval("CEREFOX_SEARCH_ALPHA");
116
+ }
117
+
47
118
  /**
48
119
  * Resolve the minimum cosine-similarity floor for hybrid/semantic search
49
120
  * (vector-only matches below this are dropped; FTS matches always pass).
@@ -56,16 +127,11 @@ export const DEFAULT_MIN_SEARCH_SCORE_LOCAL = 0.6;
56
127
  * EF path doesn't use the host `.env` anyway).
57
128
  */
58
129
  export function getMinSearchScore(): number {
59
- const raw = (globalThis as { process?: { env?: Record<string, string | undefined> } })
60
- .process?.env?.CEREFOX_MIN_SEARCH_SCORE;
61
130
  const fallback =
62
- (globalThis as { process?: { env?: Record<string, string | undefined> } })
63
- .process?.env?.CEREFOX_EMBEDDER === "local"
131
+ readEnv("CEREFOX_EMBEDDER") === "local"
64
132
  ? DEFAULT_MIN_SEARCH_SCORE_LOCAL
65
133
  : DEFAULT_MIN_SEARCH_SCORE;
66
- if (raw === undefined || raw === "") return fallback;
67
- const n = Number.parseFloat(raw);
68
- return Number.isNaN(n) || n < 0 || n > 1 ? fallback : n;
134
+ return readUnitInterval("CEREFOX_MIN_SEARCH_SCORE") ?? fallback;
69
135
  }
70
136
 
71
137
  /**
@@ -76,11 +142,7 @@ export function getMinSearchScore(): number {
76
142
  * (an unknown named argument fails the PostgREST function match).
77
143
  */
78
144
  export function getMinTermCoverage(): number | undefined {
79
- const raw = (globalThis as { process?: { env?: Record<string, string | undefined> } })
80
- .process?.env?.CEREFOX_MIN_TERM_COVERAGE;
81
- if (raw === undefined || raw === "") return undefined;
82
- const n = Number.parseFloat(raw);
83
- return Number.isNaN(n) || n < 0 || n > 1 ? undefined : n;
145
+ return readUnitInterval("CEREFOX_MIN_TERM_COVERAGE");
84
146
  }
85
147
 
86
148
  export function applyByteBudget(
@@ -11,16 +11,16 @@
11
11
  * docs/specs/polish-and-distribution-design.md §10d.
12
12
  */
13
13
 
14
- export const HELP_FULL = "# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **10 MCP tools** (9 of them have CLI equivalents — `cerefox_get_help` is MCP-only). For the full guide, search Cerefox for \"How AI Agents Use Cerefox\" or call `cerefox_get_help` to retrieve this content over MCP.\n\n## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token) | `document_id` (required) |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., \"Claude Code\", \"archiver\"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user's `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` (\"decision-log\", \"research\", \"design-doc\") and `status` (\"active\", \"draft\").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don't write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don't construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you read (shown by `cerefox_get_document`, `cerefox_search`, and `cerefox_metadata_search`) when updating a document. If it's stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer's work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc's full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean \"add\" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.\n\n## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\",\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.\n\n## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc (note its hash) -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true,\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\n## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```\n\n## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.\n";
14
+ export const HELP_FULL = "# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **14 MCP tools** (13 of them have CLI equivalents — `cerefox_get_help` is MCP-only). For the full guide, search Cerefox for \"How AI Agents Use Cerefox\" or call `cerefox_get_help` to retrieve this content over MCP.\n\n## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token) | `document_id` (required) |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_set_relation` | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., \"Claude Code\", \"archiver\"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user's `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` (\"decision-log\", \"research\", \"design-doc\") and `status` (\"active\", \"draft\").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don't write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don't construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you read (shown by `cerefox_get_document`, `cerefox_search`, and `cerefox_metadata_search`) when updating a document. If it's stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer's work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Relations express how documents relate; lifecycle tells you if knowledge is still good.** Use `cerefox_set_relation` when one document supersedes, contradicts, references, or continues another. `supersedes` marks the target **superseded**; `contradicts` marks **both** stale; `related_to`/`duplicates`/`contradicts` are symmetric (both directions written). Any other type string is accepted without special behaviour. When a search result or `cerefox_get_relations` shows a neighbour marked `[superseded]` or `[stale]`, say so rather than presenting it as current.\n12. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc's full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean \"add\" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.\n\n## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\",\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.\n\n## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc (note its hash) -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true,\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\n## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```\n\n## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_set_relation` | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.\n";
15
15
 
16
16
  /** Sections keyed by their H2 heading text (lower-cased for matching). */
17
17
  export const HELP_SECTIONS: Record<string, string> = {
18
- "Tools": "## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token) | `document_id` (required) |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |",
19
- "Essential Rules": "## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., \"Claude Code\", \"archiver\"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user's `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` (\"decision-log\", \"research\", \"design-doc\") and `status` (\"active\", \"draft\").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don't write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don't construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you read (shown by `cerefox_get_document`, `cerefox_search`, and `cerefox_metadata_search`) when updating a document. If it's stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer's work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc's full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean \"add\" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.",
18
+ "Tools": "## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token) | `document_id` (required) |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_set_relation` | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |",
19
+ "Essential Rules": "## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., \"Claude Code\", \"archiver\"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user's `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` (\"decision-log\", \"research\", \"design-doc\") and `status` (\"active\", \"draft\").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don't write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don't construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you read (shown by `cerefox_get_document`, `cerefox_search`, and `cerefox_metadata_search`) when updating a document. If it's stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer's work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Relations express how documents relate; lifecycle tells you if knowledge is still good.** Use `cerefox_set_relation` when one document supersedes, contradicts, references, or continues another. `supersedes` marks the target **superseded**; `contradicts` marks **both** stale; `related_to`/`duplicates`/`contradicts` are symmetric (both directions written). Any other type string is accepted without special behaviour. When a search result or `cerefox_get_relations` shows a neighbour marked `[superseded]` or `[stale]`, say so rather than presenting it as current.\n12. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc's full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean \"add\" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.",
20
20
  "Update Workflow (ID-based -- preferred)": "## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\",\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.",
21
21
  "Update Workflow (title-based -- fallback)": "## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc (note its hash) -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true,\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```",
22
22
  "Catch-Up Workflow": "## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```",
23
- "CLI fallback (when MCP is unavailable)": "## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.",
23
+ "CLI fallback (when MCP is unavailable)": "## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_set_relation` | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.",
24
24
  };
25
25
 
26
26
  export const HELP_SECTION_HEADINGS: string[] = ["Tools", "Essential Rules", "Update Workflow (ID-based -- preferred)", "Update Workflow (title-based -- fallback)", "Catch-Up Workflow", "CLI fallback (when MCP is unavailable)"];
@@ -12,6 +12,12 @@
12
12
  */
13
13
 
14
14
  import { auditLogTool } from "./audit-log.ts";
15
+ import {
16
+ deleteRelationTool,
17
+ getNeighborsTool,
18
+ getRelationsTool,
19
+ setRelationTool,
20
+ } from "./relations.ts";
15
21
  import { getDocumentTool } from "./get-document.ts";
16
22
  import { getHelpTool } from "./get-help.ts";
17
23
  import { ingestTool } from "./ingest.ts";
@@ -34,6 +40,11 @@ export const ALL_TOOLS: ToolDefinition[] = [
34
40
  listProjectsTool,
35
41
  setDocumentProjectsTool,
36
42
  auditLogTool,
43
+ // Document relations (iteration 29): the graph surface.
44
+ setRelationTool,
45
+ deleteRelationTool,
46
+ getRelationsTool,
47
+ getNeighborsTool,
37
48
  getHelpTool,
38
49
  ];
39
50
 
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Document-relation tools (iteration 29) — the graph surface for agents.
3
+ *
4
+ * cerefox_set_relation create/update a typed edge
5
+ * cerefox_delete_relation remove one
6
+ * cerefox_get_relations everything touching a document, both directions
7
+ * cerefox_get_neighbors walk one relation type outward
8
+ *
9
+ * All four are thin adapters over the matching RPCs — the type dictionary
10
+ * (which types are symmetric, which change lifecycle status) lives in SQL so
11
+ * every transport behaves identically. Design:
12
+ * docs/research/document-relations-and-semantic-graph.md
13
+ */
14
+
15
+ import { logUsage } from "./_utils.ts";
16
+ import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
17
+ import type { MCPSupabaseClient } from "./types.ts";
18
+
19
+ /** Relation types that carry behaviour; any other string is accepted too. */
20
+ const KNOWN_TYPES =
21
+ "related_to, references, supersedes, contradicts, duplicates, part_of, follows, reply_to";
22
+
23
+ function requireUuid(value: unknown, field: string): string {
24
+ const s = typeof value === "string" ? value.trim() : "";
25
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)) {
26
+ throw new McpInvalidParams(`${field} must be a document UUID (got: ${String(value)})`);
27
+ }
28
+ return s;
29
+ }
30
+
31
+ // ── set ──────────────────────────────────────────────────────────────────────
32
+
33
+ async function setHandler(
34
+ supabase: MCPSupabaseClient,
35
+ args: Record<string, unknown>,
36
+ ctx: ToolContext,
37
+ ): Promise<string> {
38
+ const source = requireUuid(args.source_id, "source_id");
39
+ const target = requireUuid(args.target_id, "target_id");
40
+ const relType = typeof args.rel_type === "string" ? args.rel_type.trim() : "";
41
+ if (!relType) throw new McpInvalidParams("rel_type is required");
42
+
43
+ const { data, error } = await supabase.rpc("cerefox_set_relation", {
44
+ p_source_id: source,
45
+ p_target_id: target,
46
+ p_rel_type: relType,
47
+ p_author: (args.author as string | undefined) ?? "mcp-agent",
48
+ p_author_type: "agent",
49
+ p_metadata: (args.metadata as Record<string, unknown> | undefined) ?? {},
50
+ });
51
+ if (error) throw new Error(`RPC error: ${error.message}`);
52
+
53
+ const row = (Array.isArray(data) ? data[0] : data) as
54
+ | { is_symmetric?: boolean }
55
+ | undefined;
56
+
57
+ logUsage(supabase, {
58
+ operation: "set_relation",
59
+ accessPath: ctx.accessPath,
60
+ requestor: args.requestor as string | undefined,
61
+ document_id: source,
62
+ });
63
+
64
+ const both = row?.is_symmetric
65
+ ? " (symmetric — the reverse edge was written too)"
66
+ : "";
67
+ const effect =
68
+ relType === "supersedes"
69
+ ? "\nTarget marked superseded."
70
+ : relType === "contradicts"
71
+ ? "\nBoth documents marked stale."
72
+ : "";
73
+ return `Relation set: ${source} --${relType}--> ${target}${both}.${effect}`;
74
+ }
75
+
76
+ export const setRelationTool: ToolDefinition = {
77
+ name: "cerefox_set_relation",
78
+ description:
79
+ "Link two documents with a typed, directed relation (source → target). " +
80
+ `Known types with behaviour: ${KNOWN_TYPES}. Symmetric types (related_to, ` +
81
+ "contradicts, duplicates) write both directions. `supersedes` marks the target " +
82
+ "superseded; `contradicts` marks both stale. Any other type string is accepted " +
83
+ "and stored, just without special behaviour. Re-setting the same edge updates it.",
84
+ inputSchema: {
85
+ type: "object",
86
+ required: ["source_id", "target_id", "rel_type"],
87
+ properties: {
88
+ source_id: { type: "string", description: "UUID of the source document" },
89
+ target_id: { type: "string", description: "UUID of the target document" },
90
+ rel_type: {
91
+ type: "string",
92
+ description: `Relation type, e.g. one of: ${KNOWN_TYPES}. Free text is allowed.`,
93
+ },
94
+ metadata: {
95
+ type: "object",
96
+ description: "Optional JSON context for the edge (note, confidence, …)",
97
+ },
98
+ author: { type: "string", description: "Who is creating this relation" },
99
+ requestor: { type: "string", description: "Name of the agent making this request" },
100
+ },
101
+ },
102
+ handler: setHandler,
103
+ };
104
+
105
+ // ── delete ───────────────────────────────────────────────────────────────────
106
+
107
+ async function deleteHandler(
108
+ supabase: MCPSupabaseClient,
109
+ args: Record<string, unknown>,
110
+ ctx: ToolContext,
111
+ ): Promise<string> {
112
+ const source = requireUuid(args.source_id, "source_id");
113
+ const target = requireUuid(args.target_id, "target_id");
114
+ const relType = typeof args.rel_type === "string" ? args.rel_type.trim() : "";
115
+ if (!relType) throw new McpInvalidParams("rel_type is required");
116
+
117
+ const { data, error } = await supabase.rpc("cerefox_delete_relation", {
118
+ p_source_id: source,
119
+ p_target_id: target,
120
+ p_rel_type: relType,
121
+ p_author: (args.author as string | undefined) ?? "mcp-agent",
122
+ p_author_type: "agent",
123
+ });
124
+ if (error) throw new Error(`RPC error: ${error.message}`);
125
+
126
+ const removed = typeof data === "number" ? data : 0;
127
+ logUsage(supabase, {
128
+ operation: "delete_relation",
129
+ accessPath: ctx.accessPath,
130
+ requestor: args.requestor as string | undefined,
131
+ document_id: source,
132
+ });
133
+
134
+ if (removed === 0) return "No such relation — nothing removed.";
135
+ return (
136
+ `Removed ${removed} relation row(s): ${source} --${relType}--> ${target}. ` +
137
+ "Lifecycle status is left as-is (a document may be superseded by something else too)."
138
+ );
139
+ }
140
+
141
+ export const deleteRelationTool: ToolDefinition = {
142
+ name: "cerefox_delete_relation",
143
+ description:
144
+ "Remove a typed relation between two documents. Symmetric types remove both " +
145
+ "directions. Lifecycle status set by an earlier relation is NOT reverted.",
146
+ inputSchema: {
147
+ type: "object",
148
+ required: ["source_id", "target_id", "rel_type"],
149
+ properties: {
150
+ source_id: { type: "string", description: "UUID of the source document" },
151
+ target_id: { type: "string", description: "UUID of the target document" },
152
+ rel_type: { type: "string", description: "Relation type to remove" },
153
+ author: { type: "string", description: "Who is removing this relation" },
154
+ requestor: { type: "string", description: "Name of the agent making this request" },
155
+ },
156
+ },
157
+ handler: deleteHandler,
158
+ };
159
+
160
+ // ── read ─────────────────────────────────────────────────────────────────────
161
+
162
+ async function getRelationsHandler(
163
+ supabase: MCPSupabaseClient,
164
+ args: Record<string, unknown>,
165
+ ctx: ToolContext,
166
+ ): Promise<string> {
167
+ const docId = requireUuid(args.document_id, "document_id");
168
+ const { data, error } = await supabase.rpc("cerefox_get_relations", {
169
+ p_document_id: docId,
170
+ });
171
+ if (error) throw new Error(`RPC error: ${error.message}`);
172
+
173
+ const rows = (data ?? []) as Array<{
174
+ direction: string;
175
+ rel_type: string;
176
+ other_id: string;
177
+ other_title: string;
178
+ other_lifecycle: string;
179
+ }>;
180
+
181
+ logUsage(supabase, {
182
+ operation: "get_relations",
183
+ accessPath: ctx.accessPath,
184
+ requestor: args.requestor as string | undefined,
185
+ document_id: docId,
186
+ result_count: rows.length,
187
+ });
188
+
189
+ if (rows.length === 0) return "No relations for this document.";
190
+ const lines = rows.map((r) => {
191
+ const arrow = r.direction === "outbound" ? "→" : "←";
192
+ const life = r.other_lifecycle && r.other_lifecycle !== "active"
193
+ ? ` [${r.other_lifecycle}]`
194
+ : "";
195
+ return `- ${arrow} ${r.rel_type}: ${r.other_title}${life} [id: ${r.other_id}]`;
196
+ });
197
+ return `${rows.length} relation(s):\n${lines.join("\n")}`;
198
+ }
199
+
200
+ export const getRelationsTool: ToolDefinition = {
201
+ name: "cerefox_get_relations",
202
+ description:
203
+ "List every relation touching a document, in both directions (→ outbound, " +
204
+ "← inbound). Shows each neighbour's title and lifecycle status, so an agent " +
205
+ "can tell whether retrieved knowledge has been superseded or contradicted.",
206
+ inputSchema: {
207
+ type: "object",
208
+ required: ["document_id"],
209
+ properties: {
210
+ document_id: { type: "string", description: "UUID of the document" },
211
+ requestor: { type: "string", description: "Name of the agent making this request" },
212
+ },
213
+ },
214
+ handler: getRelationsHandler,
215
+ };
216
+
217
+ async function getNeighborsHandler(
218
+ supabase: MCPSupabaseClient,
219
+ args: Record<string, unknown>,
220
+ ctx: ToolContext,
221
+ ): Promise<string> {
222
+ const docId = requireUuid(args.document_id, "document_id");
223
+ const relType = typeof args.rel_type === "string" ? args.rel_type.trim() : "";
224
+ if (!relType) throw new McpInvalidParams("rel_type is required — pick one type to traverse");
225
+ const depth = Math.max(1, Math.min((args.depth as number | undefined) ?? 1, 5));
226
+
227
+ const { data, error } = await supabase.rpc("cerefox_get_neighbors", {
228
+ p_document_id: docId,
229
+ p_rel_type: relType,
230
+ p_depth: depth,
231
+ p_from_time: (args.from_time as string | undefined) ?? null,
232
+ p_to_time: (args.to_time as string | undefined) ?? null,
233
+ p_limit: Math.max(1, Math.min((args.limit as number | undefined) ?? 50, 200)),
234
+ });
235
+ if (error) throw new Error(`RPC error: ${error.message}`);
236
+
237
+ const rows = (data ?? []) as Array<{
238
+ document_id: string;
239
+ title: string;
240
+ lifecycle_status: string;
241
+ depth: number;
242
+ direction: string;
243
+ }>;
244
+
245
+ logUsage(supabase, {
246
+ operation: "get_neighbors",
247
+ accessPath: ctx.accessPath,
248
+ requestor: args.requestor as string | undefined,
249
+ document_id: docId,
250
+ result_count: rows.length,
251
+ });
252
+
253
+ if (rows.length === 0) return `No documents reachable via "${relType}".`;
254
+ const lines = rows.map((r) => {
255
+ const life = r.lifecycle_status && r.lifecycle_status !== "active"
256
+ ? ` [${r.lifecycle_status}]`
257
+ : "";
258
+ return `- depth ${r.depth} (${r.direction}): ${r.title}${life} [id: ${r.document_id}]`;
259
+ });
260
+ return `${rows.length} document(s) via "${relType}":\n${lines.join("\n")}`;
261
+ }
262
+
263
+ export const getNeighborsTool: ToolDefinition = {
264
+ name: "cerefox_get_neighbors",
265
+ description:
266
+ "Walk the relation graph outward from a document along ONE relation type. " +
267
+ "Use after cerefox_get_relations shows which types exist. depth > 1 follows " +
268
+ "chains (useful for follows / reply_to); cycles terminate safely. Optional " +
269
+ "from_time / to_time filter neighbours by their creation time.",
270
+ inputSchema: {
271
+ type: "object",
272
+ required: ["document_id", "rel_type"],
273
+ properties: {
274
+ document_id: { type: "string", description: "UUID of the starting document" },
275
+ rel_type: { type: "string", description: "The single relation type to traverse" },
276
+ depth: { type: "integer", description: "How many hops to follow (1–5, default 1)" },
277
+ from_time: { type: "string", description: "ISO-8601: only neighbours created on/after" },
278
+ to_time: { type: "string", description: "ISO-8601: only neighbours created on/before" },
279
+ limit: { type: "integer", description: "Max documents to return (default 50, max 200)" },
280
+ requestor: { type: "string", description: "Name of the agent making this request" },
281
+ },
282
+ },
283
+ handler: getNeighborsHandler,
284
+ };
@@ -18,8 +18,8 @@
18
18
  import type { MCPSupabaseClient } from "./types.ts";
19
19
 
20
20
  import { getEmbedding, resolveEmbedderKind } from "../embeddings/index.ts";
21
- import { applyByteBudget, getMaxResponseBytes, getMinSearchScore,
22
- getMinTermCoverage, logUsage } from "./_utils.ts";
21
+ import { applyByteBudget, getConfiguredMinSearchScore, getConfiguredSearchAlpha,
22
+ getMaxResponseBytes, getMinTermCoverage, logUsage } from "./_utils.ts";
23
23
  import { lookupProjectId } from "./_projects.ts";
24
24
  import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
25
25
 
@@ -32,14 +32,20 @@ async function handler(
32
32
  const project_name = args.project_name as string | undefined;
33
33
  const match_count = (args.match_count as number | undefined) ?? 5;
34
34
  const mode = (args.mode as string | undefined) ?? "docs";
35
- const alpha = (args.alpha as number | undefined) ?? 0.7;
36
- const min_score = (args.min_score as number | undefined) ?? getMinSearchScore();
35
+ // #133: omit unconfigured tunables so the server resolves them from
36
+ // cerefox_config (one setting governs every access path).
37
+ const alpha = (args.alpha as number | undefined) ?? getConfiguredSearchAlpha();
38
+ const min_score =
39
+ (args.min_score as number | undefined) ?? getConfiguredMinSearchScore();
37
40
  // v1.0.4: coverage gate default from CEREFOX_MIN_TERM_COVERAGE; only sent
38
41
  // when configured (see getMinTermCoverage — keeps pre-0.9.1 servers working).
39
42
  const min_term_coverage =
40
43
  (args.min_term_coverage as number | undefined) ?? getMinTermCoverage();
41
44
  const coverageParam =
42
45
  min_term_coverage !== undefined ? { p_min_term_coverage: min_term_coverage } : {};
46
+ // Omitted keys let the RPC apply its cerefox_config → built-in chain (#133).
47
+ const scoreParam = min_score !== undefined ? { p_min_score: min_score } : {};
48
+ const alphaParam = alpha !== undefined ? { p_alpha: alpha } : {};
43
49
  const metadata_filter =
44
50
  (args.metadata_filter as Record<string, string> | null | undefined) ?? null;
45
51
  const requested_max_bytes = args.max_bytes as number | undefined;
@@ -99,10 +105,10 @@ async function handler(
99
105
  p_query_text: query,
100
106
  p_query_embedding: embedding,
101
107
  p_match_count: match_count,
102
- p_alpha: alpha,
103
108
  p_use_upgrade: false,
104
109
  p_project_id: projectId,
105
- p_min_score: min_score,
110
+ ...alphaParam,
111
+ ...scoreParam,
106
112
  ...metaFilterParam,
107
113
  ...coverageParam,
108
114
  };
@@ -112,9 +118,9 @@ async function handler(
112
118
  p_query_text: query,
113
119
  p_query_embedding: embedding,
114
120
  p_match_count: match_count,
115
- p_alpha: alpha,
116
121
  p_project_id: projectId,
117
- p_min_score: min_score,
122
+ ...alphaParam,
123
+ ...scoreParam,
118
124
  ...metaFilterParam,
119
125
  ...coverageParam,
120
126
  };