loopctl-mcp-server 2.72.0 → 2.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/index.js +329 -36
- package/lib/arg-aliases.js +121 -0
- package/lib/client-context.js +128 -0
- package/lib/search-notices.js +70 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -251,21 +251,21 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
|
|
|
251
251
|
| `knowledge_stats` | Aggregate article counts (`total`, `by_category`, `by_status`) via cheap `COUNT(*) GROUP BY` within agent's visible set — no article metadata loaded. Agent callers see only their own and `shared` articles. Counts span all statuses. Optional: `project_id`. |
|
|
252
252
|
| `knowledge_count` | Count articles matching filters **without returning rows** within agent's visible set. Agent callers see only their own and `shared` articles. Same filters as `knowledge_list` (`category`, `status`, `tags`, `match`, `source_type`, `source_id`, `idempotency_key`, `project_id`). With `tags`+`match: all` (+`status`) → "how many published articles tagged both X and Y (that I can see)". Returns `{ count }`. |
|
|
253
253
|
| `knowledge_facets` | Count articles grouped by **distinct tag** within agent's visible set, no rows. Agent callers see only their own and `shared` articles. `tag_prefix` (e.g. `book-`) gives the distinct count of a tag family plus per-member totals. Returns `{ data: { tag: count }, meta: { distinct_count } }`. Optional: `category`, `status`, `tags`, `match`, `project_id`, `limit`. |
|
|
254
|
-
| `knowledge_search` | Search the knowledge wiki by topic (keyword, semantic, or combined). Returns snippets. **Ranked, published-only, and LAGS writes by minutes while embeddings index — do NOT use for existence/idempotency/dedup checks (a fresh write false-negatives); use `knowledge_list` for that.** `
|
|
254
|
+
| `knowledge_search` | Search the knowledge wiki by topic (keyword, semantic, or combined). Returns snippets. **Ranked, published-only, and LAGS writes by minutes while embeddings index — do NOT use for existence/idempotency/dedup checks (a fresh write false-negatives); use `knowledge_list` for that.** `query` (historically `q`, still accepted) is optional when `tags`/`category` are supplied — that **list mode** returns the complete filtered set paginated via `offset`/`limit` over `meta.total_count`. `meta.total_count` is mode-dependent — read `meta.total_count_scope` (`keyword_matches`/`ranked_corpus`/`merged_candidates`/`filtered_set`) and don't use a relevance-mode count to size the wiki (use `knowledge_list` or `knowledge_stats`). On the semantic/combined paths `meta.ann_iterative_scan` (`off`/`applied`/`unavailable`, with `meta.ann_iterative_scan_reason` alongside `unavailable`) discloses whether the vector read ran with pgvector's iterative scan — `unavailable` means results may be INCOMPLETE, which `meta.fallback` cannot tell you. Optional: `project_id`, `story_id` for attribution. |
|
|
255
255
|
| `knowledge_hybrid_search` | Resolve a topic to a **single best answer with provenance** (US-31.4). Runs combined keyword+semantic over the full ranked pool, then decides whether a governed **curated** source actually answers. `meta.provenance` is `curated` (trust it — the canonical article is first in `data`, `meta.curated_article_id` points at it) or `retrieved` (best fuzzy match, `curated_article_id` null); `meta.confidence` is the winner's absolute score. Prefer over `knowledge_search` when you want one trustworthy answer, not a list to triage. Degrades to keyword-only like `knowledge_search` when embeddings are unavailable. Required: `query`. Optional: `project_id`, `category`, `tags`, `match`, `limit`, `offset`. |
|
|
256
|
-
| `knowledge_progressive_index` | Progressive disclosure — a **cheap, capped index** of what's relevant to a topic (compact stubs: `id`/`title`/`category`/`summary`, **no bodies**), curated-preferred and hub-enriched, capped at top-K (`meta.truncated` when the pool exceeded it). Survey a topic without flooding context, then open only what you need via `knowledge_progressive_drill`. Required: `topic
|
|
256
|
+
| `knowledge_progressive_index` | Progressive disclosure — a **cheap, capped index** of what's relevant to a topic (compact stubs: `id`/`title`/`category`/`summary`, **no bodies**), curated-preferred and hub-enriched, capped at top-K (`meta.truncated` when the pool exceeded it). Survey a topic without flooding context, then open only what you need via `knowledge_progressive_drill`. Required: `query` (historically `topic`, still accepted). Optional: `category`, `limit`. |
|
|
257
257
|
| `knowledge_heat_index` | Browse the corpus with **no query at all** — capped compact stubs (`id`/`title`/`category`/`heat`/`summary`, **no bodies**) ranked by how many **distinct readers** (agents, not key rows — repeat reads by one reader count once, ties broken by the number of distinct days read, never by raw read count) opened each article inside a window. Every other retrieval tool starts from a query and so shares one failure mode: a paraphrase, or material topically central but lexically dissimilar to the question, comes back empty and reads as "the KB has nothing" rather than "I asked badly". Reach for it when a search came back empty or thin, or to survey what the fleet actually reads before you know what to ask. **Ordering is usage, not relevance**, and drilling a listed article (`knowledge_progressive_drill`) does not add heat to what it opened, at any scope — otherwise being shown would produce the rank that shows it. A `knowledge_get` of the same id does count. `meta` states `heat_window` (the default window is snapped to a UTC day boundary so the payload is stable between refreshes and safe in a cached prefix; an explicit `since` is served verbatim), `counted_access_types`, `char_budget`/`chars` (BYTES of the encoded stub array, framing included), `truncated` and `unresolved`. Both read tools open a stub, canonicals included — pick by what the read MEANS: a drill is uncounted, a `get` is a counted vote. Optional: `category`, `limit`, `since`. |
|
|
258
258
|
| `knowledge_progressive_drill` | Open one stub from `knowledge_progressive_index` or `knowledge_heat_index` — returns the **full article body** for the given id, scope-enforced. Resolves both tenant-owned articles and published system canonicals (the same set those indexes surface). Every article opened this way is recorded under an access type `knowledge_heat_index` does not count, whatever its scope, so that index can never rank on the reads it caused. `knowledge_get` reaches the same ids and DOES count — use it when the read is a deliberate vote rather than a step in following a list. Required: `article_id`. |
|
|
259
|
-
| `knowledge_list` | List articles (`id`, `title`, `category`, `status`, `tags`, `source_type`, `source_id`,
|
|
259
|
+
| `knowledge_list` | List articles (`id`, `title`, `category`, `status`, `tags`, `source_type`, `source_id`, timestamps), filtered + paginated. **Body-less summary by default** (safe to page up to `limit=1000`); pass `include_body: true` to also return `body`, in which case the page is bounded by a ~5 MB byte budget — continue via `meta.next_offset` while `meta.has_more`. **Lag-free, all-status** read of the DB of record — unlike `knowledge_search` (ranked, published-only, lags writes) and `knowledge_index` (id/title/category only). The right tool to enumerate/dedup/repair and for idempotency/existence checks: filter by `tags`, `source_type`+`source_id`, or `idempotency_key` and read `meta.total_count` (exact) — `idempotency_key` is a FILTER only and is never returned in a row, so you check a key you already hold. Single full body → `knowledge_get`; relevant bodies → `knowledge_context`; bulk dump → `knowledge_export`. Optional: `project_id`, `category`, `status`, `tags`, `source_type`, `source_id`, `idempotency_key`, `offset`, `limit`, `include_body`. |
|
|
260
260
|
| `knowledge_get` | Get full article content by ID. Use after search to read an article in detail. Resolves tenant-owned articles **and published system canonicals**, and records a COUNTED read (it feeds `knowledge_heat_index`) — reach for `knowledge_progressive_drill` instead when you are merely following an index this system just produced. Each link carries only its FAR side (`article: {id, title}`, plus `similarity` when scored); both arrays are ranked (open conflicts first, then descending similarity, then oldest-first for the unscored) and capped at 25 per direction, with `links_total` / `links_truncated` reporting the truth (`count` returns both, so one cheap call tells you whether the full fetch is capped). Pass `links: "count"` or `"none"` when you only want the text — on a well-linked hub the link block is several times the body. `potential_conflicts` is returned in all three modes, itself capped at 25 with `conflicts_total` / `conflicts_truncated`. Optional: `links`, `project_id`, `story_id` for attribution. |
|
|
261
261
|
| `knowledge_context` | Get relevance-and-recency-ranked full articles for a task query. Best knowledge for your current context. **Agent-memory scoping**: `memory_types` (comma-separated, OR — observation/finding/summary/decision/question/task), `agents` (comma-separated agent_ids, OR), `conversation_id` (exact) filter on article `metadata` (JSONB `@>`). Optional: `project_id`, `story_id` for attribution, `limit`, `recency_weight`. |
|
|
262
262
|
| `knowledge_graph` | Multi-hop traversal of the published article-link graph from `article_id` (depth 1–3, default 1), **bounded to agent's visible articles**. Agent callers see only their own and `shared` articles. Bidirectional, cycle-safe, bounded to 100 nodes / 500 edges (`truncated` flags a cap). Returns `nodes` (`id`/`title`/`category`/`depth`) + `edges` (`source_article_id`/`target_article_id`/`relationship_type`). Explore typed connections beyond `knowledge_context`'s 1-hop links. Required: `article_id`. Optional: `depth`, `project_id`. |
|
|
263
|
-
| `knowledge_suggest_links` | Ranked typed-link **candidates** for an article by embedding similarity among **visible articles** — **read-only** (creates nothing). Excludes the article itself + any already-linked article (either direction, any type); only embedded published articles visible to the caller. Agent callers see only their own and `shared` articles. Returns `{id, title, category, similarity_score}` highest-first, to create as a **typed** link (relates_to/derived_from/contradicts/supersedes). Required: `article_id`. Optional: `threshold` (cosine floor 0–1, default 0.5), `limit` (default 5). |
|
|
263
|
+
| `knowledge_suggest_links` | Ranked typed-link **candidates** for an article by embedding similarity among **visible articles** — **read-only** (creates nothing). Excludes the article itself + any already-linked article (either direction, any type); only embedded published articles visible to the caller. Agent callers see only their own and `shared` articles. Returns `{id, title, category, similarity_score}` highest-first, to create as a **typed** link (relates_to/derived_from/contradicts/supersedes). `meta.ann_iterative_scan` (`off`/`applied`/`unavailable`, with `meta.ann_iterative_scan_reason` alongside `unavailable`) discloses whether the vector read ran with pgvector's iterative scan — `unavailable` means the list may be INCOMPLETE, which `meta.recall_truncated: false` does NOT cover, so do not read a short list as "no neighbours". Required: `article_id`. Optional: `threshold` (cosine floor 0–1, default 0.5), `limit` (default 5). |
|
|
264
264
|
| `knowledge_distant_pairs` | Distant-but-bridgeable article pairs in the optimal-novelty embedding band (cosine distance, default 0.3–0.7) — the creative sweet spot. Sampled from **agent's visible published articles**; agent callers see only their own and `shared` articles. `bridge_path: true` requires a ≤2-hop link path. Returns `{a, b, distance}` pairs, paginated. Optional: `min_distance`, `max_distance`, `bridge_path`, `limit` (default 20, max 100), `offset`. |
|
|
265
265
|
| `knowledge_novelty` | Score ideas by novelty: embeds each idea's text, returns `novelty_score` = cosine distance to the nearest **visible** prior proposal (0 = identical, higher = more novel, up to 2.0; `null` when the idea text is blank, no visible priors exist, or embedding fails — see `meta.prior_count`). Agent callers see only their own and `shared` articles as priors. Priors default to articles tagged `proposal`. Provide ideas as `texts` (strings) OR `ideas` (strings or objects), ≤50. Optional: `prior_tag`. |
|
|
266
266
|
| `knowledge_random_walk` | Random walk through the link graph from `start_id` (no cycles, up to `length` nodes), traversing only **agent's visible published articles**, surfacing unexpected connections. Agent callers see only their own and `shared` articles. Returns `{id, title, category}` in walk order. Required: `start_id`. Optional: `length` (default 4, max 25). |
|
|
267
267
|
| `knowledge_conflicts` | List potential-conflict article pairs — published articles flagged "too similar to comfortably coexist" by the auto-linker / nightly lint sweep, highest-overlap first. The KB only FLAGS the pair; it does NOT decide redundancy-vs-contradiction — that's your call with live context. Each entry has both articles (id/title/status/category) + similarity. Then merge (supersede one, `knowledge_create` the merged article, or PATCH) or reconcile if they genuinely disagree. Paginated with `total_count` in meta. Agent role. Optional: `limit` (default 50, max 1000, clamped), `offset`. |
|
|
268
|
-
| `knowledge_resolve_conflict` | Record YOUR verdict on a potential-conflict pair (from `knowledge_conflicts`). Dispositions: `dismiss` (false positive, drops from queue), `supersede` (one wins — pass `authoritative_article_id`; nightly executor links + retires loser, only at `confidence:"high"`, reversible/audited), `merge` (at high confidence an LLM synthesizes both into ONE new DRAFT, sources preserved, never auto-published). Non-destructive at agent role — you record intent, the privileged nightly job executes. Last-write-wins per pair. Required: `source_article_id`, `target_article_id`, `disposition`. Optional: `authoritative_article_id`, `classification`, `evidence
|
|
268
|
+
| `knowledge_resolve_conflict` | Record YOUR verdict on a potential-conflict pair (from `knowledge_conflicts`). Dispositions: `dismiss` (false positive, drops from queue), `supersede` (one wins — pass `authoritative_article_id`; nightly executor links + retires loser, only at `confidence:"high"`, reversible/audited), `merge` (at high confidence an LLM synthesizes both into ONE new DRAFT, sources preserved, never auto-published). Non-destructive at agent role — you record intent, the privileged nightly job executes. Last-write-wins per pair. **`supersede` is the one disposition that retires an article unattended**, so its `confidence` is capped server-side: an agent-role `"high"` is recorded as `"medium"` (`data.requested_confidence` + `note` say so) and the pair stays in `knowledge_conflicts` until an orchestrator+ key records it at high; a `high` supersede also REQUIRES `evidence` (422 without it). `merge` is never capped. Required: `source_article_id`, `target_article_id`, `disposition`. Optional: `authoritative_article_id`, `classification`, `evidence` (required for a high supersede), `confidence`. |
|
|
269
269
|
| `knowledge_create` | Create a new knowledge article. File findings, document patterns, or record decisions. **Published immediately by default** (visible per `metadata.visibility` — default `owner` for agent authors, only visible to that agent; `shared` for visibility to all agents) — the response `note` says which outcome occurred. Pass `draft: true` to stage it for later review instead (publish afterwards with `knowledge_publish`). Pass `metadata: {visibility: "shared"}` to make the article visible to other agents; higher roles can set visibility and agent_id explicitly. Pass `idempotency_key` for idempotent capture (re-creating with the same key is a no-op returning the existing article — no partial duplicates). RESERVED TAG NAMESPACE: a tag starting with `idem-` must be `idem-<family>-<digest>` (digest = 12 or 40 lowercase hex chars, e.g. `idem-url-7ebe1ca33431`) or the write is rejected 422 — never silently rewritten; put topics outside that prefix and use `idempotency_key` for idempotent capture. Optional: `category`, `tags`, `project_id`, `draft`, `idempotency_key`, `source_type`, `source_id`, `metadata`. |
|
|
270
270
|
| `knowledge_update` | Edit an EXISTING article IN PLACE, **preserving its ID** (IDs are load-bearing — cited in project CLAUDE.mds and cross-links). Fold in a new fact, tidy a hub, retag, or reclassify without churning a new row. Send only the fields to change; `tags` REPLACES the whole array. A changed body/tags re-triggers embedding + auto-linking. Agent role — KB-content curation (non-destructive + audited; the in-place edit overwrites the prior body, so it is not reversible either); visibility-scoped, so another agent's private/owner memory 404s. The reserved `idem-` tag namespace applies here too (see `knowledge_create`). Required: `article_id`. Optional: `title`, `body`, `category`, `tags`, `metadata`. |
|
|
271
271
|
| `knowledge_okf_export` | **Requires `LOOPCTL_USER_KEY`.** Export the wiki as a portable OKF (Open Knowledge Format) v0.1 bundle of markdown files. Writes to `out_dir`, or returns `{files, meta}` inline. |
|
|
@@ -284,11 +284,11 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
|
|
|
284
284
|
| Tool | Description |
|
|
285
285
|
|---|---|
|
|
286
286
|
| `memory_remember` | Write to your own working memory. `tier` selects the substrate: `long_term` (default; requires `text`, embedded asynchronously and later recalled by semantic similarity via `memory_recall`) or `session` (short-term; requires `session_id`, `content`, `expires_at` — pruned after expiry, not semantically recalled). Returns 201 with the stored memory. Optional: `confidence`, `tags`, `source_session_id`, `metadata` (long-term); `role` (session). |
|
|
287
|
-
| `memory_recall` | Semantically recall your own long-term memories most similar to `query`. When embedding generation is unavailable the response degrades to a recent-first text match with `meta.fallback: true` and a stable `meta.reason` (score is `null` on that path) — check `meta.fallback` before treating a short/empty result as a genuinely empty scope. `meta.total_count`/`meta.underfilled` are also returned. Optional: `limit`, `include_superseded`. |
|
|
287
|
+
| `memory_recall` | Semantically recall your own long-term memories most similar to `query`. When embedding generation is unavailable the response degrades to a recent-first text match with `meta.fallback: true` and a stable `meta.reason` (score is `null` on that path) — check `meta.fallback` before treating a short/empty result as a genuinely empty scope. `meta.total_count`/`meta.underfilled` are also returned. On the semantic path `meta.ann_iterative_scan` (`off`/`applied`/`unavailable`, with `meta.ann_iterative_scan_reason` alongside `unavailable`) discloses whether the vector read ran with pgvector's iterative scan — `unavailable` means results may be INCOMPLETE, which `meta.fallback`/`meta.underfilled` cannot tell you. It is absent on the ILIKE fallback AND on an `include_superseded: true` recall (a bounded exact top-k, no index scan), so absence never means the fallback ran. Optional: `limit`, `include_superseded`. |
|
|
288
288
|
| `memory_list` | List your own long-term memories, newest first, paginated with `meta.total_count/limit/offset` (the true scoped count, never silently capped by `limit`). Optional: `limit`, `offset`, `include_superseded`, `all_subjects` (superadmin only; ignored for non-superadmin keys). |
|
|
289
289
|
| `memory_forget` | Delete one of your own long-term memories by id. A foreign-subject, foreign-tenant, or unknown id returns 404 (no existence leak). Required: `id`. |
|
|
290
290
|
| `memory_promote` | Call at session end to compile this session's short-term (`session`-tier) memory into durable `long_term` memory — unlike `memory_remember` (a single explicit write), this compiles the whole session in one shot; fire it once at session end, not per turn. Returns 202 with `{session_id, status: "enqueued"}` — promotion runs asynchronously, so the resulting memory is recallable via `memory_recall` only after the worker drains. You can only promote your own sessions (scope resolved server-side from your key). Required: `session_id`. |
|
|
291
|
-
| `recall_context` | ONE round-trip returning the re-ranked `global ∪ active-project` union of long-term MEMORY **and** KNOWLEDGE for `query` — what you previously assembled by calling `memory_recall` and `knowledge_search` separately. Pass `project_id` (from `resolve_project`) to merge global with that project on both sides; absent → global-only. The knowledge half is combined-search *summaries* (not full bodies — use `knowledge_context` for those). Response carries merged `results` (each tagged `source: memory\|knowledge`) plus the untouched per-source `memory`/`knowledge` envelopes; `meta.degraded?` flags a one-sided degrade (the other side is still returned — never a 500). A blank query, or one over 500 chars, is a `422` up front. Required: `query`. Optional: `project_id`, `limit`. |
|
|
291
|
+
| `recall_context` | ONE round-trip returning the re-ranked `global ∪ active-project` union of long-term MEMORY **and** KNOWLEDGE for `query` — what you previously assembled by calling `memory_recall` and `knowledge_search` separately. Pass `project_id` (from `resolve_project`) to merge global with that project on both sides; absent → global-only. The knowledge half is combined-search *summaries* (not full bodies — use `knowledge_context` for those). Response carries merged `results` (each tagged `source: memory\|knowledge`) plus the untouched per-source `memory`/`knowledge` envelopes; `meta.degraded?` flags a one-sided degrade (the other side is still returned — never a 500). Each per-source envelope's `meta.ann_iterative_scan` describes only THAT half's vector read, and the two are resolved independently, so they may differ. A blank query, or one over 500 chars, is a `422` up front. Required: `query`. Optional: `project_id`, `limit`. |
|
|
292
292
|
| `memory_graduate` | Graduate ONE of your long-term memories into a durable Knowledge Wiki article — the explicit, on-demand version of the hourly graduation sweep. Use when a private memory has proven valuable enough to become durable knowledge. **Visibility**: the graduated article stays **owner-visible** (`metadata.visibility: "owner"`, keyed to your subject) — discoverable by YOU, NOT peer-readable (graduation does not share a memory to teammates; `re_scope: "global"` widens only the project scope, not visibility). Scope is key-derived (you can only graduate your OWN memory; a foreign/unknown `memory_id` → 404). DEDUPED by the novelty gate: `data.verdict` is `created` (novel → published) or `gated_to_draft` (near-dup → review draft) with a new article (**201**), or `duplicate`/`deduplicated` (already represented → canonical article, nothing created) (**200**). By default the article inherits the memory's project scope; pass `re_scope: "global"` to promote a PROJECT memory to a tenant-wide article — only valid on its FIRST graduation, and only if the hourly sweep hasn't graduated it project-scoped first (`409` `already_graduated` otherwise). An already-graduated global memory re-graduates idempotently (**200**). `503` `gate_unavailable` if the embedding backend is down — retry later. Required: `memory_id`. Optional: `re_scope` (`inherit`\|`global`). |
|
|
293
293
|
|
|
294
294
|
### Knowledge Management Tools (orchestrator key)
|
|
@@ -379,14 +379,14 @@ Key distribution for the dispatch pattern (Epic 26): per-dispatch ephemeral keys
|
|
|
379
379
|
| Tool | Description |
|
|
380
380
|
|---|---|
|
|
381
381
|
| `signup` | **US-26.7.1.** Create a NEW **agent-rooted (KB-tier)** tenant and mint its one-time root API key — entirely through this call, no human operator, no hardware authenticator, no existing API key required. The tenant gets the FULL knowledge-wiki surface but **cannot** perform work-breakdown / chain-of-custody operations (those require a separate human-anchored tenant via the WebAuthn ceremony at `https://loopctl.com/signup`). Rate-limited per client IP (<= 5/hour). The `raw_key` is shown ONCE — save it immediately (e.g. as `LOOPCTL_USER_KEY`). Required: `name`, `slug`, `email`. |
|
|
382
|
-
| `dispatch` | Mint an ephemeral, scoped api_key for a sub-agent dispatch, carrying its lineage path. The `raw_key` is returned ONCE — pass it to the sub-agent's launch args, never store it in env vars; it expires after `expires_in_seconds` (default 3600, max 14400). Required: `role` (`agent`/`orchestrator`), `agent_id`. Optional: `
|
|
382
|
+
| `dispatch` | Mint an ephemeral, scoped api_key for a sub-agent dispatch, carrying its lineage path. The `raw_key` is returned ONCE — pass it to the sub-agent's launch args, never store it in env vars; it expires after `expires_in_seconds` (default 3600, max 14400). Required: `role` (`agent`/`orchestrator`), `agent_id`, and `parent_dispatch_id` for every caller a dispatch minted — a dispatch may only be minted INSIDE the caller's own lineage. Omitting `parent_dispatch_id` requests a ROOT dispatch (a new independent lineage tree), which only the tenant's `user`-role operator key may create; anyone else gets `403 root_dispatch_forbidden` with their own dispatch id in `remediation.your_dispatch_id`. A parent outside the caller's lineage is `403 parent_outside_caller_lineage`. Optional: `story_id`. **LCP-1 §9.2 signed profile:** optionally enroll an agent key via `agent_pubkey` (hex) + `alg` + an `attestation` (from `custody_sign_attestation`) + `attestation_conditions`. |
|
|
383
383
|
| `register_custody_owner_key` | **LCP-1 §9.2.** Register/rotate the tenant custody OWNER key — the root of trust the attestation chain hangs from. Private half stays with you; requires `LOOPCTL_USER_KEY` and a human-anchored tenant. Required: `owner_pubkey` (hex). ROTATION additionally requires `rotation_proof` (from `custody_sign_owner_rotation`); first registration needs none. |
|
|
384
384
|
| `list_enrolled_agent_keys` | **LCP-1 §9.1.1 transparency.** List the agent public keys enrolled under your tenant, reconstructed from the tamper-evident audit chain (not a mutable listing). Compare against the keys you generated; any excess is operator-minted. Keyset-paged (`limit`, `cursor`). |
|
|
385
385
|
| `custody_generate_keypair` | **LCP-1 §9.** Generate an Ed25519 keypair LOCALLY (private key never leaves the process). Returns `public_key_hex` + `private_key_hex`. |
|
|
386
386
|
| `custody_sign_attestation` | **LCP-1 §9.2.** Sign an attestation over an agent key to enroll it — with the OWNER private key (root, `lineage_path: []`) or the PARENT agent private key (child, `lineage_path` = parent's). Returns the hex `attestation` for `dispatch`. |
|
|
387
387
|
| `custody_sign_claim` | **LCP-1 §9.3.** Sign a custody claim with your enrolled agent private key. Returns a `claim` object to pass as the `claim` param to `report_story`/`review_complete`/`verify_story` when the deployment runs the signed profile. |
|
|
388
388
|
| `custody_sign_owner_rotation` | **LCP-1 §9.2.** Sign an owner-key ROTATION proof with the OUTGOING owner private key, proving possession before it re-roots the attestation chain. Binds the old key + its set-at (Unix microseconds) so a captured proof is not replayable after a rotate-back. Returns `rotation_proof` for `register_custody_owner_key`. |
|
|
389
|
-
| `recover_cap` | Re-mint
|
|
389
|
+
| `recover_cap` | Re-mint the `start_cap` for a story you're assigned to, after a session crash lost your cap. Required: `story_id`. No other parameters: recovery only ever mints a `start_cap` (any other `cap_type` is a 422 AND is recorded as a forgery attempt), and the lineage is always derived server-side. |
|
|
390
390
|
| `get_sth` | Get the latest Signed Tree Head for a tenant's tamper-evident audit chain. Public — no auth required. Required: `tenant_id`. |
|
|
391
391
|
| `request_authenticator_challenge` | **US-26.7.2.** Step 1 of the opt-in WebAuthn trust-tier upgrade ceremony: issues a registration challenge for enrolling a hardware authenticator against an EXISTING agent-rooted (KB-tier) tenant, promoting it to `human_anchored` on success. Requires an interactive WebAuthn client. |
|
|
392
392
|
| `enroll_authenticator` | Step 2 of the WebAuthn trust-tier upgrade ceremony: completes enrollment with the attestation produced by `navigator.credentials.create()` against the challenge from `request_authenticator_challenge`. On a tenant's first enrollment the tenant is promoted to `human_anchored`. |
|
package/index.js
CHANGED
|
@@ -15,6 +15,9 @@ import os from "node:os";
|
|
|
15
15
|
import crypto from "node:crypto";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import path, { dirname, join } from "node:path";
|
|
18
|
+
import { applyArgAliases } from "./lib/arg-aliases.js";
|
|
19
|
+
import { clientContextHeader } from "./lib/client-context.js";
|
|
20
|
+
import { degradedSearchNotice } from "./lib/search-notices.js";
|
|
18
21
|
import {
|
|
19
22
|
projectsPath,
|
|
20
23
|
ingestionJobsPath,
|
|
@@ -115,6 +118,19 @@ function resolveKey(keyOverride) {
|
|
|
115
118
|
);
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
let clientContextHeaderCache;
|
|
122
|
+
function cachedClientContextHeader() {
|
|
123
|
+
if (clientContextHeaderCache === undefined) {
|
|
124
|
+
try {
|
|
125
|
+
clientContextHeaderCache = clientContextHeader({ version: SERVER_VERSION }) || null;
|
|
126
|
+
} catch {
|
|
127
|
+
// Analytics must never break a tool call.
|
|
128
|
+
clientContextHeaderCache = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return clientContextHeaderCache;
|
|
132
|
+
}
|
|
133
|
+
|
|
118
134
|
async function apiCall(method, path, body, keyOverride, { exactKey = false, timeoutMs } = {}) {
|
|
119
135
|
const url = `${getBaseUrl()}${path}`;
|
|
120
136
|
// Secret-managing tools pass exactKey:true so the request uses the EXACT
|
|
@@ -139,6 +155,14 @@ async function apiCall(method, path, body, keyOverride, { exactKey = false, time
|
|
|
139
155
|
Accept: "application/json",
|
|
140
156
|
};
|
|
141
157
|
|
|
158
|
+
// #658: client-asserted context (session id, main-vs-child, repo, effort). None of it is
|
|
159
|
+
// derivable server-side — an api key names a KEY, and under the v2 dispatch pattern a key
|
|
160
|
+
// is minted per dispatch. UNTRUSTED and analytics-only; the server stores it under a
|
|
161
|
+
// `client_` prefix and never authorizes on it. Computed once: the environment does not
|
|
162
|
+
// change mid-process, and a per-call `os.hostname()` + git shell-out is not hot-path work.
|
|
163
|
+
const clientCtx = cachedClientContextHeader();
|
|
164
|
+
if (clientCtx) headers["x-loopctl-client-context"] = clientCtx;
|
|
165
|
+
|
|
142
166
|
const serializedBody =
|
|
143
167
|
body !== undefined && body !== null ? JSON.stringify(body) : undefined;
|
|
144
168
|
|
|
@@ -355,7 +379,10 @@ function llmRemediationNotice(result) {
|
|
|
355
379
|
*/
|
|
356
380
|
function withRemediationNotice(result) {
|
|
357
381
|
const base = toContent(result);
|
|
358
|
-
|
|
382
|
+
// Two notice sources, in priority order: the BYO-key remediation (more specific), then
|
|
383
|
+
// the degraded-search notice (#658). At most one is prepended — stacking two leading
|
|
384
|
+
// blocks buries both. `degradedSearchNotice` already declines the no_embedding_key case.
|
|
385
|
+
const notice = llmRemediationNotice(result) || degradedSearchNotice(result);
|
|
359
386
|
if (!notice) return base;
|
|
360
387
|
return { ...base, content: [{ type: "text", text: notice }, ...base.content] };
|
|
361
388
|
}
|
|
@@ -952,6 +979,103 @@ async function contractStory({ story_id, story_title, ac_count }) {
|
|
|
952
979
|
return toContent(result);
|
|
953
980
|
}
|
|
954
981
|
|
|
982
|
+
// --- L1 capability plumbing (issue #621) ---
|
|
983
|
+
//
|
|
984
|
+
// A tenant with an audit signing key MUST present a capability token on the
|
|
985
|
+
// custody transitions that consume one, or the call fails 403 missing_capability.
|
|
986
|
+
// `start` is the only such transition (#621): claim returns the start_cap it mints.
|
|
987
|
+
// Callers should not have to carry it by hand — so we cache what the server issues
|
|
988
|
+
// and attach it automatically, while still honouring an explicit `capability`.
|
|
989
|
+
//
|
|
990
|
+
// The cache is per-process and therefore does NOT survive a session crash. That is
|
|
991
|
+
// what the recovery paths below are for; never treat a cache miss as fatal.
|
|
992
|
+
// Bounded so a long-lived server process cannot grow these without limit: this
|
|
993
|
+
// module lives for the life of the MCP connection and sees every story the agent
|
|
994
|
+
// touches. Insertion order is Map/Set iteration order in JS, so evicting the
|
|
995
|
+
// first key drops the oldest entry.
|
|
996
|
+
const CAP_CACHE_MAX = 256;
|
|
997
|
+
const SPENT_CAPS_MAX = 1024;
|
|
998
|
+
|
|
999
|
+
function boundedSet(store, max) {
|
|
1000
|
+
while (store.size > max) {
|
|
1001
|
+
const oldest = store.keys().next();
|
|
1002
|
+
if (oldest.done) break;
|
|
1003
|
+
store.delete(oldest.value);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const capCache = new Map();
|
|
1008
|
+
|
|
1009
|
+
const capKey = (storyId, typ) => `${storyId}:${typ}`;
|
|
1010
|
+
|
|
1011
|
+
function rememberCap(storyId, cap) {
|
|
1012
|
+
if (cap && cap.cap_id && cap.typ) {
|
|
1013
|
+
// Store the expiry alongside the id. A capability has a bounded TTL, and an
|
|
1014
|
+
// agent can sit between claim and start for longer than that (a review pause,
|
|
1015
|
+
// a crash-and-resume) — handing back a dead token afterwards costs a
|
|
1016
|
+
// round-trip and surfaces a confusing refusal instead of the recovery path.
|
|
1017
|
+
capCache.set(capKey(storyId, cap.typ), {
|
|
1018
|
+
capId: cap.cap_id,
|
|
1019
|
+
expiresAt: cap.expires_at ? Date.parse(cap.expires_at) : null,
|
|
1020
|
+
});
|
|
1021
|
+
boundedSet(capCache, CAP_CACHE_MAX);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function takeCap(storyId, typ) {
|
|
1026
|
+
const key = capKey(storyId, typ);
|
|
1027
|
+
const entry = capCache.get(key);
|
|
1028
|
+
// Capabilities are single-use: once handed to a call, drop it so a retry does
|
|
1029
|
+
// not present the same live token twice.
|
|
1030
|
+
capCache.delete(key);
|
|
1031
|
+
if (!entry) return undefined;
|
|
1032
|
+
// Treat an unparseable expiry as usable — the server is the authority on
|
|
1033
|
+
// expiry, and refusing here on a date we failed to parse would strand a token
|
|
1034
|
+
// that is actually fine.
|
|
1035
|
+
if (entry.expiresAt && Date.now() >= entry.expiresAt) return undefined;
|
|
1036
|
+
return entry.capId;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Cap ids this process has already handed to a call. Delivery is stateless
|
|
1040
|
+
// server-side and does NOT consume, so without this a retry (or a second tool
|
|
1041
|
+
// call) would present the same live token twice — and a double consume is
|
|
1042
|
+
// `:replay`, which IS byzantine and halts the whole tenant. takeCap gets the
|
|
1043
|
+
// same property for free by deleting on read.
|
|
1044
|
+
const spentCaps = new Set();
|
|
1045
|
+
|
|
1046
|
+
// Fetches a live capability of `typ` already issued to this caller's lineage.
|
|
1047
|
+
// Returns undefined when there is none — callers must degrade gracefully.
|
|
1048
|
+
async function fetchCap(storyId, typ, key) {
|
|
1049
|
+
try {
|
|
1050
|
+
const result = await apiCall("GET", `/api/v1/stories/${storyId}/capabilities`, null, key);
|
|
1051
|
+
const caps = (result && result.data) || [];
|
|
1052
|
+
const match = caps.find((c) => c.typ === typ && !spentCaps.has(c.cap_id));
|
|
1053
|
+
if (!match) return undefined;
|
|
1054
|
+
spentCaps.add(match.cap_id);
|
|
1055
|
+
boundedSet(spentCaps, SPENT_CAPS_MAX);
|
|
1056
|
+
return match.cap_id;
|
|
1057
|
+
} catch {
|
|
1058
|
+
return undefined;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Re-mints a start_cap for a story this agent owns (session-crash recovery).
|
|
1063
|
+
// Only start_cap is recoverable by design — an agent must never be able to mint a
|
|
1064
|
+
// capability to verify or report its own work.
|
|
1065
|
+
async function recoverStartCap(storyId) {
|
|
1066
|
+
try {
|
|
1067
|
+
const result = await apiCall(
|
|
1068
|
+
"POST",
|
|
1069
|
+
`/api/v1/stories/${storyId}/recover-cap`,
|
|
1070
|
+
null,
|
|
1071
|
+
process.env.LOOPCTL_AGENT_KEY
|
|
1072
|
+
);
|
|
1073
|
+
return result && result.data && result.data.cap_id;
|
|
1074
|
+
} catch {
|
|
1075
|
+
return undefined;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
955
1079
|
async function claimStory({ story_id }) {
|
|
956
1080
|
const result = await apiCall(
|
|
957
1081
|
"POST",
|
|
@@ -959,14 +1083,25 @@ async function claimStory({ story_id }) {
|
|
|
959
1083
|
null,
|
|
960
1084
|
process.env.LOOPCTL_AGENT_KEY
|
|
961
1085
|
);
|
|
1086
|
+
// The claim response carries the start_cap that POST /start will require.
|
|
1087
|
+
rememberCap(story_id, result && result.capability);
|
|
962
1088
|
return toContent(result);
|
|
963
1089
|
}
|
|
964
1090
|
|
|
965
|
-
async function startStory({ story_id }) {
|
|
1091
|
+
async function startStory({ story_id, capability }) {
|
|
1092
|
+
// Cache first, then DELIVERY of the token claim already minted, then recovery
|
|
1093
|
+
// (which mints a fresh one). Delivery also covers a legacy env-var key, whose
|
|
1094
|
+
// lineage is [] and which recover-cap cannot serve at all.
|
|
1095
|
+
const cap =
|
|
1096
|
+
capability ||
|
|
1097
|
+
takeCap(story_id, "start_cap") ||
|
|
1098
|
+
(await fetchCap(story_id, "start_cap", process.env.LOOPCTL_AGENT_KEY)) ||
|
|
1099
|
+
(await recoverStartCap(story_id));
|
|
1100
|
+
|
|
966
1101
|
const result = await apiCall(
|
|
967
1102
|
"POST",
|
|
968
1103
|
`/api/v1/stories/${story_id}/start`,
|
|
969
|
-
null,
|
|
1104
|
+
cap ? { capability: cap } : null,
|
|
970
1105
|
process.env.LOOPCTL_AGENT_KEY
|
|
971
1106
|
);
|
|
972
1107
|
return toContent(result);
|
|
@@ -1024,6 +1159,9 @@ async function reviewComplete({ story_id, review_type, findings_count, fixes_cou
|
|
|
1024
1159
|
|
|
1025
1160
|
// --- Verification Tools (orch key) ---
|
|
1026
1161
|
|
|
1162
|
+
// Verify consumes NO capability (#621): no token can be bound to the principal
|
|
1163
|
+
// this endpoint permits to spend it. The gate is structural — loopctl selects the
|
|
1164
|
+
// verifier lineage and compares it against the implementer's server-side.
|
|
1027
1165
|
async function verifyStory({ story_id, summary, review_type, claim }) {
|
|
1028
1166
|
const body = {};
|
|
1029
1167
|
if (summary) body.summary = summary;
|
|
@@ -1399,9 +1537,13 @@ async function knowledgeHybridSearch({ query, project_id, category, tags, match,
|
|
|
1399
1537
|
return withRemediationNotice(result);
|
|
1400
1538
|
}
|
|
1401
1539
|
|
|
1402
|
-
async function knowledgeProgressiveIndex({ topic, category, limit }) {
|
|
1540
|
+
async function knowledgeProgressiveIndex({ topic, query, category, limit }) {
|
|
1403
1541
|
const params = new URLSearchParams();
|
|
1404
|
-
|
|
1542
|
+
// The endpoint's parameter is `topic`; `query` is the canonical spelling this surface
|
|
1543
|
+
// converged on (#652 item 6). An explicit `topic` still wins, so a caller passing both
|
|
1544
|
+
// gets what it asked for.
|
|
1545
|
+
const resolved = topic != null && topic !== "" ? topic : query;
|
|
1546
|
+
if (resolved != null) params.set("topic", resolved);
|
|
1405
1547
|
if (category) params.set("category", category);
|
|
1406
1548
|
if (limit != null) params.set("limit", String(limit));
|
|
1407
1549
|
|
|
@@ -1429,10 +1571,19 @@ async function knowledgeHeatIndex({ category, limit, since }) {
|
|
|
1429
1571
|
return toContent(result);
|
|
1430
1572
|
}
|
|
1431
1573
|
|
|
1432
|
-
async function knowledgeProgressiveDrill({ article_id }) {
|
|
1574
|
+
async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offset }) {
|
|
1575
|
+
const params = new URLSearchParams();
|
|
1576
|
+
// 0 is meaningful on both (whole body / start at the beginning), so test for
|
|
1577
|
+
// null/undefined rather than truthiness.
|
|
1578
|
+
if (body_max_bytes !== undefined && body_max_bytes !== null)
|
|
1579
|
+
params.set("body_max_bytes", String(body_max_bytes));
|
|
1580
|
+
if (body_offset !== undefined && body_offset !== null)
|
|
1581
|
+
params.set("body_offset", String(body_offset));
|
|
1582
|
+
const qs = params.toString();
|
|
1583
|
+
const base = `/api/v1/knowledge/progressive/${article_id}`;
|
|
1433
1584
|
const result = await apiCall(
|
|
1434
1585
|
"GET",
|
|
1435
|
-
|
|
1586
|
+
qs ? `${base}?${qs}` : base,
|
|
1436
1587
|
null,
|
|
1437
1588
|
process.env.LOOPCTL_AGENT_KEY,
|
|
1438
1589
|
);
|
|
@@ -1476,11 +1627,24 @@ async function knowledgeList({
|
|
|
1476
1627
|
return toContent(result);
|
|
1477
1628
|
}
|
|
1478
1629
|
|
|
1479
|
-
async function knowledgeGet({
|
|
1630
|
+
async function knowledgeGet({
|
|
1631
|
+
article_id,
|
|
1632
|
+
project_id,
|
|
1633
|
+
story_id,
|
|
1634
|
+
links,
|
|
1635
|
+
body_max_bytes,
|
|
1636
|
+
body_offset,
|
|
1637
|
+
}) {
|
|
1480
1638
|
const params = new URLSearchParams();
|
|
1481
1639
|
if (project_id) params.set("project_id", project_id);
|
|
1482
1640
|
if (story_id) params.set("story_id", story_id);
|
|
1483
1641
|
if (links) params.set("links", links);
|
|
1642
|
+
// 0 is meaningful on both (whole body / start at the beginning), so test for
|
|
1643
|
+
// null/undefined rather than truthiness.
|
|
1644
|
+
if (body_max_bytes !== undefined && body_max_bytes !== null)
|
|
1645
|
+
params.set("body_max_bytes", String(body_max_bytes));
|
|
1646
|
+
if (body_offset !== undefined && body_offset !== null)
|
|
1647
|
+
params.set("body_offset", String(body_offset));
|
|
1484
1648
|
const qs = params.toString();
|
|
1485
1649
|
const path = qs ? `/api/v1/articles/${article_id}?${qs}` : `/api/v1/articles/${article_id}`;
|
|
1486
1650
|
const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
|
|
@@ -2772,9 +2936,12 @@ async function getSystemArticles({ slug, category } = {}) {
|
|
|
2772
2936
|
return toContent(result);
|
|
2773
2937
|
}
|
|
2774
2938
|
|
|
2775
|
-
// US-26: Cap recovery after session crash
|
|
2776
|
-
|
|
2777
|
-
|
|
2939
|
+
// US-26: Cap recovery after session crash. start_cap is the ONLY recoverable type
|
|
2940
|
+
// (#621) — the server answers any other cap_type with 422 AND records a
|
|
2941
|
+
// cap_recovery_forgery_attempt against the caller, so never forward one. `lineage`
|
|
2942
|
+
// is resolved server-side from the authenticating key and was always ignored.
|
|
2943
|
+
async function recoverCap({ story_id }) {
|
|
2944
|
+
const body = { cap_type: "start_cap" };
|
|
2778
2945
|
const result = await apiCall("POST", `/api/v1/stories/${story_id}/recover-cap`, body);
|
|
2779
2946
|
return toContent(result);
|
|
2780
2947
|
}
|
|
@@ -3563,7 +3730,10 @@ const TOOLS = [
|
|
|
3563
3730
|
{
|
|
3564
3731
|
name: "start_story",
|
|
3565
3732
|
description:
|
|
3566
|
-
"Agent starts work on a claimed story. Transitions assigned -> implementing. Uses the AGENT key."
|
|
3733
|
+
"Agent starts work on a claimed story. Transitions assigned -> implementing. Uses the AGENT key. " +
|
|
3734
|
+
"The L1 capability (start_cap) is handled for you: it is taken from the claim_story response, " +
|
|
3735
|
+
"and re-minted via recover-cap if this process lost it (e.g. after a session crash). " +
|
|
3736
|
+
"Pass `capability` only to override that.",
|
|
3567
3737
|
inputSchema: {
|
|
3568
3738
|
type: "object",
|
|
3569
3739
|
properties: {
|
|
@@ -3571,6 +3741,13 @@ const TOOLS = [
|
|
|
3571
3741
|
type: "string",
|
|
3572
3742
|
description: "The UUID of the story.",
|
|
3573
3743
|
},
|
|
3744
|
+
capability: {
|
|
3745
|
+
type: "string",
|
|
3746
|
+
description:
|
|
3747
|
+
"Optional start_cap cap_id. Normally omitted — supplied automatically from the " +
|
|
3748
|
+
"claim response or recovered. A tenant with an audit signing key cannot start " +
|
|
3749
|
+
"without one (403 missing_capability).",
|
|
3750
|
+
},
|
|
3574
3751
|
},
|
|
3575
3752
|
required: ["story_id"],
|
|
3576
3753
|
},
|
|
@@ -4023,7 +4200,7 @@ const TOOLS = [
|
|
|
4023
4200
|
name: "knowledge_list",
|
|
4024
4201
|
description:
|
|
4025
4202
|
"List articles (id, title, category, status, tags, source_type, source_id, " +
|
|
4026
|
-
"
|
|
4203
|
+
"timestamps), filtered and paginated. **Body-less summary by default** " +
|
|
4027
4204
|
"— the right tool to enumerate, dedup, or repair at scale (safe to page up to limit=1000). " +
|
|
4028
4205
|
"Pass `include_body: true` to also return the full `body`, in which case the server bounds " +
|
|
4029
4206
|
"the page by a ~5 MB serialized-body budget and returns meta.next_offset/has_more/" +
|
|
@@ -4034,7 +4211,9 @@ const TOOLS = [
|
|
|
4034
4211
|
"of record (draft, published, archived, superseded visible). Use for idempotency/existence " +
|
|
4035
4212
|
"checks: filter by `tags`, `source_type`+`source_id`, or `idempotency_key` and read " +
|
|
4036
4213
|
"`meta.total_count` (exact) to answer \"does an article for X already exist?\" reliably " +
|
|
4037
|
-
"right after a write
|
|
4214
|
+
"right after a write — `idempotency_key` is a FILTER only and is never returned in a " +
|
|
4215
|
+
"row, so you check a key you already hold rather than reading back the keys other " +
|
|
4216
|
+
"callers chose. Paginate via offset/limit.",
|
|
4038
4217
|
inputSchema: {
|
|
4039
4218
|
type: "object",
|
|
4040
4219
|
properties: {
|
|
@@ -4072,7 +4251,8 @@ const TOOLS = [
|
|
|
4072
4251
|
type: "string",
|
|
4073
4252
|
description:
|
|
4074
4253
|
"Optional: filter by exact idempotency_key — the lag-free existence check for a " +
|
|
4075
|
-
"prior capture."
|
|
4254
|
+
"prior capture. Filter only: it is not returned in the rows, so read " +
|
|
4255
|
+
"`meta.total_count`.",
|
|
4076
4256
|
},
|
|
4077
4257
|
offset: {
|
|
4078
4258
|
type: "integer",
|
|
@@ -4258,7 +4438,11 @@ const TOOLS = [
|
|
|
4258
4438
|
"Suggest ranked typed-link CANDIDATES for an article by embedding similarity — " +
|
|
4259
4439
|
"READ-ONLY, creates nothing. Excludes the article itself and any already-linked " +
|
|
4260
4440
|
"article (either direction, any relationship type); only embedded published articles. " +
|
|
4261
|
-
"Returns { data: [{id, title, category, similarity_score}] } highest-similarity
|
|
4441
|
+
"Returns { data: [{id, title, category, similarity_score}], meta } highest-similarity " +
|
|
4442
|
+
"first. Read `meta.ann_iterative_scan` before concluding an article has no neighbours: " +
|
|
4443
|
+
"`unavailable` (with `meta.ann_iterative_scan_reason`) means the vector read ran without " +
|
|
4444
|
+
"pgvector's iterative scan and the list may be INCOMPLETE — `meta.recall_truncated: false` " +
|
|
4445
|
+
"does NOT cover that case. " +
|
|
4262
4446
|
"Review them and create the one you want as a TYPED link (relates_to/derived_from/" +
|
|
4263
4447
|
"contradicts/supersedes) — unlike the auto-linker which only makes ambient relates_to. " +
|
|
4264
4448
|
"Optional: threshold (cosine floor 0–1, default 0.5), limit (default 5).",
|
|
@@ -4385,14 +4569,24 @@ const TOOLS = [
|
|
|
4385
4569
|
"— a stable tag naming WHY (e.g. no_embedding_key, embedding_circuit_open, " +
|
|
4386
4570
|
"embedding_provider_error_<status>, embedding_timeout). When the reason is a MISSING " +
|
|
4387
4571
|
"embedding key (no_embedding_key), the result leads with an ACTION REQUIRED notice + " +
|
|
4388
|
-
"meta.remediation telling you to provision it with set_llm_config (BYO — do it once)."
|
|
4572
|
+
"meta.remediation telling you to provision it with set_llm_config (BYO — do it once). " +
|
|
4573
|
+
"On the semantic/combined paths meta.ann_iterative_scan (`off`/`applied`/`unavailable`, " +
|
|
4574
|
+
"with meta.ann_iterative_scan_reason alongside `unavailable`) discloses whether the vector " +
|
|
4575
|
+
"read ran with pgvector's iterative scan — `unavailable` means results may be INCOMPLETE, " +
|
|
4576
|
+
"which meta.fallback and the total_count fields cannot tell you.",
|
|
4389
4577
|
inputSchema: {
|
|
4390
4578
|
type: "object",
|
|
4391
4579
|
properties: {
|
|
4392
|
-
|
|
4580
|
+
query: {
|
|
4393
4581
|
type: "string",
|
|
4394
4582
|
description:
|
|
4395
|
-
"Search query string. Optional when tags/category are supplied (enumeration mode)."
|
|
4583
|
+
"Search query string. Optional when tags/category are supplied (enumeration mode). " +
|
|
4584
|
+
"`query` is the canonical spelling across every search-shaped tool here; `q` is " +
|
|
4585
|
+
"the historical name and is still accepted.",
|
|
4586
|
+
},
|
|
4587
|
+
q: {
|
|
4588
|
+
type: "string",
|
|
4589
|
+
description: "Deprecated alias for `query`. Accepted; prefer `query`.",
|
|
4396
4590
|
},
|
|
4397
4591
|
project_id: {
|
|
4398
4592
|
type: "string",
|
|
@@ -4500,9 +4694,15 @@ const TOOLS = [
|
|
|
4500
4694
|
inputSchema: {
|
|
4501
4695
|
type: "object",
|
|
4502
4696
|
properties: {
|
|
4697
|
+
query: {
|
|
4698
|
+
type: "string",
|
|
4699
|
+
description:
|
|
4700
|
+
"The topic to index (max 500 characters). Required unless `topic` is given. " +
|
|
4701
|
+
"`query` is the canonical spelling across every search-shaped tool here.",
|
|
4702
|
+
},
|
|
4503
4703
|
topic: {
|
|
4504
4704
|
type: "string",
|
|
4505
|
-
description: "
|
|
4705
|
+
description: "Historical name for `query`. Accepted; prefer `query`.",
|
|
4506
4706
|
},
|
|
4507
4707
|
category: {
|
|
4508
4708
|
type: "string",
|
|
@@ -4513,7 +4713,7 @@ const TOOLS = [
|
|
|
4513
4713
|
description: "Optional: top-K override (clamped to the configured cap).",
|
|
4514
4714
|
},
|
|
4515
4715
|
},
|
|
4516
|
-
required: [
|
|
4716
|
+
required: [],
|
|
4517
4717
|
},
|
|
4518
4718
|
},
|
|
4519
4719
|
{
|
|
@@ -4572,7 +4772,11 @@ const TOOLS = [
|
|
|
4572
4772
|
"reach — both resolve the same ids now. A drill records an UNCOUNTED read, so " +
|
|
4573
4773
|
"following an index never raises the heat of what that index just showed you; " +
|
|
4574
4774
|
"knowledge_get records a counted one, which is a vote that the article was worth " +
|
|
4575
|
-
"opening on its own. Following a list is not a vote
|
|
4775
|
+
"opening on its own. Following a list is not a vote.\n\n" +
|
|
4776
|
+
"BODY: served in a byte WINDOW (default 32000 bytes) exactly like knowledge_get, so " +
|
|
4777
|
+
"an oversized article comes back in parts instead of being rejected whole by a client " +
|
|
4778
|
+
"token cap. Read body_truncated / next_body_offset to continue, or pass " +
|
|
4779
|
+
"body_max_bytes: 0 for the whole body.",
|
|
4576
4780
|
inputSchema: {
|
|
4577
4781
|
type: "object",
|
|
4578
4782
|
properties: {
|
|
@@ -4581,6 +4785,19 @@ const TOOLS = [
|
|
|
4581
4785
|
format: "uuid",
|
|
4582
4786
|
description: "The UUID of the article to open (from a progressive index stub).",
|
|
4583
4787
|
},
|
|
4788
|
+
body_max_bytes: {
|
|
4789
|
+
type: "integer",
|
|
4790
|
+
minimum: 0,
|
|
4791
|
+
description:
|
|
4792
|
+
"Optional: serialized-body byte budget (default 32000). 0 returns the whole body.",
|
|
4793
|
+
},
|
|
4794
|
+
body_offset: {
|
|
4795
|
+
type: "integer",
|
|
4796
|
+
minimum: 0,
|
|
4797
|
+
description:
|
|
4798
|
+
"Optional: byte offset to start the body window at (default 0). Pass the " +
|
|
4799
|
+
"previous response's next_body_offset to read the next part.",
|
|
4800
|
+
},
|
|
4584
4801
|
},
|
|
4585
4802
|
required: ["article_id"],
|
|
4586
4803
|
},
|
|
@@ -4610,7 +4827,13 @@ const TOOLS = [
|
|
|
4610
4827
|
"`potential_conflicts` is returned in all three modes, so opting out of the link " +
|
|
4611
4828
|
"list never hides a conflict from you; it is capped at 25 (strongest first) with " +
|
|
4612
4829
|
"`conflicts_total` / `conflicts_truncated`. To actually traverse the graph, use " +
|
|
4613
|
-
"knowledge_graph rather than raising this cap
|
|
4830
|
+
"knowledge_graph rather than raising this cap.\n\n" +
|
|
4831
|
+
"BODY: the body is served in a byte WINDOW (default 32000 bytes) so an oversized " +
|
|
4832
|
+
"article is returned in parts instead of being rejected whole by a client token " +
|
|
4833
|
+
"cap - four measured reads of 61-82KB were discarded that way after the search had " +
|
|
4834
|
+
"already found them. Every response carries body_bytes (the full size), body_offset, " +
|
|
4835
|
+
"body_returned_bytes, body_truncated and next_body_offset; pass next_body_offset back " +
|
|
4836
|
+
"as body_offset to continue, or body_max_bytes: 0 for the whole body in one read.",
|
|
4614
4837
|
inputSchema: {
|
|
4615
4838
|
type: "object",
|
|
4616
4839
|
properties: {
|
|
@@ -4628,6 +4851,20 @@ const TOOLS = [
|
|
|
4628
4851
|
"link fields. potential_conflicts (capped, with conflicts_total) is always " +
|
|
4629
4852
|
"returned.",
|
|
4630
4853
|
},
|
|
4854
|
+
body_max_bytes: {
|
|
4855
|
+
type: "integer",
|
|
4856
|
+
minimum: 0,
|
|
4857
|
+
description:
|
|
4858
|
+
"Optional: serialized-body byte budget (default 32000). 0 returns the whole " +
|
|
4859
|
+
"body. Read body_truncated / next_body_offset to continue.",
|
|
4860
|
+
},
|
|
4861
|
+
body_offset: {
|
|
4862
|
+
type: "integer",
|
|
4863
|
+
minimum: 0,
|
|
4864
|
+
description:
|
|
4865
|
+
"Optional: byte offset to start the body window at (default 0). Pass the " +
|
|
4866
|
+
"previous response's next_body_offset to read the next part.",
|
|
4867
|
+
},
|
|
4631
4868
|
project_id: {
|
|
4632
4869
|
type: "string",
|
|
4633
4870
|
format: "uuid",
|
|
@@ -4944,7 +5181,12 @@ const TOOLS = [
|
|
|
4944
5181
|
"match with `meta.fallback: true` and a stable `meta.reason` (score is null on that " +
|
|
4945
5182
|
"path) — check meta.fallback before treating a short/empty result as a genuinely " +
|
|
4946
5183
|
"empty scope. `meta.total_count` and `meta.underfilled` are also returned so you can " +
|
|
4947
|
-
"distinguish a short page from a hard cap."
|
|
5184
|
+
"distinguish a short page from a hard cap. Check `meta.ann_iterative_scan` too: " +
|
|
5185
|
+
"`unavailable` (with `meta.ann_iterative_scan_reason`) means the vector read ran " +
|
|
5186
|
+
"without pgvector's iterative scan and may be INCOMPLETE — a short page then is not " +
|
|
5187
|
+
"evidence of a sparse scope, and meta.fallback/underfilled cannot tell you that. It " +
|
|
5188
|
+
"is absent on the ILIKE fallback AND on an `include_superseded: true` recall (a " +
|
|
5189
|
+
"bounded exact top-k, no index scan), so absence never means the fallback ran.",
|
|
4948
5190
|
inputSchema: {
|
|
4949
5191
|
type: "object",
|
|
4950
5192
|
properties: {
|
|
@@ -4979,7 +5221,10 @@ const TOOLS = [
|
|
|
4979
5221
|
"untouched per-source `memory` and `knowledge` envelopes so you can re-rank. " +
|
|
4980
5222
|
"Cross-source scores are heuristic, not calibrated. If the knowledge search " +
|
|
4981
5223
|
"degrades (embedding unavailable) or errors, the memory side is still returned and " +
|
|
4982
|
-
"meta.degraded is true — never a hard failure."
|
|
5224
|
+
"meta.degraded is true — never a hard failure. Each envelope's " +
|
|
5225
|
+
"`meta.ann_iterative_scan` discloses whether THAT half's vector read ran with " +
|
|
5226
|
+
"pgvector's iterative scan (`unavailable` ⇒ possibly incomplete); the two halves " +
|
|
5227
|
+
"are resolved independently and may differ.",
|
|
4983
5228
|
inputSchema: {
|
|
4984
5229
|
type: "object",
|
|
4985
5230
|
properties: {
|
|
@@ -5400,10 +5645,16 @@ const TOOLS = [
|
|
|
5400
5645
|
"synthesize the two into ONE new DRAFT — both sources preserved, never auto-published, " +
|
|
5401
5646
|
"for you/a human to review and publish). Non-destructive " +
|
|
5402
5647
|
"at agent role — you record intent; the privileged nightly job executes it. " +
|
|
5648
|
+
"NOTE for an agent-role key: 'supersede' is the one disposition that RETIRES an " +
|
|
5649
|
+
"article unattended, so its confidence is capped server-side — your \"high\" is " +
|
|
5650
|
+
"recorded as \"medium\" (see data.requested_confidence and note in the response) and " +
|
|
5651
|
+
"the pair STAYS in knowledge_conflicts until an orchestrator+ key records it at high. " +
|
|
5652
|
+
"'merge' is never capped and executes normally at agent role. " +
|
|
5403
5653
|
"Last-write-wins per pair, so re-recording with fresher ground truth overrides. " +
|
|
5404
5654
|
"Resolve only conflicts material to your current task; adjudicate against the actual " +
|
|
5405
|
-
"system, and if you can't tell which is right,
|
|
5406
|
-
"
|
|
5655
|
+
"system, and if you can't tell which is right, LEAVE IT UNRECORDED rather than " +
|
|
5656
|
+
"guessing — recording low confidence is not a way to park it, it closes the verdict " +
|
|
5657
|
+
"as dismissed on the next nightly run.",
|
|
5407
5658
|
inputSchema: {
|
|
5408
5659
|
type: "object",
|
|
5409
5660
|
properties: {
|
|
@@ -5440,14 +5691,21 @@ const TOOLS = [
|
|
|
5440
5691
|
type: "string",
|
|
5441
5692
|
description:
|
|
5442
5693
|
"Why you're sure — ideally a ground-truth reference (commit, file:line, URL, or the " +
|
|
5443
|
-
"observed behavior). Recorded for audit and for a human reviewing low-confidence
|
|
5694
|
+
"observed behavior). Recorded for audit and for a human reviewing low-confidence " +
|
|
5695
|
+
"calls. REQUIRED for a supersede OR merge recorded at confidence 'high' (422 " +
|
|
5696
|
+
"without it) — every verdict the executor applies unattended must say why.",
|
|
5444
5697
|
},
|
|
5445
5698
|
confidence: {
|
|
5446
5699
|
type: "string",
|
|
5447
5700
|
enum: ["high", "medium", "low"],
|
|
5448
5701
|
description:
|
|
5449
|
-
"high, medium, or low. supersede auto-
|
|
5450
|
-
"recorded
|
|
5702
|
+
"high, medium, or low. Default medium. supersede/merge auto-execute only at 'high'; " +
|
|
5703
|
+
"recorded LOWER, the next nightly run closes the verdict as dismissed (both " +
|
|
5704
|
+
"articles retained) and the pair leaves the conflict queue — it is NOT left for " +
|
|
5705
|
+
"review, so re-record at 'high' if you mean it to apply. On a supersede the value " +
|
|
5706
|
+
"is a REQUEST: it is capped to 'medium' unless the calling key is orchestrator+, " +
|
|
5707
|
+
"and a CAPPED verdict is the exception — it stays in the queue for an " +
|
|
5708
|
+
"orchestrator+ key. merge is not capped.",
|
|
5451
5709
|
},
|
|
5452
5710
|
},
|
|
5453
5711
|
required: ["source_article_id", "target_article_id", "disposition"],
|
|
@@ -6362,13 +6620,22 @@ const TOOLS = [
|
|
|
6362
6620
|
description:
|
|
6363
6621
|
"Mint an ephemeral api_key for a sub-agent dispatch. " +
|
|
6364
6622
|
"The raw_key is returned ONCE — pass it to the sub-agent via its launch arguments, " +
|
|
6365
|
-
"never store it in env vars. The key expires after expires_in_seconds."
|
|
6623
|
+
"never store it in env vars. The key expires after expires_in_seconds. " +
|
|
6624
|
+
"Pass parent_dispatch_id: a dispatch may only be minted INSIDE the caller's own " +
|
|
6625
|
+
"lineage. Omitting it starts a new independent lineage tree, which only the " +
|
|
6626
|
+
"tenant's user-role operator key may do — every other caller gets 403 " +
|
|
6627
|
+
"root_dispatch_forbidden, and the 403 body returns the caller's own dispatch id " +
|
|
6628
|
+
"as remediation.your_dispatch_id.",
|
|
6366
6629
|
inputSchema: {
|
|
6367
6630
|
type: "object",
|
|
6368
6631
|
properties: {
|
|
6369
6632
|
parent_dispatch_id: {
|
|
6370
6633
|
type: "string",
|
|
6371
|
-
description:
|
|
6634
|
+
description:
|
|
6635
|
+
"UUID of the parent dispatch — your own dispatch id, or one of its " +
|
|
6636
|
+
"descendants. Required in practice: omitting it requests a ROOT dispatch, " +
|
|
6637
|
+
"which is 403 root_dispatch_forbidden for any caller a dispatch minted. " +
|
|
6638
|
+
"A parent outside your lineage is 403 parent_outside_caller_lineage.",
|
|
6372
6639
|
},
|
|
6373
6640
|
role: {
|
|
6374
6641
|
type: "string",
|
|
@@ -6694,13 +6961,11 @@ const TOOLS = [
|
|
|
6694
6961
|
},
|
|
6695
6962
|
{
|
|
6696
6963
|
name: "recover_cap",
|
|
6697
|
-
description: "Re-mint
|
|
6964
|
+
description: "Re-mint the start_cap for a story you're assigned to. Use after a session crash when you've lost it. start_cap is the only recoverable capability: asking for any other type is refused and logged as a forgery attempt.",
|
|
6698
6965
|
inputSchema: {
|
|
6699
6966
|
type: "object",
|
|
6700
6967
|
properties: {
|
|
6701
6968
|
story_id: { type: "string", description: "Story UUID." },
|
|
6702
|
-
cap_type: { type: "string", enum: ["start_cap", "report_cap"], description: "Which cap to recover (default: start_cap)." },
|
|
6703
|
-
lineage: { type: "array", items: { type: "string" }, description: "Your dispatch lineage path." },
|
|
6704
6969
|
},
|
|
6705
6970
|
required: ["story_id"],
|
|
6706
6971
|
},
|
|
@@ -6767,6 +7032,17 @@ const server = new Server(
|
|
|
6767
7032
|
}
|
|
6768
7033
|
);
|
|
6769
7034
|
|
|
7035
|
+
// The parameter names each STATIC tool declares, so the alias layer can tell a rescue from
|
|
7036
|
+
// an inert convenience fill. Dynamic per-tenant `cr_*` tools are absent here and fall back
|
|
7037
|
+
// to reporting every fill (the conservative default).
|
|
7038
|
+
const DECLARED_TOOL_ARGS = new Map(
|
|
7039
|
+
TOOLS.map((t) => [t.name, Object.keys(t.inputSchema?.properties ?? {})]),
|
|
7040
|
+
);
|
|
7041
|
+
|
|
7042
|
+
function declaredToolArgs(name) {
|
|
7043
|
+
return DECLARED_TOOL_ARGS.get(name);
|
|
7044
|
+
}
|
|
7045
|
+
|
|
6770
7046
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
6771
7047
|
// Static hand-maintained tools PLUS the calling tenant's per-tenant generated
|
|
6772
7048
|
// Context Retriever tools (US-30.5). fetchGeneratedTools degrades to the static
|
|
@@ -6776,7 +7052,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6776
7052
|
});
|
|
6777
7053
|
|
|
6778
7054
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
6779
|
-
const { name
|
|
7055
|
+
const { name } = request.params;
|
|
7056
|
+
// The callback keeps the schema inconsistency MEASURABLE rather than merely survivable:
|
|
7057
|
+
// every rescue is a call that would have been a hard 400 before, and a count of them is
|
|
7058
|
+
// the evidence for eventually converging the spellings instead of aliasing forever.
|
|
7059
|
+
//
|
|
7060
|
+
// The tool's OWN declared parameters are passed so only a genuine rescue is reported. The
|
|
7061
|
+
// fill is bidirectional, so without this a correct `knowledge_search` call carrying `q`
|
|
7062
|
+
// also filled `query` and logged a "rescue" — counting normal traffic, and drowning the
|
|
7063
|
+
// signal the count exists to carry.
|
|
7064
|
+
const args = applyArgAliases(
|
|
7065
|
+
request.params.arguments,
|
|
7066
|
+
({ canonical, alias }) => {
|
|
7067
|
+
process.stderr.write(
|
|
7068
|
+
`[loopctl-mcp] arg alias applied: '${alias}' -> '${canonical}' on tool '${name}'\n`,
|
|
7069
|
+
);
|
|
7070
|
+
},
|
|
7071
|
+
declaredToolArgs(name),
|
|
7072
|
+
);
|
|
6780
7073
|
|
|
6781
7074
|
switch (name) {
|
|
6782
7075
|
// Project Tools
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument-name aliases for MCP tool calls (#657).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. A catalogue of 1,932 real agent searches, mined from Claude session
|
|
5
|
+
* transcripts across two machines, found that 86 `knowledge_search` calls — 8% of every
|
|
6
|
+
* search call made — failed with `400 Query parameter 'q' is required`. 85 of those had
|
|
7
|
+
* passed `query` instead of `q`, and most also passed `max_results` instead of `limit`.
|
|
8
|
+
*
|
|
9
|
+
* The queries were not the problem. They were the best queries in the corpus:
|
|
10
|
+
*
|
|
11
|
+
* {"query": "custody halt tenant threshold byzantine detection", "max_results": "1"}
|
|
12
|
+
* {"query": "LCP-1 signed custody profile attestation enrollment owner key"}
|
|
13
|
+
* {"query": "dropping a legacy pgvector HNSW index shared_buffers eviction retirement"}
|
|
14
|
+
*
|
|
15
|
+
* Every one was discarded over a synonym. Agents reach for `query`/`max_results` because
|
|
16
|
+
* that is what the surrounding ecosystem uses (WebSearch takes `query`; several MCP servers
|
|
17
|
+
* take `max_results`), and no amount of tool-description wording reliably overrides that
|
|
18
|
+
* habit — these calls were made by agents that had the schema in context.
|
|
19
|
+
*
|
|
20
|
+
* A search costs an embedding call and a turn of human attention. Refusing one over a
|
|
21
|
+
* spelling is the worst trade available. Accept both.
|
|
22
|
+
*
|
|
23
|
+
* CANONICAL WINS. An explicit canonical value is never overwritten, so a caller passing both
|
|
24
|
+
* `q` and `query` gets exactly what it asked for. Only a missing/blank canonical is filled.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// BIDIRECTIONAL BY DESIGN. The 86 failures were not agents guessing wrong — this MCP
|
|
28
|
+
// server's own surface is inconsistent, and `knowledge_search` is the odd one out:
|
|
29
|
+
//
|
|
30
|
+
// query -> knowledge_hybrid_search, knowledge_context, memory_recall, recall_context
|
|
31
|
+
// q -> knowledge_search (alone)
|
|
32
|
+
//
|
|
33
|
+
// Four sibling tools take `query`; the single most-used tool takes `q`. An agent that
|
|
34
|
+
// learns `query` from any neighbour and applies it to knowledge_search gets a 400. So the
|
|
35
|
+
// mapping runs BOTH ways: whichever spelling arrives, the other is filled in, and each tool
|
|
36
|
+
// reads the key it declares. Handlers select named arguments when building their request,
|
|
37
|
+
// so the extra key is inert rather than forwarded.
|
|
38
|
+
//
|
|
39
|
+
// Renaming knowledge_search's parameter instead would be a breaking change for every
|
|
40
|
+
// existing caller that already passes `q` correctly. Accepting both costs nothing.
|
|
41
|
+
//
|
|
42
|
+
// A BIDIRECTIONAL FILL IS NOT A BIDIRECTIONAL RESCUE. Because a blank canonical is filled
|
|
43
|
+
// from whichever spelling arrived, a perfectly correct `knowledge_search` call carrying `q`
|
|
44
|
+
// ALSO gets `query` populated. Counting that as a rescue made the metric count normal
|
|
45
|
+
// traffic — far more often than real rescues — so it could not support the decision it was
|
|
46
|
+
// built to inform. `declared` (below) is the tool's own parameter list: a rescue is counted
|
|
47
|
+
// only when the key the HANDLER READS was blank and the alias supplied it.
|
|
48
|
+
//
|
|
49
|
+
// ONLY OBSERVED ALIASES. This table lists what agents were MEASURED to send, nothing more.
|
|
50
|
+
// The first draft also mapped `search`, `text`, `top_k`, `topK`, `n` and the camelCase id
|
|
51
|
+
// spellings — all invented, none seen in the data — and the drift guard below immediately
|
|
52
|
+
// caught two of them colliding with real parameters that mean something else entirely:
|
|
53
|
+
// four tools declare `query` as their own canonical, and `memory_remember` declares `text`
|
|
54
|
+
// for the CONTENT of a memory. Aliasing that into `q` would have copied a memory's body
|
|
55
|
+
// into a search-query slot.
|
|
56
|
+
//
|
|
57
|
+
// A speculative alias is not free: it is a silent rename of somebody else's parameter.
|
|
58
|
+
// Add one only when a real failing call is observed to need it.
|
|
59
|
+
//
|
|
60
|
+
// `max_results` has NO reverse entry on purpose: no tool declares it, so filling it from
|
|
61
|
+
// `limit` rescued nothing and merely put a stderr write on the hot path of every call that
|
|
62
|
+
// paginated correctly.
|
|
63
|
+
const ARG_ALIASES = {
|
|
64
|
+
q: ["query"],
|
|
65
|
+
query: ["q"],
|
|
66
|
+
limit: ["max_results"],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function isBlank(v) {
|
|
70
|
+
return v === undefined || v === null || v === "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns a NEW args object with canonical keys filled in from any alias present.
|
|
75
|
+
* Non-object input (null/undefined/array) is returned unchanged.
|
|
76
|
+
*
|
|
77
|
+
* `declared` is the set/array of parameter names the CALLED TOOL actually declares. When
|
|
78
|
+
* supplied, only a fill of a declared key is reported as a rescue — the rest are inert
|
|
79
|
+
* conveniences. Omit it and every fill is reported (the conservative default for a tool
|
|
80
|
+
* whose schema is not known, e.g. the per-tenant `cr_*` tools).
|
|
81
|
+
*/
|
|
82
|
+
function applyArgAliases(args, onAliasUsed, declared) {
|
|
83
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return args;
|
|
84
|
+
|
|
85
|
+
const out = { ...args };
|
|
86
|
+
const declaredSet = declared ? new Set(declared) : null;
|
|
87
|
+
|
|
88
|
+
for (const [canonical, aliases] of Object.entries(ARG_ALIASES)) {
|
|
89
|
+
if (!isBlank(out[canonical])) continue;
|
|
90
|
+
for (const alias of aliases) {
|
|
91
|
+
if (!isBlank(out[alias])) {
|
|
92
|
+
out[canonical] = out[alias];
|
|
93
|
+
// Report every real rescue. Aliasing treats the SYMPTOM — the real defect is that
|
|
94
|
+
// the tool surface spells the same parameter three ways (`q`, `query`, `topic`). If
|
|
95
|
+
// the rescue is invisible, the inconsistency costs nothing measurable and never gets
|
|
96
|
+
// fixed, and the alias table quietly becomes load-bearing forever. Counting it
|
|
97
|
+
// keeps the residual cost on the books — which only works if the count is of calls
|
|
98
|
+
// that WOULD HAVE FAILED, hence the `declared` gate.
|
|
99
|
+
const rescued = declaredSet === null || declaredSet.has(canonical);
|
|
100
|
+
if (rescued && typeof onAliasUsed === "function") {
|
|
101
|
+
try {
|
|
102
|
+
onAliasUsed({ canonical, alias });
|
|
103
|
+
} catch {
|
|
104
|
+
// Telemetry must never break a tool call.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Some callers send a numeric arg as a string ("6"). Coerce so downstream validation and
|
|
113
|
+
// the outbound query string both see a number rather than rejecting or double-encoding.
|
|
114
|
+
if (typeof out.limit === "string" && /^\d+$/.test(out.limit.trim())) {
|
|
115
|
+
out.limit = parseInt(out.limit.trim(), 10);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export { ARG_ALIASES, applyArgAliases };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client context reported alongside every search (#658).
|
|
3
|
+
*
|
|
4
|
+
* WHY THE CLIENT HAS TO SUPPLY THIS. None of it is derivable server-side. An api_key
|
|
5
|
+
* identifies a KEY, and under the v2 dispatch pattern a key is minted per dispatch, so the
|
|
6
|
+
* server cannot tell which agent searched, at what effort, from which repo, or whether the
|
|
7
|
+
* caller was a main session or a dispatched subagent. The MCP server runs inside the
|
|
8
|
+
* agent's own process, so it can simply read its environment.
|
|
9
|
+
*
|
|
10
|
+
* UNTRUSTED BY CONSTRUCTION. Every field here is client-asserted and trivially spoofable.
|
|
11
|
+
* It is ANALYTICS ONLY and must never gate access or authorize anything — the api_key
|
|
12
|
+
* remains the sole authority. The server stores these under a `client_` prefix so no later
|
|
13
|
+
* reader mistakes them for server-derived facts.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IS ACTUALLY AVAILABLE (verified on a live session, 2026-08-12):
|
|
16
|
+
*
|
|
17
|
+
* CLAUDE_EFFORT=high -> effort, available
|
|
18
|
+
* CLAUDE_SESSION_ID=<uuid> -> session id, available
|
|
19
|
+
* CLAUDE_CODE_CHILD_SESSION=1 -> subagent vs main session, available
|
|
20
|
+
* CLAUDE_CODE_ENTRYPOINT=cli -> entrypoint, available
|
|
21
|
+
* (no model variable) -> MODEL IS NOT AVAILABLE
|
|
22
|
+
*
|
|
23
|
+
* The model is deliberately still sent when a variable for it appears, because the session
|
|
24
|
+
* TRANSCRIPT does record it (`message.model`, e.g. `claude-opus-5`) keyed by session id —
|
|
25
|
+
* so `client_session_id` is the join key that enriches model offline today, and the field
|
|
26
|
+
* fills itself in the day the runtime exposes one.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { execFileSync } from "node:child_process";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
|
|
32
|
+
function env(name) {
|
|
33
|
+
const v = process.env[name];
|
|
34
|
+
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The repo the AGENT was working in — which is NOT the project a KB search was scoped to.
|
|
39
|
+
* "Which repos lean on the knowledge base, and which never touch it" cannot be answered
|
|
40
|
+
* without it, and the scoped project_id does not answer it.
|
|
41
|
+
*
|
|
42
|
+
* Derived from the git remote so it is stable across machines and checkout paths; falls
|
|
43
|
+
* back to the directory basename when there is no remote. Cached: this runs per search and
|
|
44
|
+
* shelling out per call would put a process spawn on the hot path.
|
|
45
|
+
*/
|
|
46
|
+
let repoCache;
|
|
47
|
+
function detectRepo() {
|
|
48
|
+
if (repoCache !== undefined) return repoCache;
|
|
49
|
+
repoCache = null;
|
|
50
|
+
try {
|
|
51
|
+
const remote = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
54
|
+
timeout: 2000,
|
|
55
|
+
}).trim();
|
|
56
|
+
// Normalise git@host:owner/repo.git and https://host/owner/repo(.git) to owner/repo.
|
|
57
|
+
const m = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
|
|
58
|
+
if (m) repoCache = m[1];
|
|
59
|
+
} catch {
|
|
60
|
+
try {
|
|
61
|
+
repoCache = process.cwd().split("/").filter(Boolean).pop() || null;
|
|
62
|
+
} catch {
|
|
63
|
+
repoCache = null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return repoCache;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Test seam: forget the cached repo. */
|
|
70
|
+
function resetRepoCache() {
|
|
71
|
+
repoCache = undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Builds the client-context payload. Every field is optional — a missing value is omitted
|
|
76
|
+
* rather than sent as null, so "we could not observe this" and "this was empty" stay
|
|
77
|
+
* distinguishable in the data.
|
|
78
|
+
*/
|
|
79
|
+
function clientContext({ version } = {}) {
|
|
80
|
+
const ctx = {
|
|
81
|
+
session_id: env("CLAUDE_SESSION_ID") || env("CLAUDE_CODE_SESSION_ID"),
|
|
82
|
+
effort: env("CLAUDE_EFFORT"),
|
|
83
|
+
model: env("CLAUDE_MODEL") || env("ANTHROPIC_MODEL"),
|
|
84
|
+
host: os.hostname(),
|
|
85
|
+
repo: detectRepo() || undefined,
|
|
86
|
+
entrypoint: env("CLAUDE_CODE_ENTRYPOINT"),
|
|
87
|
+
version,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// main vs child. A dispatched agent sets CLAUDE_CODE_CHILD_SESSION=1.
|
|
91
|
+
//
|
|
92
|
+
// Only TWO values, on purpose. A workflow agent cannot be distinguished from an ordinary
|
|
93
|
+
// subagent here: CLAUDE_CODE_WORKFLOWS is a feature flag that is present in main sessions
|
|
94
|
+
// too, and nothing else marks one. Since an audit measured materially different failure
|
|
95
|
+
// rates across main/workflow/subagent, the third value matters — but it is recoverable
|
|
96
|
+
// only OFFLINE, by joining session_id to the transcript path (wf_* vs subagents/). Report
|
|
97
|
+
// what is observable and let the join supply the rest; do not guess a value that would
|
|
98
|
+
// then be analysed as fact.
|
|
99
|
+
const child = env("CLAUDE_CODE_CHILD_SESSION");
|
|
100
|
+
if (child !== undefined) {
|
|
101
|
+
ctx.kind = child === "1" || child.toLowerCase() === "true" ? "child" : "main";
|
|
102
|
+
} else if (ctx.session_id) {
|
|
103
|
+
// A MAIN session sets no child marker at all, so keying on the marker alone filed
|
|
104
|
+
// every main session under NULL — beside every request from an older client that sends
|
|
105
|
+
// no context, which is the one population NULL has to keep meaning. An absent marker on
|
|
106
|
+
// a recognisable session IS main; with nothing identifying the caller, kind stays
|
|
107
|
+
// absent rather than guessed.
|
|
108
|
+
ctx.kind = "main";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const k of Object.keys(ctx)) {
|
|
112
|
+
if (ctx[k] === undefined || ctx[k] === null || ctx[k] === "") delete ctx[k];
|
|
113
|
+
}
|
|
114
|
+
return ctx;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Encodes the context for transport as a single header. Base64 keeps arbitrary repo names
|
|
119
|
+
* and hostnames from breaking header parsing, and one header keeps the surface small.
|
|
120
|
+
* Returns undefined when there is nothing to report, so no empty header is sent.
|
|
121
|
+
*/
|
|
122
|
+
function clientContextHeader(opts) {
|
|
123
|
+
const ctx = clientContext(opts);
|
|
124
|
+
if (Object.keys(ctx).length === 0) return undefined;
|
|
125
|
+
return Buffer.from(JSON.stringify(ctx), "utf8").toString("base64");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { clientContext, clientContextHeader, detectRepo, resetRepoCache };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leading notices for search results whose META carries something the agent must act on
|
|
3
|
+
* (#658).
|
|
4
|
+
*
|
|
5
|
+
* THE DEFECT THIS FIXES. When semantic ranking is unavailable the server degrades to
|
|
6
|
+
* keyword-only and says so LOUDLY — `meta.fallback: true`, `meta.degraded: true`,
|
|
7
|
+
* `meta.fallback_reason`, plus telemetry and a server-side warning log. The degradation is
|
|
8
|
+
* not silent at the server. It is silent at the CLIENT: agents do not read `meta`.
|
|
9
|
+
*
|
|
10
|
+
* Measured across real session transcripts on two machines: every degraded response that
|
|
11
|
+
* came back empty was treated by the receiving agent as "the knowledge base has nothing".
|
|
12
|
+
* It is a low-volume failure (roughly a dozen in ~1,900 searches) and a totally deceptive
|
|
13
|
+
* one — and it lands hardest on the BEST queries, because the keyword fallback lane uses
|
|
14
|
+
* AND semantics, so a long specific query is the most likely to match nothing at all.
|
|
15
|
+
*
|
|
16
|
+
* The remedy the agent needs is the opposite of the obvious one: do NOT rephrase. Different
|
|
17
|
+
* words cannot fix a provider timeout. Retry the SAME query.
|
|
18
|
+
*
|
|
19
|
+
* This mirrors the existing BYO-LLM `no_embedding_key` ACTION REQUIRED notice, which
|
|
20
|
+
* already established that a meta-only disclosure is not enough to change behaviour.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** True when a result degraded to a fallback lane, whatever the cause. */
|
|
24
|
+
function isDegraded(meta) {
|
|
25
|
+
return Boolean(meta && (meta.fallback === true || meta.degraded === true));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resultCount(result) {
|
|
29
|
+
if (!result || typeof result !== "object") return null;
|
|
30
|
+
if (Array.isArray(result.data)) return result.data.length;
|
|
31
|
+
if (Array.isArray(result.results)) return result.results.length;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns a notice string when a search DEGRADED, or null otherwise.
|
|
37
|
+
*
|
|
38
|
+
* The empty case gets the strong wording because it is the one that misleads: an empty
|
|
39
|
+
* degraded response is indistinguishable from a genuine miss. A degraded response that
|
|
40
|
+
* still returned rows gets a softer note — the results are real but the ranking was not
|
|
41
|
+
* the one requested, so they may be incomplete.
|
|
42
|
+
*/
|
|
43
|
+
function degradedSearchNotice(result) {
|
|
44
|
+
const meta = result && result.meta;
|
|
45
|
+
if (!isDegraded(meta)) return null;
|
|
46
|
+
|
|
47
|
+
// The BYO-key case already has its own, more specific ACTION REQUIRED notice; do not
|
|
48
|
+
// stack two notices on one result.
|
|
49
|
+
if (meta.fallback_reason === "no_embedding_key") return null;
|
|
50
|
+
|
|
51
|
+
const reason = meta.fallback_reason || "unknown";
|
|
52
|
+
const count = resultCount(result);
|
|
53
|
+
|
|
54
|
+
if (count === 0) {
|
|
55
|
+
return (
|
|
56
|
+
`DEGRADED SEARCH — THIS IS NOT "NO RESULTS". Semantic ranking was unavailable ` +
|
|
57
|
+
`(${reason}), so this ran keyword-only, and the keyword lane requires ALL terms to ` +
|
|
58
|
+
`match — a long or specific query returns nothing even when the answer exists. ` +
|
|
59
|
+
`Do NOT conclude the knowledge base lacks this, and do NOT rephrase: different ` +
|
|
60
|
+
`words cannot fix a provider failure. RETRY THE SAME QUERY.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
`PARTIAL SEARCH — semantic ranking was unavailable (${reason}), so these are ` +
|
|
66
|
+
`keyword-only matches and may be incomplete. Retry the same query for full ranking.`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { degradedSearchNotice, isDegraded };
|