loopctl-mcp-server 2.112.0 → 2.113.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/index.js +67 -25
- package/lib/arg-aliases.js +42 -3
- package/lib/article-id.js +17 -0
- package/lib/delivery-loop.js +22 -5
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import os from "node:os";
|
|
|
15
15
|
import crypto from "node:crypto";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { applyArgAliases } from "./lib/arg-aliases.js";
|
|
18
|
+
import { articleId } from "./lib/article-id.js";
|
|
18
19
|
import { clientContextHeader } from "./lib/client-context.js";
|
|
19
20
|
import { resolveClaimSessionId } from "./lib/claim-session.js";
|
|
20
21
|
import { degradedSearchNotice } from "./lib/search-notices.js";
|
|
@@ -1668,8 +1669,10 @@ async function knowledgeStats({ project_id }) {
|
|
|
1668
1669
|
}
|
|
1669
1670
|
|
|
1670
1671
|
async function knowledgeGraph({ article_id, depth, project_id }) {
|
|
1672
|
+
const art = articleId(article_id);
|
|
1673
|
+
if (art.refusal) return toContent(art.refusal);
|
|
1671
1674
|
const params = new URLSearchParams();
|
|
1672
|
-
params.set("article_id",
|
|
1675
|
+
params.set("article_id", art.id);
|
|
1673
1676
|
if (depth != null) params.set("depth", String(depth));
|
|
1674
1677
|
if (project_id) params.set("project_id", project_id);
|
|
1675
1678
|
const result = await apiCall(
|
|
@@ -1682,11 +1685,13 @@ async function knowledgeGraph({ article_id, depth, project_id }) {
|
|
|
1682
1685
|
}
|
|
1683
1686
|
|
|
1684
1687
|
async function knowledgeSuggestLinks({ article_id, limit, threshold }) {
|
|
1688
|
+
const art = articleId(article_id);
|
|
1689
|
+
if (art.refusal) return toContent(art.refusal);
|
|
1685
1690
|
const params = new URLSearchParams();
|
|
1686
1691
|
if (limit != null) params.set("limit", String(limit));
|
|
1687
1692
|
if (threshold != null) params.set("threshold", String(threshold));
|
|
1688
1693
|
const qs = params.toString();
|
|
1689
|
-
const base = `/api/v1/knowledge/articles/${
|
|
1694
|
+
const base = `/api/v1/knowledge/articles/${art.id}/suggested_links`;
|
|
1690
1695
|
const result = await apiCall(
|
|
1691
1696
|
"GET",
|
|
1692
1697
|
qs ? `${base}?${qs}` : base,
|
|
@@ -1871,6 +1876,8 @@ async function knowledgeHeatIndex({ category, limit, since }) {
|
|
|
1871
1876
|
}
|
|
1872
1877
|
|
|
1873
1878
|
async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offset }) {
|
|
1879
|
+
const art = articleId(article_id, { prefix: true });
|
|
1880
|
+
if (art.refusal) return toContent(art.refusal);
|
|
1874
1881
|
const params = new URLSearchParams();
|
|
1875
1882
|
// 0 is meaningful on both (whole body / start at the beginning), so test for
|
|
1876
1883
|
// null/undefined rather than truthiness.
|
|
@@ -1879,7 +1886,7 @@ async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offs
|
|
|
1879
1886
|
if (body_offset !== undefined && body_offset !== null)
|
|
1880
1887
|
params.set("body_offset", String(body_offset));
|
|
1881
1888
|
const qs = params.toString();
|
|
1882
|
-
const base = `/api/v1/knowledge/progressive/${
|
|
1889
|
+
const base = `/api/v1/knowledge/progressive/${art.id}`;
|
|
1883
1890
|
const result = await apiCall(
|
|
1884
1891
|
"GET",
|
|
1885
1892
|
qs ? `${base}?${qs}` : base,
|
|
@@ -1942,6 +1949,11 @@ async function knowledgeGet({
|
|
|
1942
1949
|
body_max_bytes,
|
|
1943
1950
|
body_offset,
|
|
1944
1951
|
}) {
|
|
1952
|
+
// A missing id went out as /undefined and a malformed one as whatever it spelled
|
|
1953
|
+
// ('', '#', '..', 'x?y' each reach a different route); the server's answer read as
|
|
1954
|
+
// "this article does not exist". Refused here with status 0, before any request.
|
|
1955
|
+
const art = articleId(article_id, { prefix: true });
|
|
1956
|
+
if (art.refusal) return toContent(art.refusal);
|
|
1945
1957
|
const params = new URLSearchParams();
|
|
1946
1958
|
if (project_id) params.set("project_id", project_id);
|
|
1947
1959
|
if (story_id) params.set("story_id", story_id);
|
|
@@ -1953,7 +1965,7 @@ async function knowledgeGet({
|
|
|
1953
1965
|
if (body_offset !== undefined && body_offset !== null)
|
|
1954
1966
|
params.set("body_offset", String(body_offset));
|
|
1955
1967
|
const qs = params.toString();
|
|
1956
|
-
const path = qs ? `/api/v1/articles/${
|
|
1968
|
+
const path = qs ? `/api/v1/articles/${art.id}?${qs}` : `/api/v1/articles/${art.id}`;
|
|
1957
1969
|
const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
|
|
1958
1970
|
return toContent(result);
|
|
1959
1971
|
}
|
|
@@ -2031,6 +2043,8 @@ async function knowledgeCreate({
|
|
|
2031
2043
|
// provided fields change. Agent role — KB-content curation, visibility-scoped
|
|
2032
2044
|
// server-side (another agent's private/owner memory 404s).
|
|
2033
2045
|
async function knowledgeUpdate({ article_id, title, body, category, tags, metadata }) {
|
|
2046
|
+
const art = articleId(article_id);
|
|
2047
|
+
if (art.refusal) return toContent(art.refusal);
|
|
2034
2048
|
const payload = {};
|
|
2035
2049
|
if (title != null) payload.title = title;
|
|
2036
2050
|
if (body != null) payload.body = body;
|
|
@@ -2040,7 +2054,7 @@ async function knowledgeUpdate({ article_id, title, body, category, tags, metada
|
|
|
2040
2054
|
|
|
2041
2055
|
const result = await apiCall(
|
|
2042
2056
|
"PATCH",
|
|
2043
|
-
`/api/v1/articles/${
|
|
2057
|
+
`/api/v1/articles/${art.id}`,
|
|
2044
2058
|
payload,
|
|
2045
2059
|
process.env.LOOPCTL_AGENT_KEY,
|
|
2046
2060
|
);
|
|
@@ -2231,7 +2245,9 @@ async function memoryGraduate({ memory_id, re_scope }) {
|
|
|
2231
2245
|
// --- Knowledge Management Tools (orch key) ---
|
|
2232
2246
|
|
|
2233
2247
|
async function knowledgePublish({ article_id }) {
|
|
2234
|
-
const
|
|
2248
|
+
const art = articleId(article_id);
|
|
2249
|
+
if (art.refusal) return toContent(art.refusal);
|
|
2250
|
+
const result = await apiCall("POST", `/api/v1/articles/${art.id}/publish`, null, process.env.LOOPCTL_ORCH_KEY);
|
|
2235
2251
|
return toContent(result);
|
|
2236
2252
|
}
|
|
2237
2253
|
|
|
@@ -2286,9 +2302,11 @@ async function knowledgeBulkUnpublish({ article_ids }) {
|
|
|
2286
2302
|
}
|
|
2287
2303
|
|
|
2288
2304
|
async function knowledgeUnpublish({ article_id }) {
|
|
2305
|
+
const art = articleId(article_id);
|
|
2306
|
+
if (art.refusal) return toContent(art.refusal);
|
|
2289
2307
|
const result = await apiCall(
|
|
2290
2308
|
"POST",
|
|
2291
|
-
`/api/v1/articles/${
|
|
2309
|
+
`/api/v1/articles/${art.id}/unpublish`,
|
|
2292
2310
|
null,
|
|
2293
2311
|
process.env.LOOPCTL_USER_KEY
|
|
2294
2312
|
);
|
|
@@ -2299,9 +2317,11 @@ async function knowledgeUnpublish({ article_id }) {
|
|
|
2299
2317
|
// audited, visibility-scoped server-side). NOT reversible in code — #606/#605: `:archived`
|
|
2300
2318
|
// is a terminal status. The row survives; nothing automated brings it back.
|
|
2301
2319
|
async function knowledgeArchive({ article_id }) {
|
|
2320
|
+
const art = articleId(article_id);
|
|
2321
|
+
if (art.refusal) return toContent(art.refusal);
|
|
2302
2322
|
const result = await apiCall(
|
|
2303
2323
|
"POST",
|
|
2304
|
-
`/api/v1/articles/${
|
|
2324
|
+
`/api/v1/articles/${art.id}/archive`,
|
|
2305
2325
|
null,
|
|
2306
2326
|
process.env.LOOPCTL_AGENT_KEY
|
|
2307
2327
|
);
|
|
@@ -2312,9 +2332,11 @@ async function knowledgeArchive({ article_id }) {
|
|
|
2312
2332
|
// one member of that family that undoes: nothing is destroyed and nothing is rebuilt, so
|
|
2313
2333
|
// knowledge_unsuppress restores the article to every read path immediately.
|
|
2314
2334
|
async function knowledgeSuppress({ article_id, reason }) {
|
|
2335
|
+
const art = articleId(article_id);
|
|
2336
|
+
if (art.refusal) return toContent(art.refusal);
|
|
2315
2337
|
const result = await apiCall(
|
|
2316
2338
|
"POST",
|
|
2317
|
-
`/api/v1/articles/${
|
|
2339
|
+
`/api/v1/articles/${art.id}/suppress`,
|
|
2318
2340
|
{ reason },
|
|
2319
2341
|
process.env.LOOPCTL_AGENT_KEY
|
|
2320
2342
|
);
|
|
@@ -2322,9 +2344,11 @@ async function knowledgeSuppress({ article_id, reason }) {
|
|
|
2322
2344
|
}
|
|
2323
2345
|
|
|
2324
2346
|
async function knowledgeUnsuppress({ article_id }) {
|
|
2347
|
+
const art = articleId(article_id);
|
|
2348
|
+
if (art.refusal) return toContent(art.refusal);
|
|
2325
2349
|
const result = await apiCall(
|
|
2326
2350
|
"POST",
|
|
2327
|
-
`/api/v1/articles/${
|
|
2351
|
+
`/api/v1/articles/${art.id}/unsuppress`,
|
|
2328
2352
|
null,
|
|
2329
2353
|
process.env.LOOPCTL_AGENT_KEY
|
|
2330
2354
|
);
|
|
@@ -2333,9 +2357,11 @@ async function knowledgeUnsuppress({ article_id }) {
|
|
|
2333
2357
|
|
|
2334
2358
|
// #331: soft-delete (archive) is agent-role KB curation, same as knowledge_archive.
|
|
2335
2359
|
async function knowledgeDelete({ article_id }) {
|
|
2360
|
+
const art = articleId(article_id);
|
|
2361
|
+
if (art.refusal) return toContent(art.refusal);
|
|
2336
2362
|
const result = await apiCall(
|
|
2337
2363
|
"DELETE",
|
|
2338
|
-
`/api/v1/articles/${
|
|
2364
|
+
`/api/v1/articles/${art.id}`,
|
|
2339
2365
|
null,
|
|
2340
2366
|
process.env.LOOPCTL_AGENT_KEY
|
|
2341
2367
|
);
|
|
@@ -2812,9 +2838,11 @@ async function knowledgeCurationLog({ kind, since, limit, offset } = {}) {
|
|
|
2812
2838
|
}
|
|
2813
2839
|
|
|
2814
2840
|
async function knowledgeArticleStats({ article_id }) {
|
|
2841
|
+
const art = articleId(article_id, { prefix: true });
|
|
2842
|
+
if (art.refusal) return toContent(art.refusal);
|
|
2815
2843
|
const result = await apiCall(
|
|
2816
2844
|
"GET",
|
|
2817
|
-
`/api/v1/knowledge/articles/${
|
|
2845
|
+
`/api/v1/knowledge/articles/${art.id}/stats`,
|
|
2818
2846
|
null,
|
|
2819
2847
|
process.env.LOOPCTL_ORCH_KEY
|
|
2820
2848
|
);
|
|
@@ -5542,7 +5570,8 @@ const TOOLS = [
|
|
|
5542
5570
|
article_id: {
|
|
5543
5571
|
type: "string",
|
|
5544
5572
|
format: "uuid",
|
|
5545
|
-
description:
|
|
5573
|
+
description:
|
|
5574
|
+
"Starting article UUID (required). A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
|
|
5546
5575
|
},
|
|
5547
5576
|
depth: {
|
|
5548
5577
|
type: "integer",
|
|
@@ -5575,7 +5604,8 @@ const TOOLS = [
|
|
|
5575
5604
|
article_id: {
|
|
5576
5605
|
type: "string",
|
|
5577
5606
|
format: "uuid",
|
|
5578
|
-
description:
|
|
5607
|
+
description:
|
|
5608
|
+
"The article to suggest links for (required). A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
|
|
5579
5609
|
},
|
|
5580
5610
|
limit: { type: "integer", description: "Max candidates (default 5)." },
|
|
5581
5611
|
threshold: {
|
|
@@ -5925,8 +5955,8 @@ const TOOLS = [
|
|
|
5925
5955
|
properties: {
|
|
5926
5956
|
article_id: {
|
|
5927
5957
|
type: "string",
|
|
5928
|
-
|
|
5929
|
-
|
|
5958
|
+
description:
|
|
5959
|
+
"The UUID of the article to open (from a progressive index stub). Or a prefix of at least 8 hex digits (the server matches its first 8). A missing, blank, shorter or non-hex value is refused locally with status 0 and no request is sent.",
|
|
5930
5960
|
},
|
|
5931
5961
|
body_max_bytes: {
|
|
5932
5962
|
type: "integer",
|
|
@@ -5985,7 +6015,10 @@ const TOOLS = [
|
|
|
5985
6015
|
description:
|
|
5986
6016
|
"The UUID of the article. A unique ID PREFIX (>= 8 hex characters) also " +
|
|
5987
6017
|
"resolves, so copy what you have rather than reconstructing 36 characters " +
|
|
5988
|
-
"from memory; an ambiguous prefix is a 404, never a guess."
|
|
6018
|
+
"from memory; an ambiguous prefix is a 404, never a guess. The server matches a " +
|
|
6019
|
+
"prefix on its first 8 hex digits only. Surrounding whitespace is trimmed. A " +
|
|
6020
|
+
"missing or blank id, fewer than 8 hex digits, or any character other than hex " +
|
|
6021
|
+
"digits and dashes is refused locally with status 0 and no request is sent.",
|
|
5989
6022
|
},
|
|
5990
6023
|
links: {
|
|
5991
6024
|
type: "string",
|
|
@@ -6232,7 +6265,8 @@ const TOOLS = [
|
|
|
6232
6265
|
properties: {
|
|
6233
6266
|
article_id: {
|
|
6234
6267
|
type: "string",
|
|
6235
|
-
description:
|
|
6268
|
+
description:
|
|
6269
|
+
"The UUID of the article to edit (preserved across the update). A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
|
|
6236
6270
|
},
|
|
6237
6271
|
title: {
|
|
6238
6272
|
type: "string",
|
|
@@ -6610,7 +6644,8 @@ const TOOLS = [
|
|
|
6610
6644
|
properties: {
|
|
6611
6645
|
article_id: {
|
|
6612
6646
|
type: "string",
|
|
6613
|
-
description:
|
|
6647
|
+
description:
|
|
6648
|
+
"The UUID of the draft article to publish. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
|
|
6614
6649
|
},
|
|
6615
6650
|
},
|
|
6616
6651
|
required: ["article_id"],
|
|
@@ -6678,7 +6713,8 @@ const TOOLS = [
|
|
|
6678
6713
|
properties: {
|
|
6679
6714
|
article_id: {
|
|
6680
6715
|
type: "string",
|
|
6681
|
-
description:
|
|
6716
|
+
description:
|
|
6717
|
+
"The UUID of the published article to unpublish. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
|
|
6682
6718
|
},
|
|
6683
6719
|
},
|
|
6684
6720
|
required: ["article_id"],
|
|
@@ -6701,7 +6737,8 @@ const TOOLS = [
|
|
|
6701
6737
|
properties: {
|
|
6702
6738
|
article_id: {
|
|
6703
6739
|
type: "string",
|
|
6704
|
-
description:
|
|
6740
|
+
description:
|
|
6741
|
+
"The UUID of the article to archive. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
|
|
6705
6742
|
},
|
|
6706
6743
|
},
|
|
6707
6744
|
required: ["article_id"],
|
|
@@ -6736,7 +6773,8 @@ const TOOLS = [
|
|
|
6736
6773
|
properties: {
|
|
6737
6774
|
article_id: {
|
|
6738
6775
|
type: "string",
|
|
6739
|
-
description:
|
|
6776
|
+
description:
|
|
6777
|
+
"The UUID of the article to take out of retrieval. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
|
|
6740
6778
|
},
|
|
6741
6779
|
reason: {
|
|
6742
6780
|
type: "string",
|
|
@@ -6764,7 +6802,8 @@ const TOOLS = [
|
|
|
6764
6802
|
properties: {
|
|
6765
6803
|
article_id: {
|
|
6766
6804
|
type: "string",
|
|
6767
|
-
description:
|
|
6805
|
+
description:
|
|
6806
|
+
"The UUID of the article to restore to retrieval. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
|
|
6768
6807
|
},
|
|
6769
6808
|
},
|
|
6770
6809
|
required: ["article_id"],
|
|
@@ -6786,7 +6825,8 @@ const TOOLS = [
|
|
|
6786
6825
|
properties: {
|
|
6787
6826
|
article_id: {
|
|
6788
6827
|
type: "string",
|
|
6789
|
-
description:
|
|
6828
|
+
description:
|
|
6829
|
+
"The UUID of the article to delete. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
|
|
6790
6830
|
},
|
|
6791
6831
|
},
|
|
6792
6832
|
required: ["article_id"],
|
|
@@ -8092,7 +8132,8 @@ const TOOLS = [
|
|
|
8092
8132
|
properties: {
|
|
8093
8133
|
article_id: {
|
|
8094
8134
|
type: "string",
|
|
8095
|
-
description:
|
|
8135
|
+
description:
|
|
8136
|
+
"The UUID of the article to inspect. Or a prefix of at least 8 hex digits (the server matches its first 8). A missing, blank, shorter or non-hex value is refused locally with status 0 and no request is sent.",
|
|
8096
8137
|
},
|
|
8097
8138
|
},
|
|
8098
8139
|
required: ["article_id"],
|
|
@@ -9904,6 +9945,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
9904
9945
|
);
|
|
9905
9946
|
},
|
|
9906
9947
|
declaredToolArgs(name),
|
|
9948
|
+
name,
|
|
9907
9949
|
);
|
|
9908
9950
|
|
|
9909
9951
|
switch (name) {
|
package/lib/arg-aliases.js
CHANGED
|
@@ -66,8 +66,26 @@ const ARG_ALIASES = {
|
|
|
66
66
|
limit: ["max_results"],
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
+
// TOOL-SCOPED aliases, keyed by tool name, applied only when the called tool declares the
|
|
70
|
+
// canonical and not the alias. `id` cannot go in the global table above: memory_forget and
|
|
71
|
+
// others declare `id` as their own parameter, and the drift guard refuses it there.
|
|
72
|
+
//
|
|
73
|
+
// `article_id <- id` on knowledge_get is measured: 17 of 96 knowledge_get calls in mac-mini's
|
|
74
|
+
// session transcripts (2026-10-01) passed `{"id": ...}`, the key every search result carries
|
|
75
|
+
// its id under, and the request went out as `/api/v1/articles/undefined`. All 30
|
|
76
|
+
// knowledge_get 404s in production for 2026-09-25..30 were that request.
|
|
77
|
+
// knowledge_progressive_drill is the same mechanism: its documented input is a progressive
|
|
78
|
+
// index stub, which carries the id under the same `id` key. A write verb never joins: there
|
|
79
|
+
// the alias would turn a harmless miss into a write against an id copied from the wrong row.
|
|
80
|
+
const TOOL_SCOPED_ALIASES = {
|
|
81
|
+
knowledge_get: { article_id: ["id"] },
|
|
82
|
+
knowledge_progressive_drill: { article_id: ["id"] },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Whitespace-only is blank, matching articleId(), which trims: otherwise a " " article_id
|
|
86
|
+
// blocks the rescue here and is then refused as missing there, discarding a good `id`.
|
|
69
87
|
function isBlank(v) {
|
|
70
|
-
return v === undefined || v === null || v === "";
|
|
88
|
+
return v === undefined || v === null || (typeof v === "string" && v.trim() === "");
|
|
71
89
|
}
|
|
72
90
|
|
|
73
91
|
/**
|
|
@@ -78,8 +96,11 @@ function isBlank(v) {
|
|
|
78
96
|
* supplied, only a fill of a declared key is reported as a rescue — the rest are inert
|
|
79
97
|
* conveniences. Omit it and every fill is reported (the conservative default for a tool
|
|
80
98
|
* whose schema is not known, e.g. the per-tenant `cr_*` tools).
|
|
99
|
+
*
|
|
100
|
+
* `toolName` selects that tool's TOOL_SCOPED_ALIASES, which apply only when BOTH `declared`
|
|
101
|
+
* and `toolName` are given.
|
|
81
102
|
*/
|
|
82
|
-
function applyArgAliases(args, onAliasUsed, declared) {
|
|
103
|
+
function applyArgAliases(args, onAliasUsed, declared, toolName) {
|
|
83
104
|
if (!args || typeof args !== "object" || Array.isArray(args)) return args;
|
|
84
105
|
|
|
85
106
|
const out = { ...args };
|
|
@@ -109,6 +130,24 @@ function applyArgAliases(args, onAliasUsed, declared) {
|
|
|
109
130
|
}
|
|
110
131
|
}
|
|
111
132
|
|
|
133
|
+
const scoped =
|
|
134
|
+
declaredSet !== null && toolName && Object.hasOwn(TOOL_SCOPED_ALIASES, toolName)
|
|
135
|
+
? TOOL_SCOPED_ALIASES[toolName]
|
|
136
|
+
: {};
|
|
137
|
+
for (const [canonical, aliases] of Object.entries(scoped)) {
|
|
138
|
+
if (!declaredSet.has(canonical) || !isBlank(out[canonical])) continue;
|
|
139
|
+
const alias = aliases.find((a) => !declaredSet.has(a) && !isBlank(out[a]));
|
|
140
|
+
if (alias === undefined) continue;
|
|
141
|
+
out[canonical] = out[alias];
|
|
142
|
+
if (typeof onAliasUsed === "function") {
|
|
143
|
+
try {
|
|
144
|
+
onAliasUsed({ canonical, alias });
|
|
145
|
+
} catch {
|
|
146
|
+
// Telemetry must never break a tool call.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
112
151
|
// Some callers send a numeric arg as a string ("6"). Coerce so downstream validation and
|
|
113
152
|
// the outbound query string both see a number rather than rejecting or double-encoding.
|
|
114
153
|
if (typeof out.limit === "string" && /^\d+$/.test(out.limit.trim())) {
|
|
@@ -118,4 +157,4 @@ function applyArgAliases(args, onAliasUsed, declared) {
|
|
|
118
157
|
return out;
|
|
119
158
|
}
|
|
120
159
|
|
|
121
|
-
export { ARG_ALIASES, applyArgAliases };
|
|
160
|
+
export { ARG_ALIASES, TOOL_SCOPED_ALIASES, applyArgAliases };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An `article_id` bound for a URL path or query: trimmed (an id pasted with a trailing newline
|
|
3
|
+
* is the id), then held to the shared `uuid()` check in lib/delivery-loop.js. Returns `{ id }`
|
|
4
|
+
* to send, or `{ refusal }` (status 0, value never echoed).
|
|
5
|
+
*
|
|
6
|
+
* The reads whose server path resolves an id prefix (#652: knowledge_get,
|
|
7
|
+
* knowledge_progressive_drill, knowledge_article_stats) pass `{ prefix: true }`; every other
|
|
8
|
+
* article verb takes a full UUID. A missing id used to go out as `/undefined` and come back as
|
|
9
|
+
* a 404 that agents read as "the article does not exist".
|
|
10
|
+
*/
|
|
11
|
+
import { uuid } from "./delivery-loop.js";
|
|
12
|
+
|
|
13
|
+
export function articleId(value, { prefix = false } = {}) {
|
|
14
|
+
const trimmed = typeof value === "string" ? value.trim() : value;
|
|
15
|
+
const refusal = uuid(trimmed, "article_id", { prefix });
|
|
16
|
+
return refusal ? { refusal } : { id: trimmed };
|
|
17
|
+
}
|
package/lib/delivery-loop.js
CHANGED
|
@@ -48,7 +48,9 @@ function refuse(body) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* A client-side shape check on the ids that go into a URL PATH: `story_id` and `runner_id
|
|
51
|
+
* A client-side shape check on the ids that go into a URL PATH: `story_id` and `runner_id`
|
|
52
|
+
* here, and `article_id` on the knowledge verbs through `lib/article-id.js` (which adds the
|
|
53
|
+
* `prefix` grammar its reads accept).
|
|
52
54
|
*
|
|
53
55
|
* EXPORTED, because it is one check and not a pattern to copy. `update_story`
|
|
54
56
|
* (`lib/story-update.js`) interpolates a `story_id` into a path exactly as the verbs below do
|
|
@@ -111,15 +113,30 @@ function refuse(body) {
|
|
|
111
113
|
* into the transcript.
|
|
112
114
|
*/
|
|
113
115
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
const PREFIX_RE = /^[0-9a-f-]+$/i;
|
|
117
|
+
|
|
118
|
+
// `prefix: true` also accepts what the server's id-prefix fallback resolves (#652,
|
|
119
|
+
// `Knowledge.id_prefix_range/1`, reached by knowledge_get, knowledge_progressive_drill and
|
|
120
|
+
// knowledge_article_stats): hex digits and dashes with at least 8 hex digits, dashless or
|
|
121
|
+
// partial. The server compares only the FIRST 8 hex digits and resolves when exactly one
|
|
122
|
+
// article has them: the rest of a longer value is not checked, so a full-length id with a
|
|
123
|
+
// wrong tail resolves to the article owning its first 8 (#652's design, for confabulated tails).
|
|
124
|
+
// Only hex and dashes are accepted, so nothing in a prefix can change the route ('#', '?', '/').
|
|
125
|
+
export function uuid(value, field, { prefix = false } = {}) {
|
|
126
|
+
if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) {
|
|
117
127
|
return refuse(`\`${field}\` is required.`);
|
|
118
128
|
}
|
|
119
129
|
|
|
130
|
+
if (typeof value !== "string") {
|
|
131
|
+
return refuse(`\`${field}\` must be a string; got a ${Array.isArray(value) ? "array" : typeof value}.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (prefix && PREFIX_RE.test(value) && value.replace(/-/g, "").length >= 8) return null;
|
|
135
|
+
|
|
120
136
|
if (!UUID_RE.test(value)) {
|
|
121
137
|
return refuse(
|
|
122
|
-
`\`${field}\` must be a UUID (8-4-4-4 hex digits then 12, lowercase or upper)
|
|
138
|
+
`\`${field}\` must be a UUID (8-4-4-4 hex digits then 12, lowercase or upper)` +
|
|
139
|
+
(prefix ? " or a prefix of at least 8 hex digits. " : ". ") +
|
|
123
140
|
`Got a ${value.length}-character string that is not one. The value is not repeated ` +
|
|
124
141
|
`here: a malformed id is often something pasted into the wrong argument, and a tool ` +
|
|
125
142
|
`result lands in the transcript.`,
|