@imenam/mcp-documentor 1.0.86 → 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 (84) 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 +3 -1
  4. package/dist/shared/schemas/doc.schema.js +13 -1
  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/embedding-engine.d.ts +1 -1
  8. package/dist/src/core/embedding-engine.js +3 -3
  9. package/dist/src/core/embedding-store.d.ts +1 -1
  10. package/dist/src/core/embedding-store.js +4 -4
  11. package/dist/src/core/git-sync.d.ts +5 -0
  12. package/dist/src/core/git-sync.js +29 -2
  13. package/dist/src/core/github-code-api.d.ts +3 -8
  14. package/dist/src/core/github-code-api.js +67 -36
  15. package/dist/src/core/github-log.d.ts +38 -0
  16. package/dist/src/core/github-log.js +64 -0
  17. package/dist/src/core/log-reader.d.ts +47 -0
  18. package/dist/src/core/log-reader.js +106 -0
  19. package/dist/src/core/storage-engine.d.ts +27 -2
  20. package/dist/src/core/storage-engine.js +99 -23
  21. package/dist/src/gui-worker.js +58 -1
  22. package/dist/src/index.js +78 -12
  23. package/dist/src/tools/code-sources.d.ts +9 -0
  24. package/dist/src/tools/code-sources.js +18 -16
  25. package/dist/src/tools/create-project.d.ts +19 -0
  26. package/dist/src/tools/create-project.js +42 -0
  27. package/dist/src/tools/delete-project.d.ts +16 -0
  28. package/dist/src/tools/delete-project.js +25 -0
  29. package/dist/src/tools/git-diff-doc.js +123 -98
  30. package/dist/src/tools/move-doc.js +20 -6
  31. package/dist/src/tools/patch-doc.js +14 -3
  32. package/gui/dist/assets/{arc-DBgCQU2O.js → arc-s4GL8QZ_.js} +1 -1
  33. package/gui/dist/assets/{architectureDiagram-3BPJPVTR-BIPBAzZr.js → architectureDiagram-3BPJPVTR-CfDERg0r.js} +1 -1
  34. package/gui/dist/assets/{blockDiagram-GPEHLZMM-BE9WwuyF.js → blockDiagram-GPEHLZMM-CjDoEG3-.js} +1 -1
  35. package/gui/dist/assets/{c4Diagram-AAUBKEIU-V1_4ktIt.js → c4Diagram-AAUBKEIU-9o7f2UhK.js} +1 -1
  36. package/gui/dist/assets/channel-BsKHRH5P.js +1 -0
  37. package/gui/dist/assets/{chunk-2J33WTMH-DskG-Kmf.js → chunk-2J33WTMH-B37ouRYO.js} +1 -1
  38. package/gui/dist/assets/{chunk-4BX2VUAB-DaPhidEI.js → chunk-4BX2VUAB-Dcq44Skv.js} +1 -1
  39. package/gui/dist/assets/{chunk-55IACEB6-C9r6WDFM.js → chunk-55IACEB6-CLyM-RQf.js} +1 -1
  40. package/gui/dist/assets/{chunk-727SXJPM-BA-aC8US.js → chunk-727SXJPM-BjzEo2iz.js} +1 -1
  41. package/gui/dist/assets/{chunk-AQP2D5EJ-ByBp3whV.js → chunk-AQP2D5EJ-Co03OHPi.js} +1 -1
  42. package/gui/dist/assets/{chunk-FMBD7UC4-CzqUByx7.js → chunk-FMBD7UC4-ClbF2rAp.js} +1 -1
  43. package/gui/dist/assets/{chunk-ND2GUHAM-C0_B2ryQ.js → chunk-ND2GUHAM-CN5cTCWu.js} +1 -1
  44. package/gui/dist/assets/{chunk-QZHKN3VN-B3eEwjDh.js → chunk-QZHKN3VN-Bg4-kEPK.js} +1 -1
  45. package/gui/dist/assets/classDiagram-4FO5ZUOK-DphzXyc8.js +1 -0
  46. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-DphzXyc8.js +1 -0
  47. package/gui/dist/assets/{cose-bilkent-S5V4N54A-D_i0I2pU.js → cose-bilkent-S5V4N54A-6Dkp59bE.js} +1 -1
  48. package/gui/dist/assets/{dagre-BM42HDAG-DtEVPK_z.js → dagre-BM42HDAG-2dD-ID2T.js} +1 -1
  49. package/gui/dist/assets/{diagram-2AECGRRQ-BftnXa5w.js → diagram-2AECGRRQ-DBE2f2Lb.js} +1 -1
  50. package/gui/dist/assets/{diagram-5GNKFQAL-Dh9-kl_u.js → diagram-5GNKFQAL-CkKPHiH9.js} +1 -1
  51. package/gui/dist/assets/{diagram-KO2AKTUF-CvB0BWCa.js → diagram-KO2AKTUF-uWtGAF2N.js} +1 -1
  52. package/gui/dist/assets/{diagram-LMA3HP47-CDy-J1uy.js → diagram-LMA3HP47-DyQaWg_0.js} +1 -1
  53. package/gui/dist/assets/{diagram-OG6HWLK6-iY9FwkIF.js → diagram-OG6HWLK6-H_DfuB5t.js} +1 -1
  54. package/gui/dist/assets/{erDiagram-TEJ5UH35-BT2_IX8D.js → erDiagram-TEJ5UH35-Cj9K5EXA.js} +1 -1
  55. package/gui/dist/assets/{flowDiagram-I6XJVG4X-DaixWyMx.js → flowDiagram-I6XJVG4X-BYF24Jvt.js} +1 -1
  56. package/gui/dist/assets/{ganttDiagram-6RSMTGT7-DSo3cQPH.js → ganttDiagram-6RSMTGT7-D93LjmhI.js} +1 -1
  57. package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-BfFT7Lds.js → gitGraphDiagram-PVQCEYII-EVTcusDb.js} +1 -1
  58. package/gui/dist/assets/index-CtPuh-0k.css +1 -0
  59. package/gui/dist/assets/{index-DPGmAc6z.js → index-DvVxuv5N.js} +154 -152
  60. package/gui/dist/assets/{infoDiagram-5YYISTIA-BxbxNxe2.js → infoDiagram-5YYISTIA-DeGbaexj.js} +1 -1
  61. package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DpopzqZr.js → ishikawaDiagram-YF4QCWOH-DNdk7sPJ.js} +1 -1
  62. package/gui/dist/assets/{journeyDiagram-JHISSGLW-DrOlFzIk.js → journeyDiagram-JHISSGLW-yLLfyn3W.js} +1 -1
  63. package/gui/dist/assets/{kanban-definition-UN3LZRKU-vH81lYXy.js → kanban-definition-UN3LZRKU-D8yvVgr8.js} +1 -1
  64. package/gui/dist/assets/{linear-BipFpFah.js → linear-j3lplktA.js} +1 -1
  65. package/gui/dist/assets/{mindmap-definition-RKZ34NQL-DsTxCLgj.js → mindmap-definition-RKZ34NQL-t9adVeMH.js} +1 -1
  66. package/gui/dist/assets/{pieDiagram-4H26LBE5-Bbq2ZWQU.js → pieDiagram-4H26LBE5-6Yrvo8h8.js} +1 -1
  67. package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-BdTSaEo4.js → quadrantDiagram-W4KKPZXB-C_3Su6rX.js} +1 -1
  68. package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BndI8YZy.js → requirementDiagram-4Y6WPE33-B1qYSC11.js} +1 -1
  69. package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-ofBkVITf.js → sankeyDiagram-5OEKKPKP-D3Ervn-u.js} +1 -1
  70. package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-Cjj2UrN9.js → sequenceDiagram-3UESZ5HK-CKgTeWBS.js} +1 -1
  71. package/gui/dist/assets/{stateDiagram-AJRCARHV-CzPrXUuz.js → stateDiagram-AJRCARHV-D_PjEdFH.js} +1 -1
  72. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-KPbsissC.js +1 -0
  73. package/gui/dist/assets/{timeline-definition-PNZ67QCA-Cz4X-IOO.js → timeline-definition-PNZ67QCA-HeWRiNHu.js} +1 -1
  74. package/gui/dist/assets/{vennDiagram-CIIHVFJN-BwIgXJcZ.js → vennDiagram-CIIHVFJN-CREk7vzm.js} +1 -1
  75. package/gui/dist/assets/{wardley-L42UT6IY-keQS0aNZ.js → wardley-L42UT6IY-BWtRQ7ZH.js} +1 -1
  76. package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-DhykSgbD.js → wardleyDiagram-YWT4CUSO-BvlqWWwp.js} +1 -1
  77. package/gui/dist/assets/{xychartDiagram-2RQKCTM6-DFTqc4cj.js → xychartDiagram-2RQKCTM6-BCjv6pwc.js} +1 -1
  78. package/gui/dist/index.html +2 -2
  79. package/package.json +2 -1
  80. package/gui/dist/assets/channel-B_cci0fU.js +0 -1
  81. package/gui/dist/assets/classDiagram-4FO5ZUOK-BqlcJ5BP.js +0 -1
  82. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-BqlcJ5BP.js +0 -1
  83. package/gui/dist/assets/index-CxCC1gxu.css +0 -1
  84. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DAq4ObN9.js +0 -1
