loopctl-mcp-server 2.77.0 → 2.78.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 CHANGED
@@ -347,6 +347,40 @@ nothing changes until a scope opts in.
347
347
  | `custody_claim` | The recorded **egress custody claim** for one article or memory row: the append-only sequence of per-operation postures (create, each embed, each re-embed, each classification, each merge) with the endpoints resolved for THAT operation and their verdicts, plus the aggregate. Rides the existing hash-chained audit log + signed tree heads — each entry carries a `chain_position` and the leaf's `chain_entry_hash`, so `GET /api/v1/audit/sth/{tenant_id}/inclusion/{position}` proves inclusion of *that* leaf, and the leaf's payload names this row by a recomputable `posture_digest`. THREE states, only one an attestation: `no_claim_recorded`, `claim_pending`, `claim_recorded` (`complete` / `partial_history` / `incomplete`). Completeness is measured against a persisted per-row high-water mark, so losing the tail of a sequence is a gap, not a clean claim. `third_party_egress_on_covered_paths` is `false` only for NETWORK-local endpoints; a tenant-declared (unverified) endpoint yields `"tenant_declared_unverified"`. Attests ONLY to the endpoints loopctl called on the paths in `coverage` — never to what those endpoints did afterwards. **Agent** key. |
348
348
  | `custody_failures` | Custody posture entries whose chain append was DROPPED after exhausting retries, plus `stale_pending` entries stranded by a flush that died outside its own final-attempt branch. Surfaced rather than silently absent: each `data` entry degrades its row's claim to `incomplete`, and a stranded entry would otherwise read as an in-flight claim forever. **Agent** key. |
349
349
 
350
+ ### Corpus Tools (Epic 43) — verbatim reference documents
351
+
352
+ The corpus tier indexes documents whose **files stay in your own repo**. loopctl holds
353
+ chunk pointers (and, in mode A, the text it embeds); a search hands back
354
+ `{source_ref, locator, snippet, score}` and **never the chunk body** — you open the file
355
+ yourself at that pointer. That is what keeps the file the source of truth and lets the
356
+ tier be pointed at a repo loopctl does not own.
357
+
358
+ **When to reach for it.** `corpus_search` when you need the **verbatim text of an
359
+ authoritative document** — a spec, a contract, an RFC, a manual. `knowledge_search` when
360
+ you want **what we learned** about a topic. Searching the wiki for a distillation of a
361
+ document whose exact wording you needed is the failure this tier exists to prevent; so is
362
+ reading an empty wiki result as an empty corpus.
363
+
364
+ Two modes, pinned at creation:
365
+
366
+ - **`server_embedded`** — you send chunk TEXT; loopctl embeds it on **your** embedding key
367
+ (BYO), and search runs a semantic and a keyword lane. A tenant with no embedding
368
+ credential is refused at `corpus_create` (`422 no_embedding_key`), not at first index.
369
+ - **`client_embedded`** — you send **vectors**; loopctl stores content it cannot read. No
370
+ embedding key is needed, search is **semantic-only** (there is no text to index), and
371
+ `allow_snippets` defaults to **false** — a snippet is text the server would then hold.
372
+
373
+ `corpus_search` is deliberately **not** part of `recall_context` and is never auto-injected.
374
+
375
+ | Tool | Description |
376
+ |---|---|
377
+ | `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. |
378
+ | `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. |
379
+ | `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. |
380
+ | `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. |
381
+ | `corpus_status` | Per-`source_ref` chunk count and content hash, paginated — re-index only the documents that moved. Agent key. |
382
+ | `corpus_delete` | **Requires `LOOPCTL_USER_KEY`.** Destroy a corpus and every chunk and vector in it. Irreversible and set-based, which is why it is the one user-role verb on this surface; the files were never uploaded, so recovery means re-creating and re-indexing. To drop chunks a document no longer contains, re-index it with `source_complete` instead. |
383
+
350
384
  ### Discovery Tools
351
385
 
352
386
  | Tool | Description |
