loopctl-mcp-server 2.85.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.
- package/README.md +6 -3
- package/index.js +222 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -297,7 +297,7 @@ exhausting a paginated walk is not the same as the row being absent.
|
|
|
297
297
|
|
|
298
298
|
| Tool | Description |
|
|
299
299
|
|---|---|
|
|
300
|
-
| `knowledge_index` | Browse/paginate the knowledge wiki catalog grouped by category. **Agent callers see only articles they own or marked `shared`.** Honors `category`, `tags`, `offset`, `limit` with deterministic ordering over the filtered set (`meta.categories` reports per-category totals within visibility). Use `fields` (default `id,title,category`; request `tags`/`status`/`updated_at` explicitly; `id` and `category` are always included) to keep the payload small. Optional: `project_id`, `story_id`, `category`, `tags`, `offset`, `limit`, `fields`. |
|
|
300
|
+
| `knowledge_index` | Browse/paginate the knowledge wiki catalog grouped by category. **Agent callers see only articles they own or marked `shared`.** Honors `category`, `tags`, `offset`, `limit` with deterministic ordering over the filtered set (`meta.categories` reports per-category totals within visibility). Use `fields` (default `id,title,category`; request `tags`/`status`/`updated_at`/`suppressed_at`/`suppressed_by`/`suppression_reason` explicitly; `id` and `category` are always included) to keep the payload small. Pair `suppressed: "only"` with `fields: suppressed_by,suppression_reason` to see who suppressed what and why without a read per row. Optional: `project_id`, `story_id`, `category`, `tags`, `offset`, `limit`, `fields`, `suppressed`. |
|
|
301
301
|
| `embedding_status` | This tenant's embedding-dimension state: active dimension, whether semantic recall is available (and the exact reason when it is not), the instance's supported dimension set, whether the shared system-scoped corpus has been materialized for this tenant, and per-dimension row counts. Call it when semantic search under-returns or reports `fallback_reason: semantic_recall_unavailable`. |
|
|
302
302
|
| `embedding_materialize_system_corpus` | Embed the shared SYSTEM-scoped corpus for THIS tenant at its active dimension with this tenant's own credential (system articles are keyword-only until then). Idempotent, batched. |
|
|
303
303
|
| `embedding_reembed` | Move the tenant's whole corpus (articles, per-tenant system-article materializations and agent memories) onto `target_dimension`. Recall keeps serving at the current dimension throughout; the pin flips and stale rows drop only when everything is present at the target. One-time and cost-bearing; requires `LOOPCTL_ORCH_KEY`. |
|
|
@@ -309,7 +309,7 @@ exhausting a paginated walk is not the same as the row being absent.
|
|
|
309
309
|
| `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`. Carries `meta.outcome`; this path discloses no degradation of its own, so an `empty` index really is an empty topic. |
|
|
310
310
|
| `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`. Carries `meta.outcome` like every other retrieval surface. |
|
|
311
311
|
| `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`. |
|
|
312
|
-
| `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`. Carries `meta.outcome`; on this lag-free enumeration path `empty` is load-bearing — the filtered set is genuinely empty, not missed by a ranker (a page past the end reads `success`). |
|
|
312
|
+
| `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 (suppressed articles are excluded, matching `knowledge_index` — except on an `idempotency_key` filter, which is an identity check and still sees them) — 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. Pass `suppressed: "include"` when a repair pass must see the whole table, or `"only"` to list what there is to undo. 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`, `suppressed`. Carries `meta.outcome`; on this lag-free enumeration path `empty` is load-bearing — the filtered set is genuinely empty, not missed by a ranker (a page past the end reads `success`). |
|
|
313
313
|
| `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. |
|
|
314
314
|
| `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`. Carries `meta.outcome`, so an empty context pack is distinguishable from one whose underlying search fell back to keyword-only. |
|
|
315
315
|
| `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`. |
|
|
@@ -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)
|
|
@@ -354,6 +355,8 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
|
|
|
354
355
|
| `knowledge_unpublish` | **Requires `LOOPCTL_USER_KEY`.** Revert a published article back to draft (hidden from search/context, not deleted). Required: `article_id`. |
|
|
355
356
|
| `knowledge_bulk_unpublish` | **Requires `LOOPCTL_USER_KEY`.** Revert published articles to draft in bulk, partial-success style (mirror of `knowledge_bulk_publish`): per-id `unpublished`/`skipped` (already draft, or archived/superseded)/`not_found`/`errored`. No 100-id cap (auto-chunked, ≤5000); duplicates ignored; safe to retry. Not deleted (re-publish to restore; `knowledge_bulk_delete` to archive). `meta.count`/`meta.counts`/`meta.results` give the breakdown. Required: `article_ids` (array). |
|
|
356
357
|
| `knowledge_archive` | Soft-delete an article (draft or published). Row retained for audit; hidden from all reads. **NOT reversible by you** — `:archived` is a TERMINAL status (no unarchive call, no outbound transition), so restoring one needs a user-role PATCH with an explicit status. Nothing is destroyed, but do not reach for this as an undoable action: for a retraction you can undo, use `knowledge_unpublish` and `knowledge_publish`. Agent role — KB-content curation, visibility-scoped (another agent's private/owner memory 404s). Required: `article_id`. |
|
|
358
|
+
| `knowledge_suppress` | Take an article OUT OF RETRIEVAL without changing its status, **reversibly** — the tombstone. Status stays `published`; body, embedding and links are untouched; the article is STILL readable by id with `knowledge_get`, which renders `suppressed_at`/`suppressed_by`/`suppression_reason`. It disappears from `knowledge_search`, `knowledge_hybrid_search`, `knowledge_context`, `/recall`, `knowledge_progressive_index`, `knowledge_heat_index`, suggested links, `knowledge_graph`, `knowledge_walk`, the novelty priors and the nightly consolidation scans. Pick between the three retraction verbs by what you need afterwards: **suppress** = undoable and says nothing about status, **`knowledge_unpublish`** = undoable but claims the article is a draft, **`knowledge_archive`/`knowledge_delete`** = not undoable by any call you can make. Re-suppressing is an idempotent no-op that keeps the ORIGINAL actor and reason. Agent role, visibility-scoped. Required: `article_id`, `reason` (non-blank, <=500 chars). |
|
|
359
|
+
| `knowledge_unsuppress` | Lift a retrieval suppression — the inverse of `knowledge_suppress`. Restores the article to every read path immediately; nothing is re-embedded or re-linked because suppression never touched them. A no-op on an article that is not suppressed. Does NOT undo `knowledge_archive`. Agent role, visibility-scoped. Required: `article_id`. |
|
|
357
360
|
| `knowledge_delete` | Alias for `knowledge_archive` — DELETE verb on the REST API archives under the hood (soft delete: row retained and audited, but NOT reversible by any call you can make, since `:archived` is terminal — use `knowledge_unpublish` when you need an undoable retraction). Agent role. (Irreversible HARD delete is `knowledge_bulk_delete hard:true`, which stays `LOOPCTL_USER_KEY`.) Required: `article_id`. |
|
|
358
361
|
| `knowledge_bulk_delete` | **Requires `LOOPCTL_USER_KEY`.** Bulk archive (default — non-destructive, but NOT reversible by any call: `:archived` is terminal and restoring needs a user-role PATCH) or IRREVERSIBLE hard-delete by selector. Provide exactly one selector: `article_ids` (list), `source_type`+`source_id` (every active article from a source), or `tag` (every active article with the tag — high blast radius). **There is no `confirm` parameter** — a request carrying one is `400 confirm_removed`, never ignored; see the [Design invariant](#design-invariant-no-model-visible-confirmapproved-argument) below. Default = set-based soft archive (idempotent; `meta.count`=archived, `meta.counts`/`meta.results` give the breakdown; ≤5000); `article_ids` and `source` archive immediately. **Two-step** (the `tag` archive, and every hard delete): `dry_run:true` mutates nothing and returns `meta.would_affect` plus a single-use, TTL-bounded `meta.token` frozen over the previewed id-set; call again with the same selector plus that token to run the op over the FROZEN set — the selector is CHECKED, so a token minted for one tag is `400` on a call naming another. A call with neither is `400` (`dry_run_required` on the tag archive) unless the selector matches nothing, which stays a `200` no-op on either path. The archive and delete flows mint DIFFERENT token types, so an archive proposal is not spendable as a delete or the reverse. Oversized selectors get `meta.oversized`+`meta.confirm_hash` instead of a token — echo the hash back with the same selector and the server refuses on any drift; that hash is keyed on the op, so it does not cross flows either. Hard delete is FK-correct (links first, access events cascade). |
|
|
359
362
|
| `knowledge_drafts` | List draft (unpublished) knowledge articles with pagination. Optional: `limit` (default 20, max 1000 — over-max → 400, no silent clamp), `offset` (default 0), `project_id`. Returns `meta.total_count`. |
|
package/index.js
CHANGED
|
@@ -1337,7 +1337,7 @@ async function setTokenBudget({ scope_type, scope_id, budget_millicents, alert_t
|
|
|
1337
1337
|
|
|
1338
1338
|
// --- Knowledge Wiki Tools (agent key) ---
|
|
1339
1339
|
|
|
1340
|
-
async function knowledgeIndex({ project_id, story_id, category, tags, match, offset, limit, fields }) {
|
|
1340
|
+
async function knowledgeIndex({ project_id, story_id, category, tags, match, offset, limit, fields, suppressed }) {
|
|
1341
1341
|
if (project_id && !UUID_RE.test(project_id)) {
|
|
1342
1342
|
return {
|
|
1343
1343
|
content: [{ type: "text", text: "Error: project_id must be a canonical UUID (8-4-4-4-12 hex)." }],
|
|
@@ -1355,6 +1355,7 @@ async function knowledgeIndex({ project_id, story_id, category, tags, match, off
|
|
|
1355
1355
|
if (offset != null) params.set("offset", String(offset));
|
|
1356
1356
|
if (limit != null) params.set("limit", String(limit));
|
|
1357
1357
|
if (fields) params.set("fields", Array.isArray(fields) ? fields.join(",") : fields);
|
|
1358
|
+
if (suppressed) params.set("suppressed", suppressed);
|
|
1358
1359
|
const qs = params.toString();
|
|
1359
1360
|
const path = qs ? `${basePath}?${qs}` : basePath;
|
|
1360
1361
|
const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
|
|
@@ -1644,6 +1645,7 @@ async function knowledgeList({
|
|
|
1644
1645
|
limit,
|
|
1645
1646
|
offset,
|
|
1646
1647
|
include_body,
|
|
1648
|
+
suppressed,
|
|
1647
1649
|
}) {
|
|
1648
1650
|
const params = new URLSearchParams();
|
|
1649
1651
|
if (project_id) params.set("project_id", project_id);
|
|
@@ -1659,6 +1661,11 @@ async function knowledgeList({
|
|
|
1659
1661
|
// Body-less summary by default (safe to enumerate large pages); opt into full
|
|
1660
1662
|
// bodies (byte-budget bounded server-side) with include_body: true.
|
|
1661
1663
|
if (include_body === true) params.set("include_body", "true");
|
|
1664
|
+
// Sent only when the caller asked. The server's default is per-filter — exclude
|
|
1665
|
+
// everywhere except an idempotency_key lookup, which includes suppressed rows so an
|
|
1666
|
+
// identity check cannot mint a duplicate — and sending a computed "exclude" on every
|
|
1667
|
+
// call would overwrite that.
|
|
1668
|
+
if (suppressed) params.set("suppressed", suppressed);
|
|
1662
1669
|
|
|
1663
1670
|
const result = await apiCall(
|
|
1664
1671
|
"GET",
|
|
@@ -1874,6 +1881,35 @@ async function recallContext({ query, project_id, limit }) {
|
|
|
1874
1881
|
return withRemediationNotice(result);
|
|
1875
1882
|
}
|
|
1876
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
|
+
|
|
1877
1913
|
async function memoryList({ limit, offset, include_superseded, all_subjects }) {
|
|
1878
1914
|
// all_subjects is superadmin-only server-side; a non-superadmin key sending
|
|
1879
1915
|
// this is ignored (falls back to its own subject) rather than erroring — the
|
|
@@ -2012,6 +2048,29 @@ async function knowledgeArchive({ article_id }) {
|
|
|
2012
2048
|
return toContent(result);
|
|
2013
2049
|
}
|
|
2014
2050
|
|
|
2051
|
+
// The REVERSIBLE retrieval tombstone. Agent-role KB curation like archive, but it is the
|
|
2052
|
+
// one member of that family that undoes: nothing is destroyed and nothing is rebuilt, so
|
|
2053
|
+
// knowledge_unsuppress restores the article to every read path immediately.
|
|
2054
|
+
async function knowledgeSuppress({ article_id, reason }) {
|
|
2055
|
+
const result = await apiCall(
|
|
2056
|
+
"POST",
|
|
2057
|
+
`/api/v1/articles/${article_id}/suppress`,
|
|
2058
|
+
{ reason },
|
|
2059
|
+
process.env.LOOPCTL_AGENT_KEY
|
|
2060
|
+
);
|
|
2061
|
+
return toContent(result);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
async function knowledgeUnsuppress({ article_id }) {
|
|
2065
|
+
const result = await apiCall(
|
|
2066
|
+
"POST",
|
|
2067
|
+
`/api/v1/articles/${article_id}/unsuppress`,
|
|
2068
|
+
null,
|
|
2069
|
+
process.env.LOOPCTL_AGENT_KEY
|
|
2070
|
+
);
|
|
2071
|
+
return toContent(result);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2015
2074
|
// #331: soft-delete (archive) is agent-role KB curation, same as knowledge_archive.
|
|
2016
2075
|
async function knowledgeDelete({ article_id }) {
|
|
2017
2076
|
const result = await apiCall(
|
|
@@ -4370,10 +4429,29 @@ const TOOLS = [
|
|
|
4370
4429
|
type: "array",
|
|
4371
4430
|
items: {
|
|
4372
4431
|
type: "string",
|
|
4373
|
-
enum: [
|
|
4432
|
+
enum: [
|
|
4433
|
+
"id",
|
|
4434
|
+
"title",
|
|
4435
|
+
"category",
|
|
4436
|
+
"tags",
|
|
4437
|
+
"status",
|
|
4438
|
+
"updated_at",
|
|
4439
|
+
"suppressed_at",
|
|
4440
|
+
"suppressed_by",
|
|
4441
|
+
"suppression_reason",
|
|
4442
|
+
],
|
|
4374
4443
|
},
|
|
4375
4444
|
description:
|
|
4376
|
-
"Optional: projection of article fields to return. Default: id, title, category. `id` is always included."
|
|
4445
|
+
"Optional: projection of article fields to return. Default: id, title, category. `id` is always included. " +
|
|
4446
|
+
"Pair suppressed='only' with fields=suppressed_by,suppression_reason to see who suppressed what and why without a per-row read.",
|
|
4447
|
+
},
|
|
4448
|
+
suppressed: {
|
|
4449
|
+
type: "string",
|
|
4450
|
+
enum: ["exclude", "include", "only"],
|
|
4451
|
+
description:
|
|
4452
|
+
"Optional: how to treat RETRIEVAL-SUPPRESSED articles — 'exclude' (default), 'include', or " +
|
|
4453
|
+
"'only'. 'only' is the discovery path: it lists exactly what there is to undo with " +
|
|
4454
|
+
"knowledge_unsuppress, across every status. An unrecognised value resolves to 'exclude'.",
|
|
4377
4455
|
},
|
|
4378
4456
|
},
|
|
4379
4457
|
required: [],
|
|
@@ -4396,7 +4474,11 @@ const TOOLS = [
|
|
|
4396
4474
|
"`meta.total_count` (exact) to answer \"does an article for X already exist?\" reliably " +
|
|
4397
4475
|
"right after a write — `idempotency_key` is a FILTER only and is never returned in a " +
|
|
4398
4476
|
"row, so you check a key you already hold rather than reading back the keys other " +
|
|
4399
|
-
"callers chose.
|
|
4477
|
+
"callers chose. Suppressed articles are EXCLUDED here (matching knowledge_index) except " +
|
|
4478
|
+
"on an `idempotency_key` filter, which is an identity check on a key you already hold " +
|
|
4479
|
+
"and still sees them — so the existence check stays true about the row a create would " +
|
|
4480
|
+
"dedup against. Pass `suppressed: 'include'` when a repair pass must see the whole " +
|
|
4481
|
+
"table, or `'only'` to list what there is to undo. Paginate via offset/limit.",
|
|
4400
4482
|
inputSchema: {
|
|
4401
4483
|
type: "object",
|
|
4402
4484
|
properties: {
|
|
@@ -4456,6 +4538,17 @@ const TOOLS = [
|
|
|
4456
4538
|
"rows); continue via meta.next_offset while meta.has_more is true. Leave false to " +
|
|
4457
4539
|
"enumerate metadata cheaply at scale.",
|
|
4458
4540
|
},
|
|
4541
|
+
suppressed: {
|
|
4542
|
+
type: "string",
|
|
4543
|
+
enum: ["exclude", "include", "only"],
|
|
4544
|
+
description:
|
|
4545
|
+
"Optional: how to treat RETRIEVAL-SUPPRESSED articles — 'exclude' (the default on " +
|
|
4546
|
+
"every filter but idempotency_key), 'include', or 'only'. Pass 'include' when a " +
|
|
4547
|
+
"repair or audit pass must see the whole table, and 'only' to list exactly what " +
|
|
4548
|
+
"there is to undo with knowledge_unsuppress. Omit it to keep the per-filter " +
|
|
4549
|
+
"default. The body-less rows do NOT carry the three suppressed_* fields — pair " +
|
|
4550
|
+
"with include_body: true, or read knowledge_get, to see who suppressed what and why.",
|
|
4551
|
+
},
|
|
4459
4552
|
},
|
|
4460
4553
|
required: [],
|
|
4461
4554
|
},
|
|
@@ -5429,7 +5522,19 @@ const TOOLS = [
|
|
|
5429
5522
|
"meta.degraded is true — never a hard failure. Each envelope's " +
|
|
5430
5523
|
"`meta.ann_iterative_scan` discloses whether THAT half's vector read ran with " +
|
|
5431
5524
|
"pgvector's iterative scan (`unavailable` ⇒ possibly incomplete); the two halves " +
|
|
5432
|
-
"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.",
|
|
5433
5538
|
inputSchema: {
|
|
5434
5539
|
type: "object",
|
|
5435
5540
|
properties: {
|
|
@@ -5453,6 +5558,47 @@ const TOOLS = [
|
|
|
5453
5558
|
required: ["query"],
|
|
5454
5559
|
},
|
|
5455
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
|
+
},
|
|
5456
5602
|
{
|
|
5457
5603
|
name: "memory_list",
|
|
5458
5604
|
description:
|
|
@@ -5678,6 +5824,69 @@ const TOOLS = [
|
|
|
5678
5824
|
required: ["article_id"],
|
|
5679
5825
|
},
|
|
5680
5826
|
},
|
|
5827
|
+
{
|
|
5828
|
+
name: "knowledge_suppress",
|
|
5829
|
+
description:
|
|
5830
|
+
"Take an article OUT OF RETRIEVAL without changing its status — reversibly. This is " +
|
|
5831
|
+
"the tool to reach for when an article is wrong, superseded, noisy or no longer " +
|
|
5832
|
+
"wanted in results, but you might want it back. The article stays `published`, keeps " +
|
|
5833
|
+
"its body, embedding and links, and is STILL readable by id with knowledge_get " +
|
|
5834
|
+
"(which renders suppressed_at / suppressed_by / suppression_reason) — that is what " +
|
|
5835
|
+
"makes the act inspectable and undoable. It disappears from knowledge_search, " +
|
|
5836
|
+
"knowledge_hybrid_search, knowledge_context, /recall, knowledge_progressive_index, " +
|
|
5837
|
+
"knowledge_heat_index, suggested links, knowledge_graph, knowledge_walk, the novelty " +
|
|
5838
|
+
"priors and the nightly consolidation scans. " +
|
|
5839
|
+
"Undo with knowledge_unsuppress; nothing was destroyed, so nothing is rebuilt. " +
|
|
5840
|
+
"Choose between the three retraction verbs by what you need afterwards: " +
|
|
5841
|
+
"knowledge_suppress (undoable, status untouched, the article is simply not retrieved), " +
|
|
5842
|
+
"knowledge_unpublish (undoable, but it says the article is a DRAFT — an editorial " +
|
|
5843
|
+
"claim), knowledge_archive/knowledge_delete (NOT undoable by any call you can make: " +
|
|
5844
|
+
"`:archived` is terminal). " +
|
|
5845
|
+
"A reason is REQUIRED — a tombstone that does not record why is not inspectable. " +
|
|
5846
|
+
"Re-suppressing an already-suppressed article is an idempotent no-op that KEEPS the " +
|
|
5847
|
+
"original actor and reason; to change a recorded reason, unsuppress and suppress " +
|
|
5848
|
+
"again, which records both acts. " +
|
|
5849
|
+
"Agent role. Visibility-scoped: you can only suppress an article you can see, so " +
|
|
5850
|
+
"another agent's private/owner memory returns 404.",
|
|
5851
|
+
inputSchema: {
|
|
5852
|
+
type: "object",
|
|
5853
|
+
properties: {
|
|
5854
|
+
article_id: {
|
|
5855
|
+
type: "string",
|
|
5856
|
+
description: "The UUID of the article to take out of retrieval.",
|
|
5857
|
+
},
|
|
5858
|
+
reason: {
|
|
5859
|
+
type: "string",
|
|
5860
|
+
description:
|
|
5861
|
+
"Why this article should stop being retrieved. Required and non-blank; " +
|
|
5862
|
+
"bounded at 500 characters. Recorded on the row and in the audit log, and " +
|
|
5863
|
+
"returned by knowledge_get, so write it for whoever decides later whether to " +
|
|
5864
|
+
"undo this.",
|
|
5865
|
+
},
|
|
5866
|
+
},
|
|
5867
|
+
required: ["article_id", "reason"],
|
|
5868
|
+
},
|
|
5869
|
+
},
|
|
5870
|
+
{
|
|
5871
|
+
name: "knowledge_unsuppress",
|
|
5872
|
+
description:
|
|
5873
|
+
"Lift a retrieval suppression: the inverse of knowledge_suppress. Clears the " +
|
|
5874
|
+
"tombstone and restores the article to search, context, /recall, the indexes, the " +
|
|
5875
|
+
"graph and the link surfaces immediately — nothing has to be re-embedded or " +
|
|
5876
|
+
"re-linked, because suppression never touched any of it. Unsuppressing an article " +
|
|
5877
|
+
"that is not suppressed is a harmless no-op. Agent role, visibility-scoped. " +
|
|
5878
|
+
"This does NOT undo knowledge_archive or knowledge_delete, which are terminal.",
|
|
5879
|
+
inputSchema: {
|
|
5880
|
+
type: "object",
|
|
5881
|
+
properties: {
|
|
5882
|
+
article_id: {
|
|
5883
|
+
type: "string",
|
|
5884
|
+
description: "The UUID of the article to restore to retrieval.",
|
|
5885
|
+
},
|
|
5886
|
+
},
|
|
5887
|
+
required: ["article_id"],
|
|
5888
|
+
},
|
|
5889
|
+
},
|
|
5681
5890
|
{
|
|
5682
5891
|
name: "knowledge_delete",
|
|
5683
5892
|
description:
|
|
@@ -7893,6 +8102,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
7893
8102
|
return await memoryRecall(args);
|
|
7894
8103
|
case "recall_context":
|
|
7895
8104
|
return await recallContext(args);
|
|
8105
|
+
case "recall_referenced":
|
|
8106
|
+
return await recallReferenced(args);
|
|
7896
8107
|
|
|
7897
8108
|
case "memory_list":
|
|
7898
8109
|
return await memoryList(args);
|
|
@@ -7922,6 +8133,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
7922
8133
|
case "knowledge_archive":
|
|
7923
8134
|
return await knowledgeArchive(args);
|
|
7924
8135
|
|
|
8136
|
+
case "knowledge_suppress":
|
|
8137
|
+
return await knowledgeSuppress(args);
|
|
8138
|
+
|
|
8139
|
+
case "knowledge_unsuppress":
|
|
8140
|
+
return await knowledgeUnsuppress(args);
|
|
8141
|
+
|
|
7925
8142
|
case "knowledge_delete":
|
|
7926
8143
|
return await knowledgeDelete(args);
|
|
7927
8144
|
|