loopctl-mcp-server 2.86.0 → 2.87.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 +2 -1
  2. package/index.js +85 -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)
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
@@ -5493,7 +5522,19 @@ const TOOLS = [
5493
5522
  "meta.degraded is true — never a hard failure. Each envelope's " +
5494
5523
  "`meta.ann_iterative_scan` discloses whether THAT half's vector read ran with " +
5495
5524
  "pgvector's iterative scan (`unavailable` ⇒ possibly incomplete); the two halves " +
5496
- "are resolved independently and may differ.",
5525
+ "are resolved independently and may differ. SELECTION LEDGER: every merged item " +
5526
+ "also carries `rank` (its position in THIS list), `selection_reason` (which lane " +
5527
+ "put it there — keyword|semantic|keyword+semantic|keyword_fallback for knowledge, " +
5528
+ "semantic|ilike_fallback for memory) and `tokens_estimate` (bytes/4 — an estimate, " +
5529
+ "not a tokenizer count), and `meta` carries `recall_id`, `candidates_considered`, " +
5530
+ "`selected_count`, `tokens_selected`, `tokens_candidates` and " +
5531
+ "`tokens_saved_vs_candidates`, so you can explain your own context assembly. The " +
5532
+ "merged order is deterministic (score DESC, then source, then id), so an unchanged " +
5533
+ "corpus renders a byte-identical `data` array between turns — cache that array, not " +
5534
+ "the whole response, since `meta.recall_id` is new on every call. KEEP " +
5535
+ "`meta.recall_id`: after you " +
5536
+ "answer, pass it to recall_referenced with the ids you actually used — that is the " +
5537
+ "third funnel stage and nothing else records it.",
5497
5538
  inputSchema: {
5498
5539
  type: "object",
5499
5540
  properties: {
@@ -5517,6 +5558,47 @@ const TOOLS = [
5517
5558
  required: ["query"],
5518
5559
  },
5519
5560
  },
5561
+ {
5562
+ name: "recall_referenced",
5563
+ description:
5564
+ "Record which of the articles a recall SURFACED you actually USED in your answer — " +
5565
+ "the third funnel stage (surfaced → opened → referenced). Pass the `meta.recall_id` " +
5566
+ "from a recall_context response plus the article ids you referenced. This is the " +
5567
+ "only signal that distinguishes 'the KB answered the question' from 'the KB was " +
5568
+ "searched'; surfaced-to-opened follow-through is measured at 1.67% and what " +
5569
+ "happened after an open was never recorded at all. Cheap and safe to call: only " +
5570
+ "ids that THIS recall surfaced, in your own tenant, are accepted (anything else is " +
5571
+ "a 422 not_surfaced and NOTHING is written), your key is stamped server-side, and " +
5572
+ "repeating a call cannot inflate the metric — it counts distinct (recall, article) " +
5573
+ "pairs. These rows are deliberately NOT reads: they never feed the heat index or " +
5574
+ "any ranking, because a ranking that consumed a self-report could be gamed by one. " +
5575
+ "Call it once, after you have written your answer.",
5576
+ inputSchema: {
5577
+ type: "object",
5578
+ properties: {
5579
+ recall_id: {
5580
+ type: "string",
5581
+ format: "uuid",
5582
+ description: "The `meta.recall_id` of the recall_context call that surfaced these articles.",
5583
+ },
5584
+ article_ids: {
5585
+ type: "array",
5586
+ items: { type: "string", format: "uuid" },
5587
+ description:
5588
+ "The ids you actually used, from that recall's `data`: the `article.id` of " +
5589
+ "each item whose `source` is `knowledge`. A `memory` item's id is NOT an " +
5590
+ "article and is not referenceable — including one fails the whole call. " +
5591
+ "Non-empty, at most one recall page's worth.",
5592
+ },
5593
+ project_id: {
5594
+ type: "string",
5595
+ format: "uuid",
5596
+ description: "Optional: attribution only, the same project scope you recalled under.",
5597
+ },
5598
+ },
5599
+ required: ["recall_id", "article_ids"],
5600
+ },
5601
+ },
5520
5602
  {
5521
5603
  name: "memory_list",
5522
5604
  description:
@@ -8020,6 +8102,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8020
8102
  return await memoryRecall(args);
8021
8103
  case "recall_context":
8022
8104
  return await recallContext(args);
8105
+ case "recall_referenced":
8106
+ return await recallReferenced(args);
8023
8107
 
8024
8108
  case "memory_list":
8025
8109
  return await memoryList(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.86.0",
3
+ "version": "2.87.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",