loopctl-mcp-server 2.80.0 → 2.86.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 +39 -10
- package/index.js +161 -14
- package/lib/search-notices.js +164 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -268,23 +268,50 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
|
|
|
268
268
|
| A search came back empty/thin, or you don't yet know what to ask | `knowledge_heat_index` (no query at all) |
|
|
269
269
|
| Enumerate / dedup / repair, or "does X exist?" (full fields, lag-free, all-status) | `knowledge_list` |
|
|
270
270
|
|
|
271
|
+
**Read `meta.outcome` before you believe an empty result.** EVERY read on the knowledge,
|
|
272
|
+
memory and corpus surfaces carries one of `success` | `empty` | `degraded` | `fallback` |
|
|
273
|
+
`error`, so you never have to know which per-surface flag names a degradation — the
|
|
274
|
+
retrieval tools (`knowledge_search`, `knowledge_context`, `knowledge_hybrid_search`,
|
|
275
|
+
`knowledge_progressive_index`, `knowledge_heat_index`, `memory_recall`, `recall_context`,
|
|
276
|
+
`corpus_search`), the enumerations (`knowledge_list`, `knowledge_index`, `memory_list`,
|
|
277
|
+
`corpus_list`) and the review queues. On a catalog the value is only ever `empty` or
|
|
278
|
+
`success` — it discloses no degradation of its own — but the KEY is there, so an ABSENT
|
|
279
|
+
`outcome` means one thing and one thing only: a server older than this envelope.
|
|
280
|
+
|
|
281
|
+
| `meta.outcome` | what happened | what to do |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| `success` | ran fully; this page carries rows, or an earlier one did | use them |
|
|
284
|
+
| `empty` | ran fully and the whole matched set is empty | a real miss — re-route or accept it |
|
|
285
|
+
| `degraded` | a half was shed, capacity-limited or scan-starved | this set may be SHORT — wait, then retry (a STANDING gap — `ann_iterative_scan_unavailable` or `embedding_dimension_mismatch` — only an operator clears) |
|
|
286
|
+
| `fallback` | semantic ranking was unavailable, keyword-only was served | retry the SAME query, never reword |
|
|
287
|
+
| `error` | the retrieval could not run; an empty envelope was served in its place | fix the request, then retry |
|
|
288
|
+
|
|
289
|
+
Precedence is `error > fallback > degraded > empty > success`, so a degradation is never
|
|
290
|
+
hidden behind a zero count. On `knowledge_search`, `knowledge_hybrid_search` and
|
|
291
|
+
`knowledge_context` the MCP server also prints a leading banner for the three classes that
|
|
292
|
+
need a remedy; `empty` and `success` are silent by design. Since 2.85.0 the banner also
|
|
293
|
+
fires on `knowledge_list`, `knowledge_progressive_index`, `knowledge_heat_index`,
|
|
294
|
+
`memory_recall`, `recall_context` and `corpus_search`, so every read that can report a
|
|
295
|
+
degradation now announces one. Write tools carry no `outcome`. A page walked past the end of a non-empty set is `success`, not `empty`:
|
|
296
|
+
exhausting a paginated walk is not the same as the row being absent.
|
|
297
|
+
|
|
271
298
|
| Tool | Description |
|
|
272
299
|
|---|---|
|
|
273
|
-
| `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`. |
|
|
274
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`. |
|
|
275
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. |
|
|
276
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`. |
|
|
277
304
|
| `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`. |
|
|
278
305
|
| `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 }`. |
|
|
279
306
|
| `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`. |
|
|
280
|
-
| `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. |
|
|
281
|
-
| `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`. |
|
|
282
|
-
| `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`. |
|
|
283
|
-
| `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`. |
|
|
307
|
+
| `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. Carries `meta.outcome` (see the table above) — a `fallback` or `degraded` value means an empty page is not a miss. |
|
|
308
|
+
| `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`. Carries `meta.outcome` alongside `meta.provenance`: provenance says WHICH source answered, outcome says whether the retrieval that produced it ran healthily. |
|
|
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
|
+
| `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. |
|
|
284
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`. |
|
|
285
|
-
| `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`. |
|
|
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`). |
|
|
286
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. |
|
|
287
|
-
| `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`. |
|
|
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. |
|
|
288
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`. |
|
|
289
316
|
| `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). |
|
|
290
317
|
| `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`. |
|
|
@@ -311,11 +338,11 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
|
|
|
311
338
|
| Tool | Description |
|
|
312
339
|
|---|---|
|
|
313
340
|
| `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). |
|
|
314
|
-
| `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`. |
|
|
341
|
+
| `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`. Carries `meta.outcome`, which is the one key to read here: a SHED read sets `meta.fallback: true` while serving no substitute lane, and is reported as `degraded` (wait, then retry), not `fallback`. |
|
|
315
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). |
|
|
316
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`. |
|
|
317
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`. |
|
|
318
|
-
| `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`. |
|
|
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. |
|
|
319
346
|
| `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`). |
|
|
320
347
|
|
|
321
348
|
### Knowledge Management Tools (orchestrator key)
|
|
@@ -327,6 +354,8 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
|
|
|
327
354
|
| `knowledge_unpublish` | **Requires `LOOPCTL_USER_KEY`.** Revert a published article back to draft (hidden from search/context, not deleted). Required: `article_id`. |
|
|
328
355
|
| `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). |
|
|
329
356
|
| `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`. |
|
|
357
|
+
| `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). |
|
|
358
|
+
| `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`. |
|
|
330
359
|
| `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`. |
|
|
331
360
|
| `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). |
|
|
332
361
|
| `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`. |
|
|
@@ -399,7 +428,7 @@ Two modes, pinned at creation:
|
|
|
399
428
|
|
|
400
429
|
| Tool | Description |
|
|
401
430
|
|---|---|
|
|
402
|
-
| `corpus_search` | Search a corpus for POINTERS into files loopctl does not host. Returns `{source_ref, locator, snippet, score, chunk_id, corpus_id}` — a bounded excerpt, never the chunk body — so the next step is always to open the file. `server_embedded`: send `query`. `client_embedded`: send `query_vector` (length = the corpus `dim`); a query string there is `422 query_string_not_accepted` and asking for the keyword lane is `422 keyword_lane_unavailable`. Exactly one of `query`/`query_vector` (both is `422 ambiguous_query`). Scores are rank-derived (RRF) and comparable only WITHIN one result set. Agent key. |
|
|
431
|
+
| `corpus_search` | Search a corpus for POINTERS into files loopctl does not host. Returns `{source_ref, locator, snippet, score, chunk_id, corpus_id}` — a bounded excerpt, never the chunk body — so the next step is always to open the file. `server_embedded`: send `query`. `client_embedded`: send `query_vector` (length = the corpus `dim`); a query string there is `422 query_string_not_accepted` and asking for the keyword lane is `422 keyword_lane_unavailable`. Exactly one of `query`/`query_vector` (both is `422 ambiguous_query`). Scores are rank-derived (RRF) and comparable only WITHIN one result set. Agent key. Carries `meta.outcome`, so a short lane is distinguishable from an empty corpus without knowing that `semantic_unavailable_reason` and `semantic_under_filled` are the keys that mean "ask again". |
|
|
403
432
|
| `corpus_create` | Create a corpus, pinning `mode`, `embedding_model` and `dim`. Required: `slug`, `name`, `mode`, `embedding_model`, `dim`. Optional: `description`, `allow_snippets`, `project_id`. Agent key. |
|
|
404
433
|
| `corpus_index` | Index a batch of chunks. `server_embedded` chunk: `{source_ref, locator, text, ordinal?, snippet?}`. `client_embedded` chunk: `{source_ref, locator, vector, content_hash, ordinal?, snippet?}` — there is **no** `text` parameter and sending one is `422 text_not_accepted`, not ignored. Idempotent on `(corpus, source_ref, locator)`. `source_complete` is how a RE-index removes what the document no longer contains: a bare `source_ref` string means this request carries that source's complete set, `{source_ref, locators}` declares the set explicitly for a document spanning several batches. Anything under a named source that is neither carried nor declared is deleted, and `meta.pruned_by_source` reports the cost. Agent key. |
|
|
405
434
|
| `corpus_list` | List this tenant's corpora, newest first. Call it before searching to learn a corpus's `mode` (which decides string vs vector) and its `dim`. Optional: `project_id`, `limit`, `offset`. Agent key. |
|
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);
|
|
@@ -1590,7 +1591,10 @@ async function knowledgeProgressiveIndex({ topic, query, category, limit }) {
|
|
|
1590
1591
|
null,
|
|
1591
1592
|
process.env.LOOPCTL_AGENT_KEY,
|
|
1592
1593
|
);
|
|
1593
|
-
|
|
1594
|
+
// A topic browse is a RETRIEVAL: it runs the same ranked pool, so it can come back
|
|
1595
|
+
// short or keyword-only. Without the banner a shed index reads as "the KB has no
|
|
1596
|
+
// articles on this topic", which is the exact misread meta.outcome exists to end.
|
|
1597
|
+
return withRemediationNotice(result);
|
|
1594
1598
|
}
|
|
1595
1599
|
|
|
1596
1600
|
async function knowledgeHeatIndex({ category, limit, since }) {
|
|
@@ -1605,7 +1609,9 @@ async function knowledgeHeatIndex({ category, limit, since }) {
|
|
|
1605
1609
|
null,
|
|
1606
1610
|
process.env.LOOPCTL_AGENT_KEY,
|
|
1607
1611
|
);
|
|
1608
|
-
|
|
1612
|
+
// The query-free route, reached for precisely when the query-shaped ones came back
|
|
1613
|
+
// empty — so an unannounced degradation here strands the agent with no route left.
|
|
1614
|
+
return withRemediationNotice(result);
|
|
1609
1615
|
}
|
|
1610
1616
|
|
|
1611
1617
|
async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offset }) {
|
|
@@ -1639,6 +1645,7 @@ async function knowledgeList({
|
|
|
1639
1645
|
limit,
|
|
1640
1646
|
offset,
|
|
1641
1647
|
include_body,
|
|
1648
|
+
suppressed,
|
|
1642
1649
|
}) {
|
|
1643
1650
|
const params = new URLSearchParams();
|
|
1644
1651
|
if (project_id) params.set("project_id", project_id);
|
|
@@ -1654,6 +1661,11 @@ async function knowledgeList({
|
|
|
1654
1661
|
// Body-less summary by default (safe to enumerate large pages); opt into full
|
|
1655
1662
|
// bodies (byte-budget bounded server-side) with include_body: true.
|
|
1656
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);
|
|
1657
1669
|
|
|
1658
1670
|
const result = await apiCall(
|
|
1659
1671
|
"GET",
|
|
@@ -1661,7 +1673,9 @@ async function knowledgeList({
|
|
|
1661
1673
|
null,
|
|
1662
1674
|
process.env.LOOPCTL_AGENT_KEY,
|
|
1663
1675
|
);
|
|
1664
|
-
|
|
1676
|
+
// Enumeration, not ranking — but a short page still under-reports the set, and an
|
|
1677
|
+
// agent enumerating to decide something absent is the caller least able to tell.
|
|
1678
|
+
return withRemediationNotice(result);
|
|
1665
1679
|
}
|
|
1666
1680
|
|
|
1667
1681
|
async function knowledgeGet({
|
|
@@ -1837,9 +1851,11 @@ async function memoryRecall({ query, limit, include_superseded }) {
|
|
|
1837
1851
|
process.env.LOOPCTL_AGENT_KEY,
|
|
1838
1852
|
);
|
|
1839
1853
|
// Surface meta (fallback/reason/total_count/underfilled) so the caller can tell
|
|
1840
|
-
// a degraded recall from a genuinely empty scope (AC-28.4.4)
|
|
1841
|
-
//
|
|
1842
|
-
|
|
1854
|
+
// a degraded recall from a genuinely empty scope (AC-28.4.4). meta alone was not
|
|
1855
|
+
// enough: agents do not read it, which is the whole finding behind the banner. On
|
|
1856
|
+
// the MEMORY surface a shed read otherwise looks identical to an empty scope, and
|
|
1857
|
+
// "I have never been told this" is the most consequential thing to get wrong here.
|
|
1858
|
+
return withRemediationNotice(result);
|
|
1843
1859
|
}
|
|
1844
1860
|
|
|
1845
1861
|
async function recallContext({ query, project_id, limit }) {
|
|
@@ -1858,8 +1874,11 @@ async function recallContext({ query, project_id, limit }) {
|
|
|
1858
1874
|
process.env.LOOPCTL_AGENT_KEY,
|
|
1859
1875
|
);
|
|
1860
1876
|
// Surface both per-source metas (memory fallback/underfilled + knowledge degraded)
|
|
1861
|
-
// so the caller can tell a degraded recall from a genuinely empty scope.
|
|
1862
|
-
|
|
1877
|
+
// so the caller can tell a degraded recall from a genuinely empty scope. The merged
|
|
1878
|
+
// meta can carry ONE half's failure beside the other half's rows, which the server
|
|
1879
|
+
// classifies "degraded" — a banner is the only place a caller sees that the pack it
|
|
1880
|
+
// is about to act on is a half.
|
|
1881
|
+
return withRemediationNotice(result);
|
|
1863
1882
|
}
|
|
1864
1883
|
|
|
1865
1884
|
async function memoryList({ limit, offset, include_superseded, all_subjects }) {
|
|
@@ -2000,6 +2019,29 @@ async function knowledgeArchive({ article_id }) {
|
|
|
2000
2019
|
return toContent(result);
|
|
2001
2020
|
}
|
|
2002
2021
|
|
|
2022
|
+
// The REVERSIBLE retrieval tombstone. Agent-role KB curation like archive, but it is the
|
|
2023
|
+
// one member of that family that undoes: nothing is destroyed and nothing is rebuilt, so
|
|
2024
|
+
// knowledge_unsuppress restores the article to every read path immediately.
|
|
2025
|
+
async function knowledgeSuppress({ article_id, reason }) {
|
|
2026
|
+
const result = await apiCall(
|
|
2027
|
+
"POST",
|
|
2028
|
+
`/api/v1/articles/${article_id}/suppress`,
|
|
2029
|
+
{ reason },
|
|
2030
|
+
process.env.LOOPCTL_AGENT_KEY
|
|
2031
|
+
);
|
|
2032
|
+
return toContent(result);
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
async function knowledgeUnsuppress({ article_id }) {
|
|
2036
|
+
const result = await apiCall(
|
|
2037
|
+
"POST",
|
|
2038
|
+
`/api/v1/articles/${article_id}/unsuppress`,
|
|
2039
|
+
null,
|
|
2040
|
+
process.env.LOOPCTL_AGENT_KEY
|
|
2041
|
+
);
|
|
2042
|
+
return toContent(result);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2003
2045
|
// #331: soft-delete (archive) is agent-role KB curation, same as knowledge_archive.
|
|
2004
2046
|
async function knowledgeDelete({ article_id }) {
|
|
2005
2047
|
const result = await apiCall(
|
|
@@ -3076,8 +3118,10 @@ async function corpusSearch({ corpus_id, query, query_vector, lanes, limit }) {
|
|
|
3076
3118
|
process.env.LOOPCTL_AGENT_KEY,
|
|
3077
3119
|
);
|
|
3078
3120
|
// Pointers + snippets only — the caller's next step is to open the file at
|
|
3079
|
-
// source_ref/locator. Nothing here is auto-injected into a recall pack
|
|
3080
|
-
|
|
3121
|
+
// source_ref/locator. Nothing here is auto-injected into a recall pack, so a
|
|
3122
|
+
// degradation nobody announces is never noticed downstream either: this banner is
|
|
3123
|
+
// the only disclosure a corpus read gets.
|
|
3124
|
+
return withRemediationNotice(result);
|
|
3081
3125
|
}
|
|
3082
3126
|
|
|
3083
3127
|
async function corpusStatus({ corpus_id, limit, offset }) {
|
|
@@ -4356,10 +4400,29 @@ const TOOLS = [
|
|
|
4356
4400
|
type: "array",
|
|
4357
4401
|
items: {
|
|
4358
4402
|
type: "string",
|
|
4359
|
-
enum: [
|
|
4403
|
+
enum: [
|
|
4404
|
+
"id",
|
|
4405
|
+
"title",
|
|
4406
|
+
"category",
|
|
4407
|
+
"tags",
|
|
4408
|
+
"status",
|
|
4409
|
+
"updated_at",
|
|
4410
|
+
"suppressed_at",
|
|
4411
|
+
"suppressed_by",
|
|
4412
|
+
"suppression_reason",
|
|
4413
|
+
],
|
|
4360
4414
|
},
|
|
4361
4415
|
description:
|
|
4362
|
-
"Optional: projection of article fields to return. Default: id, title, category. `id` is always included."
|
|
4416
|
+
"Optional: projection of article fields to return. Default: id, title, category. `id` is always included. " +
|
|
4417
|
+
"Pair suppressed='only' with fields=suppressed_by,suppression_reason to see who suppressed what and why without a per-row read.",
|
|
4418
|
+
},
|
|
4419
|
+
suppressed: {
|
|
4420
|
+
type: "string",
|
|
4421
|
+
enum: ["exclude", "include", "only"],
|
|
4422
|
+
description:
|
|
4423
|
+
"Optional: how to treat RETRIEVAL-SUPPRESSED articles — 'exclude' (default), 'include', or " +
|
|
4424
|
+
"'only'. 'only' is the discovery path: it lists exactly what there is to undo with " +
|
|
4425
|
+
"knowledge_unsuppress, across every status. An unrecognised value resolves to 'exclude'.",
|
|
4363
4426
|
},
|
|
4364
4427
|
},
|
|
4365
4428
|
required: [],
|
|
@@ -4382,7 +4445,11 @@ const TOOLS = [
|
|
|
4382
4445
|
"`meta.total_count` (exact) to answer \"does an article for X already exist?\" reliably " +
|
|
4383
4446
|
"right after a write — `idempotency_key` is a FILTER only and is never returned in a " +
|
|
4384
4447
|
"row, so you check a key you already hold rather than reading back the keys other " +
|
|
4385
|
-
"callers chose.
|
|
4448
|
+
"callers chose. Suppressed articles are EXCLUDED here (matching knowledge_index) except " +
|
|
4449
|
+
"on an `idempotency_key` filter, which is an identity check on a key you already hold " +
|
|
4450
|
+
"and still sees them — so the existence check stays true about the row a create would " +
|
|
4451
|
+
"dedup against. Pass `suppressed: 'include'` when a repair pass must see the whole " +
|
|
4452
|
+
"table, or `'only'` to list what there is to undo. Paginate via offset/limit.",
|
|
4386
4453
|
inputSchema: {
|
|
4387
4454
|
type: "object",
|
|
4388
4455
|
properties: {
|
|
@@ -4442,6 +4509,17 @@ const TOOLS = [
|
|
|
4442
4509
|
"rows); continue via meta.next_offset while meta.has_more is true. Leave false to " +
|
|
4443
4510
|
"enumerate metadata cheaply at scale.",
|
|
4444
4511
|
},
|
|
4512
|
+
suppressed: {
|
|
4513
|
+
type: "string",
|
|
4514
|
+
enum: ["exclude", "include", "only"],
|
|
4515
|
+
description:
|
|
4516
|
+
"Optional: how to treat RETRIEVAL-SUPPRESSED articles — 'exclude' (the default on " +
|
|
4517
|
+
"every filter but idempotency_key), 'include', or 'only'. Pass 'include' when a " +
|
|
4518
|
+
"repair or audit pass must see the whole table, and 'only' to list exactly what " +
|
|
4519
|
+
"there is to undo with knowledge_unsuppress. Omit it to keep the per-filter " +
|
|
4520
|
+
"default. The body-less rows do NOT carry the three suppressed_* fields — pair " +
|
|
4521
|
+
"with include_body: true, or read knowledge_get, to see who suppressed what and why.",
|
|
4522
|
+
},
|
|
4445
4523
|
},
|
|
4446
4524
|
required: [],
|
|
4447
4525
|
},
|
|
@@ -5664,6 +5742,69 @@ const TOOLS = [
|
|
|
5664
5742
|
required: ["article_id"],
|
|
5665
5743
|
},
|
|
5666
5744
|
},
|
|
5745
|
+
{
|
|
5746
|
+
name: "knowledge_suppress",
|
|
5747
|
+
description:
|
|
5748
|
+
"Take an article OUT OF RETRIEVAL without changing its status — reversibly. This is " +
|
|
5749
|
+
"the tool to reach for when an article is wrong, superseded, noisy or no longer " +
|
|
5750
|
+
"wanted in results, but you might want it back. The article stays `published`, keeps " +
|
|
5751
|
+
"its body, embedding and links, and is STILL readable by id with knowledge_get " +
|
|
5752
|
+
"(which renders suppressed_at / suppressed_by / suppression_reason) — that is what " +
|
|
5753
|
+
"makes the act inspectable and undoable. It disappears from knowledge_search, " +
|
|
5754
|
+
"knowledge_hybrid_search, knowledge_context, /recall, knowledge_progressive_index, " +
|
|
5755
|
+
"knowledge_heat_index, suggested links, knowledge_graph, knowledge_walk, the novelty " +
|
|
5756
|
+
"priors and the nightly consolidation scans. " +
|
|
5757
|
+
"Undo with knowledge_unsuppress; nothing was destroyed, so nothing is rebuilt. " +
|
|
5758
|
+
"Choose between the three retraction verbs by what you need afterwards: " +
|
|
5759
|
+
"knowledge_suppress (undoable, status untouched, the article is simply not retrieved), " +
|
|
5760
|
+
"knowledge_unpublish (undoable, but it says the article is a DRAFT — an editorial " +
|
|
5761
|
+
"claim), knowledge_archive/knowledge_delete (NOT undoable by any call you can make: " +
|
|
5762
|
+
"`:archived` is terminal). " +
|
|
5763
|
+
"A reason is REQUIRED — a tombstone that does not record why is not inspectable. " +
|
|
5764
|
+
"Re-suppressing an already-suppressed article is an idempotent no-op that KEEPS the " +
|
|
5765
|
+
"original actor and reason; to change a recorded reason, unsuppress and suppress " +
|
|
5766
|
+
"again, which records both acts. " +
|
|
5767
|
+
"Agent role. Visibility-scoped: you can only suppress an article you can see, so " +
|
|
5768
|
+
"another agent's private/owner memory returns 404.",
|
|
5769
|
+
inputSchema: {
|
|
5770
|
+
type: "object",
|
|
5771
|
+
properties: {
|
|
5772
|
+
article_id: {
|
|
5773
|
+
type: "string",
|
|
5774
|
+
description: "The UUID of the article to take out of retrieval.",
|
|
5775
|
+
},
|
|
5776
|
+
reason: {
|
|
5777
|
+
type: "string",
|
|
5778
|
+
description:
|
|
5779
|
+
"Why this article should stop being retrieved. Required and non-blank; " +
|
|
5780
|
+
"bounded at 500 characters. Recorded on the row and in the audit log, and " +
|
|
5781
|
+
"returned by knowledge_get, so write it for whoever decides later whether to " +
|
|
5782
|
+
"undo this.",
|
|
5783
|
+
},
|
|
5784
|
+
},
|
|
5785
|
+
required: ["article_id", "reason"],
|
|
5786
|
+
},
|
|
5787
|
+
},
|
|
5788
|
+
{
|
|
5789
|
+
name: "knowledge_unsuppress",
|
|
5790
|
+
description:
|
|
5791
|
+
"Lift a retrieval suppression: the inverse of knowledge_suppress. Clears the " +
|
|
5792
|
+
"tombstone and restores the article to search, context, /recall, the indexes, the " +
|
|
5793
|
+
"graph and the link surfaces immediately — nothing has to be re-embedded or " +
|
|
5794
|
+
"re-linked, because suppression never touched any of it. Unsuppressing an article " +
|
|
5795
|
+
"that is not suppressed is a harmless no-op. Agent role, visibility-scoped. " +
|
|
5796
|
+
"This does NOT undo knowledge_archive or knowledge_delete, which are terminal.",
|
|
5797
|
+
inputSchema: {
|
|
5798
|
+
type: "object",
|
|
5799
|
+
properties: {
|
|
5800
|
+
article_id: {
|
|
5801
|
+
type: "string",
|
|
5802
|
+
description: "The UUID of the article to restore to retrieval.",
|
|
5803
|
+
},
|
|
5804
|
+
},
|
|
5805
|
+
required: ["article_id"],
|
|
5806
|
+
},
|
|
5807
|
+
},
|
|
5667
5808
|
{
|
|
5668
5809
|
name: "knowledge_delete",
|
|
5669
5810
|
description:
|
|
@@ -7908,6 +8049,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
7908
8049
|
case "knowledge_archive":
|
|
7909
8050
|
return await knowledgeArchive(args);
|
|
7910
8051
|
|
|
8052
|
+
case "knowledge_suppress":
|
|
8053
|
+
return await knowledgeSuppress(args);
|
|
8054
|
+
|
|
8055
|
+
case "knowledge_unsuppress":
|
|
8056
|
+
return await knowledgeUnsuppress(args);
|
|
8057
|
+
|
|
7911
8058
|
case "knowledge_delete":
|
|
7912
8059
|
return await knowledgeDelete(args);
|
|
7913
8060
|
|
package/lib/search-notices.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Leading notices for search results whose META carries something the agent must act on
|
|
3
|
-
* (#658).
|
|
3
|
+
* (#658), now driven by the uniform tool-outcome envelope.
|
|
4
4
|
*
|
|
5
5
|
* THE DEFECT THIS FIXES. When semantic ranking is unavailable the server degrades to
|
|
6
6
|
* keyword-only and says so LOUDLY — `meta.fallback: true`, `meta.degraded: true`,
|
|
@@ -16,15 +16,50 @@
|
|
|
16
16
|
* The remedy the agent needs is the opposite of the obvious one: do NOT rephrase. Different
|
|
17
17
|
* words cannot fix a provider timeout. Retry the SAME query.
|
|
18
18
|
*
|
|
19
|
+
* WHAT `meta.outcome` ADDS. The knowledge, memory and corpus RETRIEVAL responses (plus the
|
|
20
|
+
* `knowledge_list` enumeration path) now carry one of `success | empty | degraded |
|
|
21
|
+
* fallback | error`, so the notice no longer has to infer the class from a handful of
|
|
22
|
+
* per-surface flag names. Three classes get a notice, and they get DIFFERENT ones because
|
|
23
|
+
* the remedies differ:
|
|
24
|
+
*
|
|
25
|
+
* - `fallback` — the semantic lane died, keyword-only was served. Retry the SAME query.
|
|
26
|
+
* - `degraded` — a half was shed or capacity-limited. WAIT, then retry. Retrying at once
|
|
27
|
+
* goes straight back into the same closed gate, which is why this is not worded as a
|
|
28
|
+
* fallback. Some causes are STANDING rather than transient (`STANDING_REASONS`) and
|
|
29
|
+
* get a remedy that does not prescribe a wait no wait can clear.
|
|
30
|
+
* - `error` — the retrieval never ran; the empty envelope is a placeholder and says
|
|
31
|
+
* NOTHING about what the corpus holds.
|
|
32
|
+
*
|
|
33
|
+
* `empty` and `success` get NO notice on purpose. A notice on every ordinary zero-result
|
|
34
|
+
* search is noise, and noise teaches agents to ignore the channel — the exact fate of the
|
|
35
|
+
* `meta` fields this exists to replace. The distinction the agent needs is still on the
|
|
36
|
+
* wire: the rendered JSON carries `meta.outcome: "empty"` next to a `degraded` one that
|
|
37
|
+
* arrives with a shouting banner above it.
|
|
38
|
+
*
|
|
19
39
|
* This mirrors the existing BYO-LLM `no_embedding_key` ACTION REQUIRED notice, which
|
|
20
40
|
* already established that a meta-only disclosure is not enough to change behaviour.
|
|
21
41
|
*/
|
|
22
42
|
|
|
43
|
+
/** The server's published vocabulary (LoopctlWeb.Outcome). */
|
|
44
|
+
const OUTCOMES = ["success", "empty", "degraded", "fallback", "error"];
|
|
45
|
+
|
|
23
46
|
/** True when a result degraded to a fallback lane, whatever the cause. */
|
|
24
47
|
function isDegraded(meta) {
|
|
25
48
|
return Boolean(meta && (meta.fallback === true || meta.degraded === true));
|
|
26
49
|
}
|
|
27
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The server-declared outcome, or null when this response predates the envelope.
|
|
53
|
+
*
|
|
54
|
+
* Validated against the published list rather than passed through: an unrecognised value
|
|
55
|
+
* means a server newer than this client, and inventing a notice for a class we do not
|
|
56
|
+
* understand is worse than falling back to the flag heuristics below.
|
|
57
|
+
*/
|
|
58
|
+
function outcomeOf(result) {
|
|
59
|
+
const value = result && result.meta && result.meta.outcome;
|
|
60
|
+
return OUTCOMES.includes(value) ? value : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
28
63
|
function resultCount(result) {
|
|
29
64
|
if (!result || typeof result !== "object") return null;
|
|
30
65
|
if (Array.isArray(result.data)) return result.data.length;
|
|
@@ -33,23 +68,143 @@ function resultCount(result) {
|
|
|
33
68
|
}
|
|
34
69
|
|
|
35
70
|
/**
|
|
36
|
-
*
|
|
71
|
+
* The most specific bounded tag the response names, or a synthesised one.
|
|
37
72
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
73
|
+
* Every surface publishes its cause under its own key; this picks whichever is present so
|
|
74
|
+
* the notice can NAME the cause. An unnamed degradation is much weaker advice — "something
|
|
75
|
+
* was short" does not tell an agent whether to wait or to reconfigure.
|
|
76
|
+
*/
|
|
77
|
+
function reasonOf(meta) {
|
|
78
|
+
const named =
|
|
79
|
+
meta.fallback_reason ||
|
|
80
|
+
meta.degraded_reason ||
|
|
81
|
+
meta.reason ||
|
|
82
|
+
meta.semantic_unavailable_reason ||
|
|
83
|
+
meta.keyword_unavailable_reason;
|
|
84
|
+
|
|
85
|
+
if (typeof named === "string" && named !== "") return named;
|
|
86
|
+
if (meta.semantic_under_filled === true) return "semantic_under_filled";
|
|
87
|
+
if (meta.ann_iterative_scan === "unavailable") return "ann_iterative_scan_unavailable";
|
|
88
|
+
return "unspecified";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fallbackNotice(reason, count) {
|
|
92
|
+
if (count === 0) {
|
|
93
|
+
return (
|
|
94
|
+
`outcome: fallback — DEGRADED SEARCH, THIS IS NOT "NO RESULTS". Semantic ranking ` +
|
|
95
|
+
`was unavailable (${reason}), so this ran keyword-only, and the keyword lane ` +
|
|
96
|
+
`requires ALL terms to match — a long or specific query returns nothing even when ` +
|
|
97
|
+
`the answer exists. Do NOT conclude the knowledge base lacks this, and do NOT ` +
|
|
98
|
+
`rephrase: different words cannot fix a provider failure. RETRY THE SAME QUERY.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
`outcome: fallback — PARTIAL SEARCH. Semantic ranking was unavailable (${reason}), so ` +
|
|
104
|
+
`these are keyword-only matches and may be incomplete. Retry the same query for full ` +
|
|
105
|
+
`ranking.`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Degradations that WAITING cannot clear, so the notice must not prescribe a wait.
|
|
111
|
+
*
|
|
112
|
+
* `ann_iterative_scan_unavailable` — the deployed pgvector ran the vector read without
|
|
113
|
+
* the iterative scan the operator enabled, and the tenant filter was applied after a
|
|
114
|
+
* single index batch, so the page may be short. The conclusive cause (pgvector < 0.8,
|
|
115
|
+
* or the extension absent) stands until the extension is upgraded.
|
|
116
|
+
*
|
|
117
|
+
* `embedding_dimension_mismatch` — the memory half's active embedding dimension does not
|
|
118
|
+
* match the stored one, so that half did not run at all. Beside rows from the other half
|
|
119
|
+
* the server classifies it `degraded` (a partial read, not a dead request), which is
|
|
120
|
+
* right — but only an operator can restore the half.
|
|
121
|
+
*
|
|
122
|
+
* Both re-run identically on retry and burn a heavy read for nothing.
|
|
123
|
+
*/
|
|
124
|
+
const STANDING_REASONS = new Set([
|
|
125
|
+
"ann_iterative_scan_unavailable",
|
|
126
|
+
"embedding_dimension_mismatch",
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
function degradedNotice(reason, count) {
|
|
130
|
+
if (STANDING_REASONS.has(reason)) {
|
|
131
|
+
const scope = count === 0 ? `THIS IS NOT "NO RESULTS"` : `PARTIAL RESULTS`;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
`outcome: degraded — ${scope}. A half of this retrieval ran short or not at all ` +
|
|
135
|
+
`(${reason}), so it may have returned FEWER rows than match and its absences ` +
|
|
136
|
+
`prove nothing. Retrying re-runs the identical read and does NOT clear this — it ` +
|
|
137
|
+
`is a standing backend condition an operator has to fix. Use what you got, widen ` +
|
|
138
|
+
`the filters, or reach for a non-vector route (knowledge_list, knowledge_heat_index).`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (count === 0) {
|
|
143
|
+
return (
|
|
144
|
+
`outcome: degraded — THIS IS NOT "NO RESULTS". A half of this retrieval was shed or ` +
|
|
145
|
+
`capacity-limited (${reason}), so the corpus was never fully read. Do NOT conclude ` +
|
|
146
|
+
`the knowledge base lacks this, and do NOT rephrase — the wording had no part in ` +
|
|
147
|
+
`it. WAIT a few seconds, then RETRY THE SAME QUERY; an immediate retry goes back ` +
|
|
148
|
+
`into the same closed gate.`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
`outcome: degraded — PARTIAL RESULTS. A half was shed or capacity-limited ` +
|
|
154
|
+
`(${reason}), so this set may be SHORT and its absences prove nothing. Wait a few ` +
|
|
155
|
+
`seconds, then retry the same query for the full set.`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function errorNotice(reason) {
|
|
160
|
+
return (
|
|
161
|
+
`outcome: error — THE RETRIEVAL DID NOT RUN (${reason}); this empty envelope was ` +
|
|
162
|
+
`served in its place. It says NOTHING about what the knowledge base holds. Fix the ` +
|
|
163
|
+
`request, then retry.`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Returns a notice string when a search needs one, or null otherwise.
|
|
169
|
+
*
|
|
170
|
+
* Prefers the server-declared `meta.outcome`; falls back to the pre-envelope flag
|
|
171
|
+
* heuristics so an older server still gets the #658 notice it used to.
|
|
172
|
+
*
|
|
173
|
+
* The zero-result cases get the strong wording because they are the ones that mislead: an
|
|
174
|
+
* empty degraded response is indistinguishable from a genuine miss. A degraded response
|
|
175
|
+
* that still returned rows gets a softer note — the results are real but the retrieval was
|
|
176
|
+
* not the one requested, so they may be incomplete.
|
|
42
177
|
*/
|
|
43
178
|
function degradedSearchNotice(result) {
|
|
44
179
|
const meta = result && result.meta;
|
|
45
|
-
if (!
|
|
180
|
+
if (!meta || typeof meta !== "object") return null;
|
|
46
181
|
|
|
47
182
|
// The BYO-key case already has its own, more specific ACTION REQUIRED notice; do not
|
|
48
183
|
// stack two notices on one result.
|
|
49
184
|
if (meta.fallback_reason === "no_embedding_key") return null;
|
|
50
185
|
|
|
51
|
-
const reason = meta.fallback_reason || "unknown";
|
|
52
186
|
const count = resultCount(result);
|
|
187
|
+
const outcome = outcomeOf(result);
|
|
188
|
+
|
|
189
|
+
if (outcome) {
|
|
190
|
+
switch (outcome) {
|
|
191
|
+
case "fallback":
|
|
192
|
+
return fallbackNotice(reasonOf(meta), count);
|
|
193
|
+
case "degraded":
|
|
194
|
+
return degradedNotice(reasonOf(meta), count);
|
|
195
|
+
case "error":
|
|
196
|
+
return errorNotice(reasonOf(meta));
|
|
197
|
+
default:
|
|
198
|
+
// success / empty — silence is the signal, see the module header.
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!isDegraded(meta)) return null;
|
|
204
|
+
|
|
205
|
+
// Pre-envelope server: one class, the historical wording, and no `outcome:` prefix to
|
|
206
|
+
// claim a classification the server never made.
|
|
207
|
+
const reason = meta.fallback_reason || "unknown";
|
|
53
208
|
|
|
54
209
|
if (count === 0) {
|
|
55
210
|
return (
|
|
@@ -67,4 +222,4 @@ function degradedSearchNotice(result) {
|
|
|
67
222
|
);
|
|
68
223
|
}
|
|
69
224
|
|
|
70
|
-
export { degradedSearchNotice, isDegraded };
|
|
225
|
+
export { degradedSearchNotice, isDegraded, outcomeOf, OUTCOMES };
|