@imenam/mcp-documentor 1.0.54 → 1.0.55

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.
@@ -0,0 +1,14 @@
1
+ import type { TrackedFile, TrackedFileInput } from "../../shared/schemas/doc.schema.js";
2
+ export interface CodeRefResult {
3
+ content: string;
4
+ additionalTrackedFiles: TrackedFileInput[];
5
+ }
6
+ export declare function resolveCodeRefs(content: string): CodeRefResult;
7
+ export declare function snapshotTrackedFiles(inputs: TrackedFileInput[]): TrackedFile[];
8
+ /**
9
+ * Merges snapshotted tracked files into an existing list.
10
+ * - If a file is not yet tracked: appended.
11
+ * - If a file is already tracked with sections and the new entry also has sections: sections are merged.
12
+ * - Otherwise (whole-file tracking involved): the existing entry is replaced.
13
+ */
14
+ export declare function mergeTrackedFiles(existing: TrackedFile[], additions: TrackedFileInput[]): TrackedFile[];
@@ -0,0 +1,118 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { snapshotSection } from "./section-tracker.js";
5
+ const EXT_TO_LANG = {
6
+ ts: "typescript",
7
+ tsx: "tsx",
8
+ js: "javascript",
9
+ jsx: "jsx",
10
+ py: "python",
11
+ css: "css",
12
+ scss: "scss",
13
+ json: "json",
14
+ md: "markdown",
15
+ html: "html",
16
+ sh: "bash",
17
+ yml: "yaml",
18
+ yaml: "yaml",
19
+ sql: "sql",
20
+ rs: "rust",
21
+ go: "go",
22
+ java: "java",
23
+ cs: "csharp",
24
+ };
25
+ // Syntax: [copy-from-codebase|/absolute/path/to/file.ts|10|50]
26
+ // Using | as separator to avoid conflicts with Windows drive letters (C:\...)
27
+ const CODE_REF_REGEX = /\[copy-from-codebase\|([^|]+)\|(\d+)\|(\d+)\]/g;
28
+ function computeFileHash(absolutePath) {
29
+ return crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex");
30
+ }
31
+ export function resolveCodeRefs(content) {
32
+ const additionalTrackedFiles = [];
33
+ const resolved = content.replace(CODE_REF_REGEX, (match, filePath, startStr, endStr) => {
34
+ const start = parseInt(startStr, 10);
35
+ const end = parseInt(endStr, 10);
36
+ if (!fs.existsSync(filePath)) {
37
+ console.warn(`[copy-from-codebase] File not found: ${filePath}`);
38
+ return match;
39
+ }
40
+ const lines = fs.readFileSync(filePath, "utf-8").split("\n");
41
+ const extracted = lines.slice(start - 1, end).join("\n");
42
+ const ext = path.extname(filePath).slice(1).toLowerCase();
43
+ const lang = EXT_TO_LANG[ext] ?? "";
44
+ additionalTrackedFiles.push({ path: filePath, lines: [{ start, end }] });
45
+ return `\`\`\`${lang}\n${extracted}\n\`\`\``;
46
+ });
47
+ return { content: resolved, additionalTrackedFiles };
48
+ }
49
+ export function snapshotTrackedFiles(inputs) {
50
+ const byPath = new Map();
51
+ for (const input of inputs) {
52
+ const fp = typeof input === "string" ? input : input.path;
53
+ const absolutePath = path.resolve(process.cwd(), fp);
54
+ const mtime = fs.existsSync(absolutePath)
55
+ ? fs.statSync(absolutePath).mtime.toISOString()
56
+ : new Date().toISOString();
57
+ const isWhole = typeof input === "string" || input.lines.length === 0;
58
+ const existing = byPath.get(absolutePath);
59
+ if (isWhole) {
60
+ byPath.set(absolutePath, { mtime, lineRanges: [], wholeFile: true });
61
+ }
62
+ else if (existing) {
63
+ if (!existing.wholeFile) {
64
+ existing.lineRanges.push(...input.lines);
65
+ }
66
+ }
67
+ else {
68
+ byPath.set(absolutePath, {
69
+ mtime,
70
+ lineRanges: [...input.lines],
71
+ wholeFile: false,
72
+ });
73
+ }
74
+ }
75
+ const tracked = [];
76
+ for (const [absolutePath, { mtime, lineRanges, wholeFile }] of byPath) {
77
+ if (!wholeFile && lineRanges.length > 0 && fs.existsSync(absolutePath)) {
78
+ const sections = lineRanges
79
+ .filter(({ start, end }) => start <= end)
80
+ .map(({ start, end }) => snapshotSection(absolutePath, start, end));
81
+ tracked.push({ path: absolutePath, lastModifiedAt: mtime, sections });
82
+ }
83
+ else {
84
+ const contentHash = fs.existsSync(absolutePath) ? computeFileHash(absolutePath) : undefined;
85
+ tracked.push({ path: absolutePath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
86
+ }
87
+ }
88
+ return tracked;
89
+ }
90
+ /**
91
+ * Merges snapshotted tracked files into an existing list.
92
+ * - If a file is not yet tracked: appended.
93
+ * - If a file is already tracked with sections and the new entry also has sections: sections are merged.
94
+ * - Otherwise (whole-file tracking involved): the existing entry is replaced.
95
+ */
96
+ export function mergeTrackedFiles(existing, additions) {
97
+ if (additions.length === 0)
98
+ return existing;
99
+ const snapshotted = snapshotTrackedFiles(additions);
100
+ const result = [...existing];
101
+ for (const newTf of snapshotted) {
102
+ const idx = result.findIndex(tf => tf.path === newTf.path);
103
+ if (idx < 0) {
104
+ result.push(newTf);
105
+ }
106
+ else if (newTf.sections && result[idx].sections) {
107
+ result[idx] = {
108
+ ...result[idx],
109
+ sections: [...result[idx].sections, ...newTf.sections],
110
+ lastModifiedAt: newTf.lastModifiedAt,
111
+ };
112
+ }
113
+ else {
114
+ result[idx] = newTf;
115
+ }
116
+ }
117
+ return result;
118
+ }
package/dist/src/index.js CHANGED
@@ -620,7 +620,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
620
620
  type: "object",
621
621
  properties: {
622
622
  title: { type: "string", description: "Title of the documentation entry" },
623
- content: { type: "string", description: "Documentation content in markdown. To link to another document inline, use the syntax [text](doc://target-doc-id) — the viewer will navigate to that document on click. To link to a heading within the same document, use [text](#heading-slug). Use check_links to validate both types. For code blocks, always specify a language identifier for syntax highlighting, e.g. ```typescript, ```python, ```bash, ```json, ```yaml, ```sql." },
623
+ content: { type: "string", description: "Documentation content in markdown. To link to another document inline, use the syntax [text](doc://target-doc-id) — the viewer will navigate to that document on click. To link to a heading within the same document, use [text](#heading-slug). Use check_links to validate both types. For code blocks, always specify a language identifier for syntax highlighting, e.g. ```typescript, ```python, ```bash, ```json, ```yaml, ```sql. To embed a code excerpt directly from a source file, use [copy-from-codebase|/absolute/path/to/file.ts|startLine|endLine] — the tag is replaced at save time by a fenced code block with automatic language detection, and the file+range is automatically added to tracked_files. IMPORTANT: always read the source file first to identify the exact line numbers before writing the tag." },
624
624
  content_file: { type: "string", description: "Absolute path to a text file whose content will be used instead of the content parameter. Use this to reuse a previously persisted draft (path is returned in the error message when a previous call failed)." },
625
625
  tracked_files: {
626
626
  type: "array",
@@ -700,7 +700,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
700
700
  type: "string",
701
701
  description: "New display title stored in frontmatter. Does not rename the file or change the document path — use move_doc to relocate or rename on disk.",
702
702
  },
703
- content: { type: "string", description: "New content (replaces existing). Supports [text](doc://target-doc-path) to link inline to other documents, and [text](#heading-slug) to link to headings within this document. For code blocks, always specify a language identifier for syntax highlighting, e.g. ```typescript, ```python, ```bash, ```json, ```yaml, ```sql." },
703
+ content: { type: "string", description: "New content (replaces existing). Supports [text](doc://target-doc-path) to link inline to other documents, and [text](#heading-slug) to link to headings within this document. For code blocks, always specify a language identifier for syntax highlighting, e.g. ```typescript, ```python, ```bash, ```json, ```yaml, ```sql. To embed a code excerpt directly from a source file, use [copy-from-codebase|/absolute/path/to/file.ts|startLine|endLine] — the tag is replaced at save time by a fenced code block with automatic language detection, and the file+range is automatically added to tracked_files. IMPORTANT: always read the source file first to identify the exact line numbers before writing the tag." },
704
704
  content_file: { type: "string", description: "Absolute path to a text file whose content will be used instead of the content parameter. Use this to reuse a previously persisted draft (path is returned in the error message when a previous call failed)." },
705
705
  tracked_files: {
706
706
  type: "array",
@@ -841,7 +841,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
841
841
  path: { type: "string", description: "Path of the documentation entry to patch, e.g. 'backend/auth/authentication'" },
842
842
  search: { type: "string", description: "Exact text to find in the current content" },
843
843
  search_file: { type: "string", description: "Absolute path to a text file whose content will be used as the search text. Use this to reuse a previously persisted draft (path is returned in the error message when a previous call failed)." },
844
- replacement: { type: "string", description: "Text to replace it with" },
844
+ replacement: { type: "string", description: "Text to replace it with. Supports [copy-from-codebase|/absolute/path/to/file.ts|startLine|endLine] — the tag is replaced at save time by a fenced code block, and the file+range is automatically added to tracked_files. IMPORTANT: always read the source file first to identify the exact line numbers before writing the tag." },
845
845
  replacement_file: { type: "string", description: "Absolute path to a text file whose content will be used as the replacement. Use this to reuse a previously persisted draft (path is returned in the error message when a previous call failed)." },
846
846
  },
847
847
  required: ["path", "search"],
@@ -1,9 +1,7 @@
1
1
  import fs from "fs";
2
- import path from "path";
3
2
  import { CreateDocArgsSchema } from "../../shared/schemas/doc.schema.js";
4
- import { computeFileHash } from "./check-freshness.js";
5
- import { snapshotSection } from "../core/section-tracker.js";
6
3
  import { persistDraft } from "../core/draft-store.js";
4
+ import { resolveCodeRefs, snapshotTrackedFiles } from "../core/code-ref-resolver.js";
7
5
  export function slugify(title) {
8
6
  return title
9
7
  .normalize("NFD")
@@ -12,34 +10,14 @@ export function slugify(title) {
12
10
  .replace(/[^a-z0-9]+/g, "-")
13
11
  .replace(/^-|-$/g, "");
14
12
  }
15
- function snapshotTrackedFiles(inputs) {
16
- const tracked = [];
17
- for (const input of inputs) {
18
- const fp = typeof input === "string" ? input : input.path;
19
- const absolutePath = path.resolve(process.cwd(), fp);
20
- const mtime = fs.existsSync(absolutePath)
21
- ? fs.statSync(absolutePath).mtime.toISOString()
22
- : new Date().toISOString();
23
- if (typeof input !== "string" && input.lines.length > 0 && fs.existsSync(absolutePath)) {
24
- const sections = input.lines
25
- .filter(({ start, end }) => start <= end)
26
- .map(({ start, end }) => snapshotSection(absolutePath, start, end));
27
- tracked.push({ path: absolutePath, lastModifiedAt: mtime, sections });
28
- }
29
- else {
30
- const contentHash = fs.existsSync(absolutePath) ? computeFileHash(absolutePath) : undefined;
31
- tracked.push({ path: absolutePath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
32
- }
33
- }
34
- return tracked;
35
- }
36
13
  export async function createDoc(storageEngine, embeddingEngine, args) {
37
14
  try {
38
15
  const validated = CreateDocArgsSchema.parse(args);
39
16
  const { project } = (args ?? {});
40
- const content = validated.content_file
17
+ const rawContent = validated.content_file
41
18
  ? fs.readFileSync(validated.content_file, "utf-8")
42
19
  : validated.content;
20
+ const { content, additionalTrackedFiles } = resolveCodeRefs(rawContent);
43
21
  const slug = slugify(validated.title);
44
22
  const id = validated.folder ? `${validated.folder}/${slug}` : slug;
45
23
  const now = new Date().toISOString();
@@ -53,7 +31,11 @@ export async function createDoc(storageEngine, embeddingEngine, args) {
53
31
  if (project) {
54
32
  storageEngine.ensureProject(project);
55
33
  }
56
- const trackedFiles = validated.tracked_files ? snapshotTrackedFiles(validated.tracked_files) : [];
34
+ const allTrackedInputs = [
35
+ ...(validated.tracked_files ?? []),
36
+ ...additionalTrackedFiles,
37
+ ];
38
+ const trackedFiles = allTrackedInputs.length > 0 ? snapshotTrackedFiles(allTrackedInputs) : [];
57
39
  const folder = validated.folder;
58
40
  const doc = {
59
41
  id,
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import { PatchDocArgsSchema } from "../../shared/schemas/doc.schema.js";
3
3
  import { persistDraft } from "../core/draft-store.js";
4
+ import { resolveCodeRefs, mergeTrackedFiles } from "../core/code-ref-resolver.js";
4
5
  export async function patchDoc(storageEngine, embeddingEngine, args) {
5
6
  try {
6
7
  const validated = PatchDocArgsSchema.parse(args);
@@ -12,9 +13,10 @@ export async function patchDoc(storageEngine, embeddingEngine, args) {
12
13
  const search = validated.search_file
13
14
  ? fs.readFileSync(validated.search_file, "utf-8")
14
15
  : validated.search;
15
- const replacement = validated.replacement_file
16
+ const rawReplacement = validated.replacement_file
16
17
  ? fs.readFileSync(validated.replacement_file, "utf-8")
17
18
  : validated.replacement;
19
+ const { content: replacement, additionalTrackedFiles } = resolveCodeRefs(rawReplacement);
18
20
  if (!doc.content.includes(search)) {
19
21
  const preview = doc.content.slice(0, 300).replace(/\n/g, "↵");
20
22
  return {
@@ -26,6 +28,7 @@ export async function patchDoc(storageEngine, embeddingEngine, args) {
26
28
  };
27
29
  }
28
30
  doc.content = doc.content.replace(search, replacement);
31
+ doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles);
29
32
  doc.updatedAt = new Date().toISOString();
30
33
  await storageEngine.saveDoc(doc, resolvedProject, "patch");
31
34
  if (embeddingEngine.isConfigured()) {
@@ -1,30 +1,7 @@
1
1
  import fs from "fs";
2
- import path from "path";
3
2
  import { UpdateDocArgsSchema } from "../../shared/schemas/doc.schema.js";
4
- import { computeFileHash } from "./check-freshness.js";
5
- import { snapshotSection } from "../core/section-tracker.js";
6
3
  import { persistDraft } from "../core/draft-store.js";
7
- function snapshotTrackedFiles(inputs) {
8
- const tracked = [];
9
- for (const input of inputs) {
10
- const fp = typeof input === "string" ? input : input.path;
11
- const absolutePath = path.resolve(process.cwd(), fp);
12
- const mtime = fs.existsSync(absolutePath)
13
- ? fs.statSync(absolutePath).mtime.toISOString()
14
- : new Date().toISOString();
15
- if (typeof input !== "string" && input.lines.length > 0 && fs.existsSync(absolutePath)) {
16
- const sections = input.lines
17
- .filter(({ start, end }) => start <= end)
18
- .map(({ start, end }) => snapshotSection(absolutePath, start, end));
19
- tracked.push({ path: absolutePath, lastModifiedAt: mtime, sections });
20
- }
21
- else {
22
- const contentHash = fs.existsSync(absolutePath) ? computeFileHash(absolutePath) : undefined;
23
- tracked.push({ path: absolutePath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
24
- }
25
- }
26
- return tracked;
27
- }
4
+ import { resolveCodeRefs, snapshotTrackedFiles, mergeTrackedFiles } from "../core/code-ref-resolver.js";
28
5
  export async function updateDoc(storageEngine, embeddingEngine, args) {
29
6
  try {
30
7
  const validated = UpdateDocArgsSchema.parse(args);
@@ -36,14 +13,25 @@ export async function updateDoc(storageEngine, embeddingEngine, args) {
36
13
  if (validated.title !== undefined) {
37
14
  doc.title = validated.title;
38
15
  }
16
+ let additionalTrackedFiles = [];
39
17
  if (validated.content_file !== undefined) {
40
- doc.content = fs.readFileSync(validated.content_file, "utf-8");
18
+ const { content, additionalTrackedFiles: refs } = resolveCodeRefs(fs.readFileSync(validated.content_file, "utf-8"));
19
+ doc.content = content;
20
+ additionalTrackedFiles = refs;
41
21
  }
42
22
  else if (validated.content !== undefined) {
43
- doc.content = validated.content;
23
+ const { content, additionalTrackedFiles: refs } = resolveCodeRefs(validated.content);
24
+ doc.content = content;
25
+ additionalTrackedFiles = refs;
44
26
  }
45
27
  if (validated.tracked_files !== undefined) {
46
- doc.tracked_files = snapshotTrackedFiles(validated.tracked_files);
28
+ doc.tracked_files = snapshotTrackedFiles([
29
+ ...validated.tracked_files,
30
+ ...additionalTrackedFiles,
31
+ ]);
32
+ }
33
+ else {
34
+ doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles);
47
35
  }
48
36
  if (validated.tags !== undefined) {
49
37
  doc.tags = validated.tags;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.54";
1
+ export declare const APP_VERSION = "1.0.55";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated version file - do not edit manually
2
- export const APP_VERSION = '1.0.54';
2
+ export const APP_VERSION = '1.0.55';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-documentor",
3
- "version": "1.0.54",
3
+ "version": "1.0.55",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",