@imenam/mcp-documentor 1.0.87 → 1.0.88

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.
Files changed (79) hide show
  1. package/README.md +7 -5
  2. package/dist/shared/read-only.js +1 -1
  3. package/dist/shared/schemas/doc.schema.d.ts +1 -0
  4. package/dist/shared/schemas/doc.schema.js +1 -0
  5. package/dist/src/core/code-source.d.ts +32 -0
  6. package/dist/src/core/code-source.js +23 -4
  7. package/dist/src/core/git-sync.d.ts +5 -0
  8. package/dist/src/core/git-sync.js +29 -2
  9. package/dist/src/core/github-code-api.d.ts +3 -8
  10. package/dist/src/core/github-code-api.js +67 -36
  11. package/dist/src/core/github-log.d.ts +38 -0
  12. package/dist/src/core/github-log.js +64 -0
  13. package/dist/src/core/log-reader.d.ts +47 -0
  14. package/dist/src/core/log-reader.js +106 -0
  15. package/dist/src/core/storage-engine.d.ts +21 -6
  16. package/dist/src/core/storage-engine.js +80 -24
  17. package/dist/src/gui-worker.js +58 -1
  18. package/dist/src/index.js +75 -10
  19. package/dist/src/tools/code-sources.d.ts +9 -0
  20. package/dist/src/tools/code-sources.js +18 -16
  21. package/dist/src/tools/create-project.d.ts +19 -0
  22. package/dist/src/tools/create-project.js +42 -0
  23. package/dist/src/tools/delete-project.d.ts +16 -0
  24. package/dist/src/tools/delete-project.js +25 -0
  25. package/dist/src/tools/git-diff-doc.js +123 -98
  26. package/dist/src/tools/patch-doc.js +14 -3
  27. package/gui/dist/assets/{arc-DBgCQU2O.js → arc-s4GL8QZ_.js} +1 -1
  28. package/gui/dist/assets/{architectureDiagram-3BPJPVTR-BIPBAzZr.js → architectureDiagram-3BPJPVTR-CfDERg0r.js} +1 -1
  29. package/gui/dist/assets/{blockDiagram-GPEHLZMM-BE9WwuyF.js → blockDiagram-GPEHLZMM-CjDoEG3-.js} +1 -1
  30. package/gui/dist/assets/{c4Diagram-AAUBKEIU-V1_4ktIt.js → c4Diagram-AAUBKEIU-9o7f2UhK.js} +1 -1
  31. package/gui/dist/assets/channel-BsKHRH5P.js +1 -0
  32. package/gui/dist/assets/{chunk-2J33WTMH-DskG-Kmf.js → chunk-2J33WTMH-B37ouRYO.js} +1 -1
  33. package/gui/dist/assets/{chunk-4BX2VUAB-DaPhidEI.js → chunk-4BX2VUAB-Dcq44Skv.js} +1 -1
  34. package/gui/dist/assets/{chunk-55IACEB6-C9r6WDFM.js → chunk-55IACEB6-CLyM-RQf.js} +1 -1
  35. package/gui/dist/assets/{chunk-727SXJPM-BA-aC8US.js → chunk-727SXJPM-BjzEo2iz.js} +1 -1
  36. package/gui/dist/assets/{chunk-AQP2D5EJ-ByBp3whV.js → chunk-AQP2D5EJ-Co03OHPi.js} +1 -1
  37. package/gui/dist/assets/{chunk-FMBD7UC4-CzqUByx7.js → chunk-FMBD7UC4-ClbF2rAp.js} +1 -1
  38. package/gui/dist/assets/{chunk-ND2GUHAM-C0_B2ryQ.js → chunk-ND2GUHAM-CN5cTCWu.js} +1 -1
  39. package/gui/dist/assets/{chunk-QZHKN3VN-B3eEwjDh.js → chunk-QZHKN3VN-Bg4-kEPK.js} +1 -1
  40. package/gui/dist/assets/classDiagram-4FO5ZUOK-DphzXyc8.js +1 -0
  41. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-DphzXyc8.js +1 -0
  42. package/gui/dist/assets/{cose-bilkent-S5V4N54A-D_i0I2pU.js → cose-bilkent-S5V4N54A-6Dkp59bE.js} +1 -1
  43. package/gui/dist/assets/{dagre-BM42HDAG-DtEVPK_z.js → dagre-BM42HDAG-2dD-ID2T.js} +1 -1
  44. package/gui/dist/assets/{diagram-2AECGRRQ-BftnXa5w.js → diagram-2AECGRRQ-DBE2f2Lb.js} +1 -1
  45. package/gui/dist/assets/{diagram-5GNKFQAL-Dh9-kl_u.js → diagram-5GNKFQAL-CkKPHiH9.js} +1 -1
  46. package/gui/dist/assets/{diagram-KO2AKTUF-CvB0BWCa.js → diagram-KO2AKTUF-uWtGAF2N.js} +1 -1
  47. package/gui/dist/assets/{diagram-LMA3HP47-CDy-J1uy.js → diagram-LMA3HP47-DyQaWg_0.js} +1 -1
  48. package/gui/dist/assets/{diagram-OG6HWLK6-iY9FwkIF.js → diagram-OG6HWLK6-H_DfuB5t.js} +1 -1
  49. package/gui/dist/assets/{erDiagram-TEJ5UH35-BT2_IX8D.js → erDiagram-TEJ5UH35-Cj9K5EXA.js} +1 -1
  50. package/gui/dist/assets/{flowDiagram-I6XJVG4X-DaixWyMx.js → flowDiagram-I6XJVG4X-BYF24Jvt.js} +1 -1
  51. package/gui/dist/assets/{ganttDiagram-6RSMTGT7-DSo3cQPH.js → ganttDiagram-6RSMTGT7-D93LjmhI.js} +1 -1
  52. package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-BfFT7Lds.js → gitGraphDiagram-PVQCEYII-EVTcusDb.js} +1 -1
  53. package/gui/dist/assets/index-CtPuh-0k.css +1 -0
  54. package/gui/dist/assets/{index-DPGmAc6z.js → index-DvVxuv5N.js} +154 -152
  55. package/gui/dist/assets/{infoDiagram-5YYISTIA-BxbxNxe2.js → infoDiagram-5YYISTIA-DeGbaexj.js} +1 -1
  56. package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DpopzqZr.js → ishikawaDiagram-YF4QCWOH-DNdk7sPJ.js} +1 -1
  57. package/gui/dist/assets/{journeyDiagram-JHISSGLW-DrOlFzIk.js → journeyDiagram-JHISSGLW-yLLfyn3W.js} +1 -1
  58. package/gui/dist/assets/{kanban-definition-UN3LZRKU-vH81lYXy.js → kanban-definition-UN3LZRKU-D8yvVgr8.js} +1 -1
  59. package/gui/dist/assets/{linear-BipFpFah.js → linear-j3lplktA.js} +1 -1
  60. package/gui/dist/assets/{mindmap-definition-RKZ34NQL-DsTxCLgj.js → mindmap-definition-RKZ34NQL-t9adVeMH.js} +1 -1
  61. package/gui/dist/assets/{pieDiagram-4H26LBE5-Bbq2ZWQU.js → pieDiagram-4H26LBE5-6Yrvo8h8.js} +1 -1
  62. package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-BdTSaEo4.js → quadrantDiagram-W4KKPZXB-C_3Su6rX.js} +1 -1
  63. package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BndI8YZy.js → requirementDiagram-4Y6WPE33-B1qYSC11.js} +1 -1
  64. package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-ofBkVITf.js → sankeyDiagram-5OEKKPKP-D3Ervn-u.js} +1 -1
  65. package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-Cjj2UrN9.js → sequenceDiagram-3UESZ5HK-CKgTeWBS.js} +1 -1
  66. package/gui/dist/assets/{stateDiagram-AJRCARHV-CzPrXUuz.js → stateDiagram-AJRCARHV-D_PjEdFH.js} +1 -1
  67. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-KPbsissC.js +1 -0
  68. package/gui/dist/assets/{timeline-definition-PNZ67QCA-Cz4X-IOO.js → timeline-definition-PNZ67QCA-HeWRiNHu.js} +1 -1
  69. package/gui/dist/assets/{vennDiagram-CIIHVFJN-BwIgXJcZ.js → vennDiagram-CIIHVFJN-CREk7vzm.js} +1 -1
  70. package/gui/dist/assets/{wardley-L42UT6IY-keQS0aNZ.js → wardley-L42UT6IY-BWtRQ7ZH.js} +1 -1
  71. package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-DhykSgbD.js → wardleyDiagram-YWT4CUSO-BvlqWWwp.js} +1 -1
  72. package/gui/dist/assets/{xychartDiagram-2RQKCTM6-DFTqc4cj.js → xychartDiagram-2RQKCTM6-BCjv6pwc.js} +1 -1
  73. package/gui/dist/index.html +2 -2
  74. package/package.json +2 -1
  75. package/gui/dist/assets/channel-B_cci0fU.js +0 -1
  76. package/gui/dist/assets/classDiagram-4FO5ZUOK-BqlcJ5BP.js +0 -1
  77. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-BqlcJ5BP.js +0 -1
  78. package/gui/dist/assets/index-CxCC1gxu.css +0 -1
  79. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DAq4ObN9.js +0 -1
