@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
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
 
@@ -310,7 +312,9 @@ Freshness is **never computed in the background** — only when something asks f
310
312
 
311
313
  Within one such request, the branch head is resolved **once** and every tracked file is compared against that same commit — no mixed verdict if someone pushes mid-scan. The next request resolves it again, so a push is visible immediately: there is no staleness window to configure.
312
314
 
313
- Everything else is content-addressed and therefore cached for the life of the process: a commit's tree by its sha, a file's bytes by its blob sha. Each tracked file also records the blob sha it was snapshotted at, so an unchanged file is proven untouched without being downloaded at all. A full scan of an unchanged repository costs about two API calls — not one per file.
315
+ Resolving the branch head is a conditional request: while the branch has not moved, GitHub answers `304 Not Modified`, which does not count against the rate limit of an authenticated token.
316
+
317
+ Everything else is content-addressed: a commit's tree is cached by its sha for the life of the process, and a file's bytes by their blob sha on disk, in `<data dir>/blob-cache/` (machine-local, excluded from sync) — each version of a file is downloaded once per machine, across restarts. Each tracked file also records the blob sha it was snapshotted at, so an unchanged file is proven untouched without being downloaded at all. A full scan of an unchanged repository costs about two API calls — not one per file.
314
318
 
315
319
  ## Data Storage
316
320
 