@@ -0,0 +1,64 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { createLogger } from "@imenam/mcp-gui-interface";
5
+ const logger = createLogger("GitHub");
6
+ export const GITHUB_LOG_FILE = "github.log";
7
+ const triggerStorage = new AsyncLocalStorage();
8
+ /** Runs `fn` with `trigger` recorded as the cause of every GitHub call it makes. */
9
+ export function runTriggeredBy(trigger, fn) {
10
+ return triggerStorage.run(trigger, fn);
11
+ }
12
+ function currentTrigger() {
13
+ return triggerStorage.getStore() ?? "server";
14
+ }
15
+ let stream = null;
16
+ /** Opens github.log in `logDir` for appending. */
17
+ export function initGitHubLog(logDir) {
18
+ fs.mkdirSync(logDir, { recursive: true });
19
+ const file = path.join(logDir, GITHUB_LOG_FILE);
20
+ stream = fs.createWriteStream(file, { flags: "a", encoding: "utf8" });
21
+ stream.on("error", (err) => {
22
+ stream = null;
23
+ logger.error(`Cannot write ${file}, GitHub calls are no longer recorded there: ${err.message}`);
24
+ });
25
+ }
26
+ /** Masks credentials embedded in URLs or tokens quoted verbatim. */
27
+ export function redactSecrets(text, secrets = []) {
28
+ let out = text.replace(/(https?:\/\/)[^@\s/]+@/gi, "$1***@");
29
+ for (const secret of secrets) {
30
+ if (secret)
31
+ out = out.split(secret).join("***");
32
+ }
33
+ return out;
34
+ }
35
+ /** "owner/repo" for a GitHub URL, the host and path for any other remote. */
36
+ export function describeRemote(remoteUrl) {
37
+ const cleaned = redactSecrets(remoteUrl).replace(/^https?:\/\/(\*\*\*@)?/i, "").replace(/^git@/i, "").replace(/\.git$/i, "");
38
+ const github = cleaned.match(/^github\.com[/:](.+)$/i);
39
+ return github ? github[1] : cleaned.replace(":", "/");
40
+ }
41
+ /** Appends one call to github.log and mirrors it in server.log. */
42
+ export function recordGitHubCall(call) {
43
+ const entry = {
44
+ ts: new Date().toISOString(),
45
+ kind: call.kind,
46
+ action: call.action,
47
+ command: call.command,
48
+ repo: call.repo,
49
+ trigger: currentTrigger(),
50
+ ok: call.ok,
51
+ status: call.status,
52
+ durationMs: call.durationMs,
53
+ ...(call.error ? { error: call.error } : {}),
54
+ ...(call.rateLimit ? { rateLimit: call.rateLimit } : {}),
55
+ };
56
+ stream?.write(`${JSON.stringify(entry)}\n`);
57
+ const summary = `${entry.command} · ${entry.status} · ${entry.durationMs} ms · ${entry.trigger}`;
58
+ if (entry.ok)
59
+ logger.info(summary);
60
+ else if (entry.status === "404" || entry.status === "422")
61
+ logger.warn(`${summary} · ${entry.error ?? ""}`);
62
+ else
63
+ logger.error(`${summary} · ${entry.error ?? ""}`);
64
+ }
@@ -0,0 +1,47 @@
1
+ import { type GitHubLogEntry } from "./github-log.js";
2
+ export declare const SERVER_LOG_FILE = "server.log";
3
+ export declare const LOG_SOURCES: {
4
+ readonly server: "server.log";
5
+ readonly github: "github.log";
6
+ };
7
+ export type LogSourceId = keyof typeof LOG_SOURCES;
8
+ export declare function isLogSource(id: string): id is LogSourceId;
9
+ export interface LogFileInfo {
10
+ id: LogSourceId;
11
+ file: string;
12
+ path: string;
13
+ exists: boolean;
14
+ size: number;
15
+ mtime: string | null;
16
+ }
17
+ /** One line of server.log, with the lines that followed it without a timestamp of their own. */
18
+ export interface ServerLogEntry {
19
+ ts: string;
20
+ pid: number;
21
+ process: string;
22
+ level: "DEBUG" | "INFO" | "WARN" | "ERROR";
23
+ scope: string;
24
+ message: string;
25
+ continuation: string[];
26
+ }
27
+ export interface GitHubLogLine extends GitHubLogEntry {
28
+ raw: string;
29
+ }
30
+ /** A window on the end of a log file. `partial` is true when older lines lie before it. */
31
+ export interface LogWindow<T> {
32
+ file: LogFileInfo;
33
+ partial: boolean;
34
+ entries: T[];
35
+ /** Lines that could not be parsed, left out of `entries`. */
36
+ unreadable: number;
37
+ }
38
+ export declare function logFileInfo(logDir: string, id: LogSourceId): LogFileInfo;
39
+ /**
40
+ * Parses server.log lines as written by the shared logger:
41
+ * [ISO date] [PID n] [process] [LEVEL] [scope] message
42
+ * Text printed straight to the console carries no level nor scope: it is read as INFO from
43
+ * "console". A line without the date prefix continues the entry above it (stack traces).
44
+ */
45
+ export declare function readServerLog(logDir: string): LogWindow<ServerLogEntry>;
46
+ /** Parses github.log: one JSON object per line. */
47
+ export declare function readGitHubLog(logDir: string): LogWindow<GitHubLogLine>;
@@ -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,9 +64,28 @@ export declare class StorageEngine {
58
64
  */
59
65
  listDocsDetailed(project: string): Promise<DocListing[]>;
60
66
  deleteDoc(id: string, project: string): Promise<boolean>;
61
- moveDoc(oldId: string, newId: 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;
76
+ /**
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.
81
+ */
82
+ moveDoc(oldId: string, project: string, newId: string, newProject: string): Promise<boolean>;
62
83
  getBundles(): ExportBundle[];
63
84
  saveBundles(bundles: ExportBundle[]): void;
64
- rewriteLinks(oldId: string, newId: string, project: string): Promise<string[]>;
85
+ /**
86
+ * Rewrites every reference to `project:oldId` so it points at `newProject:newId`: bare inside
87
+ * `newProject`, qualified everywhere else.
88
+ */
89
+ rewriteLinks(oldId: string, project: string, newId: string, newProject: string): Promise<string[]>;
65
90
  }
66
91
  export {};
@@ -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,24 +260,63 @@ export class StorageEngine {
227
260
  logger.info(`Doc deleted: ${filePath}`);
228
261
  return true;
229
262
  }
230
- async moveDoc(oldId, newId, project) {
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
+ }
267
+ /**
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.
286
+ */
287
+ async moveDoc(oldId, project, newId, newProject) {
231
288
  const projectDir = this.getProjectDir(project);
232
289
  const oldFilePath = this.docIdToFilePath(oldId, projectDir);
233
290
  if (!fs.existsSync(oldFilePath))
234
291
  return false;
235
- const newFilePath = this.docIdToFilePath(newId, projectDir);
292
+ const newProjectDir = this.getProjectDir(newProject);
293
+ const newFilePath = this.docIdToFilePath(newId, newProjectDir);
236
294
  if (fs.existsSync(newFilePath)) {
237
- throw new Error(`A document already exists at path "${newId}".`);
295
+ throw new Error(`A document already exists at path "${newId}" in project "${newProject}".`);
238
296
  }
239
297
  const doc = await this.getDoc(oldId, project);
240
298
  if (!doc)
241
299
  return false;
300
+ const crossProject = newProject !== project;
301
+ if (crossProject) {
302
+ this.historyStore?.record(project, doc, "delete");
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
+ });
313
+ }
242
314
  const newParts = newId.split("/");
