@imenam/mcp-documentor 1.0.87 → 1.0.89

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 (81) hide show
  1. package/README.md +10 -6
  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/blob-store.d.ts +5 -0
  6. package/dist/src/core/blob-store.js +49 -0
  7. package/dist/src/core/code-source.d.ts +32 -0
  8. package/dist/src/core/code-source.js +23 -4
  9. package/dist/src/core/git-sync.d.ts +5 -0
  10. package/dist/src/core/git-sync.js +41 -2
  11. package/dist/src/core/github-code-api.d.ts +7 -11
  12. package/dist/src/core/github-code-api.js +94 -41
  13. package/dist/src/core/github-log.d.ts +38 -0
  14. package/dist/src/core/github-log.js +64 -0
  15. package/dist/src/core/log-reader.d.ts +47 -0
  16. package/dist/src/core/log-reader.js +106 -0
  17. package/dist/src/core/storage-engine.d.ts +21 -6
  18. package/dist/src/core/storage-engine.js +80 -24
  19. package/dist/src/gui-worker.js +58 -1
  20. package/dist/src/index.js +77 -10
  21. package/dist/src/tools/code-sources.d.ts +9 -0
  22. package/dist/src/tools/code-sources.js +18 -16
  23. package/dist/src/tools/create-project.d.ts +19 -0
  24. package/dist/src/tools/create-project.js +42 -0
  25. package/dist/src/tools/delete-project.d.ts +16 -0
  26. package/dist/src/tools/delete-project.js +25 -0
  27. package/dist/src/tools/git-diff-doc.js +123 -98
  28. package/dist/src/tools/patch-doc.js +14 -3
  29. package/gui/dist/assets/{arc-DBgCQU2O.js → arc-BSPFCwLF.js} +1 -1
  30. package/gui/dist/assets/{architectureDiagram-3BPJPVTR-BIPBAzZr.js → architectureDiagram-3BPJPVTR-DHZcCdLS.js} +1 -1
  31. package/gui/dist/assets/{blockDiagram-GPEHLZMM-BE9WwuyF.js → blockDiagram-GPEHLZMM-D9dTrlKM.js} +1 -1
  32. package/gui/dist/assets/{c4Diagram-AAUBKEIU-V1_4ktIt.js → c4Diagram-AAUBKEIU-BF3-rTSW.js} +1 -1
  33. package/gui/dist/assets/channel-BgXnhna2.js +1 -0
  34. package/gui/dist/assets/{chunk-2J33WTMH-DskG-Kmf.js → chunk-2J33WTMH-B_rKbUyS.js} +1 -1
  35. package/gui/dist/assets/{chunk-4BX2VUAB-DaPhidEI.js → chunk-4BX2VUAB-C9w424bT.js} +1 -1
  36. package/gui/dist/assets/{chunk-55IACEB6-C9r6WDFM.js → chunk-55IACEB6-B2gLfiw3.js} +1 -1
  37. package/gui/dist/assets/{chunk-727SXJPM-BA-aC8US.js → chunk-727SXJPM-CXLwbQzD.js} +1 -1
  38. package/gui/dist/assets/{chunk-AQP2D5EJ-ByBp3whV.js → chunk-AQP2D5EJ-DIEelNex.js} +1 -1
  39. package/gui/dist/assets/{chunk-FMBD7UC4-CzqUByx7.js → chunk-FMBD7UC4-CKDjYi8Q.js} +1 -1
  40. package/gui/dist/assets/{chunk-ND2GUHAM-C0_B2ryQ.js → chunk-ND2GUHAM-D5NTnoWH.js} +1 -1
  41. package/gui/dist/assets/{chunk-QZHKN3VN-B3eEwjDh.js → chunk-QZHKN3VN-Cc9DzsoT.js} +1 -1
  42. package/gui/dist/assets/classDiagram-4FO5ZUOK-DmPDrQWl.js +1 -0
  43. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-DmPDrQWl.js +1 -0
  44. package/gui/dist/assets/{cose-bilkent-S5V4N54A-D_i0I2pU.js → cose-bilkent-S5V4N54A-zK_y1Jct.js} +1 -1
  45. package/gui/dist/assets/{dagre-BM42HDAG-DtEVPK_z.js → dagre-BM42HDAG-D87rXs2_.js} +1 -1
  46. package/gui/dist/assets/{diagram-2AECGRRQ-BftnXa5w.js → diagram-2AECGRRQ-0qYWhZfZ.js} +1 -1
  47. package/gui/dist/assets/{diagram-5GNKFQAL-Dh9-kl_u.js → diagram-5GNKFQAL-BvmrfVQ0.js} +1 -1
  48. package/gui/dist/assets/{diagram-KO2AKTUF-CvB0BWCa.js → diagram-KO2AKTUF-0DIH1FgX.js} +1 -1
  49. package/gui/dist/assets/{diagram-LMA3HP47-CDy-J1uy.js → diagram-LMA3HP47-T8W429Io.js} +1 -1
  50. package/gui/dist/assets/{diagram-OG6HWLK6-iY9FwkIF.js → diagram-OG6HWLK6-DJMnhtDn.js} +1 -1
  51. package/gui/dist/assets/{erDiagram-TEJ5UH35-BT2_IX8D.js → erDiagram-TEJ5UH35-CyuwcTwK.js} +1 -1
  52. package/gui/dist/assets/{flowDiagram-I6XJVG4X-DaixWyMx.js → flowDiagram-I6XJVG4X-xhGOn_4T.js} +1 -1
  53. package/gui/dist/assets/{ganttDiagram-6RSMTGT7-DSo3cQPH.js → ganttDiagram-6RSMTGT7-CmkhPjbj.js} +1 -1
  54. package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-BfFT7Lds.js → gitGraphDiagram-PVQCEYII-CyMVk2nl.js} +1 -1
  55. package/gui/dist/assets/{index-DPGmAc6z.js → index-CCqNIUBp.js} +154 -152
  56. package/gui/dist/assets/index-CtPuh-0k.css +1 -0
  57. package/gui/dist/assets/{infoDiagram-5YYISTIA-BxbxNxe2.js → infoDiagram-5YYISTIA-q2gOQizR.js} +1 -1
  58. package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DpopzqZr.js → ishikawaDiagram-YF4QCWOH-C_a8ouWt.js} +1 -1
  59. package/gui/dist/assets/{journeyDiagram-JHISSGLW-DrOlFzIk.js → journeyDiagram-JHISSGLW-B0XgZgmz.js} +1 -1
  60. package/gui/dist/assets/{kanban-definition-UN3LZRKU-vH81lYXy.js → kanban-definition-UN3LZRKU-BE1EyBT0.js} +1 -1
  61. package/gui/dist/assets/{linear-BipFpFah.js → linear-CJpiIHfp.js} +1 -1
  62. package/gui/dist/assets/{mindmap-definition-RKZ34NQL-DsTxCLgj.js → mindmap-definition-RKZ34NQL-BqrhFP-W.js} +1 -1
  63. package/gui/dist/assets/{pieDiagram-4H26LBE5-Bbq2ZWQU.js → pieDiagram-4H26LBE5-TchMhFLa.js} +1 -1
  64. package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-BdTSaEo4.js → quadrantDiagram-W4KKPZXB-KjwnPHRE.js} +1 -1
  65. package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BndI8YZy.js → requirementDiagram-4Y6WPE33-9wlFYy5z.js} +1 -1
  66. package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-ofBkVITf.js → sankeyDiagram-5OEKKPKP-3DSwfggy.js} +1 -1
  67. package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-Cjj2UrN9.js → sequenceDiagram-3UESZ5HK-CeGhZql1.js} +1 -1
  68. package/gui/dist/assets/{stateDiagram-AJRCARHV-CzPrXUuz.js → stateDiagram-AJRCARHV-DgPL6ffw.js} +1 -1
  69. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-y8DOR9eQ.js +1 -0
  70. package/gui/dist/assets/{timeline-definition-PNZ67QCA-Cz4X-IOO.js → timeline-definition-PNZ67QCA-BUs8-FJU.js} +1 -1
  71. package/gui/dist/assets/{vennDiagram-CIIHVFJN-BwIgXJcZ.js → vennDiagram-CIIHVFJN-Csa_wIyD.js} +1 -1
  72. package/gui/dist/assets/{wardley-L42UT6IY-keQS0aNZ.js → wardley-L42UT6IY-Cw3ksKPO.js} +1 -1
  73. package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-DhykSgbD.js → wardleyDiagram-YWT4CUSO-C0tdxIha.js} +1 -1
  74. package/gui/dist/assets/{xychartDiagram-2RQKCTM6-DFTqc4cj.js → xychartDiagram-2RQKCTM6-C6UgKrOd.js} +1 -1
  75. package/gui/dist/index.html +2 -2
  76. package/package.json +2 -1
  77. package/gui/dist/assets/channel-B_cci0fU.js +0 -1
  78. package/gui/dist/assets/classDiagram-4FO5ZUOK-BqlcJ5BP.js +0 -1
  79. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-BqlcJ5BP.js +0 -1
  80. package/gui/dist/assets/index-CxCC1gxu.css +0 -1
  81. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DAq4ObN9.js +0 -1
