mcp-docs-service 0.5.2 → 7.1.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 +1 -17
- package/dist/handlers/documents.js +484 -411
- package/dist/handlers/health.js +7 -7
- package/dist/index.js +298 -155
- package/dist/schemas/tools.js +0 -1
- package/package.json +5 -2
- package/dist/index.d.ts +0 -8
- package/dist/index.js.map +0 -1
@@ -7,6 +7,16 @@ import fs from "fs/promises";
|
|
7
7
|
import path from "path";
|
8
8
|
import { glob } from "glob";
|
9
9
|
import { createTwoFilesPatch } from "diff";
|
10
|
+
import { writeFileSync } from "fs";
|
11
|
+
// Log debug info to file
|
12
|
+
function debugLog(message) {
|
13
|
+
try {
|
14
|
+
writeFileSync("docs/debug_log.txt", message + "\n", { flag: "a" });
|
15
|
+
}
|
16
|
+
catch (error) {
|
17
|
+
// Silently fail if we can't write to the file
|
18
|
+
}
|
19
|
+
}
|
10
20
|
// File editing and diffing utilities
|
11
21
|
function normalizeLineEndings(text) {
|
12
22
|
return text.replace(/\r\n/g, "\n");
|
@@ -54,8 +64,12 @@ export function parseFrontmatter(content) {
|
|
54
64
|
}
|
55
65
|
export class DocumentHandler {
|
56
66
|
docsDir;
|
57
|
-
|
67
|
+
singleDocPath;
|
68
|
+
useSingleDoc;
|
69
|
+
constructor(docsDir, useSingleDoc = false) {
|
58
70
|
this.docsDir = docsDir;
|
71
|
+
this.useSingleDoc = useSingleDoc;
|
72
|
+
this.singleDocPath = path.join(docsDir, "single_doc.md");
|
59
73
|
}
|
60
74
|
/**
|
61
75
|
* Validates that a path is within the docs directory
|
@@ -77,13 +91,87 @@ export class DocumentHandler {
|
|
77
91
|
*/
|
78
92
|
async readDocument(docPath) {
|
79
93
|
try {
|
94
|
+
// If single-doc mode is enabled, redirect to the single doc
|
95
|
+
if (this.useSingleDoc) {
|
96
|
+
const singleDocPath = path.join(this.docsDir, "single_doc.md");
|
97
|
+
// Check if the requested path is a specific part
|
98
|
+
if (docPath.startsWith("single_doc_part") && docPath.endsWith(".md")) {
|
99
|
+
const requestedPartPath = path.join(this.docsDir, docPath);
|
100
|
+
// Check if the requested part exists
|
101
|
+
try {
|
102
|
+
await fs.access(requestedPartPath);
|
103
|
+
const content = await fs.readFile(requestedPartPath, "utf-8");
|
104
|
+
return {
|
105
|
+
content: [{ type: "text", text: content }],
|
106
|
+
metadata: {
|
107
|
+
path: docPath,
|
108
|
+
...this.parseFrontmatter(content).frontmatter,
|
109
|
+
},
|
110
|
+
};
|
111
|
+
}
|
112
|
+
catch (error) {
|
113
|
+
// If the part doesn't exist, fall back to the index
|
114
|
+
if (await this.fileExists(singleDocPath)) {
|
115
|
+
const content = await fs.readFile(singleDocPath, "utf-8");
|
116
|
+
return {
|
117
|
+
content: [{ type: "text", text: content }],
|
118
|
+
metadata: {
|
119
|
+
path: "single_doc.md",
|
120
|
+
...this.parseFrontmatter(content).frontmatter,
|
121
|
+
},
|
122
|
+
};
|
123
|
+
}
|
124
|
+
}
|
125
|
+
}
|
126
|
+
// For all other requested documents in single-doc mode, serve the single document
|
127
|
+
if (await this.fileExists(singleDocPath)) {
|
128
|
+
const content = await fs.readFile(singleDocPath, "utf-8");
|
129
|
+
// Check if this is an index file (indicating multi-part doc)
|
130
|
+
const { frontmatter } = this.parseFrontmatter(content);
|
131
|
+
if (content.includes("DOCUMENTATION INDEX") ||
|
132
|
+
content.includes("Available Parts")) {
|
133
|
+
return {
|
134
|
+
content: [{ type: "text", text: content }],
|
135
|
+
metadata: {
|
136
|
+
path: "single_doc.md",
|
137
|
+
...frontmatter,
|
138
|
+
isIndex: true,
|
139
|
+
},
|
140
|
+
};
|
141
|
+
}
|
142
|
+
return {
|
143
|
+
content: [{ type: "text", text: content }],
|
144
|
+
metadata: {
|
145
|
+
path: "single_doc.md",
|
146
|
+
...frontmatter,
|
147
|
+
},
|
148
|
+
};
|
149
|
+
}
|
150
|
+
else {
|
151
|
+
// If single-doc doesn't exist yet, try to generate it
|
152
|
+
await this.refreshSingleDoc();
|
153
|
+
// Check if it was created successfully
|
154
|
+
if (await this.fileExists(singleDocPath)) {
|
155
|
+
const content = await fs.readFile(singleDocPath, "utf-8");
|
156
|
+
return {
|
157
|
+
content: [{ type: "text", text: content }],
|
158
|
+
metadata: {
|
159
|
+
path: "single_doc.md",
|
160
|
+
...this.parseFrontmatter(content).frontmatter,
|
161
|
+
},
|
162
|
+
};
|
163
|
+
}
|
164
|
+
// If it still doesn't exist, fall back to the requested path
|
165
|
+
}
|
166
|
+
}
|
167
|
+
// Normal mode or fallback: read the actual requested document
|
80
168
|
const validPath = await this.validatePath(docPath);
|
81
169
|
const content = await fs.readFile(validPath, "utf-8");
|
82
170
|
return {
|
83
171
|
content: [{ type: "text", text: content }],
|
84
172
|
metadata: {
|
85
173
|
path: docPath,
|
86
|
-
...parseFrontmatter(content).frontmatter,
|
174
|
+
...this.parseFrontmatter(content).frontmatter,
|
87
175
|
},
|
88
176
|
};
|
89
177
|
}
|
@@ -97,6 +185,18 @@ export class DocumentHandler {
|
|
97
185
|
};
|
98
186
|
}
|
99
187
|
}
|
188
|
+
/**
|
189
|
+
* Helper method to check if a file exists
|
190
|
+
*/
|
191
|
+
async fileExists(filePath) {
|
192
|
+
try {
|
193
|
+
await fs.access(filePath);
|
194
|
+
return true;
|
195
|
+
}
|
196
|
+
catch {
|
197
|
+
return false;
|
198
|
+
}
|
199
|
+
}
|
100
200
|
/**
|
101
201
|
* Write a document to the docs directory
|
102
202
|
*/
|
@@ -108,7 +208,31 @@ export class DocumentHandler {
|
|
108
208
|
const dirPath = path.dirname(validPath);
|
109
209
|
await fs.mkdir(dirPath, { recursive: true });
|
110
210
|
}
|
211
|
+
// Write the document
|
111
212
|
await fs.writeFile(validPath, content, "utf-8");
|
213
|
+
// If in single-doc mode, refresh the single doc
|
214
|
+
if (this.useSingleDoc && !docPath.startsWith("single_doc")) {
|
215
|
+
try {
|
216
|
+
// Check if we have multi-part docs
|
217
|
+
const parts = await glob(path.join(this.docsDir, "single_doc_part*.md"));
|
218
|
+
if (parts.length > 0) {
|
219
|
+
// If we have multi-part docs, refresh all parts
|
220
|
+
await this.refreshSingleDoc();
|
221
|
+
}
|
222
|
+
else {
|
223
|
+
// Check if single_doc.md exists
|
224
|
+
const singleDocPath = path.join(this.docsDir, "single_doc.md");
|
225
|
+
if (await this.fileExists(singleDocPath)) {
|
226
|
+
// Refresh the single doc
|
227
|
+
await this.refreshSingleDoc();
|
228
|
+
}
|
229
|
+
}
|
230
|
+
}
|
231
|
+
catch (refreshError) {
|
232
|
+
// Log but don't fail if refreshing fails
|
233
|
+
console.error("Error refreshing single doc:", refreshError);
|
234
|
+
}
|
235
|
+
}
|
112
236
|
return {
|
113
237
|
content: [{ type: "text", text: `Successfully wrote to ${docPath}` }],
|
114
238
|
};
|
@@ -280,256 +404,311 @@ export class DocumentHandler {
|
|
280
404
|
}
|
281
405
|
}
|
282
406
|
/**
|
283
|
-
*
|
407
|
+
* Estimate the number of tokens in a text string
|
408
|
+
* This is a simple estimation: ~4 characters per token for English text
|
284
409
|
*/
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
410
|
+
estimateTokens(text) {
|
411
|
+
return Math.ceil(text.length / 4);
|
412
|
+
}
|
413
|
+
/**
|
414
|
+
* Splits content into multiple documents based on token limits
|
415
|
+
* Returns the number of chunks created
|
416
|
+
*/
|
417
|
+
async createDocumentChunks(sections, template, maxTokens) {
|
418
|
+
// Create a base template for each chunk
|
419
|
+
const projectName = path.basename(path.resolve(this.docsDir, ".."));
|
420
|
+
const footer = "\n---\n\n_This document is automatically maintained and optimized for LLM context. This is part {part} of a multi-part document._\n";
|
421
|
+
// Token budgeting
|
422
|
+
const footerBaseTokens = this.estimateTokens(footer.replace("{part}", "X"));
|
423
|
+
const templateTokens = this.estimateTokens(template);
|
424
|
+
const availableTokensPerChunk = maxTokens - templateTokens - footerBaseTokens;
|
425
|
+
// Track total tokens
|
426
|
+
let currentChunk = 1;
|
427
|
+
let currentTokens = 0;
|
428
|
+
let chunkContent = "";
|
429
|
+
for (const [section, content] of sections) {
|
430
|
+
// Skip empty sections
|
431
|
+
if (content.trim() === `## ${section}`.trim()) {
|
432
|
+
continue;
|
433
|
+
}
|
434
|
+
const sectionTokens = this.estimateTokens(content);
|
435
|
+
// Check if we need to start a new chunk
|
436
|
+
if (currentTokens + sectionTokens > availableTokensPerChunk &&
|
437
|
+
currentTokens > 0) {
|
438
|
+
// Write the current chunk to file
|
439
|
+
const chunkPath = path.join(this.docsDir, `single_doc_part${currentChunk}.md`);
|
440
|
+
const finalChunkContent = template +
|
441
|
+
chunkContent +
|
442
|
+
footer.replace("{part}", `${currentChunk}`) +
|
443
|
+
`\n<!-- Token usage: ${currentTokens + templateTokens + footerBaseTokens}/${maxTokens} -->`;
|
444
|
+
await fs.writeFile(chunkPath, finalChunkContent, "utf-8");
|
445
|
+
// Reset for next chunk
|
446
|
+
currentChunk++;
|
447
|
+
currentTokens = 0;
|
448
|
+
chunkContent = "";
|
449
|
+
}
|
450
|
+
// Add section to current chunk
|
451
|
+
chunkContent += content + "\n";
|
452
|
+
currentTokens += sectionTokens;
|
453
|
+
}
|
454
|
+
// Write the last chunk if there's any content
|
455
|
+
if (chunkContent) {
|
456
|
+
const chunkPath = path.join(this.docsDir, `single_doc_part${currentChunk}.md`);
|
457
|
+
const finalChunkContent = template +
|
458
|
+
chunkContent +
|
459
|
+
footer.replace("{part}", `${currentChunk} of ${currentChunk}`) +
|
460
|
+
`\n<!-- Token usage: ${currentTokens + templateTokens + footerBaseTokens}/${maxTokens} -->`;
|
461
|
+
await fs.writeFile(chunkPath, finalChunkContent, "utf-8");
|
462
|
+
}
|
463
|
+
// Create an index file that lists all parts
|
464
|
+
if (currentChunk > 1) {
|
465
|
+
const indexPath = path.join(this.docsDir, "single_doc.md");
|
466
|
+
let indexContent = `---
|
467
|
+
title: ${projectName} Documentation Index
|
468
|
+
description: Index of documentation parts optimized for LLM context
|
469
|
+
author: Documentation System
|
470
|
+
date: "${new Date().toISOString()}"
|
471
|
+
tags:
|
472
|
+
- documentation
|
473
|
+
- llm-optimized
|
474
|
+
status: published
|
475
|
+
version: 1.0.0
|
299
476
|
---
|
300
477
|
|
301
|
-
# ${
|
478
|
+
# ${projectName.toUpperCase()}: DOCUMENTATION INDEX
|
479
|
+
|
480
|
+
_This documentation has been split into multiple parts due to its size. Each part is optimized for LLM context._
|
481
|
+
|
482
|
+
## Available Parts
|
302
483
|
|
303
|
-
This is the documentation for ${folderName}.
|
304
484
|
`;
|
305
|
-
|
485
|
+
for (let i = 1; i <= currentChunk; i++) {
|
486
|
+
indexContent += `- [Part ${i}](single_doc_part${i}.md)\n`;
|
306
487
|
}
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
],
|
311
|
-
metadata: {
|
312
|
-
path: folderPath,
|
313
|
-
readme: createReadme ? path.join(folderPath, "README.md") : null,
|
314
|
-
},
|
315
|
-
};
|
316
|
-
}
|
317
|
-
catch (error) {
|
318
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
319
|
-
return {
|
320
|
-
content: [
|
321
|
-
{ type: "text", text: `Error creating folder: ${errorMessage}` },
|
322
|
-
],
|
323
|
-
isError: true,
|
324
|
-
};
|
488
|
+
indexContent +=
|
489
|
+
"\n---\n\n_This index was automatically generated to manage documentation that exceeds token limits._\n";
|
490
|
+
await fs.writeFile(indexPath, indexContent, "utf-8");
|
325
491
|
}
|
492
|
+
return currentChunk;
|
326
493
|
}
|
327
494
|
/**
|
328
|
-
*
|
495
|
+
* Refresh the single document by recompiling all markdown files
|
329
496
|
*/
|
330
|
-
async
|
497
|
+
async refreshSingleDoc() {
|
498
|
+
const singleDocPath = path.join(this.docsDir, "single_doc.md");
|
499
|
+
const MAX_TOKENS = 100000; // Limit to 100k tokens to fit within LLM context windows
|
500
|
+
const MULTI_DOC_THRESHOLD = 9000; // Lower threshold for testing - if estimated tokens exceed this, create multiple docs
|
331
501
|
try {
|
332
|
-
|
333
|
-
const
|
334
|
-
//
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
502
|
+
// Get all markdown files
|
503
|
+
const files = await glob(path.join(this.docsDir, "**/*.md"));
|
504
|
+
// Filter out the single_doc.md itself and any single_doc_part*.md files
|
505
|
+
const docsToProcess = files.filter((file) => !path.basename(file).startsWith("single_doc"));
|
506
|
+
debugLog(`Processing ${docsToProcess.length} documents`);
|
507
|
+
// Create a template for the single doc
|
508
|
+
const projectName = path.basename(path.resolve(this.docsDir, ".."));
|
509
|
+
const template = `---
|
510
|
+
title: ${projectName} Documentation
|
511
|
+
description: Complete documentation optimized for LLM context
|
512
|
+
author: Documentation System
|
513
|
+
date: "${new Date().toISOString()}"
|
514
|
+
tags:
|
515
|
+
- documentation
|
516
|
+
- llm-optimized
|
517
|
+
status: published
|
518
|
+
version: 1.0.0
|
519
|
+
---
|
520
|
+
|
521
|
+
# ${projectName.toUpperCase()}: COMPLETE REFERENCE
|
522
|
+
|
523
|
+
_This document is automatically generated and optimized for LLM context. It contains the complete reference for ${projectName}._
|
524
|
+
|
525
|
+
## TABLE OF CONTENTS
|
526
|
+
|
527
|
+
1. [Overview](#overview)
|
528
|
+
2. [Core Concepts](#core-concepts)
|
529
|
+
3. [Features](#features)
|
530
|
+
4. [API Reference](#api-reference)
|
531
|
+
5. [Usage Examples](#usage-examples)
|
532
|
+
6. [Integration](#integration)
|
533
|
+
7. [Best Practices](#best-practices)
|
534
|
+
8. [Troubleshooting](#troubleshooting)
|
535
|
+
9. [Additional Information](#additional-information)
|
536
|
+
|
537
|
+
`;
|
538
|
+
// Initialize sections
|
539
|
+
const sections = {
|
540
|
+
OVERVIEW: "## OVERVIEW\n\n",
|
541
|
+
"CORE CONCEPTS": "## CORE CONCEPTS\n\n",
|
542
|
+
FEATURES: "## FEATURES\n\n",
|
543
|
+
"API REFERENCE": "## API REFERENCE\n\n",
|
544
|
+
"USAGE EXAMPLES": "## USAGE EXAMPLES\n\n",
|
545
|
+
INTEGRATION: "## INTEGRATION\n\n",
|
546
|
+
"BEST PRACTICES": "## BEST PRACTICES\n\n",
|
547
|
+
TROUBLESHOOTING: "## TROUBLESHOOTING\n\n",
|
548
|
+
"ADDITIONAL INFORMATION": "## ADDITIONAL INFORMATION\n\n",
|
370
549
|
};
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
550
|
+
// Define section priorities (1 = highest priority)
|
551
|
+
const sectionPriorities = {
|
552
|
+
OVERVIEW: 1,
|
553
|
+
FEATURES: 2,
|
554
|
+
"API REFERENCE": 3,
|
555
|
+
"USAGE EXAMPLES": 4,
|
556
|
+
"CORE CONCEPTS": 5,
|
557
|
+
INTEGRATION: 6,
|
558
|
+
"BEST PRACTICES": 7,
|
559
|
+
TROUBLESHOOTING: 8,
|
560
|
+
"ADDITIONAL INFORMATION": 9,
|
379
561
|
};
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
// Check if source exists
|
395
|
-
try {
|
396
|
-
await fs.access(validPath);
|
397
|
-
}
|
398
|
-
catch {
|
399
|
-
throw new Error(`Source file does not exist: ${docPath}`);
|
400
|
-
}
|
401
|
-
// Check if destination already exists
|
402
|
-
try {
|
403
|
-
await fs.access(validNewPath);
|
404
|
-
throw new Error(`Destination file already exists: ${newPath}`);
|
405
|
-
}
|
406
|
-
catch (error) {
|
407
|
-
// If error is "file doesn't exist", that's good
|
408
|
-
if (!(error instanceof Error &&
|
409
|
-
error.message.includes("Destination file already exists"))) {
|
410
|
-
// Continue with rename
|
562
|
+
// Read each document and add to appropriate section
|
563
|
+
for (const file of docsToProcess) {
|
564
|
+
const relativePath = path.relative(this.docsDir, file);
|
565
|
+
const content = await fs.readFile(file, "utf-8");
|
566
|
+
// Parse frontmatter
|
567
|
+
const { frontmatter, content: docContent } = this.parseFrontmatter(content);
|
568
|
+
// Skip empty documents
|
569
|
+
if (!docContent.trim())
|
570
|
+
continue;
|
571
|
+
// Determine which section to add to
|
572
|
+
let targetSection = "ADDITIONAL INFORMATION";
|
573
|
+
if (file.includes("api") ||
|
574
|
+
(frontmatter.tags && frontmatter.tags.includes("api"))) {
|
575
|
+
targetSection = "API REFERENCE";
|
411
576
|
}
|
412
|
-
else
|
413
|
-
|
577
|
+
else if (file.includes("concept") ||
|
578
|
+
(frontmatter.tags && frontmatter.tags.includes("concept"))) {
|
579
|
+
targetSection = "CORE CONCEPTS";
|
414
580
|
}
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
// Parse frontmatter
|
419
|
-
const { frontmatter, content: docContent } = parseFrontmatter(content);
|
420
|
-
// Update title in frontmatter if it exists
|
421
|
-
if (frontmatter.title) {
|
422
|
-
frontmatter.title = newName;
|
423
|
-
}
|
424
|
-
// Reconstruct content with updated frontmatter
|
425
|
-
let frontmatterStr = "---\n";
|
426
|
-
for (const [key, value] of Object.entries(frontmatter)) {
|
427
|
-
if (Array.isArray(value)) {
|
428
|
-
frontmatterStr += `${key}:\n`;
|
429
|
-
for (const item of value) {
|
430
|
-
frontmatterStr += ` - ${item}\n`;
|
431
|
-
}
|
581
|
+
else if (file.includes("feature") ||
|
582
|
+
(frontmatter.tags && frontmatter.tags.includes("feature"))) {
|
583
|
+
targetSection = "FEATURES";
|
432
584
|
}
|
433
|
-
else
|
434
|
-
|
585
|
+
else if (file.includes("example") ||
|
586
|
+
(frontmatter.tags && frontmatter.tags.includes("example"))) {
|
587
|
+
targetSection = "USAGE EXAMPLES";
|
435
588
|
}
|
589
|
+
else if (file.includes("integration") ||
|
590
|
+
(frontmatter.tags && frontmatter.tags.includes("integration"))) {
|
591
|
+
targetSection = "INTEGRATION";
|
592
|
+
}
|
593
|
+
else if (file.includes("best-practice") ||
|
594
|
+
(frontmatter.tags && frontmatter.tags.includes("best-practice"))) {
|
595
|
+
targetSection = "BEST PRACTICES";
|
596
|
+
}
|
597
|
+
else if (file.includes("troubleshoot") ||
|
598
|
+
(frontmatter.tags && frontmatter.tags.includes("troubleshoot"))) {
|
599
|
+
targetSection = "TROUBLESHOOTING";
|
600
|
+
}
|
601
|
+
else if (file.includes("overview") ||
|
602
|
+
(frontmatter.tags && frontmatter.tags.includes("overview")) ||
|
603
|
+
file.endsWith("README.md")) {
|
604
|
+
targetSection = "OVERVIEW";
|
605
|
+
}
|
606
|
+
// Add document to the section
|
607
|
+
const title = frontmatter.title || path.basename(file, ".md");
|
608
|
+
const formattedContent = `### ${title}\n\n_Source: ${relativePath}_\n\n${docContent}\n\n`;
|
609
|
+
sections[targetSection] += formattedContent;
|
436
610
|
}
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
}
|
467
|
-
catch (error) {
|
468
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
469
|
-
return {
|
470
|
-
content: [
|
471
|
-
{ type: "text", text: `Error renaming document: ${errorMessage}` },
|
472
|
-
],
|
473
|
-
isError: true,
|
474
|
-
};
|
475
|
-
}
|
476
|
-
}
|
477
|
-
/**
|
478
|
-
* Update navigation order for a document
|
479
|
-
*/
|
480
|
-
async updateNavigationOrder(docPath, order) {
|
481
|
-
try {
|
482
|
-
const validPath = await this.validatePath(docPath);
|
483
|
-
// Check if file exists
|
484
|
-
try {
|
485
|
-
await fs.access(validPath);
|
486
|
-
}
|
487
|
-
catch {
|
488
|
-
throw new Error(`File does not exist: ${docPath}`);
|
611
|
+
// Calculate estimated total size
|
612
|
+
let estimatedTotalTokens = this.estimateTokens(template);
|
613
|
+
debugLog(`Template tokens: ${estimatedTotalTokens}`);
|
614
|
+
Object.entries(sections).forEach(([section, content]) => {
|
615
|
+
const sectionTokens = this.estimateTokens(content);
|
616
|
+
debugLog(`Section "${section}" tokens: ${sectionTokens}`);
|
617
|
+
estimatedTotalTokens += sectionTokens;
|
618
|
+
});
|
619
|
+
debugLog(`Total estimated tokens: ${estimatedTotalTokens}`);
|
620
|
+
debugLog(`Multi-doc threshold: ${MULTI_DOC_THRESHOLD}`);
|
621
|
+
// Check if we need to split into multiple documents
|
622
|
+
if (estimatedTotalTokens > MULTI_DOC_THRESHOLD) {
|
623
|
+
debugLog("Threshold exceeded, creating multiple documents");
|
624
|
+
// Delete any existing single_doc_part*.md files
|
625
|
+
const existingParts = await glob(path.join(this.docsDir, "single_doc_part*.md"));
|
626
|
+
for (const part of existingParts) {
|
627
|
+
await fs.unlink(part);
|
628
|
+
}
|
629
|
+
// Create multiple document parts
|
630
|
+
const prioritizedSections = Object.entries(sections).sort(([sectionA], [sectionB]) => sectionPriorities[sectionA] - sectionPriorities[sectionB]);
|
631
|
+
const numChunks = await this.createDocumentChunks(prioritizedSections, template, MAX_TOKENS);
|
632
|
+
return {
|
633
|
+
content: [
|
634
|
+
{
|
635
|
+
type: "text",
|
636
|
+
text: `Documentation exceeded token limits (${estimatedTotalTokens} estimated tokens). Created ${numChunks} document parts.`,
|
637
|
+
},
|
638
|
+
],
|
639
|
+
};
|
489
640
|
}
|
490
|
-
//
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
641
|
+
// If not exceeding the multi-doc threshold, continue with standard approach
|
642
|
+
let totalTokens = this.estimateTokens(template);
|
643
|
+
let includedDocuments = 0;
|
644
|
+
let truncatedSections = 0;
|
645
|
+
let omittedSections = 0;
|
646
|
+
// Sort sections by priority
|
647
|
+
const prioritizedSections = Object.entries(sections).sort(([sectionA], [sectionB]) => sectionPriorities[sectionA] - sectionPriorities[sectionB]);
|
648
|
+
// Compile the final document with token limits
|
649
|
+
let finalContent = template;
|
650
|
+
const footer = "\n---\n\n_This document is automatically maintained and optimized for LLM context._\n";
|
651
|
+
const footerTokens = this.estimateTokens(footer);
|
652
|
+
// Reserve tokens for the footer
|
653
|
+
const availableTokens = MAX_TOKENS - totalTokens - footerTokens;
|
654
|
+
for (const [section, content] of prioritizedSections) {
|
655
|
+
// Skip empty sections
|
656
|
+
if (content.trim() === `## ${section}`.trim()) {
|
657
|
+
continue;
|
658
|
+
}
|
659
|
+
const sectionTokens = this.estimateTokens(content);
|
660
|
+
if (totalTokens + sectionTokens <= MAX_TOKENS - footerTokens) {
|
661
|
+
// Full section fits
|
662
|
+
finalContent += content + "\n";
|
663
|
+
totalTokens += sectionTokens;
|
664
|
+
// Count documents in this section
|
665
|
+
const docMatches = content.match(/### .+/g);
|
666
|
+
includedDocuments += docMatches ? docMatches.length : 0;
|
504
667
|
}
|
505
668
|
else {
|
506
|
-
|
669
|
+
// Section doesn't fit entirely - try to include a truncated version
|
670
|
+
omittedSections++;
|
671
|
+
// Add a note about truncation
|
672
|
+
const truncationNote = `## ${section} (Content Limit Reached)\n\nSome content in this section was omitted to fit within token limits.\n\n`;
|
673
|
+
const truncationTokens = this.estimateTokens(truncationNote);
|
674
|
+
if (totalTokens + truncationTokens <= MAX_TOKENS - footerTokens) {
|
675
|
+
finalContent += truncationNote;
|
676
|
+
totalTokens += truncationTokens;
|
677
|
+
truncatedSections++;
|
678
|
+
}
|
507
679
|
}
|
508
680
|
}
|
509
|
-
|
510
|
-
|
511
|
-
//
|
512
|
-
|
681
|
+
// Add footer with token usage information
|
682
|
+
finalContent += footer;
|
683
|
+
// Add token usage metadata
|
684
|
+
const tokenUsageInfo = `\n<!-- Token usage: ${totalTokens}/${MAX_TOKENS} (${Math.round((totalTokens / MAX_TOKENS) * 100)}%) -->\n`;
|
685
|
+
finalContent += tokenUsageInfo;
|
686
|
+
// Delete any existing part files since we're creating a single file
|
687
|
+
const existingParts = await glob(path.join(this.docsDir, "single_doc_part*.md"));
|
688
|
+
for (const part of existingParts) {
|
689
|
+
await fs.unlink(part);
|
690
|
+
}
|
691
|
+
// Write the file
|
692
|
+
await fs.writeFile(singleDocPath, finalContent, "utf-8");
|
513
693
|
return {
|
514
694
|
content: [
|
515
695
|
{
|
516
696
|
type: "text",
|
517
|
-
text: `Successfully
|
697
|
+
text: `Successfully refreshed single_doc.md from ${docsToProcess.length} documents. Included ${includedDocuments} documents using ${totalTokens} tokens (${Math.round((totalTokens / MAX_TOKENS) * 100)}% of limit).${truncatedSections > 0
|
698
|
+
? ` ${truncatedSections} sections were truncated.`
|
699
|
+
: ""}${omittedSections > 0
|
700
|
+
? ` ${omittedSections} sections were omitted due to token limits.`
|
701
|
+
: ""}`,
|
518
702
|
},
|
519
703
|
],
|
520
|
-
metadata: {
|
521
|
-
path: docPath,
|
522
|
-
order,
|
523
|
-
},
|
524
704
|
};
|
525
705
|
}
|
526
706
|
catch (error) {
|
527
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
528
707
|
return {
|
529
708
|
content: [
|
530
709
|
{
|
531
710
|
type: "text",
|
532
|
-
text: `Error
|
711
|
+
text: `Error refreshing single_doc.md: ${error instanceof Error ? error.message : String(error)}`,
|
533
712
|
},
|
534
713
|
],
|
535
714
|
isError: true,
|
@@ -537,215 +716,109 @@ This is the documentation for ${folderName}.
|
|
537
716
|
}
|
538
717
|
}
|
539
718
|
/**
|
540
|
-
*
|
719
|
+
* Parse frontmatter from a markdown document
|
541
720
|
*/
|
542
|
-
|
721
|
+
parseFrontmatter(content) {
|
722
|
+
// If no content, return empty
|
723
|
+
if (!content || content.trim() === "") {
|
724
|
+
return { frontmatter: {}, content: "" };
|
725
|
+
}
|
726
|
+
// Check if the content has frontmatter (starts with ---)
|
727
|
+
if (!content.startsWith("---")) {
|
728
|
+
return { frontmatter: {}, content };
|
729
|
+
}
|
543
730
|
try {
|
544
|
-
//
|
545
|
-
const
|
546
|
-
|
547
|
-
|
548
|
-
const indexPath = path.join(validPath, "index.md");
|
549
|
-
const validIndexPath = await this.validatePath(indexPath);
|
550
|
-
// Create content with frontmatter
|
551
|
-
let content = "---\n";
|
552
|
-
content += `title: ${title}\n`;
|
553
|
-
content += `description: ${title} section\n`;
|
554
|
-
content += `date: ${new Date().toISOString()}\n`;
|
555
|
-
content += `status: published\n`;
|
556
|
-
if (order !== undefined) {
|
557
|
-
content += `order: ${order}\n`;
|
731
|
+
// Find the end of the frontmatter
|
732
|
+
const endIndex = content.indexOf("---", 3);
|
733
|
+
if (endIndex === -1) {
|
734
|
+
return { frontmatter: {}, content };
|
558
735
|
}
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
//
|
563
|
-
|
564
|
-
return {
|
565
|
-
content: [
|
566
|
-
{
|
567
|
-
type: "text",
|
568
|
-
text: `Successfully created section: ${title} at ${sectionPath}`,
|
569
|
-
},
|
570
|
-
],
|
571
|
-
metadata: {
|
572
|
-
title,
|
573
|
-
path: sectionPath,
|
574
|
-
indexPath: path.join(sectionPath, "index.md"),
|
575
|
-
order,
|
576
|
-
},
|
577
|
-
};
|
736
|
+
// Extract frontmatter and parse as YAML
|
737
|
+
const frontmatterText = content.substring(3, endIndex).trim();
|
738
|
+
const frontmatter = this.parseFrontmatterLines(frontmatterText);
|
739
|
+
// Extract the content after frontmatter
|
740
|
+
const contentWithoutFrontmatter = content.substring(endIndex + 3).trim();
|
741
|
+
return { frontmatter, content: contentWithoutFrontmatter };
|
578
742
|
}
|
579
743
|
catch (error) {
|
580
|
-
|
581
|
-
return {
|
582
|
-
content: [
|
583
|
-
{ type: "text", text: `Error creating section: ${errorMessage}` },
|
584
|
-
],
|
585
|
-
isError: true,
|
586
|
-
};
|
744
|
+
console.error("Error parsing frontmatter:", error);
|
745
|
+
return { frontmatter: {}, content };
|
587
746
|
}
|
588
747
|
}
|
589
748
|
/**
|
590
|
-
*
|
591
|
-
* @private
|
749
|
+
* Parse frontmatter lines into an object
|
592
750
|
*/
|
593
|
-
|
594
|
-
|
595
|
-
const
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
let
|
600
|
-
for (const
|
601
|
-
const
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
async validateLinks(basePath = "", recursive = true) {
|
623
|
-
try {
|
624
|
-
const validBasePath = await this.validatePath(basePath || this.docsDir);
|
625
|
-
// Find all markdown files
|
626
|
-
const pattern = recursive ? "**/*.md" : "*.md";
|
627
|
-
const files = await glob(pattern, { cwd: validBasePath });
|
628
|
-
const brokenLinks = [];
|
629
|
-
// Check each file for links
|
630
|
-
for (const file of files) {
|
631
|
-
const filePath = path.join(validBasePath, file);
|
632
|
-
const content = await fs.readFile(filePath, "utf-8");
|
633
|
-
const lines = content.split("\n");
|
634
|
-
// Find markdown links: [text](path)
|
635
|
-
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
636
|
-
for (let i = 0; i < lines.length; i++) {
|
637
|
-
const line = lines[i];
|
638
|
-
let match;
|
639
|
-
while ((match = linkRegex.exec(line)) !== null) {
|
640
|
-
const [, , linkPath] = match;
|
641
|
-
// Skip external links and anchors
|
642
|
-
if (linkPath.startsWith("http://") ||
|
643
|
-
linkPath.startsWith("https://") ||
|
644
|
-
linkPath.startsWith("#") ||
|
645
|
-
linkPath.startsWith("mailto:")) {
|
646
|
-
continue;
|
647
|
-
}
|
648
|
-
// Resolve the link path relative to the current file
|
649
|
-
const fileDir = path.dirname(filePath);
|
650
|
-
const resolvedPath = path.resolve(fileDir, linkPath);
|
651
|
-
// Check if the link target exists
|
652
|
-
try {
|
653
|
-
await fs.access(resolvedPath);
|
654
|
-
}
|
655
|
-
catch {
|
656
|
-
brokenLinks.push({
|
657
|
-
file: path.relative(this.docsDir, filePath),
|
658
|
-
link: linkPath,
|
659
|
-
lineNumber: i + 1,
|
660
|
-
});
|
661
|
-
}
|
751
|
+
parseFrontmatterLines(frontmatterText) {
|
752
|
+
const frontmatter = {};
|
753
|
+
const lines = frontmatterText.split("\n");
|
754
|
+
let currentKey = null;
|
755
|
+
let isMultilineValue = false;
|
756
|
+
let multilineValue = "";
|
757
|
+
let isArray = false;
|
758
|
+
for (const line of lines) {
|
759
|
+
const trimmedLine = line.trim();
|
760
|
+
if (trimmedLine === "")
|
761
|
+
continue;
|
762
|
+
if (isMultilineValue) {
|
763
|
+
if (trimmedLine.startsWith(" -")) {
|
764
|
+
// Array item
|
765
|
+
const value = trimmedLine.substring(3).trim();
|
766
|
+
if (!Array.isArray(frontmatter[currentKey])) {
|
767
|
+
frontmatter[currentKey] = [];
|
768
|
+
}
|
769
|
+
frontmatter[currentKey].push(value);
|
770
|
+
}
|
771
|
+
else if (trimmedLine.startsWith(" ")) {
|
772
|
+
// Continuation of multiline value
|
773
|
+
multilineValue += "\n" + trimmedLine.substring(2);
|
774
|
+
}
|
775
|
+
else {
|
776
|
+
// End of multiline value
|
777
|
+
if (multilineValue && !isArray) {
|
778
|
+
frontmatter[currentKey] = multilineValue.trim();
|
779
|
+
multilineValue = "";
|
662
780
|
}
|
781
|
+
isMultilineValue = false;
|
782
|
+
isArray = false;
|
783
|
+
currentKey = null;
|
663
784
|
}
|
664
785
|
}
|
665
|
-
|
666
|
-
|
667
|
-
|
668
|
-
|
669
|
-
|
670
|
-
|
671
|
-
|
672
|
-
|
673
|
-
|
674
|
-
|
675
|
-
|
676
|
-
|
677
|
-
|
678
|
-
|
679
|
-
|
680
|
-
|
681
|
-
|
682
|
-
|
683
|
-
|
684
|
-
|
685
|
-
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
690
|
-
|
691
|
-
|
692
|
-
|
693
|
-
|
694
|
-
|
695
|
-
|
696
|
-
|
697
|
-
|
698
|
-
const fields = requiredFields || ["title", "description", "status"];
|
699
|
-
// Find all markdown files
|
700
|
-
const files = await glob("**/*.md", { cwd: validBasePath });
|
701
|
-
const missingMetadata = [];
|
702
|
-
// Check each file for metadata
|
703
|
-
for (const file of files) {
|
704
|
-
const filePath = path.join(validBasePath, file);
|
705
|
-
const content = await fs.readFile(filePath, "utf-8");
|
706
|
-
// Parse frontmatter
|
707
|
-
const { frontmatter } = parseFrontmatter(content);
|
708
|
-
// Check for required fields
|
709
|
-
const missing = fields.filter((field) => !frontmatter[field]);
|
710
|
-
if (missing.length > 0) {
|
711
|
-
missingMetadata.push({
|
712
|
-
file: path.relative(this.docsDir, filePath),
|
713
|
-
missingFields: missing,
|
714
|
-
});
|
786
|
+
if (!isMultilineValue) {
|
787
|
+
const colonIndex = trimmedLine.indexOf(":");
|
788
|
+
if (colonIndex > 0) {
|
789
|
+
currentKey = trimmedLine.substring(0, colonIndex).trim();
|
790
|
+
const value = trimmedLine.substring(colonIndex + 1).trim();
|
791
|
+
if (value === "" || value === "|" || value === ">") {
|
792
|
+
// Multiline value
|
793
|
+
isMultilineValue = true;
|
794
|
+
multilineValue = "";
|
795
|
+
isArray = false;
|
796
|
+
}
|
797
|
+
else if (value === "[]" ||
|
798
|
+
(value.startsWith("[") && value.endsWith("]"))) {
|
799
|
+
// Array
|
800
|
+
frontmatter[currentKey] =
|
801
|
+
value === "[]"
|
802
|
+
? []
|
803
|
+
: value
|
804
|
+
.substring(1, value.length - 1)
|
805
|
+
.split(",")
|
806
|
+
.map((v) => v.trim());
|
807
|
+
}
|
808
|
+
else {
|
809
|
+
// Simple value
|
810
|
+
if (value === "true")
|
811
|
+
frontmatter[currentKey] = true;
|
812
|
+
else if (value === "false")
|
813
|
+
frontmatter[currentKey] = false;
|
814
|
+
else if (!isNaN(Number(value)))
|
815
|
+
frontmatter[currentKey] = Number(value);
|
816
|
+
else
|
817
|
+
frontmatter[currentKey] = value.replace(/^["'](.*)["']$/, "$1");
|
818
|
+
}
|
715
819
|
}
|
716
820
|
}
|
717
|
-
// Calculate completeness percentage
|
718
|
-
const totalFields = files.length * fields.length;
|
719
|
-
const missingFields = missingMetadata.reduce((sum, item) => sum + item.missingFields.length, 0);
|
720
|
-
const completenessPercentage = totalFields > 0
|
721
|
-
? Math.round(((totalFields - missingFields) / totalFields) * 100)
|
722
|
-
: 100;
|
723
|
-
return {
|
724
|
-
content: [
|
725
|
-
{
|
726
|
-
type: "text",
|
727
|
-
text: missingMetadata.length > 0
|
728
|
-
? `Found ${missingMetadata.length} files with missing metadata. Completeness: ${completenessPercentage}%`
|
729
|
-
: `All ${files.length} files have complete metadata. Completeness: 100%`,
|
730
|
-
},
|
731
|
-
],
|
732
|
-
metadata: {
|
733
|
-
missingMetadata,
|
734
|
-
filesChecked: files.length,
|
735
|
-
requiredFields: fields,
|
736
|
-
completenessPercentage,
|
737
|
-
basePath: path.relative(this.docsDir, validBasePath),
|
738
|
-
},
|
739
|
-
};
|
740
|
-
}
|
741
|
-
catch (error) {
|
742
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
743
|
-
return {
|
744
|
-
content: [
|
745
|
-
{ type: "text", text: `Error validating metadata: ${errorMessage}` },
|
746
|
-
],
|
747
|
-
isError: true,
|
748
|
-
};
|
749
821
|
}
|
822
|
+
return frontmatter;
|
750
823
|
}
|
751
824
|
}
|