loopctl-mcp-server 2.86.0 → 2.88.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.
Files changed (3) hide show
  1. package/README.md +4 -2
  2. package/index.js +168 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -342,7 +342,8 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
342
342
  | `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). |
343
343
  | `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`. |
344
344
  | `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`. |
345
- | `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`. The top-level `meta.outcome` classifies the whole endpoint and the `memory` envelope carries its own; `meta.degraded_reason` names the strongest-remedy half when both degrade, and `meta.search_mode` names the lane that half actually served (`keyword_only`) or is `null` when it served nothing. |
345
+ | `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`. The top-level `meta.outcome` classifies the whole endpoint and the `memory` envelope carries its own; `meta.degraded_reason` names the strongest-remedy half when both degrade, and `meta.search_mode` names the lane that half actually served (`keyword_only`) or is `null` when it served nothing. Response also carries a SELECTION LEDGER: per item `rank`, `selection_reason` (`keyword`\|`semantic`\|`keyword+semantic`\|`keyword_fallback` for knowledge, `semantic`\|`ilike_fallback` for memory) and `tokens_estimate` (bytes/4, an estimate); in `meta`, `recall_id`, `candidates_considered`, `selected_count`, `tokens_selected`, `tokens_candidates`, `tokens_saved_vs_candidates`. The merged order is deterministic (score DESC, then source, then id), so an unchanged corpus renders a byte-identical `data` array — cache that, not the whole response, since `meta.recall_id` is new on every call. Keep `meta.recall_id` for `recall_referenced`. |
346
+ | `recall_referenced` | Record which of the articles a recall SURFACED you actually USED — the third funnel stage (surfaced -> opened -> referenced), and the only one nothing else records. Pass the `meta.recall_id` from a `recall_context` response plus the ids you referenced -- the `article.id` of the `source: knowledge` items, since a `memory` item's id is not an article and is not referenceable; call it once, after writing your answer. Only ids THAT recall surfaced, in your own tenant, are accepted — anything else is `422` `not_surfaced` and NOTHING is written. Your key is stamped server-side, at most 50 ids per call, and repeats cannot inflate the metric (it counts distinct `(recall, article)` pairs). These rows are deliberately not reads: they never feed the heat index or any ranking. Required: `recall_id`, `article_ids`. Optional: `project_id`. |
346
347
  | `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`). |
347
348
 
348
349
  ### Knowledge Management Tools (orchestrator key)
@@ -383,7 +384,8 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
383
384
  | `knowledge_agent_usage` | Per-agent knowledge usage: total reads, unique articles, top read articles. Required: exactly one of `api_key_id` (credential) or `agent_id` (logical identity). Optional: `limit`, `since_days`. See Wiki Attribution section. |
384
385
  | `knowledge_unused_articles` | Published articles with zero accesses in the window. Optional: `days_unused` (default 30), `limit` (default 50, max 200). |
385
386
  | `knowledge_curation_log` | Concise human-readable log of KB CURATION adjustments — novelty-gate decisions (`gate_duplicate`/`gate_draft`/`gate_skip`) and conflict resolutions (`supersede`/`merge`/`dismiss`) — for analyzing the agents'-KB rollout, distinct from the verbose audit log. Each entry: `{at, kind, summary, refs, actor, confidence}`. **RECORDED ONLY when `settings.kb_curation_log` is on** (PATCH `/api/v1/admin/tenants/:id` with `settings:{kb_curation_log:true}`); off by default = no rows. Most recent first. Requires orchestrator role. Optional: `kind`, `since` (ISO8601), `limit` (default 50, max 500), `offset`. |
386
- | `knowledge_retrieval_metrics` | Daily retrieval-PRECISION time series: for each day, the share of RECORDED surfaced search RESULTS the agent then opened (search → get/context within a window). A proxy for whether retrieval is improving as the corpus is de-duplicated, better navigated (MOCs), and conflict-resolved. **Denominators (#582):** `precision` = `followed_through`/`searched`, where `searched` counts RECORDED surfaced RESULTS — one row per result put in front of the agent, capped at the first 20 per call (cap enforced by `Loopctl.Knowledge.Analytics.max_recorded_search_results/0`) — not search calls (`results_recorded` is the same number named for its unit). That cap makes `precision` precision@20: a call returning more contributes only 20, and an open of a result ranked beyond the cap is in neither term. The per-CALL rate is the separate `search_follow_through` = `searches_with_follow_through`/`searches` (distinct QUERY-BEARING calls — query-less `list`/`list_keyset` enumeration pages are excluded). `results_returned` is the true un-truncated count for those same calls. The four call-level fields are filtered per ROW, not per day, so a day mixing pre-#582 or enumeration rows with real searches reports a PARTIAL figure rather than 0 — and `results_returned` is NOT comparable to `searched`. Zero-result and keyless searches are unrecordable, so every ratio here is an upper bound. **`precision` ALONE rises when a search returns FEWER results** — its denominator counts surfaced RESULTS, while the two call-level rates divide CALL counts a narrower page does not shrink — so never optimise `precision` without the absolute `followed_through`. Both follow-through rates are biased DOWN by the recording cap (opens beyond rank 20 are invisible) and UP by crediting every search in the window that surfaced the opened article. **WHICH FOLLOW-THROUGH RATE TO QUOTE:** two are published over DIFFERENT populations, and picking the wrong one misstates agent behaviour by roughly 3.4x. `search_follow_through` is over every query-bearing call that survives the infrastructure exclusion — which still INCLUDES the recall hook and the session-start auto-query, channels that cannot follow through by construction — so read it as BLENDED and use it for total traffic. `scored_follow_through` = `searches_scored_with_follow_through`/`searches_scored` is over the calls carrying a session identity AND coming from a channel that can react to a result, and **IT is the rate to quote when asking whether AGENTS are consuming the KB**. It is `null` when nothing was scoreable, never `0.0` — zero would assert agents searched and opened nothing when the truth is the instrument could not see. Measured live for 2026-08-19..29: 10.8% blended against 38.0% scored, because the recall hook alone was 1,234 of that window's 1,708 calls. Most recent day first. Requires orchestrator role. Optional: `limit` (default 30, max 365), `offset`. |
387
+ | `knowledge_retrieval_metrics` | Daily retrieval-PRECISION time series: for each day, the share of RECORDED surfaced search RESULTS the agent then opened (search → get/context within a window). A proxy for whether retrieval is improving as the corpus is de-duplicated, better navigated (MOCs), and conflict-resolved. **Denominators (#582):** `precision` = `followed_through`/`searched`, where `searched` counts RECORDED surfaced RESULTS — one row per result put in front of the agent, capped at the first 20 per call (cap enforced by `Loopctl.Knowledge.Analytics.max_recorded_search_results/0`) — not search calls (`results_recorded` is the same number named for its unit). That cap makes `precision` precision@20: a call returning more contributes only 20, and an open of a result ranked beyond the cap is in neither term. The per-CALL rate is the separate `search_follow_through` = `searches_with_follow_through`/`searches` (distinct QUERY-BEARING calls — query-less `list`/`list_keyset` enumeration pages are excluded). `results_returned` is the true un-truncated count for those same calls. The four call-level fields are filtered per ROW, not per day, so a day mixing pre-#582 or enumeration rows with real searches reports a PARTIAL figure rather than 0 — and `results_returned` is NOT comparable to `searched`. Zero-result and keyless searches are unrecordable, so every ratio here is an upper bound. **`precision` ALONE rises when a search returns FEWER results** — its denominator counts surfaced RESULTS, while the two call-level rates divide CALL counts a narrower page does not shrink — so never optimise `precision` without the absolute `followed_through`. Both follow-through rates are biased DOWN by the recording cap (opens beyond rank 20 are invisible) and UP by crediting every search in the window that surfaced the opened article. **WHICH FOLLOW-THROUGH RATE TO QUOTE:** two are published over DIFFERENT populations, and picking the wrong one misstates agent behaviour by roughly 3.4x. `search_follow_through` is over every query-bearing call that survives the infrastructure exclusion — which still INCLUDES the recall hook and the session-start auto-query, channels that cannot follow through by construction — so read it as BLENDED and use it for total traffic. `scored_follow_through` = `searches_scored_with_follow_through`/`searches_scored` is over the calls carrying a session identity AND coming from a channel that can react to a result, and **IT is the rate to quote when asking whether AGENTS are consuming the KB**. It is `null` when nothing was scoreable, never `0.0` — zero would assert agents searched and opened nothing when the truth is the instrument could not see. Measured live for 2026-08-19..29: 10.8% blended against 38.0% scored, because the recall hook alone was 1,234 of that window's 1,708 calls. Most recent day first. Requires orchestrator role. Optional: `limit` (default 30, max 365), `offset`. Every ratio here divides `search_events` / `article_access_events` columns, so a NULL column shrinks a denominator instead of reporting itself — check `knowledge_search_coverage` before reading a figure here as agent behaviour rather than instrumentation. |
388
+ | `knowledge_search_coverage` | Which DECLARED columns of `search_events` are actually being filled, per search surface, over a bounded window. `search_events` shipped correct and nearly blind — 2 of its first 133 rows carried any `client_*` context, discoverable only by an audit nobody was scheduled to run — so a coverage PROFILE per `tool` declares the columns a correctly-instrumented caller should supply, and a surface emitting NOTHING reads as `rows: 0` (which no audit over existing rows can produce). **It cannot prove a PRESENT column is a CORRECT one:** one MCP process serves a session and every agent it dispatches with an environment frozen at spawn, so it labels every search `main` — such a row is 100% covered here and still wrong about the only thing `client_kind` exists to say. **Populations, not one denominator:** each column names the rows that COULD have carried it — `all`, `ran` (excludes `outcome=rejected`, which has no `mode_used`/`duration_ms` by construction), `agent` (rows carrying `client_kind` or `client_session_id`, i.e. rows that really came through the MCP client; the recall hook and smoke tests call the API directly and can never supply `client_*`). `share_missing` is `null`, never `0.0`, on an empty population. **`client_context` is the one to read FIRST:** the `agent` denominator is built from two of the columns it scores, so a client that sends NOTHING empties it and every `client_*` line reads a clean `0/0` — that figure is the same count over `all`, and it is where a fleet gone blind reports itself (a high share on `memory_recall` is the recall hook and expected). **Required vs enrichable:** `required` is fillable at record time so a miss is a defect; `enrichable` (`client_model`, `client_effort`, `agent_id`) is what no client can send and `mix loopctl.enrich_search_events` fills offline on a schedule — read a recent enrichable share as a floor, since a window ending near now measures the enrichment's lag. **Unprofiled:** every `tool` with rows and no profile is listed (`null` included), and `rows_total` covers the whole window, so a new surface cannot be silently dropped. Requires orchestrator role. Optional: `days` (default 30, max 366), `to` (ISO8601 date or datetime, exclusive upper bound; a bare date is read at 00:00:00Z, and an unparseable value is a 400 rather than a silent fallback to now). |
387
389
 
388
390
  ### Egress / Privacy Tools (US-41.4)
389
391
 
package/index.js CHANGED
@@ -1881,6 +1881,35 @@ async function recallContext({ query, project_id, limit }) {
1881
1881
  return withRemediationNotice(result);
1882
1882
  }
1883
1883
 
1884
+ async function recallReferenced({ recall_id, article_ids, project_id }) {
1885
+ // The third funnel stage. recall_id is a PATH segment: the server verifies that every
1886
+ // article id was surfaced by THAT recall before recording anything, so a wrong id fails
1887
+ // the whole call rather than recording a half-truth. The recording key is derived
1888
+ // server-side from this key — nothing about the identity is sent.
1889
+ //
1890
+ // Path-injection guard, the same `UUID_RE` check knowledgeAgentUsage runs. `format:
1891
+ // "uuid"` in the input schema is advisory — MCP does not enforce it — so a model that
1892
+ // hallucinates or mis-copies an id containing `/` or `..` would otherwise have it
1893
+ // spliced raw into the path, where URL normalisation sends the POST somewhere other
1894
+ // than the endpoint this tool describes.
1895
+ if (typeof recall_id !== "string" || !UUID_RE.test(recall_id.trim())) {
1896
+ throw new Error(
1897
+ "recall_id must be the meta.recall_id UUID from a recall_context response.",
1898
+ );
1899
+ }
1900
+
1901
+ const payload = { article_ids };
1902
+ if (project_id) payload.project_id = project_id;
1903
+
1904
+ const result = await apiCall(
1905
+ "POST",
1906
+ `/api/v1/recall/${encodeURIComponent(recall_id.trim())}/referenced`,
1907
+ payload,
1908
+ process.env.LOOPCTL_AGENT_KEY,
1909
+ );
1910
+ return toContent(result);
1911
+ }
1912
+
1884
1913
  async function memoryList({ limit, offset, include_superseded, all_subjects }) {
1885
1914
  // all_subjects is superadmin-only server-side; a non-superadmin key sending
1886
1915
  // this is ignored (falls back to its own subject) rather than erroring — the
@@ -2475,6 +2504,18 @@ async function knowledgeRetrievalMetrics({ limit, offset } = {}) {
2475
2504
  return toContent(result);
2476
2505
  }
2477
2506
 
2507
+ async function knowledgeSearchCoverage({ days, to } = {}) {
2508
+ const params = new URLSearchParams();
2509
+ if (days != null) params.set("days", String(days));
2510
+ if (to) params.set("to", to);
2511
+ const qs = params.toString();
2512
+ const path = qs
2513
+ ? `/api/v1/knowledge/analytics/search-coverage?${qs}`
2514
+ : "/api/v1/knowledge/analytics/search-coverage";
2515
+ const result = await apiCall("GET", path, null, process.env.LOOPCTL_ORCH_KEY);
2516
+ return toContent(result);
2517
+ }
2518
+
2478
2519
  async function knowledgeCurationLog({ kind, since, limit, offset } = {}) {
2479
2520
  const params = new URLSearchParams();
2480
2521
  if (kind) params.set("kind", kind);
@@ -5493,7 +5534,19 @@ const TOOLS = [
5493
5534
  "meta.degraded is true — never a hard failure. Each envelope's " +
5494
5535
  "`meta.ann_iterative_scan` discloses whether THAT half's vector read ran with " +
5495
5536
  "pgvector's iterative scan (`unavailable` ⇒ possibly incomplete); the two halves " +
5496
- "are resolved independently and may differ.",
5537
+ "are resolved independently and may differ. SELECTION LEDGER: every merged item " +
5538
+ "also carries `rank` (its position in THIS list), `selection_reason` (which lane " +
5539
+ "put it there — keyword|semantic|keyword+semantic|keyword_fallback for knowledge, " +
5540
+ "semantic|ilike_fallback for memory) and `tokens_estimate` (bytes/4 — an estimate, " +
5541
+ "not a tokenizer count), and `meta` carries `recall_id`, `candidates_considered`, " +
5542
+ "`selected_count`, `tokens_selected`, `tokens_candidates` and " +
5543
+ "`tokens_saved_vs_candidates`, so you can explain your own context assembly. The " +
5544
+ "merged order is deterministic (score DESC, then source, then id), so an unchanged " +
5545
+ "corpus renders a byte-identical `data` array between turns — cache that array, not " +
5546
+ "the whole response, since `meta.recall_id` is new on every call. KEEP " +
5547
+ "`meta.recall_id`: after you " +
5548
+ "answer, pass it to recall_referenced with the ids you actually used — that is the " +
5549
+ "third funnel stage and nothing else records it.",
5497
5550
  inputSchema: {
5498
5551
  type: "object",
5499
5552
  properties: {
@@ -5517,6 +5570,47 @@ const TOOLS = [
5517
5570
  required: ["query"],
5518
5571
  },
5519
5572
  },
5573
+ {
5574
+ name: "recall_referenced",
5575
+ description:
5576
+ "Record which of the articles a recall SURFACED you actually USED in your answer — " +
5577
+ "the third funnel stage (surfaced → opened → referenced). Pass the `meta.recall_id` " +
5578
+ "from a recall_context response plus the article ids you referenced. This is the " +
5579
+ "only signal that distinguishes 'the KB answered the question' from 'the KB was " +
5580
+ "searched'; surfaced-to-opened follow-through is measured at 1.67% and what " +
5581
+ "happened after an open was never recorded at all. Cheap and safe to call: only " +
5582
+ "ids that THIS recall surfaced, in your own tenant, are accepted (anything else is " +
5583
+ "a 422 not_surfaced and NOTHING is written), your key is stamped server-side, and " +
5584
+ "repeating a call cannot inflate the metric — it counts distinct (recall, article) " +
5585
+ "pairs. These rows are deliberately NOT reads: they never feed the heat index or " +
5586
+ "any ranking, because a ranking that consumed a self-report could be gamed by one. " +
5587
+ "Call it once, after you have written your answer.",
5588
+ inputSchema: {
5589
+ type: "object",
5590
+ properties: {
5591
+ recall_id: {
5592
+ type: "string",
5593
+ format: "uuid",
5594
+ description: "The `meta.recall_id` of the recall_context call that surfaced these articles.",
5595
+ },
5596
+ article_ids: {
5597
+ type: "array",
5598
+ items: { type: "string", format: "uuid" },
5599
+ description:
5600
+ "The ids you actually used, from that recall's `data`: the `article.id` of " +
5601
+ "each item whose `source` is `knowledge`. A `memory` item's id is NOT an " +
5602
+ "article and is not referenceable — including one fails the whole call. " +
5603
+ "Non-empty, at most one recall page's worth.",
5604
+ },
5605
+ project_id: {
5606
+ type: "string",
5607
+ format: "uuid",
5608
+ description: "Optional: attribution only, the same project scope you recalled under.",
5609
+ },
5610
+ },
5611
+ required: ["recall_id", "article_ids"],
5612
+ },
5613
+ },
5520
5614
  {
5521
5615
  name: "memory_list",
5522
5616
  description:
@@ -6853,6 +6947,11 @@ const TOOLS = [
6853
6947
  "within a window). A proxy for whether retrieval is improving — watch it trend up as " +
6854
6948
  "the corpus is de-duplicated, better navigated (MOCs), and conflict-resolved. Most " +
6855
6949
  "recent day first. Requires orchestrator role.\n\n" +
6950
+ "Every ratio here divides columns of search_events / article_access_events, so a " +
6951
+ "NULL column silently shrinks a denominator rather than reporting itself. " +
6952
+ "knowledge_search_coverage is the companion that says which of those columns are " +
6953
+ "actually being filled, per surface — check it before treating a figure here as " +
6954
+ "a measurement of agent behaviour rather than of instrumentation.\n\n" +
6856
6955
  // The cap is enforced by Loopctl.Knowledge.Analytics.max_recorded_search_results/0
6857
6956
  // (Elixir); this JS string cannot interpolate it, so change both together.
6858
6957
  "Denominators (#582): precision = followed_through / searched, and `searched` counts " +
@@ -6951,6 +7050,69 @@ const TOOLS = [
6951
7050
  required: [],
6952
7051
  },
6953
7052
  },
7053
+ {
7054
+ name: "knowledge_search_coverage",
7055
+ description:
7056
+ "Report which DECLARED columns of search_events are actually being filled, per search " +
7057
+ "surface, over a bounded window. Requires orchestrator role.\n\n" +
7058
+ "WHY IT EXISTS: search_events shipped correct and nearly blind — 2 of its first 133 " +
7059
+ "rows carried any client_* context, and that was discoverable only by an audit nobody " +
7060
+ "was scheduled to run. A declared coverage PROFILE per tool names the columns a " +
7061
+ "correctly-instrumented caller is expected to supply, so a surface emitting NOTHING " +
7062
+ "shows up as rows: 0, which no audit over existing rows can produce. Prior art: " +
7063
+ "MemoRizz v0.8.0 declares the evidence stages a task type must emit and reports the " +
7064
+ "missing stage instead of leaving it to an audit.\n\n" +
7065
+ "WHAT IT CANNOT PROVE: that a PRESENT column is a CORRECT one. client_kind is the " +
7066
+ "worked example — one MCP process serves a session and every agent it dispatches with " +
7067
+ "an environment frozen at spawn, so it labels every search 'main'. Such a row is 100% " +
7068
+ "covered here and still wrong about the only thing that column exists to say; the " +
7069
+ "offline enrichment is what refines it. It also cannot see a search path that records " +
7070
+ "NO row at all.\n\n" +
7071
+ "POPULATIONS, NOT ONE DENOMINATOR: each column names the rows that COULD have carried " +
7072
+ "it, reported as scope/population beside every count. 'all' is every row; 'ran' " +
7073
+ "excludes outcome=rejected (a rejected call never ran, so it has no mode_used and no " +
7074
+ "duration_ms by construction); 'agent' is rows carrying client_kind or " +
7075
+ "client_session_id, i.e. rows that really came through the MCP client — the recall " +
7076
+ "hook and smoke tests call the API directly and can never supply client_*, so scoring " +
7077
+ "them would measure loopctl's own automation. share_missing is null, never 0.0, on an " +
7078
+ "empty population.\n\n" +
7079
+ "CLIENT_CONTEXT: the 'agent' denominator is built from two of the columns it scores, " +
7080
+ "so a client that sends NOTHING empties it and every client_* line then reads a clean " +
7081
+ "0/0. Each profile therefore also carries client_context, scored over 'all', whose " +
7082
+ "missing is the rows that carried NO client context at all — that is where a fleet gone " +
7083
+ "blind reports itself. A high share on memory_recall is the recall hook and expected; a " +
7084
+ "high share on knowledge_search is not.\n\n" +
7085
+ "REQUIRED vs ENRICHABLE: required is what a client or the server can fill at record " +
7086
+ "time, so a miss is a defect. enrichable (client_model, client_effort, agent_id) is " +
7087
+ "what no client can send — the first two do not exist in the MCP server's spawn " +
7088
+ "environment and are filled offline by mix loopctl.enrich_search_events, and agent_id " +
7089
+ "is server-derived from a key that may own no agent. That enrichment runs on a " +
7090
+ "schedule, so a window ending near now measures its LAG: read a recent enrichable " +
7091
+ "share as a floor.\n\n" +
7092
+ "UNPROFILED: every tool value with rows and no declared profile is listed with its row " +
7093
+ "count, null included. rows_total counts the whole window, so rows_total minus the sum " +
7094
+ "of profile rows is exactly the unprofiled traffic — a new surface cannot be silently " +
7095
+ "dropped from the accounting.",
7096
+ inputSchema: {
7097
+ type: "object",
7098
+ properties: {
7099
+ days: {
7100
+ type: "integer",
7101
+ description: "Window length in days back from `to` (default 30, max 366). Clamped, never rejected.",
7102
+ minimum: 1,
7103
+ maximum: 366,
7104
+ },
7105
+ to: {
7106
+ type: "string",
7107
+ description:
7108
+ "ISO8601 date or datetime, exclusive upper bound. Default now. A bare date is " +
7109
+ "read at 00:00:00Z. The window is [from, to). Rejected with 400 when it cannot " +
7110
+ "be parsed — never silently replaced with now.",
7111
+ },
7112
+ },
7113
+ required: [],
7114
+ },
7115
+ },
6954
7116
  {
6955
7117
  name: "knowledge_analytics_top",
6956
7118
  description:
@@ -8020,6 +8182,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8020
8182
  return await memoryRecall(args);
8021
8183
  case "recall_context":
8022
8184
  return await recallContext(args);
8185
+ case "recall_referenced":
8186
+ return await recallReferenced(args);
8023
8187
 
8024
8188
  case "memory_list":
8025
8189
  return await memoryList(args);
@@ -8141,6 +8305,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8141
8305
  case "knowledge_retrieval_metrics":
8142
8306
  return await knowledgeRetrievalMetrics(args);
8143
8307
 
8308
+ case "knowledge_search_coverage":
8309
+ return await knowledgeSearchCoverage(args);
8310
+
8144
8311
  case "knowledge_analytics_top":
8145
8312
  return await knowledgeAnalyticsTop(args);
8146
8313
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.86.0",
3
+ "version": "2.88.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",