@@ -0,0 +1,38 @@
1
+ export declare const GITHUB_LOG_FILE = "github.log";
2
+ /**
3
+ * One call to GitHub, as written to github.log — one JSON object per line.
4
+ *
5
+ * kind "git" for a git command reaching the remote, "api" for a REST request
6
+ * action what was done, in plain words
7
+ * command the exact git command or HTTP request
8
+ * repo "owner/repo" the call targets, when it targets one
9
+ * trigger the tool call, GUI request or server step that caused it
10
+ * status HTTP status, or "ok" / "rejected" / "failed" / "network error"
11
+ */
12
+ export interface GitHubLogEntry {
13
+ ts: string;
14
+ kind: "git" | "api";
15
+ action: string;
16
+ command: string;
17
+ repo: string | null;
18
+ trigger: string;
19
+ ok: boolean;
20
+ status: string;
21
+ durationMs: number;
22
+ error?: string;
23
+ rateLimit?: {
24
+ remaining: number;
25
+ limit: number;
26
+ reset: string;
27
+ };
28
+ }
29
+ /** Runs `fn` with `trigger` recorded as the cause of every GitHub call it makes. */
30
+ export declare function runTriggeredBy<T>(trigger: string, fn: () => T): T;
31
+ /** Opens github.log in `logDir` for appending. */
32
+ export declare function initGitHubLog(logDir: string): void;
33
+ /** Masks credentials embedded in URLs or tokens quoted verbatim. */
34
+ export declare function redactSecrets(text: string, secrets?: Array<string | undefined>): string;
35
+ /** "owner/repo" for a GitHub URL, the host and path for any other remote. */
36
+ export declare function describeRemote(remoteUrl: string): string;
37
+ /** Appends one call to github.log and mirrors it in server.log. */
38
+ export declare function recordGitHubCall(call: Omit<GitHubLogEntry, "ts" | "trigger">): void;
@@ -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,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)