@systemdox/mcp-server 0.1.12 → 0.1.14
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/dist/cli.js +101 -21
- package/dist/http.js +100 -20
- package/dist/index.js +100 -20
- package/dist/lib/context-builder.d.ts +9 -0
- package/dist/lib/context-builder.js +69 -11
- package/dist/tools/get-docs.js +108 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -444,20 +444,28 @@ function registerGetDocs(server2, apiClient2) {
|
|
|
444
444
|
"get_docs",
|
|
445
445
|
{
|
|
446
446
|
title: "Get Docs",
|
|
447
|
-
description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search.",
|
|
447
|
+
description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search. Pass doc_id with section (a heading anchor) or chunk_id to retrieve just that section instead of the whole document.",
|
|
448
448
|
inputSchema: {
|
|
449
449
|
repo: z3.string().optional().describe("Filter by repo (owner/repo format)"),
|
|
450
450
|
path: z3.string().optional().describe("File path glob pattern (e.g., docs/adr/*.md)"),
|
|
451
451
|
search: z3.string().optional().describe("Keyword search across document content"),
|
|
452
452
|
doc_id: z3.string().optional().describe(
|
|
453
453
|
"Specific document ID to retrieve full content"
|
|
454
|
+
),
|
|
455
|
+
section: z3.string().optional().describe(
|
|
456
|
+
"Heading anchor (e.g. 'rollback-strategy') \u2014 with doc_id, returns just that section"
|
|
457
|
+
),
|
|
458
|
+
chunk_id: z3.string().optional().describe(
|
|
459
|
+
"Section chunk id ('{path_hash}#{anchor}') from get_context \u2014 with doc_id, returns just that section"
|
|
454
460
|
)
|
|
455
461
|
}
|
|
456
462
|
},
|
|
457
|
-
async ({ repo, path, search, doc_id }) => {
|
|
463
|
+
async ({ repo, path, search, doc_id, section, chunk_id }) => {
|
|
458
464
|
if (doc_id) {
|
|
465
|
+
const anchor = section ?? (chunk_id ? chunk_id.split("#").pop() : void 0);
|
|
459
466
|
const doc = await apiClient2.get(
|
|
460
|
-
`/v1/docs/${encodeURIComponent(doc_id)}/content
|
|
467
|
+
`/v1/docs/${encodeURIComponent(doc_id)}/content`,
|
|
468
|
+
anchor ? { section: anchor } : void 0
|
|
461
469
|
);
|
|
462
470
|
if (doc.error) {
|
|
463
471
|
return {
|
|
@@ -470,8 +478,18 @@ function registerGetDocs(server2, apiClient2) {
|
|
|
470
478
|
isError: true
|
|
471
479
|
};
|
|
472
480
|
}
|
|
481
|
+
let text2 = doc.content;
|
|
482
|
+
if (anchor && doc.section_resolved === false) {
|
|
483
|
+
text2 = `_Section "${anchor}" not found \u2014 returning the full document._
|
|
484
|
+
|
|
485
|
+
` + text2;
|
|
486
|
+
} else if (anchor && doc.heading) {
|
|
487
|
+
text2 = `## ${doc.heading}
|
|
488
|
+
|
|
489
|
+
${doc.content}`;
|
|
490
|
+
}
|
|
473
491
|
return {
|
|
474
|
-
content: [{ type: "text", text:
|
|
492
|
+
content: [{ type: "text", text: text2 }]
|
|
475
493
|
};
|
|
476
494
|
}
|
|
477
495
|
const params = {};
|
|
@@ -495,10 +513,14 @@ function registerGetDocs(server2, apiClient2) {
|
|
|
495
513
|
]
|
|
496
514
|
};
|
|
497
515
|
}
|
|
498
|
-
const text = items.map(
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
516
|
+
const text = items.map((d) => {
|
|
517
|
+
const sections = (d.outline ?? []).slice(0, 8);
|
|
518
|
+
const sectionHint = sections.length > 0 ? `
|
|
519
|
+
sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
|
|
520
|
+
return `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})
|
|
521
|
+
doc_id: \`${d.id}\`` + (d.snippet ? `
|
|
522
|
+
> ${d.snippet}` : "") + sectionHint;
|
|
523
|
+
}).join("\n");
|
|
502
524
|
return {
|
|
503
525
|
content: [
|
|
504
526
|
{
|
|
@@ -507,7 +529,7 @@ function registerGetDocs(server2, apiClient2) {
|
|
|
507
529
|
|
|
508
530
|
${text}
|
|
509
531
|
|
|
510
|
-
Use doc_id to
|
|
532
|
+
Use doc_id for full content, or doc_id + section to fetch one section.`
|
|
511
533
|
}
|
|
512
534
|
]
|
|
513
535
|
};
|
|
@@ -908,18 +930,41 @@ ${spec.taskContent}`);
|
|
|
908
930
|
if (!body) throw new Error("spec has no content");
|
|
909
931
|
return body;
|
|
910
932
|
}
|
|
911
|
-
async function fetchDocBody(api, docId) {
|
|
933
|
+
async function fetchDocBody(api, docId, anchor) {
|
|
912
934
|
const doc = await api.get(
|
|
913
|
-
`/v1/docs/${encodeURIComponent(docId)}/content
|
|
935
|
+
`/v1/docs/${encodeURIComponent(docId)}/content`,
|
|
936
|
+
anchor ? { section: anchor } : void 0
|
|
914
937
|
);
|
|
915
938
|
if (doc.error) throw new Error(doc.error);
|
|
916
939
|
const body = (doc.content ?? "").trim();
|
|
917
940
|
if (!body) throw new Error("document is empty");
|
|
918
941
|
return body;
|
|
919
942
|
}
|
|
943
|
+
function githubDeepLink(repoFullName, path, anchor) {
|
|
944
|
+
return `https://github.com/${repoFullName}/blob/HEAD/${path}#${anchor}`;
|
|
945
|
+
}
|
|
946
|
+
function formatSectionPointer(repoFullName, path, s) {
|
|
947
|
+
return `\`${path}#${s.anchor}\` \xB7 \`chunk_id: ${s.chunkId}\` \xB7 [${s.heading}](${githubDeepLink(repoFullName, path, s.anchor)})`;
|
|
948
|
+
}
|
|
949
|
+
function selectSection(outline, taskDescription) {
|
|
950
|
+
if (!outline || outline.length === 0) return void 0;
|
|
951
|
+
const keywords = taskDescription.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !SECTION_STOP_WORDS.has(w));
|
|
952
|
+
let best = outline[0];
|
|
953
|
+
let bestScore = -1;
|
|
954
|
+
for (const s of outline) {
|
|
955
|
+
const heading = s.heading.toLowerCase();
|
|
956
|
+
let score = 0;
|
|
957
|
+
for (const kw of keywords) if (heading.includes(kw)) score++;
|
|
958
|
+
if (score > bestScore) {
|
|
959
|
+
best = s;
|
|
960
|
+
bestScore = score;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return best;
|
|
964
|
+
}
|
|
920
965
|
async function hydrate(api, item, meta) {
|
|
921
966
|
try {
|
|
922
|
-
const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id);
|
|
967
|
+
const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id, meta.section?.anchor);
|
|
923
968
|
return { item, meta, body };
|
|
924
969
|
} catch (err) {
|
|
925
970
|
return {
|
|
@@ -931,13 +976,19 @@ async function hydrate(api, item, meta) {
|
|
|
931
976
|
}
|
|
932
977
|
async function buildContext(api, { taskDescription, filePaths = [], maxTokens }) {
|
|
933
978
|
const budget = maxTokens ?? DEFAULT_BUDGET;
|
|
934
|
-
const [
|
|
979
|
+
const [rawGuardrails, rawSpecs, docs, stack, rules, docRepos] = await Promise.all([
|
|
935
980
|
api.get("/v1/guardrails").catch(() => []),
|
|
936
981
|
api.get("/v1/specs").catch(() => []),
|
|
937
982
|
api.get("/v1/docs").catch(() => ({ items: [] })),
|
|
938
983
|
api.get("/v1/ai/settings/stack").catch(() => []),
|
|
939
|
-
api.get("/v1/standards/rules").catch(() => [])
|
|
984
|
+
api.get("/v1/standards/rules").catch(() => []),
|
|
985
|
+
// Capability states — FAIL-OPEN: an unreadable config (older backend, a
|
|
986
|
+
// blip) gates nothing, it can never hide working context.
|
|
987
|
+
api.get("/v1/doc-repos").catch(() => null)
|
|
940
988
|
]);
|
|
989
|
+
const disabled = (key2) => docRepos?.capabilities?.[key2]?.status === "disabled";
|
|
990
|
+
const guardrails = disabled("fitness") ? [] : rawGuardrails;
|
|
991
|
+
const specs = disabled("requirements") && disabled("design") && disabled("specs") ? [] : rawSpecs;
|
|
941
992
|
const refs = /* @__PURE__ */ new Map();
|
|
942
993
|
const key = (type, id) => `${type}:${id}`;
|
|
943
994
|
const scorable = [
|
|
@@ -967,12 +1018,17 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
|
|
|
967
1018
|
};
|
|
968
1019
|
}),
|
|
969
1020
|
...docs.items.map((d) => {
|
|
1021
|
+
const section = selectSection(d.outline, taskDescription);
|
|
1022
|
+
const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
|
|
970
1023
|
refs.set(key("doc", d.id), {
|
|
971
1024
|
type: "doc",
|
|
972
1025
|
id: d.id,
|
|
973
1026
|
title: d.title,
|
|
974
|
-
|
|
975
|
-
|
|
1027
|
+
section,
|
|
1028
|
+
pointer,
|
|
1029
|
+
listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)` + (pointer ? `
|
|
1030
|
+
\u21B3 ${pointer}` : ""),
|
|
1031
|
+
fetchHint: section ? `get_docs(doc_id="${d.id}", section="${section.anchor}")` : `get_docs(doc_id="${d.id}")`
|
|
976
1032
|
});
|
|
977
1033
|
return {
|
|
978
1034
|
type: "doc",
|
|
@@ -1045,6 +1101,8 @@ ${formatStack(stack)}`;
|
|
|
1045
1101
|
const blocks = [];
|
|
1046
1102
|
for (const h of hydrated) {
|
|
1047
1103
|
itemsLeft--;
|
|
1104
|
+
const headline = h.meta.pointer ? `${h.item.content}
|
|
1105
|
+
${h.meta.pointer}` : h.item.content;
|
|
1048
1106
|
const emit = (block) => {
|
|
1049
1107
|
bodyBudget -= Math.max(
|
|
1050
1108
|
0,
|
|
@@ -1055,7 +1113,7 @@ ${formatStack(stack)}`;
|
|
|
1055
1113
|
if (h.error !== void 0) {
|
|
1056
1114
|
bodyFailures++;
|
|
1057
1115
|
emit(
|
|
1058
|
-
`${
|
|
1116
|
+
`${headline}
|
|
1059
1117
|
|
|
1060
1118
|
_Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
|
|
1061
1119
|
);
|
|
@@ -1068,7 +1126,7 @@ _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
|
|
|
1068
1126
|
);
|
|
1069
1127
|
if (allowance < MIN_BODY_TOKENS) {
|
|
1070
1128
|
emit(
|
|
1071
|
-
`${
|
|
1129
|
+
`${headline}
|
|
1072
1130
|
|
|
1073
1131
|
_Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`._`
|
|
1074
1132
|
);
|
|
@@ -1080,7 +1138,7 @@ _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`
|
|
|
1080
1138
|
);
|
|
1081
1139
|
itemsWithBody++;
|
|
1082
1140
|
emit(
|
|
1083
|
-
`${
|
|
1141
|
+
`${headline}
|
|
1084
1142
|
|
|
1085
1143
|
${body2}` + (truncated ? `
|
|
1086
1144
|
|
|
@@ -1099,7 +1157,7 @@ _[truncated to fit the token budget \u2014 full text via \`${h.meta.fetchHint}\`
|
|
|
1099
1157
|
sections.push(
|
|
1100
1158
|
`## Relevant Specs & Docs (${blocks.length})
|
|
1101
1159
|
|
|
1102
|
-
|
|
1160
|
+
_Highest-ranked items \u2014 docs are scoped to their most relevant section. Each carries the ID / \`chunk_id\` to fetch more._
|
|
1103
1161
|
|
|
1104
1162
|
` + blocks.join("\n\n---\n\n")
|
|
1105
1163
|
);
|
|
@@ -1142,7 +1200,7 @@ _Ranked lower \u2014 fetch with \`get_specs\` / \`get_docs\` if you need them._
|
|
|
1142
1200
|
_Context: ${itemsWithBody} full text, ${itemsListed} listed, ${stats.itemsOmitted} omitted \xB7 ~${tokensUsed} of ${budget} tokens` + (bodyFailures > 0 ? ` \xB7 ${bodyFailures} body fetch(es) failed` : "") + `_`;
|
|
1143
1201
|
return { text: body + footer, stats };
|
|
1144
1202
|
}
|
|
1145
|
-
var DEFAULT_BUDGET, RESERVE_TOKENS, GUARDRAIL_SHARE, MIN_BODY_TOKENS, MAX_HYDRATED, MAX_TAIL, TAIL_SHARE, TRUNCATION_NOTICE_TOKENS;
|
|
1203
|
+
var DEFAULT_BUDGET, RESERVE_TOKENS, GUARDRAIL_SHARE, MIN_BODY_TOKENS, MAX_HYDRATED, MAX_TAIL, TAIL_SHARE, TRUNCATION_NOTICE_TOKENS, SECTION_STOP_WORDS;
|
|
1146
1204
|
var init_context_builder = __esm({
|
|
1147
1205
|
"src/lib/context-builder.ts"() {
|
|
1148
1206
|
"use strict";
|
|
@@ -1156,6 +1214,28 @@ var init_context_builder = __esm({
|
|
|
1156
1214
|
MAX_TAIL = 10;
|
|
1157
1215
|
TAIL_SHARE = 0.15;
|
|
1158
1216
|
TRUNCATION_NOTICE_TOKENS = 30;
|
|
1217
|
+
SECTION_STOP_WORDS = /* @__PURE__ */ new Set([
|
|
1218
|
+
"the",
|
|
1219
|
+
"a",
|
|
1220
|
+
"an",
|
|
1221
|
+
"of",
|
|
1222
|
+
"to",
|
|
1223
|
+
"and",
|
|
1224
|
+
"or",
|
|
1225
|
+
"for",
|
|
1226
|
+
"in",
|
|
1227
|
+
"on",
|
|
1228
|
+
"with",
|
|
1229
|
+
"how",
|
|
1230
|
+
"why",
|
|
1231
|
+
"what",
|
|
1232
|
+
"add",
|
|
1233
|
+
"new",
|
|
1234
|
+
"use",
|
|
1235
|
+
"using",
|
|
1236
|
+
"update",
|
|
1237
|
+
"fix"
|
|
1238
|
+
]);
|
|
1159
1239
|
}
|
|
1160
1240
|
});
|
|
1161
1241
|
|
package/dist/http.js
CHANGED
|
@@ -384,20 +384,28 @@ function registerGetDocs(server, apiClient) {
|
|
|
384
384
|
"get_docs",
|
|
385
385
|
{
|
|
386
386
|
title: "Get Docs",
|
|
387
|
-
description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search.",
|
|
387
|
+
description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search. Pass doc_id with section (a heading anchor) or chunk_id to retrieve just that section instead of the whole document.",
|
|
388
388
|
inputSchema: {
|
|
389
389
|
repo: z3.string().optional().describe("Filter by repo (owner/repo format)"),
|
|
390
390
|
path: z3.string().optional().describe("File path glob pattern (e.g., docs/adr/*.md)"),
|
|
391
391
|
search: z3.string().optional().describe("Keyword search across document content"),
|
|
392
392
|
doc_id: z3.string().optional().describe(
|
|
393
393
|
"Specific document ID to retrieve full content"
|
|
394
|
+
),
|
|
395
|
+
section: z3.string().optional().describe(
|
|
396
|
+
"Heading anchor (e.g. 'rollback-strategy') \u2014 with doc_id, returns just that section"
|
|
397
|
+
),
|
|
398
|
+
chunk_id: z3.string().optional().describe(
|
|
399
|
+
"Section chunk id ('{path_hash}#{anchor}') from get_context \u2014 with doc_id, returns just that section"
|
|
394
400
|
)
|
|
395
401
|
}
|
|
396
402
|
},
|
|
397
|
-
async ({ repo, path, search, doc_id }) => {
|
|
403
|
+
async ({ repo, path, search, doc_id, section, chunk_id }) => {
|
|
398
404
|
if (doc_id) {
|
|
405
|
+
const anchor = section ?? (chunk_id ? chunk_id.split("#").pop() : void 0);
|
|
399
406
|
const doc = await apiClient.get(
|
|
400
|
-
`/v1/docs/${encodeURIComponent(doc_id)}/content
|
|
407
|
+
`/v1/docs/${encodeURIComponent(doc_id)}/content`,
|
|
408
|
+
anchor ? { section: anchor } : void 0
|
|
401
409
|
);
|
|
402
410
|
if (doc.error) {
|
|
403
411
|
return {
|
|
@@ -410,8 +418,18 @@ function registerGetDocs(server, apiClient) {
|
|
|
410
418
|
isError: true
|
|
411
419
|
};
|
|
412
420
|
}
|
|
421
|
+
let text2 = doc.content;
|
|
422
|
+
if (anchor && doc.section_resolved === false) {
|
|
423
|
+
text2 = `_Section "${anchor}" not found \u2014 returning the full document._
|
|
424
|
+
|
|
425
|
+
` + text2;
|
|
426
|
+
} else if (anchor && doc.heading) {
|
|
427
|
+
text2 = `## ${doc.heading}
|
|
428
|
+
|
|
429
|
+
${doc.content}`;
|
|
430
|
+
}
|
|
413
431
|
return {
|
|
414
|
-
content: [{ type: "text", text:
|
|
432
|
+
content: [{ type: "text", text: text2 }]
|
|
415
433
|
};
|
|
416
434
|
}
|
|
417
435
|
const params = {};
|
|
@@ -435,10 +453,14 @@ function registerGetDocs(server, apiClient) {
|
|
|
435
453
|
]
|
|
436
454
|
};
|
|
437
455
|
}
|
|
438
|
-
const text = items.map(
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
456
|
+
const text = items.map((d) => {
|
|
457
|
+
const sections = (d.outline ?? []).slice(0, 8);
|
|
458
|
+
const sectionHint = sections.length > 0 ? `
|
|
459
|
+
sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
|
|
460
|
+
return `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})
|
|
461
|
+
doc_id: \`${d.id}\`` + (d.snippet ? `
|
|
462
|
+
> ${d.snippet}` : "") + sectionHint;
|
|
463
|
+
}).join("\n");
|
|
442
464
|
return {
|
|
443
465
|
content: [
|
|
444
466
|
{
|
|
@@ -447,7 +469,7 @@ function registerGetDocs(server, apiClient) {
|
|
|
447
469
|
|
|
448
470
|
${text}
|
|
449
471
|
|
|
450
|
-
Use doc_id to
|
|
472
|
+
Use doc_id for full content, or doc_id + section to fetch one section.`
|
|
451
473
|
}
|
|
452
474
|
]
|
|
453
475
|
};
|
|
@@ -837,18 +859,63 @@ ${spec.taskContent}`);
|
|
|
837
859
|
if (!body) throw new Error("spec has no content");
|
|
838
860
|
return body;
|
|
839
861
|
}
|
|
840
|
-
async function fetchDocBody(api, docId) {
|
|
862
|
+
async function fetchDocBody(api, docId, anchor) {
|
|
841
863
|
const doc = await api.get(
|
|
842
|
-
`/v1/docs/${encodeURIComponent(docId)}/content
|
|
864
|
+
`/v1/docs/${encodeURIComponent(docId)}/content`,
|
|
865
|
+
anchor ? { section: anchor } : void 0
|
|
843
866
|
);
|
|
844
867
|
if (doc.error) throw new Error(doc.error);
|
|
845
868
|
const body = (doc.content ?? "").trim();
|
|
846
869
|
if (!body) throw new Error("document is empty");
|
|
847
870
|
return body;
|
|
848
871
|
}
|
|
872
|
+
function githubDeepLink(repoFullName, path, anchor) {
|
|
873
|
+
return `https://github.com/${repoFullName}/blob/HEAD/${path}#${anchor}`;
|
|
874
|
+
}
|
|
875
|
+
function formatSectionPointer(repoFullName, path, s) {
|
|
876
|
+
return `\`${path}#${s.anchor}\` \xB7 \`chunk_id: ${s.chunkId}\` \xB7 [${s.heading}](${githubDeepLink(repoFullName, path, s.anchor)})`;
|
|
877
|
+
}
|
|
878
|
+
var SECTION_STOP_WORDS = /* @__PURE__ */ new Set([
|
|
879
|
+
"the",
|
|
880
|
+
"a",
|
|
881
|
+
"an",
|
|
882
|
+
"of",
|
|
883
|
+
"to",
|
|
884
|
+
"and",
|
|
885
|
+
"or",
|
|
886
|
+
"for",
|
|
887
|
+
"in",
|
|
888
|
+
"on",
|
|
889
|
+
"with",
|
|
890
|
+
"how",
|
|
891
|
+
"why",
|
|
892
|
+
"what",
|
|
893
|
+
"add",
|
|
894
|
+
"new",
|
|
895
|
+
"use",
|
|
896
|
+
"using",
|
|
897
|
+
"update",
|
|
898
|
+
"fix"
|
|
899
|
+
]);
|
|
900
|
+
function selectSection(outline, taskDescription) {
|
|
901
|
+
if (!outline || outline.length === 0) return void 0;
|
|
902
|
+
const keywords = taskDescription.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !SECTION_STOP_WORDS.has(w));
|
|
903
|
+
let best = outline[0];
|
|
904
|
+
let bestScore = -1;
|
|
905
|
+
for (const s of outline) {
|
|
906
|
+
const heading = s.heading.toLowerCase();
|
|
907
|
+
let score = 0;
|
|
908
|
+
for (const kw of keywords) if (heading.includes(kw)) score++;
|
|
909
|
+
if (score > bestScore) {
|
|
910
|
+
best = s;
|
|
911
|
+
bestScore = score;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return best;
|
|
915
|
+
}
|
|
849
916
|
async function hydrate(api, item, meta) {
|
|
850
917
|
try {
|
|
851
|
-
const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id);
|
|
918
|
+
const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id, meta.section?.anchor);
|
|
852
919
|
return { item, meta, body };
|
|
853
920
|
} catch (err) {
|
|
854
921
|
return {
|
|
@@ -860,13 +927,19 @@ async function hydrate(api, item, meta) {
|
|
|
860
927
|
}
|
|
861
928
|
async function buildContext(api, { taskDescription, filePaths = [], maxTokens }) {
|
|
862
929
|
const budget = maxTokens ?? DEFAULT_BUDGET;
|
|
863
|
-
const [
|
|
930
|
+
const [rawGuardrails, rawSpecs, docs, stack, rules, docRepos] = await Promise.all([
|
|
864
931
|
api.get("/v1/guardrails").catch(() => []),
|
|
865
932
|
api.get("/v1/specs").catch(() => []),
|
|
866
933
|
api.get("/v1/docs").catch(() => ({ items: [] })),
|
|
867
934
|
api.get("/v1/ai/settings/stack").catch(() => []),
|
|
868
|
-
api.get("/v1/standards/rules").catch(() => [])
|
|
935
|
+
api.get("/v1/standards/rules").catch(() => []),
|
|
936
|
+
// Capability states — FAIL-OPEN: an unreadable config (older backend, a
|
|
937
|
+
// blip) gates nothing, it can never hide working context.
|
|
938
|
+
api.get("/v1/doc-repos").catch(() => null)
|
|
869
939
|
]);
|
|
940
|
+
const disabled = (key2) => docRepos?.capabilities?.[key2]?.status === "disabled";
|
|
941
|
+
const guardrails = disabled("fitness") ? [] : rawGuardrails;
|
|
942
|
+
const specs = disabled("requirements") && disabled("design") && disabled("specs") ? [] : rawSpecs;
|
|
870
943
|
const refs = /* @__PURE__ */ new Map();
|
|
871
944
|
const key = (type, id) => `${type}:${id}`;
|
|
872
945
|
const scorable = [
|
|
@@ -896,12 +969,17 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
|
|
|
896
969
|
};
|
|
897
970
|
}),
|
|
898
971
|
...docs.items.map((d) => {
|
|
972
|
+
const section = selectSection(d.outline, taskDescription);
|
|
973
|
+
const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
|
|
899
974
|
refs.set(key("doc", d.id), {
|
|
900
975
|
type: "doc",
|
|
901
976
|
id: d.id,
|
|
902
977
|
title: d.title,
|
|
903
|
-
|
|
904
|
-
|
|
978
|
+
section,
|
|
979
|
+
pointer,
|
|
980
|
+
listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)` + (pointer ? `
|
|
981
|
+
\u21B3 ${pointer}` : ""),
|
|
982
|
+
fetchHint: section ? `get_docs(doc_id="${d.id}", section="${section.anchor}")` : `get_docs(doc_id="${d.id}")`
|
|
905
983
|
});
|
|
906
984
|
return {
|
|
907
985
|
type: "doc",
|
|
@@ -974,6 +1052,8 @@ ${formatStack(stack)}`;
|
|
|
974
1052
|
const blocks = [];
|
|
975
1053
|
for (const h of hydrated) {
|
|
976
1054
|
itemsLeft--;
|
|
1055
|
+
const headline = h.meta.pointer ? `${h.item.content}
|
|
1056
|
+
${h.meta.pointer}` : h.item.content;
|
|
977
1057
|
const emit = (block) => {
|
|
978
1058
|
bodyBudget -= Math.max(
|
|
979
1059
|
0,
|
|
@@ -984,7 +1064,7 @@ ${formatStack(stack)}`;
|
|
|
984
1064
|
if (h.error !== void 0) {
|
|
985
1065
|
bodyFailures++;
|
|
986
1066
|
emit(
|
|
987
|
-
`${
|
|
1067
|
+
`${headline}
|
|
988
1068
|
|
|
989
1069
|
_Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
|
|
990
1070
|
);
|
|
@@ -997,7 +1077,7 @@ _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
|
|
|
997
1077
|
);
|
|
998
1078
|
if (allowance < MIN_BODY_TOKENS) {
|
|
999
1079
|
emit(
|
|
1000
|
-
`${
|
|
1080
|
+
`${headline}
|
|
1001
1081
|
|
|
1002
1082
|
_Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`._`
|
|
1003
1083
|
);
|
|
@@ -1009,7 +1089,7 @@ _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`
|
|
|
1009
1089
|
);
|
|
1010
1090
|
itemsWithBody++;
|
|
1011
1091
|
emit(
|
|
1012
|
-
`${
|
|
1092
|
+
`${headline}
|
|
1013
1093
|
|
|
1014
1094
|
${body2}` + (truncated ? `
|
|
1015
1095
|
|
|
@@ -1028,7 +1108,7 @@ _[truncated to fit the token budget \u2014 full text via \`${h.meta.fetchHint}\`
|
|
|
1028
1108
|
sections.push(
|
|
1029
1109
|
`## Relevant Specs & Docs (${blocks.length})
|
|
1030
1110
|
|
|
1031
|
-
|
|
1111
|
+
_Highest-ranked items \u2014 docs are scoped to their most relevant section. Each carries the ID / \`chunk_id\` to fetch more._
|
|
1032
1112
|
|
|
1033
1113
|
` + blocks.join("\n\n---\n\n")
|
|
1034
1114
|
);
|
package/dist/index.js
CHANGED
|
@@ -400,20 +400,28 @@ function registerGetDocs(server2, apiClient2) {
|
|
|
400
400
|
"get_docs",
|
|
401
401
|
{
|
|
402
402
|
title: "Get Docs",
|
|
403
|
-
description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search.",
|
|
403
|
+
description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search. Pass doc_id with section (a heading anchor) or chunk_id to retrieve just that section instead of the whole document.",
|
|
404
404
|
inputSchema: {
|
|
405
405
|
repo: z3.string().optional().describe("Filter by repo (owner/repo format)"),
|
|
406
406
|
path: z3.string().optional().describe("File path glob pattern (e.g., docs/adr/*.md)"),
|
|
407
407
|
search: z3.string().optional().describe("Keyword search across document content"),
|
|
408
408
|
doc_id: z3.string().optional().describe(
|
|
409
409
|
"Specific document ID to retrieve full content"
|
|
410
|
+
),
|
|
411
|
+
section: z3.string().optional().describe(
|
|
412
|
+
"Heading anchor (e.g. 'rollback-strategy') \u2014 with doc_id, returns just that section"
|
|
413
|
+
),
|
|
414
|
+
chunk_id: z3.string().optional().describe(
|
|
415
|
+
"Section chunk id ('{path_hash}#{anchor}') from get_context \u2014 with doc_id, returns just that section"
|
|
410
416
|
)
|
|
411
417
|
}
|
|
412
418
|
},
|
|
413
|
-
async ({ repo, path, search, doc_id }) => {
|
|
419
|
+
async ({ repo, path, search, doc_id, section, chunk_id }) => {
|
|
414
420
|
if (doc_id) {
|
|
421
|
+
const anchor = section ?? (chunk_id ? chunk_id.split("#").pop() : void 0);
|
|
415
422
|
const doc = await apiClient2.get(
|
|
416
|
-
`/v1/docs/${encodeURIComponent(doc_id)}/content
|
|
423
|
+
`/v1/docs/${encodeURIComponent(doc_id)}/content`,
|
|
424
|
+
anchor ? { section: anchor } : void 0
|
|
417
425
|
);
|
|
418
426
|
if (doc.error) {
|
|
419
427
|
return {
|
|
@@ -426,8 +434,18 @@ function registerGetDocs(server2, apiClient2) {
|
|
|
426
434
|
isError: true
|
|
427
435
|
};
|
|
428
436
|
}
|
|
437
|
+
let text2 = doc.content;
|
|
438
|
+
if (anchor && doc.section_resolved === false) {
|
|
439
|
+
text2 = `_Section "${anchor}" not found \u2014 returning the full document._
|
|
440
|
+
|
|
441
|
+
` + text2;
|
|
442
|
+
} else if (anchor && doc.heading) {
|
|
443
|
+
text2 = `## ${doc.heading}
|
|
444
|
+
|
|
445
|
+
${doc.content}`;
|
|
446
|
+
}
|
|
429
447
|
return {
|
|
430
|
-
content: [{ type: "text", text:
|
|
448
|
+
content: [{ type: "text", text: text2 }]
|
|
431
449
|
};
|
|
432
450
|
}
|
|
433
451
|
const params = {};
|
|
@@ -451,10 +469,14 @@ function registerGetDocs(server2, apiClient2) {
|
|
|
451
469
|
]
|
|
452
470
|
};
|
|
453
471
|
}
|
|
454
|
-
const text = items.map(
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
472
|
+
const text = items.map((d) => {
|
|
473
|
+
const sections = (d.outline ?? []).slice(0, 8);
|
|
474
|
+
const sectionHint = sections.length > 0 ? `
|
|
475
|
+
sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
|
|
476
|
+
return `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})
|
|
477
|
+
doc_id: \`${d.id}\`` + (d.snippet ? `
|
|
478
|
+
> ${d.snippet}` : "") + sectionHint;
|
|
479
|
+
}).join("\n");
|
|
458
480
|
return {
|
|
459
481
|
content: [
|
|
460
482
|
{
|
|
@@ -463,7 +485,7 @@ function registerGetDocs(server2, apiClient2) {
|
|
|
463
485
|
|
|
464
486
|
${text}
|
|
465
487
|
|
|
466
|
-
Use doc_id to
|
|
488
|
+
Use doc_id for full content, or doc_id + section to fetch one section.`
|
|
467
489
|
}
|
|
468
490
|
]
|
|
469
491
|
};
|
|
@@ -853,18 +875,63 @@ ${spec.taskContent}`);
|
|
|
853
875
|
if (!body) throw new Error("spec has no content");
|
|
854
876
|
return body;
|
|
855
877
|
}
|
|
856
|
-
async function fetchDocBody(api, docId) {
|
|
878
|
+
async function fetchDocBody(api, docId, anchor) {
|
|
857
879
|
const doc = await api.get(
|
|
858
|
-
`/v1/docs/${encodeURIComponent(docId)}/content
|
|
880
|
+
`/v1/docs/${encodeURIComponent(docId)}/content`,
|
|
881
|
+
anchor ? { section: anchor } : void 0
|
|
859
882
|
);
|
|
860
883
|
if (doc.error) throw new Error(doc.error);
|
|
861
884
|
const body = (doc.content ?? "").trim();
|
|
862
885
|
if (!body) throw new Error("document is empty");
|
|
863
886
|
return body;
|
|
864
887
|
}
|
|
888
|
+
function githubDeepLink(repoFullName, path, anchor) {
|
|
889
|
+
return `https://github.com/${repoFullName}/blob/HEAD/${path}#${anchor}`;
|
|
890
|
+
}
|
|
891
|
+
function formatSectionPointer(repoFullName, path, s) {
|
|
892
|
+
return `\`${path}#${s.anchor}\` \xB7 \`chunk_id: ${s.chunkId}\` \xB7 [${s.heading}](${githubDeepLink(repoFullName, path, s.anchor)})`;
|
|
893
|
+
}
|
|
894
|
+
var SECTION_STOP_WORDS = /* @__PURE__ */ new Set([
|
|
895
|
+
"the",
|
|
896
|
+
"a",
|
|
897
|
+
"an",
|
|
898
|
+
"of",
|
|
899
|
+
"to",
|
|
900
|
+
"and",
|
|
901
|
+
"or",
|
|
902
|
+
"for",
|
|
903
|
+
"in",
|
|
904
|
+
"on",
|
|
905
|
+
"with",
|
|
906
|
+
"how",
|
|
907
|
+
"why",
|
|
908
|
+
"what",
|
|
909
|
+
"add",
|
|
910
|
+
"new",
|
|
911
|
+
"use",
|
|
912
|
+
"using",
|
|
913
|
+
"update",
|
|
914
|
+
"fix"
|
|
915
|
+
]);
|
|
916
|
+
function selectSection(outline, taskDescription) {
|
|
917
|
+
if (!outline || outline.length === 0) return void 0;
|
|
918
|
+
const keywords = taskDescription.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !SECTION_STOP_WORDS.has(w));
|
|
919
|
+
let best = outline[0];
|
|
920
|
+
let bestScore = -1;
|
|
921
|
+
for (const s of outline) {
|
|
922
|
+
const heading = s.heading.toLowerCase();
|
|
923
|
+
let score = 0;
|
|
924
|
+
for (const kw of keywords) if (heading.includes(kw)) score++;
|
|
925
|
+
if (score > bestScore) {
|
|
926
|
+
best = s;
|
|
927
|
+
bestScore = score;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return best;
|
|
931
|
+
}
|
|
865
932
|
async function hydrate(api, item, meta) {
|
|
866
933
|
try {
|
|
867
|
-
const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id);
|
|
934
|
+
const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id, meta.section?.anchor);
|
|
868
935
|
return { item, meta, body };
|
|
869
936
|
} catch (err) {
|
|
870
937
|
return {
|
|
@@ -876,13 +943,19 @@ async function hydrate(api, item, meta) {
|
|
|
876
943
|
}
|
|
877
944
|
async function buildContext(api, { taskDescription, filePaths = [], maxTokens }) {
|
|
878
945
|
const budget = maxTokens ?? DEFAULT_BUDGET;
|
|
879
|
-
const [
|
|
946
|
+
const [rawGuardrails, rawSpecs, docs, stack, rules, docRepos] = await Promise.all([
|
|
880
947
|
api.get("/v1/guardrails").catch(() => []),
|
|
881
948
|
api.get("/v1/specs").catch(() => []),
|
|
882
949
|
api.get("/v1/docs").catch(() => ({ items: [] })),
|
|
883
950
|
api.get("/v1/ai/settings/stack").catch(() => []),
|
|
884
|
-
api.get("/v1/standards/rules").catch(() => [])
|
|
951
|
+
api.get("/v1/standards/rules").catch(() => []),
|
|
952
|
+
// Capability states — FAIL-OPEN: an unreadable config (older backend, a
|
|
953
|
+
// blip) gates nothing, it can never hide working context.
|
|
954
|
+
api.get("/v1/doc-repos").catch(() => null)
|
|
885
955
|
]);
|
|
956
|
+
const disabled = (key2) => docRepos?.capabilities?.[key2]?.status === "disabled";
|
|
957
|
+
const guardrails = disabled("fitness") ? [] : rawGuardrails;
|
|
958
|
+
const specs = disabled("requirements") && disabled("design") && disabled("specs") ? [] : rawSpecs;
|
|
886
959
|
const refs = /* @__PURE__ */ new Map();
|
|
887
960
|
const key = (type, id) => `${type}:${id}`;
|
|
888
961
|
const scorable = [
|
|
@@ -912,12 +985,17 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
|
|
|
912
985
|
};
|
|
913
986
|
}),
|
|
914
987
|
...docs.items.map((d) => {
|
|
988
|
+
const section = selectSection(d.outline, taskDescription);
|
|
989
|
+
const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
|
|
915
990
|
refs.set(key("doc", d.id), {
|
|
916
991
|
type: "doc",
|
|
917
992
|
id: d.id,
|
|
918
993
|
title: d.title,
|
|
919
|
-
|
|
920
|
-
|
|
994
|
+
section,
|
|
995
|
+
pointer,
|
|
996
|
+
listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)` + (pointer ? `
|
|
997
|
+
\u21B3 ${pointer}` : ""),
|
|
998
|
+
fetchHint: section ? `get_docs(doc_id="${d.id}", section="${section.anchor}")` : `get_docs(doc_id="${d.id}")`
|
|
921
999
|
});
|
|
922
1000
|
return {
|
|
923
1001
|
type: "doc",
|
|
@@ -990,6 +1068,8 @@ ${formatStack(stack)}`;
|
|
|
990
1068
|
const blocks = [];
|
|
991
1069
|
for (const h of hydrated) {
|
|
992
1070
|
itemsLeft--;
|
|
1071
|
+
const headline = h.meta.pointer ? `${h.item.content}
|
|
1072
|
+
${h.meta.pointer}` : h.item.content;
|
|
993
1073
|
const emit = (block) => {
|
|
994
1074
|
bodyBudget -= Math.max(
|
|
995
1075
|
0,
|
|
@@ -1000,7 +1080,7 @@ ${formatStack(stack)}`;
|
|
|
1000
1080
|
if (h.error !== void 0) {
|
|
1001
1081
|
bodyFailures++;
|
|
1002
1082
|
emit(
|
|
1003
|
-
`${
|
|
1083
|
+
`${headline}
|
|
1004
1084
|
|
|
1005
1085
|
_Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
|
|
1006
1086
|
);
|
|
@@ -1013,7 +1093,7 @@ _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
|
|
|
1013
1093
|
);
|
|
1014
1094
|
if (allowance < MIN_BODY_TOKENS) {
|
|
1015
1095
|
emit(
|
|
1016
|
-
`${
|
|
1096
|
+
`${headline}
|
|
1017
1097
|
|
|
1018
1098
|
_Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`._`
|
|
1019
1099
|
);
|
|
@@ -1025,7 +1105,7 @@ _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`
|
|
|
1025
1105
|
);
|
|
1026
1106
|
itemsWithBody++;
|
|
1027
1107
|
emit(
|
|
1028
|
-
`${
|
|
1108
|
+
`${headline}
|
|
1029
1109
|
|
|
1030
1110
|
${body2}` + (truncated ? `
|
|
1031
1111
|
|
|
@@ -1044,7 +1124,7 @@ _[truncated to fit the token budget \u2014 full text via \`${h.meta.fetchHint}\`
|
|
|
1044
1124
|
sections.push(
|
|
1045
1125
|
`## Relevant Specs & Docs (${blocks.length})
|
|
1046
1126
|
|
|
1047
|
-
|
|
1127
|
+
_Highest-ranked items \u2014 docs are scoped to their most relevant section. Each carries the ID / \`chunk_id\` to fetch more._
|
|
1048
1128
|
|
|
1049
1129
|
` + blocks.join("\n\n---\n\n")
|
|
1050
1130
|
);
|
|
@@ -23,6 +23,13 @@ export interface SpecSummary {
|
|
|
23
23
|
requirementCount: number;
|
|
24
24
|
updatedAt: string;
|
|
25
25
|
}
|
|
26
|
+
/** One heading-scoped section of a document (#865). */
|
|
27
|
+
export interface DocSection {
|
|
28
|
+
heading: string;
|
|
29
|
+
level: number;
|
|
30
|
+
anchor: string;
|
|
31
|
+
chunkId: string;
|
|
32
|
+
}
|
|
26
33
|
export interface DocListResponse {
|
|
27
34
|
items: Array<{
|
|
28
35
|
id: string;
|
|
@@ -32,6 +39,8 @@ export interface DocListResponse {
|
|
|
32
39
|
doc_type: string;
|
|
33
40
|
size: number;
|
|
34
41
|
synced_at: string;
|
|
42
|
+
/** Structured section outline, when the doc has been re-indexed (#865). */
|
|
43
|
+
outline?: DocSection[];
|
|
35
44
|
}>;
|
|
36
45
|
}
|
|
37
46
|
export interface TechStackEntry {
|
|
@@ -335,18 +335,63 @@ ${spec.taskContent}`);
|
|
|
335
335
|
if (!body) throw new Error("spec has no content");
|
|
336
336
|
return body;
|
|
337
337
|
}
|
|
338
|
-
async function fetchDocBody(api, docId) {
|
|
338
|
+
async function fetchDocBody(api, docId, anchor) {
|
|
339
339
|
const doc = await api.get(
|
|
340
|
-
`/v1/docs/${encodeURIComponent(docId)}/content
|
|
340
|
+
`/v1/docs/${encodeURIComponent(docId)}/content`,
|
|
341
|
+
anchor ? { section: anchor } : void 0
|
|
341
342
|
);
|
|
342
343
|
if (doc.error) throw new Error(doc.error);
|
|
343
344
|
const body = (doc.content ?? "").trim();
|
|
344
345
|
if (!body) throw new Error("document is empty");
|
|
345
346
|
return body;
|
|
346
347
|
}
|
|
348
|
+
function githubDeepLink(repoFullName, path, anchor) {
|
|
349
|
+
return `https://github.com/${repoFullName}/blob/HEAD/${path}#${anchor}`;
|
|
350
|
+
}
|
|
351
|
+
function formatSectionPointer(repoFullName, path, s) {
|
|
352
|
+
return `\`${path}#${s.anchor}\` \xB7 \`chunk_id: ${s.chunkId}\` \xB7 [${s.heading}](${githubDeepLink(repoFullName, path, s.anchor)})`;
|
|
353
|
+
}
|
|
354
|
+
var SECTION_STOP_WORDS = /* @__PURE__ */ new Set([
|
|
355
|
+
"the",
|
|
356
|
+
"a",
|
|
357
|
+
"an",
|
|
358
|
+
"of",
|
|
359
|
+
"to",
|
|
360
|
+
"and",
|
|
361
|
+
"or",
|
|
362
|
+
"for",
|
|
363
|
+
"in",
|
|
364
|
+
"on",
|
|
365
|
+
"with",
|
|
366
|
+
"how",
|
|
367
|
+
"why",
|
|
368
|
+
"what",
|
|
369
|
+
"add",
|
|
370
|
+
"new",
|
|
371
|
+
"use",
|
|
372
|
+
"using",
|
|
373
|
+
"update",
|
|
374
|
+
"fix"
|
|
375
|
+
]);
|
|
376
|
+
function selectSection(outline, taskDescription) {
|
|
377
|
+
if (!outline || outline.length === 0) return void 0;
|
|
378
|
+
const keywords = taskDescription.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !SECTION_STOP_WORDS.has(w));
|
|
379
|
+
let best = outline[0];
|
|
380
|
+
let bestScore = -1;
|
|
381
|
+
for (const s of outline) {
|
|
382
|
+
const heading = s.heading.toLowerCase();
|
|
383
|
+
let score = 0;
|
|
384
|
+
for (const kw of keywords) if (heading.includes(kw)) score++;
|
|
385
|
+
if (score > bestScore) {
|
|
386
|
+
best = s;
|
|
387
|
+
bestScore = score;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return best;
|
|
391
|
+
}
|
|
347
392
|
async function hydrate(api, item, meta) {
|
|
348
393
|
try {
|
|
349
|
-
const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id);
|
|
394
|
+
const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id, meta.section?.anchor);
|
|
350
395
|
return { item, meta, body };
|
|
351
396
|
} catch (err) {
|
|
352
397
|
return {
|
|
@@ -358,13 +403,19 @@ async function hydrate(api, item, meta) {
|
|
|
358
403
|
}
|
|
359
404
|
async function buildContext(api, { taskDescription, filePaths = [], maxTokens }) {
|
|
360
405
|
const budget = maxTokens ?? DEFAULT_BUDGET;
|
|
361
|
-
const [
|
|
406
|
+
const [rawGuardrails, rawSpecs, docs, stack, rules, docRepos] = await Promise.all([
|
|
362
407
|
api.get("/v1/guardrails").catch(() => []),
|
|
363
408
|
api.get("/v1/specs").catch(() => []),
|
|
364
409
|
api.get("/v1/docs").catch(() => ({ items: [] })),
|
|
365
410
|
api.get("/v1/ai/settings/stack").catch(() => []),
|
|
366
|
-
api.get("/v1/standards/rules").catch(() => [])
|
|
411
|
+
api.get("/v1/standards/rules").catch(() => []),
|
|
412
|
+
// Capability states — FAIL-OPEN: an unreadable config (older backend, a
|
|
413
|
+
// blip) gates nothing, it can never hide working context.
|
|
414
|
+
api.get("/v1/doc-repos").catch(() => null)
|
|
367
415
|
]);
|
|
416
|
+
const disabled = (key2) => docRepos?.capabilities?.[key2]?.status === "disabled";
|
|
417
|
+
const guardrails = disabled("fitness") ? [] : rawGuardrails;
|
|
418
|
+
const specs = disabled("requirements") && disabled("design") && disabled("specs") ? [] : rawSpecs;
|
|
368
419
|
const refs = /* @__PURE__ */ new Map();
|
|
369
420
|
const key = (type, id) => `${type}:${id}`;
|
|
370
421
|
const scorable = [
|
|
@@ -394,12 +445,17 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
|
|
|
394
445
|
};
|
|
395
446
|
}),
|
|
396
447
|
...docs.items.map((d) => {
|
|
448
|
+
const section = selectSection(d.outline, taskDescription);
|
|
449
|
+
const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
|
|
397
450
|
refs.set(key("doc", d.id), {
|
|
398
451
|
type: "doc",
|
|
399
452
|
id: d.id,
|
|
400
453
|
title: d.title,
|
|
401
|
-
|
|
402
|
-
|
|
454
|
+
section,
|
|
455
|
+
pointer,
|
|
456
|
+
listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)` + (pointer ? `
|
|
457
|
+
\u21B3 ${pointer}` : ""),
|
|
458
|
+
fetchHint: section ? `get_docs(doc_id="${d.id}", section="${section.anchor}")` : `get_docs(doc_id="${d.id}")`
|
|
403
459
|
});
|
|
404
460
|
return {
|
|
405
461
|
type: "doc",
|
|
@@ -472,6 +528,8 @@ ${formatStack(stack)}`;
|
|
|
472
528
|
const blocks = [];
|
|
473
529
|
for (const h of hydrated) {
|
|
474
530
|
itemsLeft--;
|
|
531
|
+
const headline = h.meta.pointer ? `${h.item.content}
|
|
532
|
+
${h.meta.pointer}` : h.item.content;
|
|
475
533
|
const emit = (block) => {
|
|
476
534
|
bodyBudget -= Math.max(
|
|
477
535
|
0,
|
|
@@ -482,7 +540,7 @@ ${formatStack(stack)}`;
|
|
|
482
540
|
if (h.error !== void 0) {
|
|
483
541
|
bodyFailures++;
|
|
484
542
|
emit(
|
|
485
|
-
`${
|
|
543
|
+
`${headline}
|
|
486
544
|
|
|
487
545
|
_Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
|
|
488
546
|
);
|
|
@@ -495,7 +553,7 @@ _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
|
|
|
495
553
|
);
|
|
496
554
|
if (allowance < MIN_BODY_TOKENS) {
|
|
497
555
|
emit(
|
|
498
|
-
`${
|
|
556
|
+
`${headline}
|
|
499
557
|
|
|
500
558
|
_Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`._`
|
|
501
559
|
);
|
|
@@ -507,7 +565,7 @@ _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`
|
|
|
507
565
|
);
|
|
508
566
|
itemsWithBody++;
|
|
509
567
|
emit(
|
|
510
|
-
`${
|
|
568
|
+
`${headline}
|
|
511
569
|
|
|
512
570
|
${body2}` + (truncated ? `
|
|
513
571
|
|
|
@@ -526,7 +584,7 @@ _[truncated to fit the token budget \u2014 full text via \`${h.meta.fetchHint}\`
|
|
|
526
584
|
sections.push(
|
|
527
585
|
`## Relevant Specs & Docs (${blocks.length})
|
|
528
586
|
|
|
529
|
-
|
|
587
|
+
_Highest-ranked items \u2014 docs are scoped to their most relevant section. Each carries the ID / \`chunk_id\` to fetch more._
|
|
530
588
|
|
|
531
589
|
` + blocks.join("\n\n---\n\n")
|
|
532
590
|
);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/tools/get-docs.ts
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
function matchGlob(path, pattern) {
|
|
6
|
+
const regex = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
|
|
7
|
+
return new RegExp(`^${regex}$`).test(path);
|
|
8
|
+
}
|
|
9
|
+
function registerGetDocs(server, apiClient) {
|
|
10
|
+
server.registerTool(
|
|
11
|
+
"get_docs",
|
|
12
|
+
{
|
|
13
|
+
title: "Get Docs",
|
|
14
|
+
description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search. Pass doc_id with section (a heading anchor) or chunk_id to retrieve just that section instead of the whole document.",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
repo: z.string().optional().describe("Filter by repo (owner/repo format)"),
|
|
17
|
+
path: z.string().optional().describe("File path glob pattern (e.g., docs/adr/*.md)"),
|
|
18
|
+
search: z.string().optional().describe("Keyword search across document content"),
|
|
19
|
+
doc_id: z.string().optional().describe(
|
|
20
|
+
"Specific document ID to retrieve full content"
|
|
21
|
+
),
|
|
22
|
+
section: z.string().optional().describe(
|
|
23
|
+
"Heading anchor (e.g. 'rollback-strategy') \u2014 with doc_id, returns just that section"
|
|
24
|
+
),
|
|
25
|
+
chunk_id: z.string().optional().describe(
|
|
26
|
+
"Section chunk id ('{path_hash}#{anchor}') from get_context \u2014 with doc_id, returns just that section"
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
async ({ repo, path, search, doc_id, section, chunk_id }) => {
|
|
31
|
+
if (doc_id) {
|
|
32
|
+
const anchor = section ?? (chunk_id ? chunk_id.split("#").pop() : void 0);
|
|
33
|
+
const doc = await apiClient.get(
|
|
34
|
+
`/v1/docs/${encodeURIComponent(doc_id)}/content`,
|
|
35
|
+
anchor ? { section: anchor } : void 0
|
|
36
|
+
);
|
|
37
|
+
if (doc.error) {
|
|
38
|
+
return {
|
|
39
|
+
content: [
|
|
40
|
+
{
|
|
41
|
+
type: "text",
|
|
42
|
+
text: `Error retrieving document: ${doc.error}`
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
isError: true
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
let text2 = doc.content;
|
|
49
|
+
if (anchor && doc.section_resolved === false) {
|
|
50
|
+
text2 = `_Section "${anchor}" not found \u2014 returning the full document._
|
|
51
|
+
|
|
52
|
+
` + text2;
|
|
53
|
+
} else if (anchor && doc.heading) {
|
|
54
|
+
text2 = `## ${doc.heading}
|
|
55
|
+
|
|
56
|
+
${doc.content}`;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text", text: text2 }]
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const params = {};
|
|
63
|
+
if (repo) params.repo = repo;
|
|
64
|
+
if (search) params.q = search;
|
|
65
|
+
const response = await apiClient.get(
|
|
66
|
+
"/v1/docs",
|
|
67
|
+
params
|
|
68
|
+
);
|
|
69
|
+
let items = response.items;
|
|
70
|
+
if (path) {
|
|
71
|
+
items = items.filter((item) => matchGlob(item.path, path));
|
|
72
|
+
}
|
|
73
|
+
if (items.length === 0) {
|
|
74
|
+
return {
|
|
75
|
+
content: [
|
|
76
|
+
{
|
|
77
|
+
type: "text",
|
|
78
|
+
text: `No documents found${repo ? ` in ${repo}` : ""}${path ? ` matching "${path}"` : ""}${search ? ` for "${search}"` : ""}.`
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const text = items.map((d) => {
|
|
84
|
+
const sections = (d.outline ?? []).slice(0, 8);
|
|
85
|
+
const sectionHint = sections.length > 0 ? `
|
|
86
|
+
sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
|
|
87
|
+
return `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})
|
|
88
|
+
doc_id: \`${d.id}\`` + (d.snippet ? `
|
|
89
|
+
> ${d.snippet}` : "") + sectionHint;
|
|
90
|
+
}).join("\n");
|
|
91
|
+
return {
|
|
92
|
+
content: [
|
|
93
|
+
{
|
|
94
|
+
type: "text",
|
|
95
|
+
text: `Found ${items.length} document(s) (${response.total} total):
|
|
96
|
+
|
|
97
|
+
${text}
|
|
98
|
+
|
|
99
|
+
Use doc_id for full content, or doc_id + section to fetch one section.`
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
export {
|
|
107
|
+
registerGetDocs
|
|
108
|
+
};
|
package/package.json
CHANGED