@@ -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, {
@@ -0,0 +1,5 @@
1
+ export declare function setBlobStoreDir(dir: string): void;
2
+ /** A stored blob, or undefined when it is not on disk. An entry that does not hash to its sha is dropped. */
3
+ export declare function readStoredBlob(sha: string): Buffer | undefined;
4
+ /** Stores a blob. Written to a temporary file then renamed, so a reader never sees a partial one. */
5
+ export declare function storeBlob(sha: string, bytes: Buffer): void;
@@ -0,0 +1,49 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { createLogger } from "@imenam/mcp-gui-interface";
5
+ const logger = createLogger("BlobStore");
6
+ /**
7
+ * Git blobs downloaded from GitHub, kept on disk by sha. A blob's content is fixed by its sha, so
8
+ * an entry never goes stale: each version of a file is downloaded once per machine, whatever the
9
+ * number of restarts. Entries are checked against their sha when read.
10
+ */
11
+ let storeDir;
12
+ export function setBlobStoreDir(dir) {
13
+ storeDir = dir;
14
+ }
15
+ function blobPath(sha) {
16
+ if (!storeDir || !/^[0-9a-f]{40}$/.test(sha))
17
+ return undefined;
18
+ return path.join(storeDir, sha.slice(0, 2), sha);
19
+ }
20
+ /** The git blob sha of some bytes. */
21
+ function gitBlobSha(bytes) {
22
+ return crypto.createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
23
+ }
24
+ /** A stored blob, or undefined when it is not on disk. An entry that does not hash to its sha is dropped. */
25
+ export function readStoredBlob(sha) {
26
+ const file = blobPath(sha);
27
+ if (!file || !fs.existsSync(file))
28
+ return undefined;
29
+ const bytes = fs.readFileSync(file);
30
+ if (gitBlobSha(bytes) === sha)
31
+ return bytes;
32
+ logger.warn(`Stored blob ${sha.slice(0, 7)} does not match its sha; removing it`);
33
+ fs.rmSync(file, { force: true });
34
+ return undefined;
35
+ }
36
+ /** Stores a blob. Written to a temporary file then renamed, so a reader never sees a partial one. */
37
+ export function storeBlob(sha, bytes) {
38
+ const file = blobPath(sha);
39
+ if (!file)
40
+ return;
41
+ if (gitBlobSha(bytes) !== sha) {
42
+ logger.warn(`Downloaded content of blob ${sha.slice(0, 7)} does not match its sha; not storing it`);
43
+ return;
44
+ }
45
+ fs.mkdirSync(path.dirname(file), { recursive: true });
46
+ const temp = `${file}.${process.pid}.tmp`;
47
+ fs.writeFileSync(temp, bytes);
48
+ fs.renameSync(temp, file);
49
+ }
@@ -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",
@@ -20,6 +21,7 @@ const GITIGNORE_CONTENT = [
20
21
  "errors-tmp-files/",
21
22
  "export-bundles.json",
22
23
  "local-state.json",
24
+ "blob-cache/",
23
25
  "",
24
26
  ].join("\n");
25
27
  /**
@@ -72,6 +74,31 @@ export class SyncEngine {
72
74
  return cfg.remoteUrl;
73
75
  }
74
76
  }
77
+ /**
78
+ * Runs one git command that reaches the remote and records it in github.log. Error messages
79
+ * are recorded with the token masked: git quotes the authenticated URL in some of them.
80
+ */
81
+ async remoteGit(cfg, action, command, run) {
82
+ const startedAt = Date.now();
83
+ const call = { kind: "git", action, command, repo: describeRemote(cfg.remoteUrl) };
84
+ try {
85
+ const result = await run();
86
+ recordGitHubCall({ ...call, ok: true, status: "ok", durationMs: Date.now() - startedAt });
87
+ return result;
88
+ }
89
+ catch (err) {
90
+ const message = err instanceof Error ? err.message : String(err);
91
+ const rejected = /fetch first|non-fast-forward|\[rejected\]/i.test(message);
92
+ recordGitHubCall({
93
+ ...call,
94
+ ok: false,
95
+ status: rejected ? "rejected" : "failed",
96
+ durationMs: Date.now() - startedAt,
97
+ error: redactSecrets(message, [cfg.token]).trim(),
98
+ });
99
+ throw err;
100
+ }
101
+ }
75
102
  requireConfig() {
76
103
  const cfg = this.storage.getSyncConfig();
77
104
  if (!cfg?.remoteUrl) {
@@ -117,10 +144,21 @@ export class SyncEngine {
117
144
  async ensureRepo() {
118
145
  const cfg = this.requireConfig();
119
146
  const authed = this.authedUrl(cfg);
147
+ // Entries a newer server added are appended to an existing file, so machine-local state it
148
+ // introduces is never staged in a repo initialized before it.
120
149
  const gitignorePath = path.join(this.dataDir, ".gitignore");
121
150
  if (!fs.existsSync(gitignorePath)) {
122
151
  fs.writeFileSync(gitignorePath, GITIGNORE_CONTENT, "utf-8");
123
152
  }
153
+ else {
154
+ const current = fs.readFileSync(gitignorePath, "utf-8");
155
+ const present = new Set(current.split(/\r?\n/).map((line) => line.trim()));
156
+ const missing = GITIGNORE_CONTENT.split("\n").filter((line) => line && !line.startsWith("#") && !present.has(line));
157
+ if (missing.length > 0) {
158
+ const separator = current === "" || current.endsWith("\n") ? "" : "\n";
159
+ fs.writeFileSync(gitignorePath, `${current}${separator}${missing.join("\n")}\n`, "utf-8");
160
+ }
161
+ }
124
162
  // Rewritten when it drifts, so an upgraded server updates the routing of
125
163
  // repos that were initialized by an older version.
126
164
  const attributesPath = path.join(this.dataDir, ".gitattributes");
@@ -185,7 +223,7 @@ export class SyncEngine {
185
223
  const pending = await this.git.status();
186
224
  return { status: "conflict", conflicted: pending.conflicted };
187
225
  }
188
- await this.git.fetch("origin", cfg.branch);
226
+ await this.remoteGit(cfg, "Fetch the docs repository", `git fetch origin ${cfg.branch}`, () => this.git.fetch("origin", cfg.branch));
189
227
  const remoteRef = `refs/remotes/origin/${cfg.branch}`;
190
228
  // No remote branch yet (fresh repo) — nothing to pull.
191
229
  const hasRemote = await this.git
@@ -274,8 +312,9 @@ export class SyncEngine {
274
312
  "Resolve it with resolve_sync_conflict before pushing, otherwise the conflict markers would be committed as documentation content.");
275
313
  }
276
314
  const committed = await this.commitLocalChanges(message);
315
+ const unpushed = (await this.git.status()).ahead;
277
316
  try {
278
- await this.git.push(["-u", "origin", `HEAD:${cfg.branch}`]);
317
+ 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
318
  }
280
319
  catch (e) {
281
320
  const msg = e instanceof Error ? e.message : String(e);
@@ -59,8 +59,8 @@ export declare function searchRepositories(term: string, token: string | undefin
59
59
  * - the commit a ref points at — the only time-varying one, so it is resolved once per
60
60
  * operation and never carried across them (see `code-operation`);
61
61
  * - that commit's tree (path → blob sha) — immutable, cached per commit;
62
- * - a blob's bytes — immutable, cached per blob sha, so a file that survives a commit
63
- * untouched is never downloaded twice.
62
+ * - a blob's bytes — immutable, cached per blob sha in memory and on disk (see `blob-store`),
63
+ * so a given version of a file is downloaded once per machine.
64
64
  *
65
65
  * One instance per (repo, ref) pair; instances are shared across projects by `code-source`.
66
66
  */
@@ -78,7 +78,8 @@ export declare class GitHubCodeApi {
78
78
  * The commit the configured ref points at right now.
79
79
  *
80
80
  * Resolved afresh for each operation — so a push is visible to the very next call — but only
81
- * once within it, however many files that operation ends up reading.
81
+ * once within it, however many files that operation ends up reading. The request is conditional:
82
+ * while the branch has not moved, GitHub answers 304 and the call costs no rate limit.
82
83
  */
83
84
  resolveRef(): Promise<ResolvedRef>;
84
85
  /**
@@ -101,15 +102,10 @@ export declare class GitHubCodeApi {
101
102
  isTreeTruncated(commitSha: string): boolean;
102
103
  /** Blob sha for one path at one commit, or undefined if the file does not exist there. */
103
104
  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
105
  /**
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.
106
+ * A blob's bytes, read from the blob store when this machine already downloaded them.
107
+ * `filePath` names the file in github.log.
112
108
  */
113
- comparePatches(baseSha: string, headSha: string): Promise<Map<string, string>>;
109
+ getBlob(blobSha: string, filePath?: string): Promise<Buffer>;
114
110
  }
115
111
  export {};
@@ -1,5 +1,7 @@
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";
4
+ import { readStoredBlob, storeBlob } from "./blob-store.js";
3
5
  const logger = createLogger("GitHubCodeApi");
4
6
  const API_BASE = "https://api.github.com";
5
7
  export function formatRepoSpec(spec) {
@@ -39,12 +41,22 @@ export class GitHubApiError extends Error {
39
41
  this.name = "GitHubApiError";
40
42
  }
41
43
  }
44
+ /** Last answer to each revalidated path, under the token it was read with. */
45
+ const revalidated = new Map();
46
+ function readRateLimit(response) {
47
+ const remaining = Number(response.headers.get("x-ratelimit-remaining"));
48
+ const limit = Number(response.headers.get("x-ratelimit-limit"));
49
+ const reset = Number(response.headers.get("x-ratelimit-reset"));
50
+ if (!Number.isFinite(remaining) || !Number.isFinite(limit) || !limit)
51
+ return undefined;
52
+ return { remaining, limit, reset: Number.isFinite(reset) ? new Date(reset * 1000).toISOString() : "" };
53
+ }
42
54
  /**
43
- * One GET against the GitHub REST API. `label` names what was asked for in error messages.
55
+ * One GET against the GitHub REST API, recorded in github.log whatever its outcome.
44
56
  * 404 and 422 both mean "nothing at that address": 422 is what a valid repository returns for a
45
57
  * ref that does not exist.
46
58
  */
47
- async function githubRequest(apiPath, token, label) {
59
+ async function githubRequest(apiPath, token, purpose) {
48
60
  const headers = {
49
61
  Accept: "application/vnd.github+json",
50
62
  "X-GitHub-Api-Version": "2022-11-28",
@@ -52,31 +64,70 @@ async function githubRequest(apiPath, token, label) {
52
64
  };
53
65
  if (token)
54
66
  headers.Authorization = `Bearer ${token}`;
67
+ const cacheKey = `${token ?? ""} ${apiPath}`;
68
+ const previous = purpose.revalidate ? revalidated.get(cacheKey) : undefined;
69
+ if (previous)
70
+ headers["If-None-Match"] = previous.etag;
71
+ const { label } = purpose;
72
+ const startedAt = Date.now();
73
+ const record = (status, ok, error, rateLimit) => recordGitHubCall({
74
+ kind: "api",
75
+ action: purpose.action,
76
+ command: `GET ${apiPath}`,
77
+ repo: purpose.repo,
78
+ ok,
79
+ status,
80
+ durationMs: Date.now() - startedAt,
81
+ error: error && redactSecrets(error, [token]),
82
+ rateLimit,
83
+ });
55
84
  let response;
56
85
  try {
57
86
  response = await fetch(`${API_BASE}${apiPath}`, { headers });
58
87
  }
59
88
  catch (err) {
60
89
  const message = err instanceof Error ? err.message : String(err);
90
+ record("network error", false, message);
61
91
  throw new GitHubApiError(`network error contacting GitHub (${message})`);
62
92
  }
93
+ const rateLimit = readRateLimit(response);
94
+ const status = String(response.status);
95
+ if (response.status === 304 && previous) {
96
+ record(status, true, undefined, rateLimit);
97
+ return previous.body;
98
+ }
63
99
  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);
100
+ const message = `not found: ${label} (repository, branch or path missing — or the token cannot see this repository)`;
101
+ record(status, false, message, rateLimit);
102
+ throw new GitHubApiError(message, response.status);
65
103
  }
66
104
  if (response.status === 401 || response.status === 403) {
67
105
  const rateLimited = response.headers.get("x-ratelimit-remaining") === "0";
68
- throw new GitHubApiError(rateLimited
106
+ const message = rateLimited
69
107
  ? `GitHub API rate limit exhausted for ${label}`
70
- : `access denied to ${label} — check the GitHub token in the project settings`, response.status);
108
+ : `access denied to ${label} — check the GitHub token in the project settings`;
109
+ record(status, false, message, rateLimit);
110
+ throw new GitHubApiError(message, response.status);
71
111
  }
72
112
  if (!response.ok) {
73
- throw new GitHubApiError(`GitHub API returned ${response.status} for ${apiPath}`, response.status);
113
+ const message = `GitHub API returned ${response.status} for ${apiPath}`;
114
+ record(status, false, message, rateLimit);
115
+ throw new GitHubApiError(message, response.status);
74
116
  }
75
- return response.json();
117
+ record(status, true, undefined, rateLimit);
118
+ const body = await response.json();
119
+ const etag = response.headers.get("etag");
120
+ if (purpose.revalidate && etag)
121
+ revalidated.set(cacheKey, { etag, body });
122
+ return body;
76
123
  }
77
124
  /** Visibility and default branch of a repository. */
78
125
  export async function getRepositoryInfo(owner, repo, token) {
79
- const data = await githubRequest(`/repos/${owner}/${repo}`, token, `${owner}/${repo}`);
126
+ const data = await githubRequest(`/repos/${owner}/${repo}`, token, {
127
+ label: `${owner}/${repo}`,
128
+ action: "Read the repository details",
129
+ repo: `${owner}/${repo}`,
130
+ });
80
131
  return {
81
132
  fullName: data.full_name,
82
133
  private: !!data.private,
@@ -89,7 +140,7 @@ const MAX_BRANCH_PAGES = 10;
89
140
  export async function listBranches(owner, repo, token) {
90
141
  const names = [];
91
142
  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}`);
143
+ 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
144
  if (!Array.isArray(batch))
94
145
  break;
95
146
  for (const branch of batch)
@@ -101,7 +152,7 @@ export async function listBranches(owner, repo, token) {
101
152
  }
102
153
  /** The commit a branch points at, with its first message line. */
103
154
  export async function getBranchHead(owner, repo, branch, token) {
104
- const commit = await githubRequest(`/repos/${owner}/${repo}/commits/${encodeURIComponent(branch)}`, token, `${owner}/${repo}@${branch}`);
155
+ 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}`, revalidate: true });
105
156
  return {
106
157
  sha: commit.sha,
107
158
  message: String(commit.commit?.message ?? "").split("\n")[0],
@@ -110,7 +161,11 @@ export async function getBranchHead(owner, repo, branch, token) {
110
161
  }
111
162
  /** Login of the account a token belongs to. */
112
163
  export async function getTokenLogin(token) {
113
- const user = await githubRequest("/user", token, "the authenticated user");
164
+ const user = await githubRequest("/user", token, {
165
+ label: "the authenticated user",
166
+ action: "Identify the account the token belongs to",
167
+ repo: null,
168
+ });
114
169
  return user.login;
115
170
  }
116
171
  /**
@@ -122,7 +177,7 @@ export async function searchRepositories(term, token, limit) {
122
177
  const owner = slash >= 0 ? term.slice(0, slash).trim() : "";
123
178
  const name = slash >= 0 ? term.slice(slash + 1).trim() : term.trim();
124
179
  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}"`);
180
+ 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
181
  return (data.items ?? []).map((item) => ({
127
182
  fullName: item.full_name,
128
183
  private: !!item.private,
@@ -136,8 +191,8 @@ export async function searchRepositories(term, token, limit) {
136
191
  * - the commit a ref points at — the only time-varying one, so it is resolved once per
137
192
  * operation and never carried across them (see `code-operation`);
138
193
  * - that commit's tree (path → blob sha) — immutable, cached per commit;
139
- * - a blob's bytes — immutable, cached per blob sha, so a file that survives a commit
140
- * untouched is never downloaded twice.
194
+ * - a blob's bytes — immutable, cached per blob sha in memory and on disk (see `blob-store`),
195
+ * so a given version of a file is downloaded once per machine.
141
196
  *
142
197
  * One instance per (repo, ref) pair; instances are shared across projects by `code-source`.
143
198
  */
@@ -158,19 +213,25 @@ export class GitHubCodeApi {
158
213
  get label() {
159
214
  return formatRepoSpec(this.spec);
160
215
  }
161
- request(path) {
162
- return githubRequest(path, this.token, this.label);
216
+ request(path, action, revalidate = false) {
217
+ return githubRequest(path, this.token, {
218
+ label: this.label,
219
+ action,
220
+ repo: `${this.spec.owner}/${this.spec.repo}`,
221
+ revalidate,
222
+ });
163
223
  }
164
224
  /**
165
225
  * The commit the configured ref points at right now.
166
226
  *
167
227
  * Resolved afresh for each operation — so a push is visible to the very next call — but only
168
- * once within it, however many files that operation ends up reading.
228
+ * once within it, however many files that operation ends up reading. The request is conditional:
229
+ * while the branch has not moved, GitHub answers 304 and the call costs no rate limit.
169
230
  */
170
231
  resolveRef() {
171
232
  return oncePerOperation(`ref:${this.label}`, async () => {
172
233
  const ref = this.spec.ref ?? "HEAD";
173
- const commit = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits/${encodeURIComponent(ref)}`);
234
+ const commit = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits/${encodeURIComponent(ref)}`, `Resolve ${ref} to its current commit`, true);
174
235
  logger.debug(`${this.label} is at ${String(commit.sha).slice(0, 7)}`);
175
236
  return {
176
237
  commitSha: commit.sha,
@@ -190,7 +251,7 @@ export class GitHubCodeApi {
190
251
  return this.cached(this.commitByDate, isoDate, async () => {
191
252
  const ref = this.spec.ref ?? "HEAD";
192
253
  const commits = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits` +
193
- `?sha=${encodeURIComponent(ref)}&until=${encodeURIComponent(isoDate)}&per_page=1`);
254
+ `?sha=${encodeURIComponent(ref)}&until=${encodeURIComponent(isoDate)}&per_page=1`, `Find the last commit on ${ref} at or before ${isoDate}`);
194
255
  const sha = Array.isArray(commits) && typeof commits[0]?.sha === "string" ? commits[0].sha : undefined;
195
256
  logger.debug(`${this.label}: commit at or before ${isoDate} is ${sha ? sha.slice(0, 7) : "none"}`);
196
257
  return sha;
@@ -199,7 +260,7 @@ export class GitHubCodeApi {
199
260
  /** path → blob sha for every file in the commit's tree. A commit's tree never changes. */
200
261
  getTree(commitSha) {
201
262
  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`);
263
+ 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
264
  const map = new Map();
204
265
  for (const entry of tree.tree ?? []) {
205
266
  if (entry.type === "blob")
@@ -240,7 +301,7 @@ export class GitHubCodeApi {
240
301
  if (known || !this.isTreeTruncated(commitSha))
241
302
  return known;
242
303
  try {
243
- const contents = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/contents/${encodeURI(repoPath)}?ref=${commitSha}`);
304
+ 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
305
  return typeof contents?.sha === "string" ? contents.sha : undefined;
245
306
  }
246
307
  catch (err) {
@@ -249,27 +310,19 @@ export class GitHubCodeApi {
249
310
  throw err;
250
311
  }
251
312
  }
252
- /** A blob's bytes. Blobs are immutable, so this is cached for the life of the process. */
253
- getBlob(blobSha) {
254
- return this.cached(this.blobCache, blobSha, async () => {
255
- const blob = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/blobs/${blobSha}`);
256
- return Buffer.from(blob.content ?? "", blob.encoding === "base64" ? "base64" : "utf-8");
257
- });
258
- }
259
313
  /**
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.
314
+ * A blob's bytes, read from the blob store when this machine already downloaded them.
315
+ * `filePath` names the file in github.log.
265
316
  */
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;
317
+ getBlob(blobSha, filePath) {
318
+ return this.cached(this.blobCache, blobSha, async () => {
319
+ const stored = readStoredBlob(blobSha);
320
+ if (stored)
321
+ return stored;
322
+ const blob = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/blobs/${blobSha}`, filePath ? `Download ${filePath}` : `Download blob ${blobSha.slice(0, 7)}`);
323
+ const bytes = Buffer.from(blob.content ?? "", blob.encoding === "base64" ? "base64" : "utf-8");
324
+ storeBlob(blobSha, bytes);
325
+ return bytes;
326
+ });
274
327
  }
275
328
  }