package/index.js CHANGED
@@ -24,6 +24,14 @@ import {
24
24
  llmUsagePath,
25
25
  memoryPath,
26
26
  parseJsonResponseBody,
27
+ corporaPath,
28
+ corpusPath,
29
+ corpusIndexPath,
30
+ corpusSearchPath,
31
+ corpusStatusPath,
32
+ buildCorpusCreateBody,
33
+ buildCorpusIndexBody,
34
+ buildCorpusSearchBody,
27
35
  } from "./lib/http-helpers.js";
28
36
  import {
29
37
  createWitnessClient,
@@ -3010,6 +3018,83 @@ async function getAcceptanceCriteria({ story_id }) {
3010
3018
  return toContent(result);
3011
3019
  }
3012
3020
 
3021
+ // ---------------------------------------------------------------------------
3022
+ // Corpus tier (Epic 43) — the index for reference documents whose files stay in
3023
+ // the caller's own repo.
3024
+ //
3025
+ // Every path AND request body below is built in lib/http-helpers.js, imported
3026
+ // above, so the corpus tests exercise the code this server ships rather than a
3027
+ // mirror re-implemented inside a test file (AC-43.4.1/AC-43.4.6).
3028
+ //
3029
+ // `corpus_delete` is the ONE verb here that takes LOOPCTL_USER_KEY: it is
3030
+ // set-based AND irreversible, the same AND that puts the KB's bulk ops behind a
3031
+ // user key. Everything else on this surface is agent-role.
3032
+ // ---------------------------------------------------------------------------
3033
+
3034
+ async function corpusCreate(args) {
3035
+ const result = await apiCall(
3036
+ "POST",
3037
+ corporaPath(),
3038
+ buildCorpusCreateBody(args),
3039
+ process.env.LOOPCTL_AGENT_KEY,
3040
+ );
3041
+ return toContent(result);
3042
+ }
3043
+
3044
+ async function corpusList(args = {}) {
3045
+ const result = await apiCall(
3046
+ "GET",
3047
+ corporaPath(args),
3048
+ null,
3049
+ process.env.LOOPCTL_AGENT_KEY,
3050
+ );
3051
+ return toContent(result);
3052
+ }
3053
+
3054
+ async function corpusIndex({ corpus_id, chunks, source_complete }) {
3055
+ // source_complete is forwarded in both of its declared forms; without it the
3056
+ // prune is unreachable through the only surface an agent uses (AC-43.4.1).
3057
+ const result = await apiCall(
3058
+ "POST",
3059
+ corpusIndexPath(corpus_id),
3060
+ buildCorpusIndexBody({ chunks, source_complete }),
3061
+ process.env.LOOPCTL_AGENT_KEY,
3062
+ );
3063
+ return toContent(result);
3064
+ }
3065
+
3066
+ async function corpusSearch({ corpus_id, query, query_vector, lanes, limit }) {
3067
+ const result = await apiCall(
3068
+ "POST",
3069
+ corpusSearchPath(corpus_id),
3070
+ buildCorpusSearchBody({ query, query_vector, lanes, limit }),
3071
+ process.env.LOOPCTL_AGENT_KEY,
3072
+ );
3073
+ // Pointers + snippets only — the caller's next step is to open the file at
3074
+ // source_ref/locator. Nothing here is auto-injected into a recall pack.
3075
+ return toContent(result);
3076
+ }
3077
+
3078
+ async function corpusStatus({ corpus_id, limit, offset }) {
3079
+ const result = await apiCall(
3080
+ "GET",
3081
+ corpusStatusPath(corpus_id, { limit, offset }),
3082
+ null,
3083
+ process.env.LOOPCTL_AGENT_KEY,
3084
+ );
3085
+ return toContent(result);
3086
+ }
3087
+
3088
+ async function corpusDelete({ corpus_id }) {
3089
+ const result = await apiCall(
3090
+ "DELETE",
3091
+ corpusPath(corpus_id),
3092
+ null,
3093
+ process.env.LOOPCTL_USER_KEY,
3094
+ );
3095
+ return toContent(result);
3096
+ }
3097
+
3013
3098
  // ---------------------------------------------------------------------------
3014
3099
  // Tool definitions
3015
3100
  // ---------------------------------------------------------------------------
@@ -7196,6 +7281,260 @@ const TOOLS = [
7196
7281
  required: ["story_id"],
7197
7282
  },
7198
7283
  },
