loopctl-mcp-server 2.72.0 → 2.74.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 +356 -39
- package/lib/arg-aliases.js +121 -0
- package/lib/client-context.js +146 -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;
|
|
@@ -1359,9 +1497,16 @@ async function knowledgeFacets({
|
|
|
1359
1497
|
return toContent(result);
|
|
1360
1498
|
}
|
|
1361
1499
|
|
|
1362
|
-
async function knowledgeSearch({ q, project_id, story_id, category, tags, match, mode, limit, offset }) {
|
|
1500
|
+
async function knowledgeSearch({ q, project_id, story_id, category, tags, match, mode, format, limit, offset }) {
|
|
1363
1501
|
const params = new URLSearchParams();
|
|
1364
1502
|
if (q != null && q !== "") params.set("q", q);
|
|
1503
|
+
// `format` is the SHAPE of the response, not a different search (#678). The server
|
|
1504
|
+
// dispatches `stubs` to the same progressive_index/3 and `bodies` to the same
|
|
1505
|
+
// get_context/3 that knowledge_progressive_index and knowledge_context call, so those
|
|
1506
|
+
// tools remain and are not retired — they are now siblings on one path rather than
|
|
1507
|
+
// separate doors an agent has to choose between. That choice was unobservable and
|
|
1508
|
+
// therefore confounded any measurement of the ranking behind it.
|
|
1509
|
+
if (format) params.set("format", format);
|
|
1365
1510
|
if (project_id) params.set("project_id", project_id);
|
|
1366
1511
|
if (story_id) params.set("story_id", story_id);
|
|
1367
1512
|
if (category) params.set("category", category);
|
|
@@ -1399,9 +1544,13 @@ async function knowledgeHybridSearch({ query, project_id, category, tags, match,
|
|
|
1399
1544
|
return withRemediationNotice(result);
|
|
1400
1545
|
}
|
|
1401
1546
|
|
|
1402
|
-
async function knowledgeProgressiveIndex({ topic, category, limit }) {
|
|
1547
|
+
async function knowledgeProgressiveIndex({ topic, query, category, limit }) {
|
|
1403
1548
|
const params = new URLSearchParams();
|
|
1404
|
-
|
|
1549
|
+
// The endpoint's parameter is `topic`; `query` is the canonical spelling this surface
|
|
1550
|
+
// converged on (#652 item 6). An explicit `topic` still wins, so a caller passing both
|
|
1551
|
+
// gets what it asked for.
|
|
1552
|
+
const resolved = topic != null && topic !== "" ? topic : query;
|
|
1553
|
+
if (resolved != null) params.set("topic", resolved);
|
|
1405
1554
|
if (category) params.set("category", category);
|
|
1406
1555
|
if (limit != null) params.set("limit", String(limit));
|
|
1407
1556
|
|
|
@@ -1429,10 +1578,19 @@ async function knowledgeHeatIndex({ category, limit, since }) {
|
|
|
1429
1578
|
return toContent(result);
|
|
1430
1579
|
}
|
|
1431
1580
|
|
|
1432
|
-
async function knowledgeProgressiveDrill({ article_id }) {
|
|
1581
|
+
async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offset }) {
|
|
1582
|
+
const params = new URLSearchParams();
|
|
1583
|
+
// 0 is meaningful on both (whole body / start at the beginning), so test for
|
|
1584
|
+
// null/undefined rather than truthiness.
|
|
1585
|
+
if (body_max_bytes !== undefined && body_max_bytes !== null)
|
|
1586
|
+
params.set("body_max_bytes", String(body_max_bytes));
|
|
1587
|
+
if (body_offset !== undefined && body_offset !== null)
|
|
1588
|
+
params.set("body_offset", String(body_offset));
|
|
1589
|
+
const qs = params.toString();
|
|
1590
|
+
const base = `/api/v1/knowledge/progressive/${article_id}`;
|
|
1433
1591
|
const result = await apiCall(
|
|
1434
1592
|
"GET",
|
|
1435
|
-
|
|
1593
|
+
qs ? `${base}?${qs}` : base,
|
|
1436
1594
|
null,
|
|
1437
1595
|
process.env.LOOPCTL_AGENT_KEY,
|
|
1438
1596
|
);
|
|
@@ -1476,11 +1634,24 @@ async function knowledgeList({
|
|
|
1476
1634
|
return toContent(result);
|
|
1477
1635
|
}
|
|
1478
1636
|
|
|
1479
|
-
async function knowledgeGet({
|
|
1637
|
+
async function knowledgeGet({
|
|
1638
|
+
article_id,
|
|
1639
|
+
project_id,
|
|
1640
|
+
story_id,
|
|
1641
|
+
links,
|
|
1642
|
+
body_max_bytes,
|
|
1643
|
+
body_offset,
|
|
1644
|
+
}) {
|
|
1480
1645
|
const params = new URLSearchParams();
|
|
1481
1646
|
if (project_id) params.set("project_id", project_id);
|
|
1482
1647
|
if (story_id) params.set("story_id", story_id);
|
|
1483
1648
|
if (links) params.set("links", links);
|
|
1649
|
+
// 0 is meaningful on both (whole body / start at the beginning), so test for
|
|
1650
|
+
// null/undefined rather than truthiness.
|
|
1651
|
+
if (body_max_bytes !== undefined && body_max_bytes !== null)
|
|
1652
|
+
params.set("body_max_bytes", String(body_max_bytes));
|
|
1653
|
+
if (body_offset !== undefined && body_offset !== null)
|
|
1654
|
+
params.set("body_offset", String(body_offset));
|
|
1484
1655
|
const qs = params.toString();
|
|
1485
1656
|
const path = qs ? `/api/v1/articles/${article_id}?${qs}` : `/api/v1/articles/${article_id}`;
|
|
1486
1657
|
const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
|
|
@@ -2772,9 +2943,12 @@ async function getSystemArticles({ slug, category } = {}) {
|
|
|
2772
2943
|
return toContent(result);
|
|
2773
2944
|
}
|
|
2774
2945
|
|
|
2775
|
-
// US-26: Cap recovery after session crash
|
|
2776
|
-
|
|
2777
|
-
|
|
2946
|
+
// US-26: Cap recovery after session crash. start_cap is the ONLY recoverable type
|
|
2947
|
+
// (#621) — the server answers any other cap_type with 422 AND records a
|
|
2948
|
+
// cap_recovery_forgery_attempt against the caller, so never forward one. `lineage`
|
|
2949
|
+
// is resolved server-side from the authenticating key and was always ignored.
|
|
2950
|
+
async function recoverCap({ story_id }) {
|
|
2951
|
+
const body = { cap_type: "start_cap" };
|
|
2778
2952
|
const result = await apiCall("POST", `/api/v1/stories/${story_id}/recover-cap`, body);
|
|
2779
2953
|
return toContent(result);
|
|
2780
2954
|
}
|
|
@@ -3563,7 +3737,10 @@ const TOOLS = [
|
|
|
3563
3737
|
{
|
|
3564
3738
|
name: "start_story",
|
|
3565
3739
|
description:
|
|
3566
|
-
"Agent starts work on a claimed story. Transitions assigned -> implementing. Uses the AGENT key."
|
|
3740
|
+
"Agent starts work on a claimed story. Transitions assigned -> implementing. Uses the AGENT key. " +
|
|
3741
|
+
"The L1 capability (start_cap) is handled for you: it is taken from the claim_story response, " +
|
|
3742
|
+
"and re-minted via recover-cap if this process lost it (e.g. after a session crash). " +
|
|
3743
|
+
"Pass `capability` only to override that.",
|
|
3567
3744
|
inputSchema: {
|
|
3568
3745
|
type: "object",
|
|
3569
3746
|
properties: {
|
|
@@ -3571,6 +3748,13 @@ const TOOLS = [
|
|
|
3571
3748
|
type: "string",
|
|
3572
3749
|
description: "The UUID of the story.",
|
|
3573
3750
|
},
|
|
3751
|
+
capability: {
|
|
3752
|
+
type: "string",
|
|
3753
|
+
description:
|
|
3754
|
+
"Optional start_cap cap_id. Normally omitted — supplied automatically from the " +
|
|
3755
|
+
"claim response or recovered. A tenant with an audit signing key cannot start " +
|
|
3756
|
+
"without one (403 missing_capability).",
|
|
3757
|
+
},
|
|
3574
3758
|
},
|
|
3575
3759
|
required: ["story_id"],
|
|
3576
3760
|
},
|
|
@@ -4023,7 +4207,7 @@ const TOOLS = [
|
|
|
4023
4207
|
name: "knowledge_list",
|
|
4024
4208
|
description:
|
|
4025
4209
|
"List articles (id, title, category, status, tags, source_type, source_id, " +
|
|
4026
|
-
"
|
|
4210
|
+
"timestamps), filtered and paginated. **Body-less summary by default** " +
|
|
4027
4211
|
"— the right tool to enumerate, dedup, or repair at scale (safe to page up to limit=1000). " +
|
|
4028
4212
|
"Pass `include_body: true` to also return the full `body`, in which case the server bounds " +
|
|
4029
4213
|
"the page by a ~5 MB serialized-body budget and returns meta.next_offset/has_more/" +
|
|
@@ -4034,7 +4218,9 @@ const TOOLS = [
|
|
|
4034
4218
|
"of record (draft, published, archived, superseded visible). Use for idempotency/existence " +
|
|
4035
4219
|
"checks: filter by `tags`, `source_type`+`source_id`, or `idempotency_key` and read " +
|
|
4036
4220
|
"`meta.total_count` (exact) to answer \"does an article for X already exist?\" reliably " +
|
|
4037
|
-
"right after a write
|
|
4221
|
+
"right after a write — `idempotency_key` is a FILTER only and is never returned in a " +
|
|
4222
|
+
"row, so you check a key you already hold rather than reading back the keys other " +
|
|
4223
|
+
"callers chose. Paginate via offset/limit.",
|
|
4038
4224
|
inputSchema: {
|
|
4039
4225
|
type: "object",
|
|
4040
4226
|
properties: {
|
|
@@ -4072,7 +4258,8 @@ const TOOLS = [
|
|
|
4072
4258
|
type: "string",
|
|
4073
4259
|
description:
|
|
4074
4260
|
"Optional: filter by exact idempotency_key — the lag-free existence check for a " +
|
|
4075
|
-
"prior capture."
|
|
4261
|
+
"prior capture. Filter only: it is not returned in the rows, so read " +
|
|
4262
|
+
"`meta.total_count`.",
|
|
4076
4263
|
},
|
|
4077
4264
|
offset: {
|
|
4078
4265
|
type: "integer",
|
|
@@ -4258,7 +4445,11 @@ const TOOLS = [
|
|
|
4258
4445
|
"Suggest ranked typed-link CANDIDATES for an article by embedding similarity — " +
|
|
4259
4446
|
"READ-ONLY, creates nothing. Excludes the article itself and any already-linked " +
|
|
4260
4447
|
"article (either direction, any relationship type); only embedded published articles. " +
|
|
4261
|
-
"Returns { data: [{id, title, category, similarity_score}] } highest-similarity
|
|
4448
|
+
"Returns { data: [{id, title, category, similarity_score}], meta } highest-similarity " +
|
|
4449
|
+
"first. Read `meta.ann_iterative_scan` before concluding an article has no neighbours: " +
|
|
4450
|
+
"`unavailable` (with `meta.ann_iterative_scan_reason`) means the vector read ran without " +
|
|
4451
|
+
"pgvector's iterative scan and the list may be INCOMPLETE — `meta.recall_truncated: false` " +
|
|
4452
|
+
"does NOT cover that case. " +
|
|
4262
4453
|
"Review them and create the one you want as a TYPED link (relates_to/derived_from/" +
|
|
4263
4454
|
"contradicts/supersedes) — unlike the auto-linker which only makes ambient relates_to. " +
|
|
4264
4455
|
"Optional: threshold (cosine floor 0–1, default 0.5), limit (default 5).",
|
|
@@ -4385,14 +4576,24 @@ const TOOLS = [
|
|
|
4385
4576
|
"— a stable tag naming WHY (e.g. no_embedding_key, embedding_circuit_open, " +
|
|
4386
4577
|
"embedding_provider_error_<status>, embedding_timeout). When the reason is a MISSING " +
|
|
4387
4578
|
"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)."
|
|
4579
|
+
"meta.remediation telling you to provision it with set_llm_config (BYO — do it once). " +
|
|
4580
|
+
"On the semantic/combined paths meta.ann_iterative_scan (`off`/`applied`/`unavailable`, " +
|
|
4581
|
+
"with meta.ann_iterative_scan_reason alongside `unavailable`) discloses whether the vector " +
|
|
4582
|
+
"read ran with pgvector's iterative scan — `unavailable` means results may be INCOMPLETE, " +
|
|
4583
|
+
"which meta.fallback and the total_count fields cannot tell you.",
|
|
4389
4584
|
inputSchema: {
|
|
4390
4585
|
type: "object",
|
|
4391
4586
|
properties: {
|
|
4392
|
-
|
|
4587
|
+
query: {
|
|
4393
4588
|
type: "string",
|
|
4394
4589
|
description:
|
|
4395
|
-
"Search query string. Optional when tags/category are supplied (enumeration mode)."
|
|
4590
|
+
"Search query string. Optional when tags/category are supplied (enumeration mode). " +
|
|
4591
|
+
"`query` is the canonical spelling across every search-shaped tool here; `q` is " +
|
|
4592
|
+
"the historical name and is still accepted.",
|
|
4593
|
+
},
|
|
4594
|
+
q: {
|
|
4595
|
+
type: "string",
|
|
4596
|
+
description: "Deprecated alias for `query`. Accepted; prefer `query`.",
|
|
4396
4597
|
},
|
|
4397
4598
|
project_id: {
|
|
4398
4599
|
type: "string",
|
|
@@ -4422,6 +4623,21 @@ const TOOLS = [
|
|
|
4422
4623
|
enum: ["keyword", "semantic", "combined"],
|
|
4423
4624
|
description: "Optional: search mode (keyword, semantic, or combined).",
|
|
4424
4625
|
},
|
|
4626
|
+
format: {
|
|
4627
|
+
type: "string",
|
|
4628
|
+
enum: ["results", "stubs", "bodies"],
|
|
4629
|
+
description:
|
|
4630
|
+
"Optional: the SHAPE of the response, not a different search. 'results' " +
|
|
4631
|
+
"(default) is ranked results plus snippets and is the only shape that " +
|
|
4632
|
+
"supports cursor pagination. 'stubs' returns capped stubs with one hop of hub " +
|
|
4633
|
+
"enrichment — use it to survey a broad topic without pulling bodies into " +
|
|
4634
|
+
"context, then knowledge_progressive_drill into a chosen stub. 'bodies' " +
|
|
4635
|
+
"returns full article bodies plus linked references for one deep read. " +
|
|
4636
|
+
"'stubs' and 'bodies' REQUIRE a query; sending either without one is a 400, " +
|
|
4637
|
+
"as is an unknown value (it is never silently downgraded to 'results'). These " +
|
|
4638
|
+
"dispatch to exactly the same code knowledge_progressive_index and " +
|
|
4639
|
+
"knowledge_context call, which both remain available.",
|
|
4640
|
+
},
|
|
4425
4641
|
limit: {
|
|
4426
4642
|
type: "integer",
|
|
4427
4643
|
description: "Optional: maximum number of results to return.",
|
|
@@ -4500,9 +4716,15 @@ const TOOLS = [
|
|
|
4500
4716
|
inputSchema: {
|
|
4501
4717
|
type: "object",
|
|
4502
4718
|
properties: {
|
|
4719
|
+
query: {
|
|
4720
|
+
type: "string",
|
|
4721
|
+
description:
|
|
4722
|
+
"The topic to index (max 500 characters). Required unless `topic` is given. " +
|
|
4723
|
+
"`query` is the canonical spelling across every search-shaped tool here.",
|
|
4724
|
+
},
|
|
4503
4725
|
topic: {
|
|
4504
4726
|
type: "string",
|
|
4505
|
-
description: "
|
|
4727
|
+
description: "Historical name for `query`. Accepted; prefer `query`.",
|
|
4506
4728
|
},
|
|
4507
4729
|
category: {
|
|
4508
4730
|
type: "string",
|
|
@@ -4513,7 +4735,7 @@ const TOOLS = [
|
|
|
4513
4735
|
description: "Optional: top-K override (clamped to the configured cap).",
|
|
4514
4736
|
},
|
|
4515
4737
|
},
|
|
4516
|
-
required: [
|
|
4738
|
+
required: [],
|
|
4517
4739
|
},
|
|
4518
4740
|
},
|
|
4519
4741
|
{
|
|
@@ -4572,7 +4794,11 @@ const TOOLS = [
|
|
|
4572
4794
|
"reach — both resolve the same ids now. A drill records an UNCOUNTED read, so " +
|
|
4573
4795
|
"following an index never raises the heat of what that index just showed you; " +
|
|
4574
4796
|
"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
|
|
4797
|
+
"opening on its own. Following a list is not a vote.\n\n" +
|
|
4798
|
+
"BODY: served in a byte WINDOW (default 32000 bytes) exactly like knowledge_get, so " +
|
|
4799
|
+
"an oversized article comes back in parts instead of being rejected whole by a client " +
|
|
4800
|
+
"token cap. Read body_truncated / next_body_offset to continue, or pass " +
|
|
4801
|
+
"body_max_bytes: 0 for the whole body.",
|
|
4576
4802
|
inputSchema: {
|
|
4577
4803
|
type: "object",
|
|
4578
4804
|
properties: {
|
|
@@ -4581,6 +4807,19 @@ const TOOLS = [
|
|
|
4581
4807
|
format: "uuid",
|
|
4582
4808
|
description: "The UUID of the article to open (from a progressive index stub).",
|
|
4583
4809
|
},
|
|
4810
|
+
body_max_bytes: {
|
|
4811
|
+
type: "integer",
|
|
4812
|
+
minimum: 0,
|
|
4813
|
+
description:
|
|
4814
|
+
"Optional: serialized-body byte budget (default 32000). 0 returns the whole body.",
|
|
4815
|
+
},
|
|
4816
|
+
body_offset: {
|
|
4817
|
+
type: "integer",
|
|
4818
|
+
minimum: 0,
|
|
4819
|
+
description:
|
|
4820
|
+
"Optional: byte offset to start the body window at (default 0). Pass the " +
|
|
4821
|
+
"previous response's next_body_offset to read the next part.",
|
|
4822
|
+
},
|
|
4584
4823
|
},
|
|
4585
4824
|
required: ["article_id"],
|
|
4586
4825
|
},
|
|
@@ -4610,14 +4849,22 @@ const TOOLS = [
|
|
|
4610
4849
|
"`potential_conflicts` is returned in all three modes, so opting out of the link " +
|
|
4611
4850
|
"list never hides a conflict from you; it is capped at 25 (strongest first) with " +
|
|
4612
4851
|
"`conflicts_total` / `conflicts_truncated`. To actually traverse the graph, use " +
|
|
4613
|
-
"knowledge_graph rather than raising this cap
|
|
4852
|
+
"knowledge_graph rather than raising this cap.\n\n" +
|
|
4853
|
+
"BODY: the body is served in a byte WINDOW (default 32000 bytes) so an oversized " +
|
|
4854
|
+
"article is returned in parts instead of being rejected whole by a client token " +
|
|
4855
|
+
"cap - four measured reads of 61-82KB were discarded that way after the search had " +
|
|
4856
|
+
"already found them. Every response carries body_bytes (the full size), body_offset, " +
|
|
4857
|
+
"body_returned_bytes, body_truncated and next_body_offset; pass next_body_offset back " +
|
|
4858
|
+
"as body_offset to continue, or body_max_bytes: 0 for the whole body in one read.",
|
|
4614
4859
|
inputSchema: {
|
|
4615
4860
|
type: "object",
|
|
4616
4861
|
properties: {
|
|
4617
4862
|
article_id: {
|
|
4618
4863
|
type: "string",
|
|
4619
|
-
|
|
4620
|
-
|
|
4864
|
+
description:
|
|
4865
|
+
"The UUID of the article. A unique ID PREFIX (>= 8 hex characters) also " +
|
|
4866
|
+
"resolves, so copy what you have rather than reconstructing 36 characters " +
|
|
4867
|
+
"from memory; an ambiguous prefix is a 404, never a guess.",
|
|
4621
4868
|
},
|
|
4622
4869
|
links: {
|
|
4623
4870
|
type: "string",
|
|
@@ -4628,6 +4875,20 @@ const TOOLS = [
|
|
|
4628
4875
|
"link fields. potential_conflicts (capped, with conflicts_total) is always " +
|
|
4629
4876
|
"returned.",
|
|
4630
4877
|
},
|
|
4878
|
+
body_max_bytes: {
|
|
4879
|
+
type: "integer",
|
|
4880
|
+
minimum: 0,
|
|
4881
|
+
description:
|
|
4882
|
+
"Optional: serialized-body byte budget (default 32000). 0 returns the whole " +
|
|
4883
|
+
"body. Read body_truncated / next_body_offset to continue.",
|
|
4884
|
+
},
|
|
4885
|
+
body_offset: {
|
|
4886
|
+
type: "integer",
|
|
4887
|
+
minimum: 0,
|
|
4888
|
+
description:
|
|
4889
|
+
"Optional: byte offset to start the body window at (default 0). Pass the " +
|
|
4890
|
+
"previous response's next_body_offset to read the next part.",
|
|
4891
|
+
},
|
|
4631
4892
|
project_id: {
|
|
4632
4893
|
type: "string",
|
|
4633
4894
|
format: "uuid",
|
|
@@ -4944,7 +5205,12 @@ const TOOLS = [
|
|
|
4944
5205
|
"match with `meta.fallback: true` and a stable `meta.reason` (score is null on that " +
|
|
4945
5206
|
"path) — check meta.fallback before treating a short/empty result as a genuinely " +
|
|
4946
5207
|
"empty scope. `meta.total_count` and `meta.underfilled` are also returned so you can " +
|
|
4947
|
-
"distinguish a short page from a hard cap."
|
|
5208
|
+
"distinguish a short page from a hard cap. Check `meta.ann_iterative_scan` too: " +
|
|
5209
|
+
"`unavailable` (with `meta.ann_iterative_scan_reason`) means the vector read ran " +
|
|
5210
|
+
"without pgvector's iterative scan and may be INCOMPLETE — a short page then is not " +
|
|
5211
|
+
"evidence of a sparse scope, and meta.fallback/underfilled cannot tell you that. It " +
|
|
5212
|
+
"is absent on the ILIKE fallback AND on an `include_superseded: true` recall (a " +
|
|
5213
|
+
"bounded exact top-k, no index scan), so absence never means the fallback ran.",
|
|
4948
5214
|
inputSchema: {
|
|
4949
5215
|
type: "object",
|
|
4950
5216
|
properties: {
|
|
@@ -4979,7 +5245,10 @@ const TOOLS = [
|
|
|
4979
5245
|
"untouched per-source `memory` and `knowledge` envelopes so you can re-rank. " +
|
|
4980
5246
|
"Cross-source scores are heuristic, not calibrated. If the knowledge search " +
|
|
4981
5247
|
"degrades (embedding unavailable) or errors, the memory side is still returned and " +
|
|
4982
|
-
"meta.degraded is true — never a hard failure."
|
|
5248
|
+
"meta.degraded is true — never a hard failure. Each envelope's " +
|
|
5249
|
+
"`meta.ann_iterative_scan` discloses whether THAT half's vector read ran with " +
|
|
5250
|
+
"pgvector's iterative scan (`unavailable` ⇒ possibly incomplete); the two halves " +
|
|
5251
|
+
"are resolved independently and may differ.",
|
|
4983
5252
|
inputSchema: {
|
|
4984
5253
|
type: "object",
|
|
4985
5254
|
properties: {
|
|
@@ -5400,10 +5669,16 @@ const TOOLS = [
|
|
|
5400
5669
|
"synthesize the two into ONE new DRAFT — both sources preserved, never auto-published, " +
|
|
5401
5670
|
"for you/a human to review and publish). Non-destructive " +
|
|
5402
5671
|
"at agent role — you record intent; the privileged nightly job executes it. " +
|
|
5672
|
+
"NOTE for an agent-role key: 'supersede' is the one disposition that RETIRES an " +
|
|
5673
|
+
"article unattended, so its confidence is capped server-side — your \"high\" is " +
|
|
5674
|
+
"recorded as \"medium\" (see data.requested_confidence and note in the response) and " +
|
|
5675
|
+
"the pair STAYS in knowledge_conflicts until an orchestrator+ key records it at high. " +
|
|
5676
|
+
"'merge' is never capped and executes normally at agent role. " +
|
|
5403
5677
|
"Last-write-wins per pair, so re-recording with fresher ground truth overrides. " +
|
|
5404
5678
|
"Resolve only conflicts material to your current task; adjudicate against the actual " +
|
|
5405
|
-
"system, and if you can't tell which is right,
|
|
5406
|
-
"
|
|
5679
|
+
"system, and if you can't tell which is right, LEAVE IT UNRECORDED rather than " +
|
|
5680
|
+
"guessing — recording low confidence is not a way to park it, it closes the verdict " +
|
|
5681
|
+
"as dismissed on the next nightly run.",
|
|
5407
5682
|
inputSchema: {
|
|
5408
5683
|
type: "object",
|
|
5409
5684
|
properties: {
|
|
@@ -5440,14 +5715,21 @@ const TOOLS = [
|
|
|
5440
5715
|
type: "string",
|
|
5441
5716
|
description:
|
|
5442
5717
|
"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
|
|
5718
|
+
"observed behavior). Recorded for audit and for a human reviewing low-confidence " +
|
|
5719
|
+
"calls. REQUIRED for a supersede OR merge recorded at confidence 'high' (422 " +
|
|
5720
|
+
"without it) — every verdict the executor applies unattended must say why.",
|
|
5444
5721
|
},
|
|
5445
5722
|
confidence: {
|
|
5446
5723
|
type: "string",
|
|
5447
5724
|
enum: ["high", "medium", "low"],
|
|
5448
5725
|
description:
|
|
5449
|
-
"high, medium, or low. supersede auto-
|
|
5450
|
-
"recorded
|
|
5726
|
+
"high, medium, or low. Default medium. supersede/merge auto-execute only at 'high'; " +
|
|
5727
|
+
"recorded LOWER, the next nightly run closes the verdict as dismissed (both " +
|
|
5728
|
+
"articles retained) and the pair leaves the conflict queue — it is NOT left for " +
|
|
5729
|
+
"review, so re-record at 'high' if you mean it to apply. On a supersede the value " +
|
|
5730
|
+
"is a REQUEST: it is capped to 'medium' unless the calling key is orchestrator+, " +
|
|
5731
|
+
"and a CAPPED verdict is the exception — it stays in the queue for an " +
|
|
5732
|
+
"orchestrator+ key. merge is not capped.",
|
|
5451
5733
|
},
|
|
5452
5734
|
},
|
|
5453
5735
|
required: ["source_article_id", "target_article_id", "disposition"],
|
|
@@ -6362,13 +6644,22 @@ const TOOLS = [
|
|
|
6362
6644
|
description:
|
|
6363
6645
|
"Mint an ephemeral api_key for a sub-agent dispatch. " +
|
|
6364
6646
|
"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."
|
|
6647
|
+
"never store it in env vars. The key expires after expires_in_seconds. " +
|
|
6648
|
+
"Pass parent_dispatch_id: a dispatch may only be minted INSIDE the caller's own " +
|
|
6649
|
+
"lineage. Omitting it starts a new independent lineage tree, which only the " +
|
|
6650
|
+
"tenant's user-role operator key may do — every other caller gets 403 " +
|
|
6651
|
+
"root_dispatch_forbidden, and the 403 body returns the caller's own dispatch id " +
|
|
6652
|
+
"as remediation.your_dispatch_id.",
|
|
6366
6653
|
inputSchema: {
|
|
6367
6654
|
type: "object",
|
|
6368
6655
|
properties: {
|
|
6369
6656
|
parent_dispatch_id: {
|
|
6370
6657
|
type: "string",
|
|
6371
|
-
description:
|
|
6658
|
+
description:
|
|
6659
|
+
"UUID of the parent dispatch — your own dispatch id, or one of its " +
|
|
6660
|
+
"descendants. Required in practice: omitting it requests a ROOT dispatch, " +
|
|
6661
|
+
"which is 403 root_dispatch_forbidden for any caller a dispatch minted. " +
|
|
6662
|
+
"A parent outside your lineage is 403 parent_outside_caller_lineage.",
|
|
6372
6663
|
},
|
|
6373
6664
|
role: {
|
|
6374
6665
|
type: "string",
|
|
@@ -6694,13 +6985,11 @@ const TOOLS = [
|
|
|
6694
6985
|
},
|
|
6695
6986
|
{
|
|
6696
6987
|
name: "recover_cap",
|
|
6697
|
-
description: "Re-mint
|
|
6988
|
+
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
6989
|
inputSchema: {
|
|
6699
6990
|
type: "object",
|
|
6700
6991
|
properties: {
|
|
6701
6992
|
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
6993
|
},
|
|
6705
6994
|
required: ["story_id"],
|
|
6706
6995
|
},
|
|
@@ -6767,6 +7056,17 @@ const server = new Server(
|
|
|
6767
7056
|
}
|
|
6768
7057
|
);
|
|
6769
7058
|
|
|
7059
|
+
// The parameter names each STATIC tool declares, so the alias layer can tell a rescue from
|
|
7060
|
+
// an inert convenience fill. Dynamic per-tenant `cr_*` tools are absent here and fall back
|
|
7061
|
+
// to reporting every fill (the conservative default).
|
|
7062
|
+
const DECLARED_TOOL_ARGS = new Map(
|
|
7063
|
+
TOOLS.map((t) => [t.name, Object.keys(t.inputSchema?.properties ?? {})]),
|
|
7064
|
+
);
|
|
7065
|
+
|
|
7066
|
+
function declaredToolArgs(name) {
|
|
7067
|
+
return DECLARED_TOOL_ARGS.get(name);
|
|
7068
|
+
}
|
|
7069
|
+
|
|
6770
7070
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
6771
7071
|
// Static hand-maintained tools PLUS the calling tenant's per-tenant generated
|
|
6772
7072
|
// Context Retriever tools (US-30.5). fetchGeneratedTools degrades to the static
|
|
@@ -6776,7 +7076,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6776
7076
|
});
|
|
6777
7077
|
|
|
6778
7078
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
6779
|
-
const { name
|
|
7079
|
+
const { name } = request.params;
|
|
7080
|
+
// The callback keeps the schema inconsistency MEASURABLE rather than merely survivable:
|
|
7081
|
+
// every rescue is a call that would have been a hard 400 before, and a count of them is
|
|
7082
|
+
// the evidence for eventually converging the spellings instead of aliasing forever.
|
|
7083
|
+
//
|
|
7084
|
+
// The tool's OWN declared parameters are passed so only a genuine rescue is reported. The
|
|
7085
|
+
// fill is bidirectional, so without this a correct `knowledge_search` call carrying `q`
|
|
7086
|
+
// also filled `query` and logged a "rescue" — counting normal traffic, and drowning the
|
|
7087
|
+
// signal the count exists to carry.
|
|
7088
|
+
const args = applyArgAliases(
|
|
7089
|
+
request.params.arguments,
|
|
7090
|
+
({ canonical, alias }) => {
|
|
7091
|
+
process.stderr.write(
|
|
7092
|
+
`[loopctl-mcp] arg alias applied: '${alias}' -> '${canonical}' on tool '${name}'\n`,
|
|
7093
|
+
);
|
|
7094
|
+
},
|
|
7095
|
+
declaredToolArgs(name),
|
|
7096
|
+
);
|
|
6780
7097
|
|
|
6781
7098
|
switch (name) {
|
|
6782
7099
|
// 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,146 @@
|
|
|
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 — re-measured 2026-08-12 by reading /proc/<mcp-pid>/environ on
|
|
16
|
+
* a live session, which corrected two entries an earlier pass got wrong:
|
|
17
|
+
*
|
|
18
|
+
* CLAUDE_CODE_SESSION_ID=<uuid> -> session id, available
|
|
19
|
+
* CLAUDE_CODE_ENTRYPOINT=cli -> entrypoint, available
|
|
20
|
+
* CLAUDE_SESSION_ID -> ABSENT (the CODE_ spelling is the one that is set)
|
|
21
|
+
* CLAUDE_EFFORT -> ABSENT from THIS process. It is set for Bash-tool
|
|
22
|
+
* invocations, which is where the earlier claim that
|
|
23
|
+
* it was "available" came from; the MCP server does
|
|
24
|
+
* not get it, so `effort` is enriched offline.
|
|
25
|
+
* CLAUDE_CODE_CHILD_SESSION -> ABSENT, and it would not mean what it looks like:
|
|
26
|
+
* it is set to 1 for Bash-tool invocations of a MAIN
|
|
27
|
+
* session, so it marks "a child PROCESS", not "a
|
|
28
|
+
* dispatched agent".
|
|
29
|
+
* (no model variable) -> MODEL IS NOT AVAILABLE
|
|
30
|
+
*
|
|
31
|
+
* THE KIND REPORTED HERE IS THE SESSION'S, NOT THE CALLER'S. One MCP server process is
|
|
32
|
+
* spawned per session and serves the main session AND every agent it dispatches, and the
|
|
33
|
+
* environment above is read once and cached for the life of that process. So `kind` is a
|
|
34
|
+
* property of the session, and every search it labels comes back `main`. The three-way
|
|
35
|
+
* main/subagent/workflow split a measurement actually needs is recoverable only from the
|
|
36
|
+
* transcript, where `isSidechain` plus the file's path give it unambiguously.
|
|
37
|
+
*
|
|
38
|
+
* Two fields are therefore sent as JOIN KEYS rather than as answers: `session_id` is what
|
|
39
|
+
* lets `mix loopctl.enrich_search_events` find the transcript that records the model, the
|
|
40
|
+
* effort and the real kind. Note that a RESUMED session breaks even that — the new process
|
|
41
|
+
* reports a fresh session id while the transcript keeps appending under the original — so
|
|
42
|
+
* the enrichment carries a query-only fallback for exactly that case.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { execFileSync } from "node:child_process";
|
|
46
|
+
import os from "node:os";
|
|
47
|
+
|
|
48
|
+
function env(name) {
|
|
49
|
+
const v = process.env[name];
|
|
50
|
+
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The repo the AGENT was working in — which is NOT the project a KB search was scoped to.
|
|
55
|
+
* "Which repos lean on the knowledge base, and which never touch it" cannot be answered
|
|
56
|
+
* without it, and the scoped project_id does not answer it.
|
|
57
|
+
*
|
|
58
|
+
* Derived from the git remote so it is stable across machines and checkout paths; falls
|
|
59
|
+
* back to the directory basename when there is no remote. Cached: this runs per search and
|
|
60
|
+
* shelling out per call would put a process spawn on the hot path.
|
|
61
|
+
*/
|
|
62
|
+
let repoCache;
|
|
63
|
+
function detectRepo() {
|
|
64
|
+
if (repoCache !== undefined) return repoCache;
|
|
65
|
+
repoCache = null;
|
|
66
|
+
try {
|
|
67
|
+
const remote = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
68
|
+
encoding: "utf8",
|
|
69
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
70
|
+
timeout: 2000,
|
|
71
|
+
}).trim();
|
|
72
|
+
// Normalise git@host:owner/repo.git and https://host/owner/repo(.git) to owner/repo.
|
|
73
|
+
const m = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
|
|
74
|
+
if (m) repoCache = m[1];
|
|
75
|
+
} catch {
|
|
76
|
+
try {
|
|
77
|
+
repoCache = process.cwd().split("/").filter(Boolean).pop() || null;
|
|
78
|
+
} catch {
|
|
79
|
+
repoCache = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return repoCache;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Test seam: forget the cached repo. */
|
|
86
|
+
function resetRepoCache() {
|
|
87
|
+
repoCache = undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Builds the client-context payload. Every field is optional — a missing value is omitted
|
|
92
|
+
* rather than sent as null, so "we could not observe this" and "this was empty" stay
|
|
93
|
+
* distinguishable in the data.
|
|
94
|
+
*/
|
|
95
|
+
function clientContext({ version } = {}) {
|
|
96
|
+
const ctx = {
|
|
97
|
+
session_id: env("CLAUDE_SESSION_ID") || env("CLAUDE_CODE_SESSION_ID"),
|
|
98
|
+
effort: env("CLAUDE_EFFORT"),
|
|
99
|
+
model: env("CLAUDE_MODEL") || env("ANTHROPIC_MODEL"),
|
|
100
|
+
host: os.hostname(),
|
|
101
|
+
repo: detectRepo() || undefined,
|
|
102
|
+
entrypoint: env("CLAUDE_CODE_ENTRYPOINT"),
|
|
103
|
+
version,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// The SESSION's kind — see the header. This process is shared by the main session and
|
|
107
|
+
// every agent it dispatches, so in practice this resolves to "main" for all of them and
|
|
108
|
+
// is NOT caller-level evidence. It is still worth sending: it is the only thing available
|
|
109
|
+
// before the offline enrichment runs, and the enrichment treats it as an assertion to be
|
|
110
|
+
// corrected rather than as a value to be preserved.
|
|
111
|
+
//
|
|
112
|
+
// Do not try to widen it to three values from the environment. CLAUDE_CODE_WORKFLOWS is a
|
|
113
|
+
// feature flag present in main sessions too, and CLAUDE_CODE_CHILD_SESSION marks a child
|
|
114
|
+
// PROCESS rather than a dispatched agent. Guessing here would be worse than the null it
|
|
115
|
+
// replaces, because the number this column exists to support is a comparison BETWEEN
|
|
116
|
+
// kinds.
|
|
117
|
+
const child = env("CLAUDE_CODE_CHILD_SESSION");
|
|
118
|
+
if (child !== undefined) {
|
|
119
|
+
ctx.kind = child === "1" || child.toLowerCase() === "true" ? "child" : "main";
|
|
120
|
+
} else if (ctx.session_id) {
|
|
121
|
+
// A MAIN session sets no child marker at all, so keying on the marker alone filed
|
|
122
|
+
// every main session under NULL — beside every request from an older client that sends
|
|
123
|
+
// no context, which is the one population NULL has to keep meaning. An absent marker on
|
|
124
|
+
// a recognisable session IS main; with nothing identifying the caller, kind stays
|
|
125
|
+
// absent rather than guessed.
|
|
126
|
+
ctx.kind = "main";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const k of Object.keys(ctx)) {
|
|
130
|
+
if (ctx[k] === undefined || ctx[k] === null || ctx[k] === "") delete ctx[k];
|
|
131
|
+
}
|
|
132
|
+
return ctx;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Encodes the context for transport as a single header. Base64 keeps arbitrary repo names
|
|
137
|
+
* and hostnames from breaking header parsing, and one header keeps the surface small.
|
|
138
|
+
* Returns undefined when there is nothing to report, so no empty header is sent.
|
|
139
|
+
*/
|
|
140
|
+
function clientContextHeader(opts) {
|
|
141
|
+
const ctx = clientContext(opts);
|
|
142
|
+
if (Object.keys(ctx).length === 0) return undefined;
|
|
143
|
+
return Buffer.from(JSON.stringify(ctx), "utf8").toString("base64");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
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 };
|