@@ -0,0 +1,106 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { GITHUB_LOG_FILE } from "./github-log.js";
4
+ export const SERVER_LOG_FILE = "server.log";
5
+ export const LOG_SOURCES = {
6
+ server: SERVER_LOG_FILE,
7
+ github: GITHUB_LOG_FILE,
8
+ };
9
+ export function isLogSource(id) {
10
+ return Object.prototype.hasOwnProperty.call(LOG_SOURCES, id);
11
+ }
12
+ /** Bytes read from the end of each file: the most recent part, which is what the viewer shows. */
13
+ const TAIL_BYTES = {
14
+ server: 4 * 1024 * 1024,
15
+ github: 8 * 1024 * 1024,
16
+ };
17
+ export function logFileInfo(logDir, id) {
18
+ const filePath = path.join(logDir, LOG_SOURCES[id]);
19
+ const stat = fs.existsSync(filePath) ? fs.statSync(filePath) : null;
20
+ return {
21
+ id,
22
+ file: LOG_SOURCES[id],
23
+ path: filePath,
24
+ exists: !!stat,
25
+ size: stat?.size ?? 0,
26
+ mtime: stat ? stat.mtime.toISOString() : null,
27
+ };
28
+ }
29
+ /** The last `maxBytes` of a file, starting on a whole line. */
30
+ function readTail(filePath, size, maxBytes) {
31
+ const start = Math.max(0, size - maxBytes);
32
+ const length = size - start;
33
+ const buffer = Buffer.alloc(length);
34
+ const fd = fs.openSync(filePath, "r");
35
+ try {
36
+ fs.readSync(fd, buffer, 0, length, start);
37
+ }
38
+ finally {
39
+ fs.closeSync(fd);
40
+ }
41
+ let text = buffer.toString("utf8");
42
+ if (start > 0)
43
+ text = text.slice(text.indexOf("\n") + 1);
44
+ return { text, partial: start > 0 };
45
+ }
46
+ const LINE_PREFIX = /^\[(\d{4}-\d{2}-\d{2}T[^\]]+)\] \[PID (\d+)\] \[([^\]]+)\] (.*)$/;
47
+ const LEVEL_AND_SCOPE = /^\[(DEBUG|INFO|WARN|ERROR)\] \[([^\]]+)\] ?(.*)$/;
48
+ /**
49
+ * Parses server.log lines as written by the shared logger:
50
+ * [ISO date] [PID n] [process] [LEVEL] [scope] message
51
+ * Text printed straight to the console carries no level nor scope: it is read as INFO from
52
+ * "console". A line without the date prefix continues the entry above it (stack traces).
53
+ */
54
+ export function readServerLog(logDir) {
55
+ const file = logFileInfo(logDir, "server");
56
+ if (!file.exists)
57
+ return { file, partial: false, entries: [], unreadable: 0 };
58
+ const { text, partial } = readTail(file.path, file.size, TAIL_BYTES.server);
59
+ const entries = [];
60
+ let unreadable = 0;
61
+ for (const line of text.split(/\r?\n/)) {
62
+ if (!line)
63
+ continue;
64
+ const prefix = line.match(LINE_PREFIX);
65
+ if (!prefix) {
66
+ const previous = entries[entries.length - 1];
67
+ if (previous)
68
+ previous.continuation.push(line);
69
+ else
70
+ unreadable++;
71
+ continue;
72
+ }
73
+ const [, ts, pid, processLabel, rest] = prefix;
74
+ const tagged = rest.match(LEVEL_AND_SCOPE);
75
+ entries.push({
76
+ ts,
77
+ pid: Number(pid),
78
+ process: processLabel,
79
+ level: tagged ? tagged[1] : "INFO",
80
+ scope: tagged ? tagged[2] : "console",
81
+ message: tagged ? tagged[3] : rest,
82
+ continuation: [],
83
+ });
84
+ }
85
+ return { file, partial, entries, unreadable };
86
+ }
87
+ /** Parses github.log: one JSON object per line. */
88
+ export function readGitHubLog(logDir) {
89
+ const file = logFileInfo(logDir, "github");
90
+ if (!file.exists)
91
+ return { file, partial: false, entries: [], unreadable: 0 };
92
+ const { text, partial } = readTail(file.path, file.size, TAIL_BYTES.github);
93
+ const entries = [];
94
+ let unreadable = 0;
95
+ for (const line of text.split(/\r?\n/)) {
96
+ if (!line.trim())
97
+ continue;
98
+ try {
99
+ entries.push({ ...JSON.parse(line), raw: line });
100
+ }
101
+ catch {
102
+ unreadable++;
103
+ }
104
+ }
105
+ return { file, partial, entries, unreadable };
106
+ }
@@ -42,6 +42,12 @@ export declare class StorageEngine {
42
42
  /** Reads sync settings from environment variables (admin-controlled, not agent-settable). */
43
43
  getSyncConfig(): SyncConfig | undefined;
44
44
  ensureProject(project: string): void;
45
+ /**
46
+ * Removes a project that holds nothing but its settings file. Any other file —
47
+ * doc, asset or otherwise — makes it refuse, listing what it found.
48
+ */
49
+ deleteProject(project: string): void;
50
+ private walkFiles;
45
51
  private walkMdFiles;
46
52
  private docIdToFilePath;
47
53
  private cleanEmptyDirs;
@@ -58,18 +64,27 @@ export declare class StorageEngine {
58
64
  */
59
65
  listDocsDetailed(project: string): Promise<DocListing[]>;
60
66
  deleteDoc(id: string, project: string): Promise<boolean>;
67
+ /** Known project names, longest first, so a name is never matched as the prefix of a longer one. */
68
+ private projectsLongestFirst;
69
+ /**
70
+ * Matches every `doc://` URL: group 1 is the project of a qualified reference (project names
71
+ * may contain spaces), undefined for a bare one; group 2 is the doc id.
72
+ */
73
+ private docUrlRegex;
74
+ /** Splits a `related_docs` entry into its project, when qualified with a known one, and its id. */
75
+ private parseDocRef;
61
76
  /**
62
- * Moves a doc to `newId` in `newProject`. Across projects, the moved doc's own
63
- * same-project references (`doc://id`, bare related_docs) are qualified with the
64
- * source project so they keep pointing at the same entries; the history records
65
- * a delete in the source project and a create in the target.
77
+ * Moves a doc to `newId` in `newProject`. Across projects, the moved doc's own references are
78
+ * rewritten so they keep their target: bare ones are qualified with the source project, and any
79
+ * reference into the target project becomes bare. The history records a delete in the source
80
+ * project and a create in the target.
66
81
  */
67
82
  moveDoc(oldId: string, project: string, newId: string, newProject: string): Promise<boolean>;
68
83
  getBundles(): ExportBundle[];
69
84
  saveBundles(bundles: ExportBundle[]): void;
70
85
  /**
71
- * Rewrites every reference to `project:oldId` so it points at `newProject:newId`.
72
- * In the source project, bare references become qualified when the doc left it.
86
+ * Rewrites every reference to `project:oldId` so it points at `newProject:newId`: bare inside
87
+ * `newProject`, qualified everywhere else.
73
88
  */
74
89
  rewriteLinks(oldId: string, project: string, newId: string, newProject: string): Promise<string[]>;
75
90
  }
@@ -91,6 +91,39 @@ export class StorageEngine {
91
91
  }
92
92
  logger.info(`Project "${project}" ensured at ${projectDir}`);
93
93
  }
