@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
package/README.md CHANGED
@@ -119,13 +119,15 @@ that work on a whole project (`list_docs`, `get_doc_tree`, `check_freshness` wit
119
119
  | `list_docs` | List docs with staleness indicators (optionally filter by project) |
120
120
  | `get_doc_tree` | Return folder/doc structure for a project (or all projects) |
121
121
  | `update_doc` | Fully replace content and/or metadata (`project` required; prefer `patch_doc` for small edits) |
122
- | `patch_doc` | Search-and-replace a fragment of a doc’s content (`project` required) |
123
- | `move_doc` | Change a doc’s path (`new_path`) and/or project (`new_project`); links are rewritten across all projects (`project` required) |
122
+ | `patch_doc` | Search-and-replace a fragment of a doc’s content; the text must occur once unless `replace_all` is set (`project` required) |
123
+ | `move_doc` | Change a doc’s path (`new_path`) and/or project (`new_project`); links are rewritten across all projects, bare within their own project (`project` required) |
124
124
  | `delete_doc` | Permanently delete a documentation entry (`project` required) |
125
+ | `create_project` | Create an empty project, optionally with its code source (`code_source`, as `set_code_source`) |
126
+ | `delete_project` | Delete a project; refused while it holds any doc, image or other file besides its settings |
125
127
  | `search_docs` | Semantic search over docs (natural language; requires OpenAI) |
126
128
  | `check_freshness` | Staleness for one doc (`project` required) or a full project scan |
127
129
  | `refresh_file_snapshot` | Mark tracked files as up-to-date without editing the doc (`project` required) |
128
- | `git_diff_doc` | Git diff for tracked files vs snapshot baseline (`project` required) |
130
+ | `git_diff_doc` | Diff of changed tracked files between their snapshot content and the current one (`project` required) |
129
131
  | `check_links` | Validate `related_docs`, `doc://` links, and `#heading` anchors (`project` required) |
130
132
  | `list_projects` | List all documentation projects |
131
133
  | `upload_image` | Upload an image from a local file path to a project's assets (`project` required); returns the markdown link to insert |
@@ -291,7 +293,7 @@ The GUI then shows a report of what each project became and which entries were n
291
293
  | Sees uncommitted edits | yes | no — only what is pushed |
292
294
  | Affected by `git checkout` | yes | no |
293
295
  | Needs the codebase on the server | yes | no |
294
- | `git_diff_doc` baseline | last commit before the snapshot date | the exact commit the snapshot was taken at |
296
+ | `git_diff_doc` baseline | the commit the snapshot was taken at, else the last one before its date | the blob the snapshot was taken from, else its commit, else the last one before its date |
295
297
 
296
298
  Every report names the source it used (`Code source: GitHub you/my-repo@main`), so a verdict is never ambiguous. If the source cannot be reached — network, auth, rate limit — the status is `unverifiable` with the reason attached, **never** `stale`.
297
299
 
@@ -302,7 +304,7 @@ Stored tracked-file paths are already relative to the code root, and a code root
302
304
  - Hashes are computed on line-ending-normalised content, so a snapshot taken from a CRLF checkout still matches the LF blob in the repository. Hashes stored by earlier versions are still recognised.
303
305
  - Absolute paths sent by an agent are reduced by the code root when one is configured, and otherwise matched against the repository tree by their longest unambiguous suffix (a bare filename is only accepted when the repository holds exactly one file by that name).
304
306
 
305
- Docs snapshotted before the switch carry no source commit: `check_freshness` works immediately, while `git_diff_doc` will ask for one `refresh_file_snapshot` to anchor them on the branch.
307
+ Docs snapshotted before the switch carry no source commit: `check_freshness` works immediately, and `git_diff_doc` falls back on the last commit before the snapshot date. Whatever its origin, a baseline is checked against the hash stored at the snapshot; when it does not match — typically after a rebase that rewrote commit dates — the diff is reported impossible, and one `refresh_file_snapshot` anchors the file on its blob, which survives rebases.
306
308
 
307
309
  ### API usage
308
310
 
@@ -5,7 +5,7 @@ export function isReadOnly() {
5
5
  }
6
6
  /** Outils MCP qui créent / modifient / suppriment de la donnée. */
7
7
  export const WRITE_TOOLS = new Set([
8
- "create_doc", "update_doc", "patch_doc", "move_doc", "delete_doc",
8
+ "create_doc", "update_doc", "patch_doc", "move_doc", "delete_doc", "create_project", "delete_project",
9
9
  "restore_doc_version", "refresh_file_snapshot", "reindex_embeddings",
10
10
  "prepare_export_bundle", "delete_export_bundle", "upload_image",
11
11
  // Sync: pull_docs and sync_status stay allowed (read-only consumer machines
@@ -178,6 +178,7 @@ export declare const GetDocMetadataArgsSchema: z.ZodObject<{
178
178
  project: z.ZodString;
179
179
  }, z.core.$strip>;
180
180
  export declare const PatchDocArgsSchema: z.ZodObject<{
181
+ replace_all: z.ZodOptional<z.ZodBoolean>;
181
182
  replacement_file_path: z.ZodOptional<z.ZodString>;
182
183
  replacement_file_content: z.ZodOptional<z.ZodString>;
183
184
  replacement_draft: z.ZodOptional<z.ZodString>;
@@ -166,6 +166,7 @@ export const PatchDocArgsSchema = z.object({
166
166
  ...textSourceFields("search", "the search text"),
167
167
  replacement: z.string().optional().describe("Text to replace it with"),
168
168
  ...textSourceFields("replacement", "the replacement"),
169
+ replace_all: z.boolean().optional().describe("Replace every occurrence. Without it, a search text that occurs more than once is refused."),
169
170
  }).refine(d => d.search !== undefined || d.search_file_path !== undefined || d.search_file_content !== undefined || d.search_draft !== undefined, {
170
171
  message: "One of search, search_file_path or search_draft is required",
171
172
  }).refine(d => d.replacement !== undefined || d.replacement_file_path !== undefined || d.replacement_file_content !== undefined || d.replacement_draft !== undefined, {
@@ -48,6 +48,36 @@ export interface CodeSource {
48
48
  /** Converts a path supplied by an agent into the portable form persisted in a doc. */
49
49
  toStoredPath(inputPath: string): Promise<string>;
50
50
  }
51
+ export declare class GitHubCodeSource implements CodeSource {
52
+ readonly api: GitHubCodeApi;
53
+ private readonly project;
54
+ readonly kind: "github";
55
+ /** Agent-supplied path → repo-relative path, per source. Repo layouts barely move. */
56
+ private readonly repoPathCache;
57
+ constructor(api: GitHubCodeApi, project: string);
58
+ get label(): string;
59
+ read(storedPath: string): Promise<SourceFile | undefined>;
60
+ /** The blob sha from the commit's tree — already fetched for the whole repository in one call. */
61
+ contentId(storedPath: string): Promise<string | undefined>;
62
+ /**
63
+ * A blob's content by its sha. A blob outlives the commits that referenced it, rebases included.
64
+ * `repoPath` names the file in github.log.
65
+ */
66
+ readBlob(blobSha: string, repoPath?: string): Promise<FileContent>;
67
+ /** A stored path's content at a given commit, or undefined when the file does not exist there. */
68
+ readAt(storedPath: string, commitSha: string): Promise<FileContent | undefined>;
69
+ toStoredPath(inputPath: string): Promise<string>;
70
+ /**
71
+ * Maps a path onto the repository layout.
72
+ *
73
+ * A relative path is already repo-relative. An absolute one — what an agent working on a local
74
+ * clone naturally sends — is reduced by the configured code root when there is one, and
75
+ * otherwise matched against the repository tree by its longest unambiguous path suffix, so the
76
+ * feature works without forcing every machine to declare a root.
77
+ */
78
+ private toRepoPath;
79
+ private computeRepoPath;
80
+ }
51
81
  /** Where the code sources read their configuration. */
52
82
  export interface CodeSourceProvider {
53
83
  /** The project's code source setting. Throws when the settings cannot be read. */
@@ -86,5 +116,7 @@ export declare function locateTrackedSections(project: string, storedPath: strin
86
116
  * it simply matches nothing, which is the right outcome for a filter.
87
117
  */
88
118
  export declare function toStoredPathForMatching(project: string, inputPath: string): Promise<string>;
119
+ /** A project's source when it reads a GitHub branch, undefined when it reads the local working copy. */
120
+ export declare function getGitHubSource(project: string): GitHubCodeSource | undefined;
89
121
  /** The GitHub client behind a project's source, or undefined when it reads the local working copy. */
90
122
  export declare function getGitHubApi(project: string): GitHubCodeApi | undefined;
@@ -49,7 +49,7 @@ class LocalCodeSource {
49
49
  }
50
50
  }
51
51
  const toForwardSlashes = (p) => p.split("\\").join("/");
52
- class GitHubCodeSource {
52
+ export class GitHubCodeSource {
53
53
  api;
54
54
  project;
55
55
  kind = "github";
@@ -71,7 +71,7 @@ class GitHubCodeSource {
71
71
  if (!blobSha)
72
72
  return undefined;
73
73
  return {
74
- content: bytesToFileContent(await this.api.getBlob(blobSha)),
74
+ content: bytesToFileContent(await this.api.getBlob(blobSha, repoPath)),
75
75
  lastModifiedAt: commitDate,
76
76
  revision: commitSha,
77
77
  blobId: blobSha,
@@ -83,6 +83,21 @@ class GitHubCodeSource {
83
83
  const repoPath = await this.toRepoPath(storedPath, commitSha);
84
84
  return repoPath ? this.api.getBlobSha(commitSha, repoPath) : undefined;
85
85
  }
86
+ /**
87
+ * A blob's content by its sha. A blob outlives the commits that referenced it, rebases included.
88
+ * `repoPath` names the file in github.log.
89
+ */
90
+ async readBlob(blobSha, repoPath) {
91
+ return bytesToFileContent(await this.api.getBlob(blobSha, repoPath));
92
+ }
93
+ /** A stored path's content at a given commit, or undefined when the file does not exist there. */
94
+ async readAt(storedPath, commitSha) {
95
+ const repoPath = await this.toRepoPath(storedPath, commitSha);
96
+ if (!repoPath)
97
+ return undefined;
98
+ const blobSha = await this.api.getBlobSha(commitSha, repoPath);
99
+ return blobSha ? this.readBlob(blobSha, repoPath) : undefined;
100
+ }
86
101
  async toStoredPath(inputPath) {
87
102
  const { commitSha } = await this.api.resolveRef();
88
103
  const repoPath = await this.toRepoPath(inputPath, commitSha);
@@ -264,8 +279,12 @@ export async function toStoredPathForMatching(project, inputPath) {
264
279
  return toForwardSlashes(inputPath);
265
280
  }
266
281
  }
282
+ /** A project's source when it reads a GitHub branch, undefined when it reads the local working copy. */
283
+ export function getGitHubSource(project) {
284
+ const source = getCodeSource(project);
285
+ return source instanceof GitHubCodeSource ? source : undefined;
286
+ }
267
287
  /** The GitHub client behind a project's source, or undefined when it reads the local working copy. */
268
288
  export function getGitHubApi(project) {
269
- const source = getCodeSource(project);
270
- return source instanceof GitHubCodeSource ? source.api : undefined;
289
+ return getGitHubSource(project)?.api;
271
290
  }
@@ -61,6 +61,11 @@ export declare class SyncEngine {
61
61
  * ever stored in .git/config (which git never commits), never versioned.
62
62
  */
63
63
  private authedUrl;
64
+ /**
65
+ * Runs one git command that reaches the remote and records it in github.log. Error messages
66
+ * are recorded with the token masked: git quotes the authenticated URL in some of them.
67
+ */
68
+ private remoteGit;
64
69
  private requireConfig;
65
70
  /**
66
71
  * Registers the structured merge drivers in .git/config, which git never
@@ -4,6 +4,7 @@ import path from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { simpleGit } from "simple-git";
6
6
  import { createLogger } from "@imenam/mcp-gui-interface";
7
+ import { describeRemote, recordGitHubCall, redactSecrets } from "./github-log.js";
7
8
  const logger = createLogger("GitSync");
8
9
  const GITIGNORE_CONTENT = [
9
10
  "# Derived / machine-local state — never synced",
@@ -72,6 +73,31 @@ export class SyncEngine {
72
73
  return cfg.remoteUrl;
73
74
  }
74
75
  }
76
+ /**
77
+ * Runs one git command that reaches the remote and records it in github.log. Error messages
78
+ * are recorded with the token masked: git quotes the authenticated URL in some of them.
79
+ */
80
+ async remoteGit(cfg, action, command, run) {
81
+ const startedAt = Date.now();
82
+ const call = { kind: "git", action, command, repo: describeRemote(cfg.remoteUrl) };
83
+ try {
84
+ const result = await run();
85
+ recordGitHubCall({ ...call, ok: true, status: "ok", durationMs: Date.now() - startedAt });
86
+ return result;
87
+ }
88
+ catch (err) {
89
+ const message = err instanceof Error ? err.message : String(err);
90
+ const rejected = /fetch first|non-fast-forward|\[rejected\]/i.test(message);
91
+ recordGitHubCall({
92
+ ...call,
93
+ ok: false,
94
+ status: rejected ? "rejected" : "failed",
95
+ durationMs: Date.now() - startedAt,
96
+ error: redactSecrets(message, [cfg.token]).trim(),
97
+ });
98
+ throw err;
99
+ }
100
+ }
75
101
  requireConfig() {
76
102
  const cfg = this.storage.getSyncConfig();
77
103
  if (!cfg?.remoteUrl) {
@@ -185,7 +211,7 @@ export class SyncEngine {
185
211
  const pending = await this.git.status();
186
212
  return { status: "conflict", conflicted: pending.conflicted };
187
213
  }
188
- await this.git.fetch("origin", cfg.branch);
214
+ await this.remoteGit(cfg, "Fetch the docs repository", `git fetch origin ${cfg.branch}`, () => this.git.fetch("origin", cfg.branch));
189
215
  const remoteRef = `refs/remotes/origin/${cfg.branch}`;
190
216
  // No remote branch yet (fresh repo) — nothing to pull.
191
217
  const hasRemote = await this.git
@@ -274,8 +300,9 @@ export class SyncEngine {
274
300
  "Resolve it with resolve_sync_conflict before pushing, otherwise the conflict markers would be committed as documentation content.");
275
301
  }
276
302
  const committed = await this.commitLocalChanges(message);
303
+ const unpushed = (await this.git.status()).ahead;
277
304
  try {
278
- await this.git.push(["-u", "origin", `HEAD:${cfg.branch}`]);
305
+ await this.remoteGit(cfg, unpushed > 0 ? `Push ${unpushed} local doc commit${unpushed > 1 ? "s" : ""}` : "Push the docs branch", `git push -u origin HEAD:${cfg.branch}`, () => this.git.push(["-u", "origin", `HEAD:${cfg.branch}`]));
279
306
  }
280
307
  catch (e) {
281
308
  const msg = e instanceof Error ? e.message : String(e);
@@ -101,15 +101,10 @@ export declare class GitHubCodeApi {
101
101
  isTreeTruncated(commitSha: string): boolean;
102
102
  /** Blob sha for one path at one commit, or undefined if the file does not exist there. */
103
103
  getBlobSha(commitSha: string, repoPath: string): Promise<string | undefined>;
104
- /** A blob's bytes. Blobs are immutable, so this is cached for the life of the process. */
105
- getBlob(blobSha: string): Promise<Buffer>;
106
104
  /**
107
- * Unified diff of every file that changed between two commits, keyed by path.
108
- *
109
- * One call covers a whole doc, so `git_diff_doc` groups its tracked files by the commit they
110
- * were snapshotted at rather than asking per file. GitHub caps the response at 300 files;
111
- * paths beyond that are simply absent from the map.
105
+ * A blob's bytes. Blobs are immutable, so this is cached for the life of the process.
106
+ * `filePath` names the file in github.log.
112
107
  */
113
- comparePatches(baseSha: string, headSha: string): Promise<Map<string, string>>;
108
+ getBlob(blobSha: string, filePath?: string): Promise<Buffer>;
114
109
  }
115
110
  export {};
@@ -1,5 +1,6 @@
1
1
  import { createLogger } from "@imenam/mcp-gui-interface";
2
2
  import { oncePerOperation } from "./code-operation.js";
3
+ import { recordGitHubCall, redactSecrets } from "./github-log.js";
3
4
  const logger = createLogger("GitHubCodeApi");
4
5
  const API_BASE = "https://api.github.com";
5
6
  export function formatRepoSpec(spec) {
@@ -39,12 +40,20 @@ export class GitHubApiError extends Error {
39
40
  this.name = "GitHubApiError";
40
41
  }
41
42
  }
43
+ function readRateLimit(response) {
44
+ const remaining = Number(response.headers.get("x-ratelimit-remaining"));
45
+ const limit = Number(response.headers.get("x-ratelimit-limit"));
46
+ const reset = Number(response.headers.get("x-ratelimit-reset"));
47
+ if (!Number.isFinite(remaining) || !Number.isFinite(limit) || !limit)
48
+ return undefined;
49
+ return { remaining, limit, reset: Number.isFinite(reset) ? new Date(reset * 1000).toISOString() : "" };
50
+ }
42
51
  /**
43
- * One GET against the GitHub REST API. `label` names what was asked for in error messages.
52
+ * One GET against the GitHub REST API, recorded in github.log whatever its outcome.
44
53
  * 404 and 422 both mean "nothing at that address": 422 is what a valid repository returns for a
45
54
  * ref that does not exist.
46
55
  */
47
- async function githubRequest(apiPath, token, label) {
56
+ async function githubRequest(apiPath, token, purpose) {
48
57
  const headers = {
49
58
  Accept: "application/vnd.github+json",
50
59
  "X-GitHub-Api-Version": "2022-11-28",
@@ -52,31 +61,58 @@ async function githubRequest(apiPath, token, label) {
52
61
  };
53
62
  if (token)
54
63
  headers.Authorization = `Bearer ${token}`;
64
+ const { label } = purpose;
65
+ const startedAt = Date.now();
66
+ const record = (status, ok, error, rateLimit) => recordGitHubCall({
67
+ kind: "api",
68
+ action: purpose.action,
69
+ command: `GET ${apiPath}`,
70
+ repo: purpose.repo,
71
+ ok,
72
+ status,
73
+ durationMs: Date.now() - startedAt,
74
+ error: error && redactSecrets(error, [token]),
75
+ rateLimit,
76
+ });
55
77
  let response;
56
78
  try {
57
79
  response = await fetch(`${API_BASE}${apiPath}`, { headers });
58
80
  }
59
81
  catch (err) {
60
82
  const message = err instanceof Error ? err.message : String(err);
83
+ record("network error", false, message);
61
84
  throw new GitHubApiError(`network error contacting GitHub (${message})`);
62
85
  }
86
+ const rateLimit = readRateLimit(response);
87
+ const status = String(response.status);
63
88
  if (response.status === 404 || response.status === 422) {
64
- throw new GitHubApiError(`not found: ${label} (repository, branch or path missing — or the token cannot see this repository)`, response.status);
89
+ const message = `not found: ${label} (repository, branch or path missing — or the token cannot see this repository)`;
90
+ record(status, false, message, rateLimit);
91
+ throw new GitHubApiError(message, response.status);
65
92
  }
66
93
  if (response.status === 401 || response.status === 403) {
67
94
  const rateLimited = response.headers.get("x-ratelimit-remaining") === "0";
68
- throw new GitHubApiError(rateLimited
95
+ const message = rateLimited
69
96
  ? `GitHub API rate limit exhausted for ${label}`
70
- : `access denied to ${label} — check the GitHub token in the project settings`, response.status);
97
+ : `access denied to ${label} — check the GitHub token in the project settings`;
98
+ record(status, false, message, rateLimit);
99
+ throw new GitHubApiError(message, response.status);
71
100
  }
72
101
  if (!response.ok) {
73
- throw new GitHubApiError(`GitHub API returned ${response.status} for ${apiPath}`, response.status);
102
+ const message = `GitHub API returned ${response.status} for ${apiPath}`;
103
+ record(status, false, message, rateLimit);
104
+ throw new GitHubApiError(message, response.status);
74
105
  }
106
+ record(status, true, undefined, rateLimit);
75
107
  return response.json();
76
108
  }
77
109
  /** Visibility and default branch of a repository. */
78
110
  export async function getRepositoryInfo(owner, repo, token) {
79
- const data = await githubRequest(`/repos/${owner}/${repo}`, token, `${owner}/${repo}`);
111
+ const data = await githubRequest(`/repos/${owner}/${repo}`, token, {
112
+ label: `${owner}/${repo}`,
113
+ action: "Read the repository details",
114
+ repo: `${owner}/${repo}`,
115
+ });
80
116
  return {
81
117
  fullName: data.full_name,
82
118
  private: !!data.private,
@@ -89,7 +125,7 @@ const MAX_BRANCH_PAGES = 10;
89
125
  export async function listBranches(owner, repo, token) {
90
126
  const names = [];
91
127
  for (let page = 1; page <= MAX_BRANCH_PAGES; page++) {
92
- const batch = await githubRequest(`/repos/${owner}/${repo}/branches?per_page=100&page=${page}`, token, `${owner}/${repo}`);
128
+ const batch = await githubRequest(`/repos/${owner}/${repo}/branches?per_page=100&page=${page}`, token, { label: `${owner}/${repo}`, action: `List the repository branches (page ${page})`, repo: `${owner}/${repo}` });
93
129
  if (!Array.isArray(batch))
94
130
  break;
95
131
  for (const branch of batch)
@@ -101,7 +137,7 @@ export async function listBranches(owner, repo, token) {
101
137
  }
102
138
  /** The commit a branch points at, with its first message line. */
103
139
  export async function getBranchHead(owner, repo, branch, token) {
104
- const commit = await githubRequest(`/repos/${owner}/${repo}/commits/${encodeURIComponent(branch)}`, token, `${owner}/${repo}@${branch}`);
140
+ const commit = await githubRequest(`/repos/${owner}/${repo}/commits/${encodeURIComponent(branch)}`, token, { label: `${owner}/${repo}@${branch}`, action: `Read the head commit of ${branch}`, repo: `${owner}/${repo}` });
105
141
  return {
106
142
  sha: commit.sha,
107
143
  message: String(commit.commit?.message ?? "").split("\n")[0],
@@ -110,7 +146,11 @@ export async function getBranchHead(owner, repo, branch, token) {
110
146
  }
111
147
  /** Login of the account a token belongs to. */
112
148
  export async function getTokenLogin(token) {
113
- const user = await githubRequest("/user", token, "the authenticated user");
149
+ const user = await githubRequest("/user", token, {
150
+ label: "the authenticated user",
151
+ action: "Identify the account the token belongs to",
152
+ repo: null,
153
+ });
114
154
  return user.login;
115
155
  }
116
156
  /**
@@ -122,7 +162,7 @@ export async function searchRepositories(term, token, limit) {
122
162
  const owner = slash >= 0 ? term.slice(0, slash).trim() : "";
123
163
  const name = slash >= 0 ? term.slice(slash + 1).trim() : term.trim();
124
164
  const qualifiers = [name, "in:name", ...(owner ? [`user:${owner}`] : [])].filter(Boolean).join(" ");
125
- const data = await githubRequest(`/search/repositories?q=${encodeURIComponent(qualifiers)}&per_page=${limit}`, token, `repositories matching "${term}"`);
165
+ const data = await githubRequest(`/search/repositories?q=${encodeURIComponent(qualifiers)}&per_page=${limit}`, token, { label: `repositories matching "${term}"`, action: `Search repositories matching "${term}"`, repo: null });
126
166
  return (data.items ?? []).map((item) => ({
127
167
  fullName: item.full_name,
128
168
  private: !!item.private,
@@ -158,8 +198,12 @@ export class GitHubCodeApi {
158
198
  get label() {
159
199
  return formatRepoSpec(this.spec);
160
200
  }
161
- request(path) {
162
- return githubRequest(path, this.token, this.label);
201
+ request(path, action) {
202
+ return githubRequest(path, this.token, {
203
+ label: this.label,
204
+ action,
205
+ repo: `${this.spec.owner}/${this.spec.repo}`,
206
+ });
163
207
  }
164
208
  /**
165
209
  * The commit the configured ref points at right now.
@@ -170,7 +214,7 @@ export class GitHubCodeApi {
170
214
  resolveRef() {
171
215
  return oncePerOperation(`ref:${this.label}`, async () => {
172
216
  const ref = this.spec.ref ?? "HEAD";
173
- const commit = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits/${encodeURIComponent(ref)}`);
217
+ const commit = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits/${encodeURIComponent(ref)}`, `Resolve ${ref} to its current commit`);
174
218
  logger.debug(`${this.label} is at ${String(commit.sha).slice(0, 7)}`);
175
219
  return {
176
220
  commitSha: commit.sha,
@@ -190,7 +234,7 @@ export class GitHubCodeApi {
190
234
  return this.cached(this.commitByDate, isoDate, async () => {
191
235
  const ref = this.spec.ref ?? "HEAD";
192
236
  const commits = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits` +
193
- `?sha=${encodeURIComponent(ref)}&until=${encodeURIComponent(isoDate)}&per_page=1`);
237
+ `?sha=${encodeURIComponent(ref)}&until=${encodeURIComponent(isoDate)}&per_page=1`, `Find the last commit on ${ref} at or before ${isoDate}`);
194
238
  const sha = Array.isArray(commits) && typeof commits[0]?.sha === "string" ? commits[0].sha : undefined;
195
239
  logger.debug(`${this.label}: commit at or before ${isoDate} is ${sha ? sha.slice(0, 7) : "none"}`);
196
240
  return sha;
@@ -199,7 +243,7 @@ export class GitHubCodeApi {
199
243
  /** path → blob sha for every file in the commit's tree. A commit's tree never changes. */
200
244
  getTree(commitSha) {
201
245
  return this.cached(this.treeByCommit, commitSha, async () => {
202
- const tree = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/trees/${commitSha}?recursive=1`);
246
+ const tree = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/trees/${commitSha}?recursive=1`, `List the files of commit ${commitSha.slice(0, 7)}`);
203
247
  const map = new Map();
204
248
  for (const entry of tree.tree ?? []) {
205
249
  if (entry.type === "blob")
@@ -240,7 +284,7 @@ export class GitHubCodeApi {
240
284
  if (known || !this.isTreeTruncated(commitSha))
241
285
  return known;
242
286
  try {
243
- const contents = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/contents/${encodeURI(repoPath)}?ref=${commitSha}`);
287
+ const contents = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/contents/${encodeURI(repoPath)}?ref=${commitSha}`, `Look up ${repoPath} at ${commitSha.slice(0, 7)}`);
244
288
  return typeof contents?.sha === "string" ? contents.sha : undefined;
245
289
  }
246
290
  catch (err) {
@@ -249,27 +293,14 @@ export class GitHubCodeApi {
249
293
  throw err;
250
294
  }
251
295
  }
252
- /** A blob's bytes. Blobs are immutable, so this is cached for the life of the process. */
253
- getBlob(blobSha) {
296
+ /**
297
+ * A blob's bytes. Blobs are immutable, so this is cached for the life of the process.
298
+ * `filePath` names the file in github.log.
299
+ */
300
+ getBlob(blobSha, filePath) {
254
301
  return this.cached(this.blobCache, blobSha, async () => {
255
- const blob = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/blobs/${blobSha}`);
302
+ const blob = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/blobs/${blobSha}`, filePath ? `Download ${filePath}` : `Download blob ${blobSha.slice(0, 7)}`);
256
303
  return Buffer.from(blob.content ?? "", blob.encoding === "base64" ? "base64" : "utf-8");
257
304
  });
258
305
  }
259
- /**
260
- * Unified diff of every file that changed between two commits, keyed by path.
261
- *
262
- * One call covers a whole doc, so `git_diff_doc` groups its tracked files by the commit they
263
- * were snapshotted at rather than asking per file. GitHub caps the response at 300 files;
264
- * paths beyond that are simply absent from the map.
265
- */
266
- async comparePatches(baseSha, headSha) {
267
- const comparison = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/compare/${baseSha}...${headSha}`);
268
- const patches = new Map();
269
- for (const file of comparison.files ?? []) {
270
- if (typeof file.patch === "string")
271
- patches.set(file.filename, file.patch);
272
- }
273
- return patches;
274
- }
275
306
  }
@@ -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>;