7284
+ // Corpus Tools (Epic 43) — verbatim reference documents whose FILES stay in your
7285
+ // own repo. loopctl indexes chunks and hands back pointers; it never hosts the file.
7286
+ {
7287
+ name: "corpus_search",
7288
+ description:
7289
+ "Search an indexed reference DOCUMENT for the place that says it — use this when you " +
7290
+ "need the VERBATIM text of an authoritative source (a spec, a contract, an RFC, a " +
7291
+ "manual), and knowledge_search when you want what we LEARNED about a topic. " +
7292
+ "TRADE-OFF: this returns POINTERS, not bodies. Each result is {source_ref, locator, " +
7293
+ "snippet, score, chunk_id, corpus_id}; the snippet is a bounded excerpt and the full " +
7294
+ "chunk text is NEVER returned, so your next step is always to open the file yourself " +
7295
+ "at source_ref/locator. A server_embedded corpus takes `query` (a string) and fuses a " +
7296
+ "semantic and a keyword lane. A client_embedded corpus is SEMANTIC-ONLY — loopctl " +
7297
+ "holds no text to index — so send `query_vector` (its length must equal the corpus " +
7298
+ "dim, from corpus_list); a query STRING there is refused (422 " +
7299
+ "query_string_not_accepted) and so is asking for the keyword lane (422 " +
7300
+ "keyword_lane_unavailable). Send exactly ONE of query/query_vector: both is 422 " +
7301
+ "ambiguous_query, and a query_vector to a server_embedded corpus is 422 " +
7302
+ "query_vector_not_accepted. Scores are rank-derived (RRF) and comparable only WITHIN " +
7303
+ "one result set — there is no absolute floor, so judge by rank. Deliberately NOT part " +
7304
+ "of recall_context: nothing here is auto-injected. Agent key.",
7305
+ inputSchema: {
7306
+ type: "object",
7307
+ properties: {
7308
+ corpus_id: {
7309
+ type: "string",
7310
+ description: "The corpus id or slug to search.",
7311
+ },
7312
+ query: {
7313
+ type: "string",
7314
+ description:
7315
+ "The query text. server_embedded corpora ONLY. Send this or query_vector, never both.",
7316
+ },
7317
+ query_vector: {
7318
+ type: "array",
7319
+ items: { type: "number" },
7320
+ description:
7321
+ "A locally-produced query vector whose length equals the corpus dim. " +
7322
+ "client_embedded corpora ONLY. Send this or query, never both.",
7323
+ },
7324
+ lanes: {
7325
+ type: "array",
7326
+ items: { type: "string", enum: ["semantic", "keyword"] },
7327
+ description:
7328
+ "Optional: the lanes to run (default: every lane the corpus offers). A " +
7329
+ "client_embedded corpus offers only `semantic`.",
7330
+ },
7331
+ limit: { type: "integer", description: "Optional: max results (clamped server-side)." },
7332
+ },
7333
+ required: ["corpus_id"],
7334
+ },
7335
+ },
7336
+ {
7337
+ name: "corpus_create",
7338
+ description:
7339
+ "Create a corpus — a named index over reference documents whose FILES stay in your own " +
7340
+ "repo (use knowledge_create instead when you are writing a curated article loopctl " +
7341
+ "should own). TRADE-OFF: `mode` is pinned at creation and decides everything after " +
7342
+ "it. In `server_embedded` you send chunk TEXT and loopctl embeds it on YOUR embedding " +
7343
+ "key — so a tenant with no embedding credential is refused HERE (422 no_embedding_key) " +
7344
+ "rather than at first index — and both search lanes work. In `client_embedded` you " +
7345
+ "send VECTORS and loopctl stores content it cannot read: no embedding key is needed, " +
7346
+ "search is semantic-only, and allow_snippets defaults to FALSE (a snippet IS text the " +
7347
+ "server would then hold) — ask for it explicitly if you want excerpts back. " +
7348
+ "`embedding_model` and `dim` are pinned too, and a dim that disagrees with a known " +
7349
+ "model's native dimension is refused. Agent key.",
7350
+ inputSchema: {
7351
+ type: "object",
7352
+ properties: {
7353
+ slug: { type: "string", description: "URL-safe identifier, unique per tenant." },
7354
+ name: { type: "string", description: "Human-readable name." },
7355
+ mode: {
7356
+ type: "string",
7357
+ enum: ["server_embedded", "client_embedded"],
7358
+ description:
7359
+ "server_embedded: you send text, loopctl embeds it on your key, both lanes " +
7360
+ "work. client_embedded: you send vectors, loopctl never sees the text, " +
7361
+ "semantic lane only. Permanent for the corpus.",
7362
+ },
7363
+ embedding_model: {
7364
+ type: "string",
7365
+ description: "The embedding model this corpus is pinned to, e.g. text-embedding-3-small.",
7366
+ },
7367
+ dim: {
7368
+ type: "integer",
7369
+ description: "The embedding dimension. Every vector indexed or searched must match it.",
7370
+ },
7371
+ description: { type: "string", description: "Optional: what this corpus holds." },
7372
+ allow_snippets: {
7373
+ type: "boolean",
7374
+ description:
7375
+ "Optional: allow stored excerpts to come back on search results. Defaults to " +
7376
+ "FALSE in client_embedded mode, because a snippet is text the server would hold.",
7377
+ },
7378
+ project_id: { type: "string", description: "Optional: scope the corpus to one project." },
7379
+ },
7380
+ required: ["slug", "name", "mode", "embedding_model", "dim"],
7381
+ },
7382
+ },
7383
+ {
7384
+ name: "corpus_index",
7385
+ description:
7386
+ "Index a batch of chunks into a corpus — this is how a document becomes searchable; " +
7387
+ "it never uploads the file, only pointers plus whatever the corpus mode needs to rank " +
7388
+ "them. TRADE-OFF: the chunk shape is decided by the corpus mode and a mismatch is " +
7389
+ "REFUSED, not ignored. In a server_embedded corpus a chunk is {source_ref, locator, " +
7390
+ "text, ordinal?, snippet?} and content_hash is computed server-side. In a " +
7391
+ "client_embedded corpus a chunk is {source_ref, locator, vector, content_hash, " +
7392
+ "ordinal?, snippet?} — there is NO text parameter, and a chunk carrying one is 422 " +
7393
+ "text_not_accepted (dropping it would let you believe a keyword lane works on a corpus " +
7394
+ "with no text). Indexing is IDEMPOTENT on (corpus, source_ref, locator): an unchanged " +
7395
+ "batch writes nothing and spends no embedding tokens. `source_complete` is what makes " +
7396
+ "a RE-index remove what the document no longer contains: name a source_ref as a bare " +
7397
+ "STRING to declare that this request carries its complete chunk set, or as " +
7398
+ "{source_ref, locators} to declare that set explicitly when the document spans several " +
7399
+ "batches. Every stored chunk of a named source that is neither carried nor declared is " +
7400
+ "DELETED, and meta.pruned_by_source reports what each name cost. Omit source_complete " +
7401
+ "and stale chunks survive forever. Split large batches — an over-size body is 413 and " +
7402
+ "vectors are bytes. Agent key.",
7403
+ inputSchema: {
7404
+ type: "object",
7405
+ properties: {
7406
+ corpus_id: { type: "string", description: "The corpus id or slug to index into." },
7407
+ chunks: {
7408
+ type: "array",
7409
+ description:
7410
+ "The chunks to index. server_embedded: {source_ref, locator, text, ordinal?, " +
7411
+ "snippet?}. client_embedded: {source_ref, locator, vector, content_hash, " +
7412
+ "ordinal?, snippet?} — no text.",
7413
+ items: {
7414
+ type: "object",
7415
+ properties: {
7416
+ source_ref: {
7417
+ type: "string",
7418
+ description: "The document this chunk came from, e.g. a repo-relative file path.",
7419
+ },
7420
+ locator: {
7421
+ description:
7422
+ "Your own opaque pointer into that document (a page, a heading, a line " +
7423
+ "range), stored verbatim and handed back on every search hit.",
7424
+ },
7425
+ text: {
7426
+ type: "string",
7427
+ description: "The chunk text. server_embedded ONLY — refused in client_embedded.",
7428
+ },
7429
+ vector: {
7430
+ type: "array",
7431
+ items: { type: "number" },
7432
+ description:
7433
+ "Your locally-produced embedding. client_embedded ONLY; length must equal " +
7434
+ "the corpus dim.",
7435
+ },
7436
+ content_hash: {
7437
+ type: "string",
7438
+ description:
7439
+ "client_embedded ONLY: your opaque idempotency token for this chunk. " +
7440
+ "loopctl cannot verify it against the vector or the file — rotate it to " +
7441
+ "publish a new vector for an otherwise unchanged chunk.",
7442
+ },
7443
+ ordinal: { type: "integer", description: "Optional: order within the source." },
7444
+ snippet: {
7445
+ type: "string",
7446
+ description:
7447
+ "Optional excerpt returned on search hits. Refused (422 " +
7448
+ "snippets_not_allowed) unless the corpus was created with allow_snippets.",
7449
+ },
7450
+ },
7451
+ required: ["source_ref"],
7452
+ },
7453
+ },
7454
+ source_complete: {
7455
+ type: "array",
7456
+ description:
7457
+ "The sources to RECONCILE, each declaring its complete chunk set. A bare " +
7458
+ "source_ref string means this request carries that source's whole set; " +
7459
+ "{source_ref, locators} declares it explicitly so a document spanning several " +
7460
+ "batches is reconciled on the batch that completes it. Anything stored under a " +
7461
+ "named source and neither carried nor declared is deleted.",
7462
+ items: {
7463
+ oneOf: [
7464
+ { type: "string" },
7465
+ {
7466
+ type: "object",
7467
+ properties: {
7468
+ source_ref: { type: "string" },
7469
+ locators: {
7470
+ type: "array",
7471
+ description:
7472
+ "The source's COMPLETE locator set. Must include every locator this " +
7473
+ "request carries for it.",
7474
+ },
7475
+ },
7476
+ required: ["source_ref", "locators"],
7477
+ },
7478
+ ],
7479
+ },
7480
+ },
7481
+ },
7482
+ required: ["corpus_id", "chunks"],
7483
+ },
7484
+ },
7485
+ {
7486
+ name: "corpus_list",
7487
+ description:
7488
+ "List this tenant's corpora, newest first — call it before corpus_search to learn a " +
7489
+ "corpus's slug, its `mode` (which decides whether you send a query string or a query " +
7490
+ "vector) and its `dim`. Reading a mode off an ERROR is the failure this avoids. " +
7491
+ "Agent key.",
7492
+ inputSchema: {
7493
+ type: "object",
7494
+ properties: {
7495
+ project_id: { type: "string", description: "Optional: restrict to one project scope." },
7496
+ limit: { type: "integer", description: "Optional: page size (clamped)." },
7497
+ offset: { type: "integer", description: "Optional: rows to skip." },
7498
+ },
7499
+ required: [],
7500
+ },
7501
+ },
7502
+ {
7503
+ name: "corpus_status",
7504
+ description:
7505
+ "List what is actually indexed in a corpus, one row per source_ref with its chunk " +
7506
+ "count and a content hash over that source's chunks. TRADE-OFF: use it to re-index " +
7507
+ "only the documents that MOVED instead of resubmitting the corpus — a hash that " +
7508
+ "matches your local one means that source needs no work. Paginated: a corpus with " +
7509
+ "thousands of sources does not come back in one body. Agent key.",
7510
+ inputSchema: {
7511
+ type: "object",
7512
+ properties: {
7513
+ corpus_id: { type: "string", description: "The corpus id or slug." },
7514
+ limit: { type: "integer", description: "Optional: sources per page (clamped)." },
7515
+ offset: { type: "integer", description: "Optional: sources to skip." },
7516
+ },
7517
+ required: ["corpus_id"],
7518
+ },
7519
+ },
7520
+ {
7521
+ name: "corpus_delete",
7522
+ description:
7523
+ "Delete a corpus and every chunk and vector in it. **Requires LOOPCTL_USER_KEY** " +
7524
+ "(user role — an agent or orchestrator key is NOT sufficient), because this is the " +
7525
+ "one verb on this surface that is both set-based and IRREVERSIBLE: nothing in loopctl " +
7526
+ "restores it. The files themselves are yours and were never uploaded, so the recovery " +
7527
+ "path is to re-create the corpus and re-index them. To drop chunks a document no longer " +
7528
+ "contains, re-index that document with corpus_index's source_complete instead of " +
7529
+ "deleting the corpus. Agent and orchestrator keys get 403.",
7530
+ inputSchema: {
7531
+ type: "object",
7532
+ properties: {
7533
+ corpus_id: { type: "string", description: "The corpus id or slug to destroy." },
7534
+ },
7535
+ required: ["corpus_id"],
7536
+ },
7537
+ },
7199
7538
  ];