94
+ /**
95
+ * Removes a project that holds nothing but its settings file. Any other file —
96
+ * doc, asset or otherwise — makes it refuse, listing what it found.
97
+ */
98
+ deleteProject(project) {
99
+ const projectDir = this.getProjectDir(project);
100
+ const contents = this.walkFiles(projectDir)
101
+ .map((file) => path.relative(projectDir, file).split(path.sep).join("/"))
102
+ .filter((rel) => rel !== "project.json");
103
+ if (contents.length > 0) {
104
+ const shown = contents.slice(0, 20).map((rel) => ` - ${rel}`).join("\n");
105
+ const more = contents.length > 20 ? `\n … and ${contents.length - 20} more` : "";
106
+ throw new Error(`Project "${project}" is not empty (${contents.length} file(s)) — nothing deleted. Move or delete its content first:\n${shown}${more}`);
107
+ }
108
+ fs.rmSync(projectDir, { recursive: true, force: true });
109
+ const config = this.getConfig();
110
+ config.projects = config.projects.filter((p) => p !== project);
111
+ this.saveConfig(config);
112
+ logger.info(`Project "${project}" deleted`);
113
+ }
114
+ walkFiles(dir) {
115
+ if (!fs.existsSync(dir))
116
+ return [];
117
+ const results = [];
118
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
119
+ const fullPath = path.join(dir, entry.name);
120
+ if (entry.isDirectory())
121
+ results.push(...this.walkFiles(fullPath));
122
+ else
123
+ results.push(fullPath);
124
+ }
125
+ return results;
126
+ }
94
127
  walkMdFiles(dir) {
95
128
  if (!fs.existsSync(dir))
96
129
  return [];
@@ -227,11 +260,29 @@ export class StorageEngine {
227
260
  logger.info(`Doc deleted: ${filePath}`);
228
261
  return true;
229
262
  }
263
+ /** Known project names, longest first, so a name is never matched as the prefix of a longer one. */
264
+ projectsLongestFirst() {
265
+ return [...this.getConfig().projects].sort((a, b) => b.length - a.length);
266
+ }
230
267
  /**
231
- * Moves a doc to `newId` in `newProject`. Across projects, the moved doc's own
232
- * same-project references (`doc://id`, bare related_docs) are qualified with the
233
- * source project so they keep pointing at the same entries; the history records
234
- * a delete in the source project and a create in the target.
268
+ * Matches every `doc://` URL: group 1 is the project of a qualified reference (project names
269
+ * may contain spaces), undefined for a bare one; group 2 is the doc id.
270
+ */
271
+ docUrlRegex() {
272
+ const projects = this.projectsLongestFirst().map(escapeRegex).join("|");
273
+ const qualifier = projects ? `(?:(${projects}):)?` : "()?";
274
+ return new RegExp(`doc://${qualifier}([^):#\\s]+)(?=[)#\\s]|$)`, "g");
275
+ }
276
+ /** Splits a `related_docs` entry into its project, when qualified with a known one, and its id. */
277
+ parseDocRef(ref) {
278
+ const project = this.projectsLongestFirst().find((p) => ref.startsWith(`${p}:`));
279
+ return project ? { project, id: ref.slice(project.length + 1) } : { id: ref };
280
+ }
281
+ /**
282
+ * Moves a doc to `newId` in `newProject`. Across projects, the moved doc's own references are
283
+ * rewritten so they keep their target: bare ones are qualified with the source project, and any
284
+ * reference into the target project becomes bare. The history records a delete in the source
285
+ * project and a create in the target.
235
286
  */
236
287
  async moveDoc(oldId, project, newId, newProject) {
237
288
  const projectDir = this.getProjectDir(project);
@@ -249,8 +300,16 @@ export class StorageEngine {
249
300
  const crossProject = newProject !== project;
250
301
  if (crossProject) {
251
302
  this.historyStore?.record(project, doc, "delete");
252
- doc.content = doc.content.replace(/doc:\/\/([^):#\s]+)(?=[)#\s]|$)/g, `doc://${project}:$1`);
253
- doc.related_docs = doc.related_docs.map((ref) => (ref.includes(":") ? ref : `${project}:${ref}`));
303
+ const relocate = (ref) => {
304
+ const target = ref.project ?? project;
305
+ return target === newProject ? ref.id : `${target}:${ref.id}`;
306
+ };
307
+ doc.content = doc.content.replace(this.docUrlRegex(), (_match, refProject, id) => `doc://${relocate({ project: refProject, id })}`);
308
+ doc.related_docs = doc.related_docs.map((ref) => {
309
+ const parsed = this.parseDocRef(ref);
310
+ // A qualifier naming no known project is left as is: it cannot be resolved either way.
311
+ return !parsed.project && ref.includes(":") ? ref : relocate(parsed);
312
+ });
254
313
  }
255
314
  const newParts = newId.split("/");
256
315
  doc.id = newId;
@@ -279,16 +338,16 @@ export class StorageEngine {
279
338
  fs.writeFileSync(p, JSON.stringify(bundles, null, 2), "utf-8");
280
339
  }
281
340
  /**
282
- * Rewrites every reference to `project:oldId` so it points at `newProject:newId`.
283
- * In the source project, bare references become qualified when the doc left it.
341
+ * Rewrites every reference to `project:oldId` so it points at `newProject:newId`: bare inside
342
+ * `newProject`, qualified everywhere else.
284
343
  */
285
344
  async rewriteLinks(oldId, project, newId, newProject) {
286
345
  const config = this.getConfig();
287
346
  const proj = project;
288
347
  const crossProject = newProject !== proj;
289
- const qualifiedTarget = `${newProject}:${newId}`;
290
- const bareTarget = crossProject ? qualifiedTarget : newId;
348
+ const targetFrom = (docProject) => (docProject === newProject ? newId : `${newProject}:${newId}`);
291
349
  const boundary = "(?=[)#\\s]|$)";
350
+ const qualifiedOld = new RegExp(`doc://${escapeRegex(proj)}:${escapeRegex(oldId)}${boundary}`, "g");
292
351
  const updated = [];
293
352
  // Within the source project: rewrite bare refs and explicit self-cross-project refs
294
353
  const sameProjDocs = await this.listDocs(proj);
@@ -298,25 +357,22 @@ export class StorageEngine {
298
357
  const doc = await this.getDoc(summary.id, proj);
299
358
  if (!doc)
300
359
  continue;
360
+ const target = targetFrom(proj);
301
361
  let changed = false;
302
- let newContent = doc.content.replace(new RegExp(`doc://${escapeRegex(oldId)}${boundary}`, "g"), `doc://${bareTarget}`);
303
- newContent = newContent.replace(new RegExp(`doc://${escapeRegex(proj)}:${escapeRegex(oldId)}${boundary}`, "g"), `doc://${qualifiedTarget}`);
362
+ const newContent = doc.content
363
+ .replace(new RegExp(`doc://${escapeRegex(oldId)}${boundary}`, "g"), `doc://${target}`)
364
+ .replace(qualifiedOld, `doc://${target}`);
304
365
  if (newContent !== doc.content) {
305
366
  doc.content = newContent;
306
367
  changed = true;
307
368
  }
308
- const newRelated = doc.related_docs.map((ref) => {
309
- if (ref === oldId) {
310
- changed = true;
311
- return bareTarget;
312
- }
313
- if (ref === `${proj}:${oldId}`) {
369
+ doc.related_docs = doc.related_docs.map((ref) => {
370
+ if (ref === oldId || ref === `${proj}:${oldId}`) {
314
371
  changed = true;
315
- return qualifiedTarget;
372
+ return target;
316
373
  }
317
374
  return ref;
318
375
  });
319
- doc.related_docs = newRelated;
320
376
  if (changed) {
321
377
  doc.updatedAt = new Date().toISOString();
322
378
  await this.saveDoc(doc, proj);
@@ -328,24 +384,24 @@ export class StorageEngine {
328
384
  if (otherProj === proj)
329
385
  continue;
330
386
  const otherDocs = await this.listDocs(otherProj);
387
+ const target = targetFrom(otherProj);
331
388
  for (const summary of otherDocs) {
332
389
  const doc = await this.getDoc(summary.id, otherProj);
333
390
  if (!doc)
334
391
  continue;
335
392
  let changed = false;
336
- const newContent = doc.content.replace(new RegExp(`doc://${escapeRegex(proj)}:${escapeRegex(oldId)}${boundary}`, "g"), `doc://${qualifiedTarget}`);
393
+ const newContent = doc.content.replace(qualifiedOld, `doc://${target}`);
337
394
  if (newContent !== doc.content) {
338
395
  doc.content = newContent;
339
396
  changed = true;
340
397
  }
341
- const newRelated = doc.related_docs.map((ref) => {
398
+ doc.related_docs = doc.related_docs.map((ref) => {
342
399
  if (ref === `${proj}:${oldId}`) {
343
400
  changed = true;
344
- return qualifiedTarget;
401
+ return target;
345
402
  }
346
403
  return ref;
347
404
  });
348
- doc.related_docs = newRelated;
349
405
  if (changed) {
350
406
  doc.updatedAt = new Date().toISOString();
351
407
  await this.saveDoc(doc, otherProj);
@@ -7,12 +7,14 @@ import { IPCMessageSchema } from "../shared/schemas/doc.schema.js";
7
7
  import { isReadOnly } from "../shared/read-only.js";
8
8
  import path from "path";
9
9
  import fs from "fs";
10
- import { execFile } from "child_process";
10
+ import { execFile, spawn } from "child_process";
11
+ import { Readable } from "stream";
11
12
  import { promisify } from "util";
12
13
  const execFileAsync = promisify(execFile);
13
14
  import { fileURLToPath } from "url";
14
15
  import { createLogger, setupLogging } from "@imenam/mcp-gui-interface";
15
16
  import { resolveLogDir } from "./core/log-dir.js";
17
+ import { isLogSource, logFileInfo, readGitHubLog, readServerLog } from "./core/log-reader.js";
16
18
  import matter from "gray-matter";
17
19
  import JSZip from "jszip";
18
20
  import puppeteer from "puppeteer-core";
@@ -427,6 +429,61 @@ app.post("/api/code-roots/apply", async (c) => {
427
429
  app.get("/api/github-token", (c) => relay(c, "GET_GITHUB_TOKEN", {}, 15000));
428
430
  app.get("/api/code-source-migration", (c) => relay(c, "GET_CODE_SOURCE_MIGRATION", {}, 5000));
429
431
  app.post("/api/code-source-migration/ack", (c) => relay(c, "ACK_CODE_SOURCE_MIGRATION", {}, 5000));
432
+ // --- Logs ---
433
+ // Read straight from the log directory: both processes write there, and reading needs no IPC.
434
+ const logDir = resolveLogDir(rootDir);
435
+ app.get("/api/logs", (c) => c.json({ dir: logDir, files: [logFileInfo(logDir, "server"), logFileInfo(logDir, "github")] }));
436
+ // `knownSize` and `knownMtime` echo the file state the client already holds: when the file has
437
+ // not changed, nothing is re-read.
438
+ app.get("/api/logs/:source", (c) => {
439
+ const source = c.req.param("source");
440
+ if (!isLogSource(source))
441
+ return c.json({ error: `Unknown log "${source}"` }, 404);
442
+ const info = logFileInfo(logDir, source);
443
+ if (c.req.query("knownSize") === String(info.size) && c.req.query("knownMtime") === (info.mtime ?? "")) {
444
+ return c.json({ unchanged: true, file: info });
445
+ }
446
+ try {
447
+ return c.json(source === "server" ? readServerLog(logDir) : readGitHubLog(logDir));
448
+ }
449
+ catch (err) {
450
+ return c.json({ error: `Cannot read ${info.path}: ${err.message}` }, 500);
451
+ }
452
+ });
453
+ app.get("/api/logs/:source/download", (c) => {
454
+ const source = c.req.param("source");
455
+ if (!isLogSource(source))
456
+ return c.json({ error: `Unknown log "${source}"` }, 404);
457
+ const info = logFileInfo(logDir, source);
458
+ if (!info.exists)
459
+ return c.json({ error: `${info.path} does not exist yet` }, 404);
460
+ const body = Readable.toWeb(fs.createReadStream(info.path));
461
+ return new Response(body, {
462
+ headers: {
463
+ "Content-Type": "text/plain; charset=utf-8",
464
+ "Content-Length": String(info.size),
465
+ "Content-Disposition": `attachment; filename="${info.file}"`,
466
+ },
467
+ });
468
+ });
469
+ // Opens the log directory in the file manager of the machine running the server.
470
+ app.post("/api/logs/open-folder", async (c) => {
471
+ const command = process.platform === "win32" ? "explorer" : process.platform === "darwin" ? "open" : "xdg-open";
472
+ try {
473
+ await new Promise((resolve, reject) => {
474
+ const child = spawn(command, [logDir], { detached: true, stdio: "ignore" });
475
+ child.once("error", reject);
476
+ child.once("spawn", () => {
477
+ child.unref();
478
+ resolve();
479
+ });
480
+ });
481
+ return c.json({ ok: true });
482
+ }
483
+ catch (err) {
484
+ return c.json({ error: `Cannot open ${logDir} with ${command}: ${err.message}` }, 500);
485
+ }
486
+ });
430
487
  app.get("/api/projects", async (c) => {
431
488
  const result = await ipcRequest("LIST_PROJECTS");
432
489
  if (result.error)
package/dist/src/index.js CHANGED
@@ -9,6 +9,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
9
9
  import { APP_VERSION } from "./version.js";
10
10
  import { GuiLauncher, createLogger, setupLogging } from "@imenam/mcp-gui-interface";
11
11
  import { resolveLogDir } from "./core/log-dir.js";
12
+ import { initGitHubLog, runTriggeredBy } from "./core/github-log.js";
12
13
  import { StorageEngine } from "./core/storage-engine.js";
13
14
  import { EmbeddingStore } from "./core/embedding-store.js";
14
15
  import { EmbeddingEngine } from "./core/embedding-engine.js";
@@ -35,6 +36,8 @@ import { gitDiffDoc } from "./tools/git-diff-doc.js";
35
36
  import { checkLinks } from "./tools/check-links.js";
36
37
  import { deleteDoc } from "./tools/delete-doc.js";
37
38
  import { moveDoc } from "./tools/move-doc.js";
39
+ import { deleteProject } from "./tools/delete-project.js";
40
+ import { createProject } from "./tools/create-project.js";
38
41
  import { getDocTree } from "./tools/get-doc-tree.js";
39
42
  import { migrateFolders } from "./core/migrate-folders.js";
40
43
  import { migrateEmbeddings } from "./core/migrate-embeddings.js";
@@ -79,6 +82,7 @@ function findProjectRoot(startDir) {
79
82
  const rootDir = findProjectRoot(__dirname);
80
83
  const packageEnvPath = path.join(rootDir, ".env");
81
84
  setupLogging({ processLabel: "mcp-documentor", logDir: resolveLogDir(rootDir) });
85
+ initGitHubLog(resolveLogDir(rootDir));
82
86
  const logger = createLogger("mcp-documentor");
83
87
  function getConfig() {
84
88
  if (fs.existsSync(packageEnvPath)) {
@@ -126,9 +130,13 @@ function broadcastToGUI(message) {
126
130
  }
127
131
  }
128
132
  }
129
- /** Each GUI message is one operation: the branch head it reads is resolved once, and freshly. */
133
+ /**
134
+ * Each GUI message is one operation: the branch head it reads is resolved once, and freshly.
135
+ * GitHub calls it makes are attributed to it in github.log.
136
+ */
130
137
  function handleIPCMessage(msg) {
131
- return runInCodeOperation(() => dispatchIPCMessage(msg));
138
+ const trigger = `GUI · ${String(msg?.type ?? "unknown").toLowerCase().replace(/_/g, " ")}`;
139
+ return runInCodeOperation(() => runTriggeredBy(trigger, () => dispatchIPCMessage(msg)));
132
140
  }
133
141
  function dispatchIPCMessage(msg) {
134
142
  if (msg.type === "ALREADY_RUNNING") {
@@ -1129,14 +1137,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1129
1137
  },
1130
1138
  {
1131
1139
  name: "move_doc",
1132
- description: "Move a documentation entry to a new path (changes its folder and/or name) and/or to another project. All existing doc:// links and related_docs references pointing to the old location are automatically rewritten across all projects. When the entry changes project, its own same-project links are qualified with the source project so they keep their target, and its tracked_files are then resolved against the target project's code source. This is the only way to relocate a document — update_doc does not change the location. Provide new_path, new_project, or both.",
1140
+ description: "Move a documentation entry to a new path (changes its folder and/or name) and/or to another project. All existing doc:// links and related_docs references pointing to the old location are automatically rewritten across all projects. When the entry changes project, its own links keep their target: bare ones are qualified with the source project, and those into the target project become bare. Links are always bare within their own project. Its tracked_files are then resolved against the target project's code source. This is the only way to relocate a document — update_doc does not change the location. Provide new_path, new_project, or both.",
1133
1141
  inputSchema: {
1134
1142
  type: "object",
1135
1143
  properties: {
1136
1144
  path: { type: "string", description: "Current path of the documentation entry, e.g. 'backend/authentication'" },
1137
1145
  new_path: { type: "string", description: "New path for the documentation entry, e.g. 'backend/v2/authentication'. Can change folder, name, or both. Omitted: the entry keeps its current path (new_project is then required)." },
1138
1146
  project: { type: "string", description: PROJECT_PARAM_DESCRIPTION + " This is the project the entry is moved from." },
1139
- new_project: { type: "string", description: "Project the entry is moved to. Must already exist (see list_projects). Omitted: the entry stays in `project`." },
1147
+ new_project: { type: "string", description: "Project the entry is moved to. Must already exist (see list_projects; create it with create_project). Omitted: the entry stays in `project`." },
1140
1148
  },
1141
1149
  required: ["path", "project"],
1142
1150
  },
@@ -1173,6 +1181,38 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1173
1181
  properties: {},
1174
1182
  },
1175
1183
  },
1184
+ {
1185
+ name: "create_project",
1186
+ description: "Create an empty documentation project, optionally with its code source (as set_code_source would set it). A GitHub repository and branch are checked first: if they cannot be found, nothing is created.",
1187
+ inputSchema: {
1188
+ type: "object",
1189
+ properties: {
1190
+ project: { type: "string", description: "Name of the project to create. Must not exist yet, nor contain '/', '\' or ':'." },
1191
+ code_source: {
1192
+ type: "object",
1193
+ description: "Where the project's code is read from (defaults to the local working copy).",
1194
+ properties: {
1195
+ kind: { type: "string", enum: ["local", "github"], description: "'local' reads the working copy, 'github' reads a repository branch" },
1196
+ repo: { type: "string", description: "Repository when kind is 'github': 'owner/repo' or a GitHub URL" },
1197
+ branch: { type: "string", description: "Branch when kind is 'github' (defaults to the repository's default branch)" },
1198
+ },
1199
+ required: ["kind"],
1200
+ },
1201
+ },
1202
+ required: ["project"],
1203
+ },
1204
+ },
1205
+ {
1206
+ name: "delete_project",
1207
+ description: "Delete an empty documentation project. Refuses — and lists what remains — while the project holds any doc, image or other file besides its settings: move_doc (with new_project) or delete_doc its entries first. Also forgets this machine's code root for the project.",
1208
+ inputSchema: {
1209
+ type: "object",
1210
+ properties: {
1211
+ project: { type: "string", description: "Project to delete. Must exist and be empty." },
1212
+ },
1213
+ required: ["project"],
1214
+ },
1215
+ },
1176
1216
  {
1177
1217
  name: "refresh_file_snapshot",
1178
1218
  description: "Mark tracked files as up-to-date by updating their snapshot timestamps. Use when file changes don't affect the documentation. Accepts one or more doc paths.",
@@ -1208,7 +1248,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1208
1248
  },
1209
1249
  {
1210
1250
  name: "patch_doc",
1211
- description: "PREFERRED method for modifying documentation. Replaces a specific portion of a documentation entry's content using an exact search-and-replace, without touching the rest of the doc. Always use this instead of update_doc when making partial changes.",
1251
+ description: "PREFERRED method for modifying documentation. Replaces a specific portion of a documentation entry's content using an exact search-and-replace, without touching the rest of the doc. The search text must occur exactly once, unless replace_all is set; the answer gives the number of occurrences replaced. The replacement is inserted verbatim. Always use this instead of update_doc when making partial changes.",
1212
1252
  inputSchema: {
1213
1253
  type: "object",
1214
1254
  properties: {
@@ -1217,6 +1257,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1217
1257
  ...textSourceProperties("search", "the search text"),
1218
1258
  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." },
1219
1259
  ...textSourceProperties("replacement", "the replacement"),
1260
+ replace_all: { type: "boolean", description: "Replace every occurrence of the search text. Without it, a search text that occurs more than once is refused." },
1220
1261
  project: { type: "string", description: PROJECT_PARAM_DESCRIPTION },
1221
1262
  },
1222
1263
  required: ["path", "project"],
@@ -1224,7 +1265,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1224
1265
  },
1225
1266
  {
1226
1267
  name: "git_diff_doc",
1227
- description: "Show the git diff for each tracked file of one or more docs, between their current state and the last commit before the stored snapshot date.",
1268
+ description: "Show, for each changed tracked file of one or more docs, the diff between the content recorded at its last snapshot and its current content at the project's code source. The snapshot content is read from the recorded blob, else from the recorded commit, else from the newest commit at or before the snapshot date; it is checked against the stored hash, and the diff is reported impossible when it does not match.",
1228
1269
  inputSchema: {
1229
1270
  type: "object",
1230
1271
  properties: {
@@ -1521,8 +1562,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1521
1562
  };
1522
1563
  });
1523
1564
  // Each tool call is one operation: the branch head every tracked file is compared against is
1524
- // resolved once for the whole call, and never carried over to the next one.
1525
- server.setRequestHandler(CallToolRequestSchema, (request) => runInCodeOperation(() => handleToolCall(request)));
1565
+ // resolved once for the whole call, and never carried over to the next one. GitHub calls it makes
1566
+ // are attributed to the tool in github.log.
1567
+ server.setRequestHandler(CallToolRequestSchema, (request) => runInCodeOperation(() => runTriggeredBy(request.params.name, () => handleToolCall(request))));
1526
1568
  async function handleToolCall(request) {
1527
1569
  const { name, arguments: args } = request.params;
1528
1570
  if (isReadOnly() && WRITE_TOOLS.has(name)) {
@@ -1555,12 +1597,35 @@ async function handleToolCall(request) {
1555
1597
  type: "text",
1556
1598
  text: "set_project no longer exists: there is no active project. " +
1557
1599
  "Pass the project explicitly to every tool call (use list_projects to see them). " +
1558
- "A project is created on demand by create_doc.",
1600
+ "Create a project with create_project, or on demand with create_doc.",
1559
1601
  },
1560
1602
  ],
1561
1603
  };
1562
1604
  case "list_projects":
1563
1605
  return await listProjects(storageEngine);
1606
+ case "create_project": {
1607
+ const result = await createProject(storageEngine, localState, args);
1608
+ if (!result.isError) {
1609
+ broadcastToGUI({
1610
+ type: "PROJECTS_CHANGED",
1611
+ data: { projects: storageEngine.getConfig().projects },
1612
+ timestamp: new Date().toISOString(),
1613
+ });
1614
+ broadcastCodeSourcesChanged(args?.project);
1615
+ }
1616
+ return result;
1617
+ }
1618
+ case "delete_project": {
1619
+ const result = await deleteProject(storageEngine, localState, args);
1620
+ if (!result.isError) {
1621
+ broadcastToGUI({
1622
+ type: "PROJECTS_CHANGED",
1623
+ data: { projects: storageEngine.getConfig().projects },
1624
+ timestamp: new Date().toISOString(),
1625
+ });
1626
+ }
1627
+ return result;
1628
+ }
1564
1629
  case "refresh_file_snapshot":
1565
1630
  return await refreshFileSnapshot(storageEngine, historyStore, args);
1566
1631
  case "get_doc_metadata":
@@ -1685,7 +1750,7 @@ async function syncPullOnStartup() {
1685
1750
  async function main() {
1686
1751
  await migrateFolders(storageEngine);
1687
1752
  await migrateEmbeddings(embeddingStore, storageEngine);
1688
- await syncPullOnStartup();
1753
+ await runTriggeredBy("startup sync", syncPullOnStartup);
1689
1754
  migrateCodeSources(storageEngine, localState);
1690
1755
  const transport = new StdioServerTransport();
1691
1756
  await server.connect(transport);
@@ -88,6 +88,15 @@ export declare function getCodeSourceTool(storageEngine: StorageEngine, localSta
88
88
  text: string;
89
89
  }[];
90
90
  }>;
91
+ /** A code source as an agent describes it. */
92
+ export interface CodeSourceInput {
93
+ kind?: string;
94
+ repo?: string;
95
+ branch?: string;
96
+ }
97
+ /** Turns an agent's description into a configuration, checking a GitHub repository and branch exist. */
98
+ export declare function resolveCodeSourceInput({ kind, repo, branch }: CodeSourceInput): Promise<CodeSourceConfig>;
99
+ export declare function describeCodeSourceConfig(config: CodeSourceConfig): string;
91
100
  export declare function setCodeSourceTool(storageEngine: StorageEngine, localState: LocalState, args: unknown): Promise<{
92
101
  content: {
93
102
  type: string;
@@ -221,29 +221,31 @@ export async function getCodeSourceTool(storageEngine, localState, args) {
221
221
  `The code source is shared by every machine (synced with the docs); code roots belong to this machine.`;
222
222
  return { content: [{ type: "text", text }] };
223
223
  }
224
- export async function setCodeSourceTool(storageEngine, localState, args) {
225
- const { project, kind, repo, branch } = (args ?? {});
226
- if (!project)
227
- throw new Error("project is required");
228
- assertKnownProject(storageEngine, project);
229
- let config;
230
- if (kind === "local") {
231
- config = { kind: "local" };
232
- }
233
- else if (kind === "github") {
224
+ /** Turns an agent's description into a configuration, checking a GitHub repository and branch exist. */
225
+ export async function resolveCodeSourceInput({ kind, repo, branch }) {
226
+ if (kind === "local")
227
+ return { kind: "local" };
228
+ if (kind === "github") {
234
229
  if (!repo)
235
230
  throw new Error('repo is required when kind is "github"');
236
231
  const inspection = await inspectRepository({ repo, branch });
237
232
  if (!inspection.ok)
238
233
  throw new Error(inspection.error);
239
- config = { kind: "github", repo: inspection.repo.fullName, ...(branch && { branch: inspection.branch }) };
240
- }
241
- else {
242
- throw new Error('kind must be "local" or "github"');
234
+ return { kind: "github", repo: inspection.repo.fullName, ...(branch && { branch: inspection.branch }) };
243
235
  }
236
+ throw new Error('kind must be "local" or "github"');
237
+ }
238
+ export function describeCodeSourceConfig(config) {
239
+ return config.kind === "github" ? `GitHub ${config.repo} · ${config.branch ?? "default branch"}` : "its local working copy";
240
+ }
241
+ export async function setCodeSourceTool(storageEngine, localState, args) {
242
+ const { project, ...input } = (args ?? {});
243
+ if (!project)
244
+ throw new Error("project is required");
245
+ assertKnownProject(storageEngine, project);
246
+ const config = await resolveCodeSourceInput(input);
244
247
  saveCodeSource(storageEngine, localState, project, config);
245
- const where = config.kind === "github" ? `GitHub ${config.repo} · ${config.branch ?? "default branch"}` : "its local working copy";
246
- return { content: [{ type: "text", text: `Project "${project}" now reads its code from ${where}.` }] };
248
+ return { content: [{ type: "text", text: `Project "${project}" now reads its code from ${describeCodeSourceConfig(config)}.` }] };
247
249
  }
248
250
  export function setCodeRootTool(storageEngine, localState, args) {
249
251
  const { path: root, project } = (args ?? {});