243
315
  doc.id = newId;
244
316
  doc.folder = newParts.length > 1 ? newParts.slice(0, -1).join("/") : undefined;
245
317
  doc.updatedAt = new Date().toISOString();
246
318
  fs.mkdirSync(path.dirname(newFilePath), { recursive: true });
247
- await this.saveDoc(doc, project, "update");
319
+ await this.saveDoc(doc, newProject, crossProject ? "create" : "update");
248
320
  fs.unlinkSync(oldFilePath);
249
321
  this.cleanEmptyDirs(path.dirname(oldFilePath), projectDir);
250
322
  logger.info(`Doc moved: ${oldFilePath} → ${newFilePath}`);
@@ -265,67 +337,71 @@ export class StorageEngine {
265
337
  const p = path.join(this.getDataDir(), "export-bundles.json");
266
338
  fs.writeFileSync(p, JSON.stringify(bundles, null, 2), "utf-8");
267
339
  }
268
- async rewriteLinks(oldId, newId, project) {
340
+ /**
341
+ * Rewrites every reference to `project:oldId` so it points at `newProject:newId`: bare inside
342
+ * `newProject`, qualified everywhere else.
343
+ */
344
+ async rewriteLinks(oldId, project, newId, newProject) {
269
345
  const config = this.getConfig();
270
346
  const proj = project;
347
+ const crossProject = newProject !== proj;
348
+ const targetFrom = (docProject) => (docProject === newProject ? newId : `${newProject}:${newId}`);
349
+ const boundary = "(?=[)#\\s]|$)";
350
+ const qualifiedOld = new RegExp(`doc://${escapeRegex(proj)}:${escapeRegex(oldId)}${boundary}`, "g");
271
351
  const updated = [];
272
- // Within the same project: rewrite same-project refs and explicit self-cross-project refs
352
+ // Within the source project: rewrite bare refs and explicit self-cross-project refs
273
353
  const sameProjDocs = await this.listDocs(proj);
274
354
  for (const summary of sameProjDocs) {
275
- if (summary.id === newId)
355
+ if (!crossProject && summary.id === newId)
276
356
  continue;
277
357
  const doc = await this.getDoc(summary.id, proj);
278
358
  if (!doc)
279
359
  continue;
360
+ const target = targetFrom(proj);
280
361
  let changed = false;
281
- let newContent = doc.content.replace(new RegExp(`doc://${escapeRegex(oldId)}`, "g"), `doc://${newId}`);
282
- // Also rewrite explicit self cross-project refs (doc://proj:oldId → doc://proj:newId)
283
- newContent = newContent.replace(new RegExp(`doc://${escapeRegex(proj)}:${escapeRegex(oldId)}`, "g"), `doc://${proj}:${newId}`);
362
+ const newContent = doc.content
363
+ .replace(new RegExp(`doc://${escapeRegex(oldId)}${boundary}`, "g"), `doc://${target}`)
364
+ .replace(qualifiedOld, `doc://${target}`);
284
365
  if (newContent !== doc.content) {
285
366
  doc.content = newContent;
286
367
  changed = true;
287
368
  }
288
- const newRelated = doc.related_docs.map((ref) => {
289
- if (ref === oldId) {
290
- changed = true;
291
- return newId;
292
- }
293
- if (ref === `${proj}:${oldId}`) {
369
+ doc.related_docs = doc.related_docs.map((ref) => {
370
+ if (ref === oldId || ref === `${proj}:${oldId}`) {
294
371
  changed = true;
295
- return `${proj}:${newId}`;
372
+ return target;
296
373
  }
297
374
  return ref;
298
375
  });
299
- doc.related_docs = newRelated;
300
376
  if (changed) {
301
377
  doc.updatedAt = new Date().toISOString();
302
378
  await this.saveDoc(doc, proj);
303
379
  updated.push(summary.id);
304
380
  }
305
381
  }
306
- // In other projects: rewrite cross-project refs pointing to this project
382
+ // In other projects: rewrite cross-project refs pointing to the source project
307
383
  for (const otherProj of config.projects) {
308
384
  if (otherProj === proj)
309
385
  continue;
310
386
  const otherDocs = await this.listDocs(otherProj);
387
+ const target = targetFrom(otherProj);
311
388
  for (const summary of otherDocs) {
312
389
  const doc = await this.getDoc(summary.id, otherProj);
313
390
  if (!doc)
314
391
  continue;
315
392
  let changed = false;
316
- const newContent = doc.content.replace(new RegExp(`doc://${escapeRegex(proj)}:${escapeRegex(oldId)}`, "g"), `doc://${proj}:${newId}`);
393
+ const newContent = doc.content.replace(qualifiedOld, `doc://${target}`);
317
394
  if (newContent !== doc.content) {
318
395
  doc.content = newContent;
319
396
  changed = true;
320
397
  }
321
- const newRelated = doc.related_docs.map((ref) => {
398
+ doc.related_docs = doc.related_docs.map((ref) => {
322
399
  if (ref === `${proj}:${oldId}`) {
323
400
  changed = true;
324
- return `${proj}:${newId}`;
401
+ return target;
325
402
  }
326
403
  return ref;
327
404
  });
328
- doc.related_docs = newRelated;
329
405
  if (changed) {
330
406
  doc.updatedAt = new Date().toISOString();
331
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)