7200
7539
 
7201
7540
  // ---------------------------------------------------------------------------
@@ -7682,6 +8021,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
7682
8021
  case "get_acceptance_criteria":
7683
8022
  return await getAcceptanceCriteria(args);
7684
8023
 
8024
+ // Corpus Tools (Epic 43)
8025
+ case "corpus_create":
8026
+ return await corpusCreate(args);
8027
+
8028
+ case "corpus_index":
8029
+ return await corpusIndex(args);
8030
+
8031
+ case "corpus_search":
8032
+ return await corpusSearch(args);
8033
+
8034
+ case "corpus_list":
8035
+ return await corpusList(args);
8036
+
8037
+ case "corpus_status":
8038
+ return await corpusStatus(args);
8039
+
8040
+ case "corpus_delete":
8041
+ return await corpusDelete(args);
8042
+
7685
8043
  default:
7686
8044
  // Per-tenant generated Context Retriever tools (US-30.5) are not in the
7687
8045
  // static switch — dispatch any unknown `cr_`-prefixed name generically to
@@ -214,3 +214,150 @@ export function parseJsonResponseBody(rawText, status) {
214
214
  };
215
215
  }
216
216
  }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Corpus tier (Epic 43) — paths and request bodies for the `corpus_*` tools.
220
+ //
221
+ // EVERY corpus tool's path AND body is built here rather than inline in
222
+ // index.js, because index.js is a stdio entry point with top-level await that
223
+ // cannot be imported by a test (AC-43.4.1). Extracting both halves is what lets
224
+ // the corpus tests exercise the SHIPPED code instead of a hand-copied mirror.
225
+ //
226
+ // The bodies are built to the shapes `LoopctlWeb.CorpusController`'s `operation/2`
227
+ // specs declare. A nullish optional is OMITTED rather than sent as `null`: the
228
+ // search action dispatches on the VALUE of `query_vector`, so emitting an explicit
229
+ // `null` for an unset one sent a mode A request down the mode B path to be refused
230
+ // with `query_vector_not_accepted`. An EMPTY ARRAY is deliberately NOT treated as
231
+ // absent — the server refuses it by name (`invalid_query_vector`), and swallowing
232
+ // it here would turn a malformed vector into a silently different request.
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * Path for `corpus_list` (GET /api/v1/corpora), honoring project_id/limit/offset.
237
+ *
238
+ * @param {{ project_id?: string, limit?: number, offset?: number }} [args]
239
+ * @returns {string}
240
+ */
241
+ export function corporaPath({ project_id, limit, offset } = {}) {
242
+ return `/api/v1/corpora${buildQuery([
243
+ ["project_id", project_id],
244
+ ["limit", limit],
245
+ ["offset", offset],
246
+ ])}`;
247
+ }
248
+
249
+ /**
250
+ * Path for one corpus (GET/DELETE /api/v1/corpora/:id). The segment accepts an id
251
+ * OR a slug, both client-supplied, so it is encoded (the `retrieveEntityPath`
252
+ * precedent) rather than interpolated raw.
253
+ *
254
+ * @param {string} id
255
+ * @returns {string}
256
+ */
257
+ export function corpusPath(id) {
258
+ return `/api/v1/corpora/${encodeURIComponent(id)}`;
259
+ }
260
+
261
+ /**
262
+ * Path for `corpus_index` (POST /api/v1/corpora/:id/index).
263
+ *
264
+ * @param {string} id
265
+ * @returns {string}
266
+ */
267
+ export function corpusIndexPath(id) {
268
+ return `${corpusPath(id)}/index`;
269
+ }
270
+
271
+ /**
272
+ * Path for `corpus_search` (POST /api/v1/corpora/:id/search).
273
+ *
274
+ * @param {string} id
275
+ * @returns {string}
276
+ */
277
+ export function corpusSearchPath(id) {
278
+ return `${corpusPath(id)}/search`;
279
+ }
280
+
281
+ /**
282
+ * Path for `corpus_status` (GET /api/v1/corpora/:id/status), honoring limit/offset
283
+ * — the per-source listing is paginated, so a corpus with thousands of sources
284
+ * does not come back in one body.
285
+ *
286
+ * @param {string} id
287
+ * @param {{ limit?: number, offset?: number }} [args]
288
+ * @returns {string}
289
+ */
290
+ export function corpusStatusPath(id, { limit, offset } = {}) {
291
+ return `${corpusPath(id)}/status${buildQuery([
292
+ ["limit", limit],
293
+ ["offset", offset],
294
+ ])}`;
295
+ }
296
+
297
+ /**
298
+ * Body for `corpus_create` (POST /api/v1/corpora).
299
+ *
300
+ * `allow_snippets` is filtered on `!= null`, never on falsiness: `false` is the
301
+ * meaningful mode B default and a truthiness check would drop an explicit opt-out.
302
+ *
303
+ * @param {{ slug?: string, name?: string, mode?: string, embedding_model?: string,
304
+ * dim?: number, description?: string, allow_snippets?: boolean, project_id?: string }} [args]
305
+ * @returns {object}
306
+ */
307
+ export function buildCorpusCreateBody(args = {}) {
308
+ const body = {};
309
+ for (const key of [
310
+ "slug",
311
+ "name",
312
+ "mode",
313
+ "embedding_model",
314
+ "dim",
315
+ "description",
316
+ "allow_snippets",
317
+ "project_id",
318
+ ]) {
319
+ if (args[key] != null) body[key] = args[key];
320
+ }
321
+ return body;
322
+ }
323
+
324
+ /**
325
+ * Body for `corpus_index` (POST /api/v1/corpora/:id/index).
326
+ *
327
+ * `chunks` passes through verbatim — its shape is mode-dependent (mode A carries
328
+ * `text`, mode B carries `vector` + `content_hash`) and the server is the one that
329
+ * decides, so nothing is reshaped or filtered here.
330
+ *
331
+ * `source_complete` is forwarded in BOTH declared forms (a bare `source_ref`
332
+ * string, or `{source_ref, locators}`) because it is the ONLY way to reach US-43.2's
333
+ * prune: omit it from the tool surface and a re-indexed document's removed chunks
334
+ * are unreachable through the surface an agent actually uses (AC-43.4.1).
335
+ *
336
+ * @param {{ chunks?: unknown[], source_complete?: unknown[] }} [args]
337
+ * @returns {object}
338
+ */
339
+ export function buildCorpusIndexBody({ chunks, source_complete } = {}) {
340
+ const body = { chunks: chunks ?? [] };
341
+ if (source_complete != null) body.source_complete = source_complete;
342
+ return body;
343
+ }
344
+
345
+ /**
346
+ * Body for `corpus_search` (POST /api/v1/corpora/:id/search).
347
+ *
348
+ * The parameter is named `query` (not `q`) to match every sibling on this surface
349
+ * except legacy `knowledge_search` — agents copy parameter spellings from
350
+ * neighbouring tools, and `lib/arg-aliases.js` documents the 8% of searches that
351
+ * cost.
352
+ *
353
+ * @param {{ query?: string, query_vector?: number[], lanes?: string[], limit?: number }} [args]
354
+ * @returns {object}
355
+ */
356
+ export function buildCorpusSearchBody({ query, query_vector, lanes, limit } = {}) {
357
+ const body = {};
358
+ if (query != null) body.query = query;
359
+ if (query_vector != null) body.query_vector = query_vector;
360
+ if (lanes != null) body.lanes = lanes;
361
+ if (limit != null) body.limit = limit;
362
+ return body;
363
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.77.0",
3
+ "version": "2.78.0",
4
4
  "description": "MCP server for loopctl \u2014 structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",