@imenam/mcp-documentor 1.0.86 → 1.0.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +7 -5
  2. package/dist/shared/read-only.js +1 -1
  3. package/dist/shared/schemas/doc.schema.d.ts +3 -1
  4. package/dist/shared/schemas/doc.schema.js +13 -1
  5. package/dist/src/core/code-source.d.ts +32 -0
  6. package/dist/src/core/code-source.js +23 -4
  7. package/dist/src/core/embedding-engine.d.ts +1 -1
  8. package/dist/src/core/embedding-engine.js +3 -3
  9. package/dist/src/core/embedding-store.d.ts +1 -1
  10. package/dist/src/core/embedding-store.js +4 -4
  11. package/dist/src/core/git-sync.d.ts +5 -0
  12. package/dist/src/core/git-sync.js +29 -2
  13. package/dist/src/core/github-code-api.d.ts +3 -8
  14. package/dist/src/core/github-code-api.js +67 -36
  15. package/dist/src/core/github-log.d.ts +38 -0
  16. package/dist/src/core/github-log.js +64 -0
  17. package/dist/src/core/log-reader.d.ts +47 -0
  18. package/dist/src/core/log-reader.js +106 -0
  19. package/dist/src/core/storage-engine.d.ts +27 -2
  20. package/dist/src/core/storage-engine.js +99 -23
  21. package/dist/src/gui-worker.js +58 -1
  22. package/dist/src/index.js +78 -12
  23. package/dist/src/tools/code-sources.d.ts +9 -0
  24. package/dist/src/tools/code-sources.js +18 -16
  25. package/dist/src/tools/create-project.d.ts +19 -0
  26. package/dist/src/tools/create-project.js +42 -0
  27. package/dist/src/tools/delete-project.d.ts +16 -0
  28. package/dist/src/tools/delete-project.js +25 -0
  29. package/dist/src/tools/git-diff-doc.js +123 -98
  30. package/dist/src/tools/move-doc.js +20 -6
  31. package/dist/src/tools/patch-doc.js +14 -3
  32. package/gui/dist/assets/{arc-DBgCQU2O.js → arc-s4GL8QZ_.js} +1 -1
  33. package/gui/dist/assets/{architectureDiagram-3BPJPVTR-BIPBAzZr.js → architectureDiagram-3BPJPVTR-CfDERg0r.js} +1 -1
  34. package/gui/dist/assets/{blockDiagram-GPEHLZMM-BE9WwuyF.js → blockDiagram-GPEHLZMM-CjDoEG3-.js} +1 -1
  35. package/gui/dist/assets/{c4Diagram-AAUBKEIU-V1_4ktIt.js → c4Diagram-AAUBKEIU-9o7f2UhK.js} +1 -1
  36. package/gui/dist/assets/channel-BsKHRH5P.js +1 -0
  37. package/gui/dist/assets/{chunk-2J33WTMH-DskG-Kmf.js → chunk-2J33WTMH-B37ouRYO.js} +1 -1
  38. package/gui/dist/assets/{chunk-4BX2VUAB-DaPhidEI.js → chunk-4BX2VUAB-Dcq44Skv.js} +1 -1
  39. package/gui/dist/assets/{chunk-55IACEB6-C9r6WDFM.js → chunk-55IACEB6-CLyM-RQf.js} +1 -1
  40. package/gui/dist/assets/{chunk-727SXJPM-BA-aC8US.js → chunk-727SXJPM-BjzEo2iz.js} +1 -1
  41. package/gui/dist/assets/{chunk-AQP2D5EJ-ByBp3whV.js → chunk-AQP2D5EJ-Co03OHPi.js} +1 -1
  42. package/gui/dist/assets/{chunk-FMBD7UC4-CzqUByx7.js → chunk-FMBD7UC4-ClbF2rAp.js} +1 -1
  43. package/gui/dist/assets/{chunk-ND2GUHAM-C0_B2ryQ.js → chunk-ND2GUHAM-CN5cTCWu.js} +1 -1
  44. package/gui/dist/assets/{chunk-QZHKN3VN-B3eEwjDh.js → chunk-QZHKN3VN-Bg4-kEPK.js} +1 -1
  45. package/gui/dist/assets/classDiagram-4FO5ZUOK-DphzXyc8.js +1 -0
  46. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-DphzXyc8.js +1 -0
  47. package/gui/dist/assets/{cose-bilkent-S5V4N54A-D_i0I2pU.js → cose-bilkent-S5V4N54A-6Dkp59bE.js} +1 -1
  48. package/gui/dist/assets/{dagre-BM42HDAG-DtEVPK_z.js → dagre-BM42HDAG-2dD-ID2T.js} +1 -1
  49. package/gui/dist/assets/{diagram-2AECGRRQ-BftnXa5w.js → diagram-2AECGRRQ-DBE2f2Lb.js} +1 -1
  50. package/gui/dist/assets/{diagram-5GNKFQAL-Dh9-kl_u.js → diagram-5GNKFQAL-CkKPHiH9.js} +1 -1
  51. package/gui/dist/assets/{diagram-KO2AKTUF-CvB0BWCa.js → diagram-KO2AKTUF-uWtGAF2N.js} +1 -1
  52. package/gui/dist/assets/{diagram-LMA3HP47-CDy-J1uy.js → diagram-LMA3HP47-DyQaWg_0.js} +1 -1
  53. package/gui/dist/assets/{diagram-OG6HWLK6-iY9FwkIF.js → diagram-OG6HWLK6-H_DfuB5t.js} +1 -1
  54. package/gui/dist/assets/{erDiagram-TEJ5UH35-BT2_IX8D.js → erDiagram-TEJ5UH35-Cj9K5EXA.js} +1 -1
  55. package/gui/dist/assets/{flowDiagram-I6XJVG4X-DaixWyMx.js → flowDiagram-I6XJVG4X-BYF24Jvt.js} +1 -1
  56. package/gui/dist/assets/{ganttDiagram-6RSMTGT7-DSo3cQPH.js → ganttDiagram-6RSMTGT7-D93LjmhI.js} +1 -1
  57. package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-BfFT7Lds.js → gitGraphDiagram-PVQCEYII-EVTcusDb.js} +1 -1
  58. package/gui/dist/assets/index-CtPuh-0k.css +1 -0
  59. package/gui/dist/assets/{index-DPGmAc6z.js → index-DvVxuv5N.js} +154 -152
  60. package/gui/dist/assets/{infoDiagram-5YYISTIA-BxbxNxe2.js → infoDiagram-5YYISTIA-DeGbaexj.js} +1 -1
  61. package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DpopzqZr.js → ishikawaDiagram-YF4QCWOH-DNdk7sPJ.js} +1 -1
  62. package/gui/dist/assets/{journeyDiagram-JHISSGLW-DrOlFzIk.js → journeyDiagram-JHISSGLW-yLLfyn3W.js} +1 -1
  63. package/gui/dist/assets/{kanban-definition-UN3LZRKU-vH81lYXy.js → kanban-definition-UN3LZRKU-D8yvVgr8.js} +1 -1
  64. package/gui/dist/assets/{linear-BipFpFah.js → linear-j3lplktA.js} +1 -1
  65. package/gui/dist/assets/{mindmap-definition-RKZ34NQL-DsTxCLgj.js → mindmap-definition-RKZ34NQL-t9adVeMH.js} +1 -1
  66. package/gui/dist/assets/{pieDiagram-4H26LBE5-Bbq2ZWQU.js → pieDiagram-4H26LBE5-6Yrvo8h8.js} +1 -1
  67. package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-BdTSaEo4.js → quadrantDiagram-W4KKPZXB-C_3Su6rX.js} +1 -1
  68. package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BndI8YZy.js → requirementDiagram-4Y6WPE33-B1qYSC11.js} +1 -1
  69. package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-ofBkVITf.js → sankeyDiagram-5OEKKPKP-D3Ervn-u.js} +1 -1
  70. package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-Cjj2UrN9.js → sequenceDiagram-3UESZ5HK-CKgTeWBS.js} +1 -1
  71. package/gui/dist/assets/{stateDiagram-AJRCARHV-CzPrXUuz.js → stateDiagram-AJRCARHV-D_PjEdFH.js} +1 -1
  72. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-KPbsissC.js +1 -0
  73. package/gui/dist/assets/{timeline-definition-PNZ67QCA-Cz4X-IOO.js → timeline-definition-PNZ67QCA-HeWRiNHu.js} +1 -1
  74. package/gui/dist/assets/{vennDiagram-CIIHVFJN-BwIgXJcZ.js → vennDiagram-CIIHVFJN-CREk7vzm.js} +1 -1
  75. package/gui/dist/assets/{wardley-L42UT6IY-keQS0aNZ.js → wardley-L42UT6IY-BWtRQ7ZH.js} +1 -1
  76. package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-DhykSgbD.js → wardleyDiagram-YWT4CUSO-BvlqWWwp.js} +1 -1
  77. package/gui/dist/assets/{xychartDiagram-2RQKCTM6-DFTqc4cj.js → xychartDiagram-2RQKCTM6-BCjv6pwc.js} +1 -1
  78. package/gui/dist/index.html +2 -2
  79. package/package.json +2 -1
  80. package/gui/dist/assets/channel-B_cci0fU.js +0 -1
  81. package/gui/dist/assets/classDiagram-4FO5ZUOK-BqlcJ5BP.js +0 -1
  82. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-BqlcJ5BP.js +0 -1
  83. package/gui/dist/assets/index-CxCC1gxu.css +0 -1
  84. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DAq4ObN9.js +0 -1
package/dist/src/index.js CHANGED
@@ -9,6 +9,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
9
9
  import { APP_VERSION } from "./version.js";
10
10
  import { GuiLauncher, createLogger, setupLogging } from "@imenam/mcp-gui-interface";
11
11
  import { resolveLogDir } from "./core/log-dir.js";
12
+ import { initGitHubLog, runTriggeredBy } from "./core/github-log.js";
12
13
  import { StorageEngine } from "./core/storage-engine.js";
13
14
  import { EmbeddingStore } from "./core/embedding-store.js";
14
15
  import { EmbeddingEngine } from "./core/embedding-engine.js";
@@ -35,6 +36,8 @@ import { gitDiffDoc } from "./tools/git-diff-doc.js";
35
36
  import { checkLinks } from "./tools/check-links.js";
36
37
  import { deleteDoc } from "./tools/delete-doc.js";
37
38
  import { moveDoc } from "./tools/move-doc.js";
39
+ import { deleteProject } from "./tools/delete-project.js";
40
+ import { createProject } from "./tools/create-project.js";
38
41
  import { getDocTree } from "./tools/get-doc-tree.js";
39
42
  import { migrateFolders } from "./core/migrate-folders.js";
40
43
  import { migrateEmbeddings } from "./core/migrate-embeddings.js";
@@ -79,6 +82,7 @@ function findProjectRoot(startDir) {
79
82
  const rootDir = findProjectRoot(__dirname);
80
83
  const packageEnvPath = path.join(rootDir, ".env");
81
84
  setupLogging({ processLabel: "mcp-documentor", logDir: resolveLogDir(rootDir) });
85
+ initGitHubLog(resolveLogDir(rootDir));
82
86
  const logger = createLogger("mcp-documentor");
83
87
  function getConfig() {
84
88
  if (fs.existsSync(packageEnvPath)) {
@@ -126,9 +130,13 @@ function broadcastToGUI(message) {
126
130
  }
127
131
  }
128
132
  }
129
- /** Each GUI message is one operation: the branch head it reads is resolved once, and freshly. */
133
+ /**
134
+ * Each GUI message is one operation: the branch head it reads is resolved once, and freshly.
135
+ * GitHub calls it makes are attributed to it in github.log.
136
+ */
130
137
  function handleIPCMessage(msg) {
131
- return runInCodeOperation(() => dispatchIPCMessage(msg));
138
+ const trigger = `GUI · ${String(msg?.type ?? "unknown").toLowerCase().replace(/_/g, " ")}`;
139
+ return runInCodeOperation(() => runTriggeredBy(trigger, () => dispatchIPCMessage(msg)));
132
140
  }
133
141
  function dispatchIPCMessage(msg) {
134
142
  if (msg.type === "ALREADY_RUNNING") {
@@ -1129,15 +1137,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1129
1137
  },
1130
1138
  {
1131
1139
  name: "move_doc",
1132
- description: "Move a documentation entry to a new path (changes its folder and/or name). All existing doc:// links and related_docs references pointing to the old path are automatically rewritten across the project. This is the only way to relocate a document — update_doc does not change the location.",
1140
+ description: "Move a documentation entry to a new path (changes its folder and/or name) and/or to another project. All existing doc:// links and related_docs references pointing to the old location are automatically rewritten across all projects. When the entry changes project, its own links keep their target: bare ones are qualified with the source project, and those into the target project become bare. Links are always bare within their own project. Its tracked_files are then resolved against the target project's code source. This is the only way to relocate a document — update_doc does not change the location. Provide new_path, new_project, or both.",
1133
1141
  inputSchema: {
1134
1142
  type: "object",
1135
1143
  properties: {
1136
1144
  path: { type: "string", description: "Current path of the documentation entry, e.g. 'backend/authentication'" },
1137
- new_path: { type: "string", description: "New path for the documentation entry, e.g. 'backend/v2/authentication'. Can change folder, name, or both." },
1138
- project: { type: "string", description: PROJECT_PARAM_DESCRIPTION + " The doc stays in this project — move_doc never moves an entry across projects." },
1145
+ new_path: { type: "string", description: "New path for the documentation entry, e.g. 'backend/v2/authentication'. Can change folder, name, or both. Omitted: the entry keeps its current path (new_project is then required)." },
1146
+ project: { type: "string", description: PROJECT_PARAM_DESCRIPTION + " This is the project the entry is moved from." },
1147
+ new_project: { type: "string", description: "Project the entry is moved to. Must already exist (see list_projects; create it with create_project). Omitted: the entry stays in `project`." },
1139
1148
  },
1140
- required: ["path", "new_path", "project"],
1149
+ required: ["path", "project"],
1141
1150
  },
1142
1151
  },
1143
1152
  {
@@ -1172,6 +1181,38 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1172
1181
  properties: {},
1173
1182
  },
1174
1183
  },
1184
+ {
1185
+ name: "create_project",
1186
+ description: "Create an empty documentation project, optionally with its code source (as set_code_source would set it). A GitHub repository and branch are checked first: if they cannot be found, nothing is created.",
1187
+ inputSchema: {
1188
+ type: "object",
1189
+ properties: {
1190
+ project: { type: "string", description: "Name of the project to create. Must not exist yet, nor contain '/', '\' or ':'." },
1191
+ code_source: {
1192
+ type: "object",
1193
+ description: "Where the project's code is read from (defaults to the local working copy).",
1194
+ properties: {
1195
+ kind: { type: "string", enum: ["local", "github"], description: "'local' reads the working copy, 'github' reads a repository branch" },
1196
+ repo: { type: "string", description: "Repository when kind is 'github': 'owner/repo' or a GitHub URL" },
1197
+ branch: { type: "string", description: "Branch when kind is 'github' (defaults to the repository's default branch)" },
1198
+ },
1199
+ required: ["kind"],
1200
+ },
1201
+ },
1202
+ required: ["project"],
1203
+ },
1204
+ },
1205
+ {
1206
+ name: "delete_project",
1207
+ description: "Delete an empty documentation project. Refuses — and lists what remains — while the project holds any doc, image or other file besides its settings: move_doc (with new_project) or delete_doc its entries first. Also forgets this machine's code root for the project.",
1208
+ inputSchema: {
1209
+ type: "object",
1210
+ properties: {
1211
+ project: { type: "string", description: "Project to delete. Must exist and be empty." },
1212
+ },
1213
+ required: ["project"],
1214
+ },
1215
+ },
1175
1216
  {
1176
1217
  name: "refresh_file_snapshot",
1177
1218
  description: "Mark tracked files as up-to-date by updating their snapshot timestamps. Use when file changes don't affect the documentation. Accepts one or more doc paths.",
@@ -1207,7 +1248,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1207
1248
  },
1208
1249
  {
1209
1250
  name: "patch_doc",
1210
- description: "PREFERRED method for modifying documentation. Replaces a specific portion of a documentation entry's content using an exact search-and-replace, without touching the rest of the doc. Always use this instead of update_doc when making partial changes.",
1251
+ description: "PREFERRED method for modifying documentation. Replaces a specific portion of a documentation entry's content using an exact search-and-replace, without touching the rest of the doc. The search text must occur exactly once, unless replace_all is set; the answer gives the number of occurrences replaced. The replacement is inserted verbatim. Always use this instead of update_doc when making partial changes.",
1211
1252
  inputSchema: {
1212
1253
  type: "object",
1213
1254
  properties: {
@@ -1216,6 +1257,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1216
1257
  ...textSourceProperties("search", "the search text"),
1217
1258
  replacement: { type: "string", description: "Text to replace it with. Supports [copy-from-codebase|/absolute/path/to/file.ts|startLine|endLine] — the tag is replaced at save time by a fenced code block, and the file+range is automatically added to tracked_files. IMPORTANT: always read the source file first to identify the exact line numbers before writing the tag." },
1218
1259
  ...textSourceProperties("replacement", "the replacement"),
1260
+ replace_all: { type: "boolean", description: "Replace every occurrence of the search text. Without it, a search text that occurs more than once is refused." },
1219
1261
  project: { type: "string", description: PROJECT_PARAM_DESCRIPTION },
1220
1262
  },
1221
1263
  required: ["path", "project"],
@@ -1223,7 +1265,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1223
1265
  },
1224
1266
  {
1225
1267
  name: "git_diff_doc",
1226
- description: "Show the git diff for each tracked file of one or more docs, between their current state and the last commit before the stored snapshot date.",
1268
+ description: "Show, for each changed tracked file of one or more docs, the diff between the content recorded at its last snapshot and its current content at the project's code source. The snapshot content is read from the recorded blob, else from the recorded commit, else from the newest commit at or before the snapshot date; it is checked against the stored hash, and the diff is reported impossible when it does not match.",
1227
1269
  inputSchema: {
1228
1270
  type: "object",
1229
1271
  properties: {
@@ -1520,8 +1562,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1520
1562
  };
1521
1563
  });
1522
1564
  // Each tool call is one operation: the branch head every tracked file is compared against is
1523
- // resolved once for the whole call, and never carried over to the next one.
1524
- server.setRequestHandler(CallToolRequestSchema, (request) => runInCodeOperation(() => handleToolCall(request)));
1565
+ // resolved once for the whole call, and never carried over to the next one. GitHub calls it makes
1566
+ // are attributed to the tool in github.log.
1567
+ server.setRequestHandler(CallToolRequestSchema, (request) => runInCodeOperation(() => runTriggeredBy(request.params.name, () => handleToolCall(request))));
1525
1568
  async function handleToolCall(request) {
1526
1569
  const { name, arguments: args } = request.params;
1527
1570
  if (isReadOnly() && WRITE_TOOLS.has(name)) {
@@ -1554,12 +1597,35 @@ async function handleToolCall(request) {
1554
1597
  type: "text",
1555
1598
  text: "set_project no longer exists: there is no active project. " +
1556
1599
  "Pass the project explicitly to every tool call (use list_projects to see them). " +
1557
- "A project is created on demand by create_doc.",
1600
+ "Create a project with create_project, or on demand with create_doc.",
1558
1601
  },
1559
1602
  ],
1560
1603
  };
1561
1604
  case "list_projects":
1562
1605
  return await listProjects(storageEngine);
1606
+ case "create_project": {
1607
+ const result = await createProject(storageEngine, localState, args);
1608
+ if (!result.isError) {
1609
+ broadcastToGUI({
1610
+ type: "PROJECTS_CHANGED",
1611
+ data: { projects: storageEngine.getConfig().projects },
1612
+ timestamp: new Date().toISOString(),
1613
+ });
1614
+ broadcastCodeSourcesChanged(args?.project);
1615
+ }
1616
+ return result;
1617
+ }
1618
+ case "delete_project": {
1619
+ const result = await deleteProject(storageEngine, localState, args);
1620
+ if (!result.isError) {
1621
+ broadcastToGUI({
1622
+ type: "PROJECTS_CHANGED",
1623
+ data: { projects: storageEngine.getConfig().projects },
1624
+ timestamp: new Date().toISOString(),
1625
+ });
1626
+ }
1627
+ return result;
1628
+ }
1563
1629
  case "refresh_file_snapshot":
1564
1630
  return await refreshFileSnapshot(storageEngine, historyStore, args);
1565
1631
  case "get_doc_metadata":
@@ -1684,7 +1750,7 @@ async function syncPullOnStartup() {
1684
1750
  async function main() {
1685
1751
  await migrateFolders(storageEngine);
1686
1752
  await migrateEmbeddings(embeddingStore, storageEngine);
1687
- await syncPullOnStartup();
1753
+ await runTriggeredBy("startup sync", syncPullOnStartup);
1688
1754
  migrateCodeSources(storageEngine, localState);
1689
1755
  const transport = new StdioServerTransport();
1690
1756
  await server.connect(transport);
@@ -88,6 +88,15 @@ export declare function getCodeSourceTool(storageEngine: StorageEngine, localSta
88
88
  text: string;
89
89
  }[];
90
90
  }>;
91
+ /** A code source as an agent describes it. */
92
+ export interface CodeSourceInput {
93
+ kind?: string;
94
+ repo?: string;
95
+ branch?: string;
96
+ }
97
+ /** Turns an agent's description into a configuration, checking a GitHub repository and branch exist. */
98
+ export declare function resolveCodeSourceInput({ kind, repo, branch }: CodeSourceInput): Promise<CodeSourceConfig>;
99
+ export declare function describeCodeSourceConfig(config: CodeSourceConfig): string;
91
100
  export declare function setCodeSourceTool(storageEngine: StorageEngine, localState: LocalState, args: unknown): Promise<{
92
101
  content: {
93
102
  type: string;
@@ -221,29 +221,31 @@ export async function getCodeSourceTool(storageEngine, localState, args) {
221
221
  `The code source is shared by every machine (synced with the docs); code roots belong to this machine.`;
222
222
  return { content: [{ type: "text", text }] };
223
223
  }
224
- export async function setCodeSourceTool(storageEngine, localState, args) {
225
- const { project, kind, repo, branch } = (args ?? {});
226
- if (!project)
227
- throw new Error("project is required");
228
- assertKnownProject(storageEngine, project);
229
- let config;
230
- if (kind === "local") {
231
- config = { kind: "local" };
232
- }
233
- else if (kind === "github") {
224
+ /** Turns an agent's description into a configuration, checking a GitHub repository and branch exist. */
225
+ export async function resolveCodeSourceInput({ kind, repo, branch }) {
226
+ if (kind === "local")
227
+ return { kind: "local" };
228
+ if (kind === "github") {
234
229
  if (!repo)
235
230
  throw new Error('repo is required when kind is "github"');
236
231
  const inspection = await inspectRepository({ repo, branch });
237
232
  if (!inspection.ok)
238
233
  throw new Error(inspection.error);
239
- config = { kind: "github", repo: inspection.repo.fullName, ...(branch && { branch: inspection.branch }) };
240
- }
241
- else {
242
- throw new Error('kind must be "local" or "github"');
234
+ return { kind: "github", repo: inspection.repo.fullName, ...(branch && { branch: inspection.branch }) };
243
235
  }
236
+ throw new Error('kind must be "local" or "github"');
237
+ }
238
+ export function describeCodeSourceConfig(config) {
239
+ return config.kind === "github" ? `GitHub ${config.repo} · ${config.branch ?? "default branch"}` : "its local working copy";
240
+ }
241
+ export async function setCodeSourceTool(storageEngine, localState, args) {
242
+ const { project, ...input } = (args ?? {});
243
+ if (!project)
244
+ throw new Error("project is required");
245
+ assertKnownProject(storageEngine, project);
246
+ const config = await resolveCodeSourceInput(input);
244
247
  saveCodeSource(storageEngine, localState, project, config);
245
- const where = config.kind === "github" ? `GitHub ${config.repo} · ${config.branch ?? "default branch"}` : "its local working copy";
246
- return { content: [{ type: "text", text: `Project "${project}" now reads its code from ${where}.` }] };
248
+ return { content: [{ type: "text", text: `Project "${project}" now reads its code from ${describeCodeSourceConfig(config)}.` }] };
247
249
  }
248
250
  export function setCodeRootTool(storageEngine, localState, args) {
249
251
  const { path: root, project } = (args ?? {});
@@ -0,0 +1,19 @@
1
+ import type { StorageEngine } from "../core/storage-engine.js";
2
+ import type { LocalState } from "../core/local-state.js";
3
+ /**
4
+ * Creates an empty project, with its code source when one is given. The source is checked before
5
+ * anything is written, so an unknown repository or branch creates nothing.
6
+ */
7
+ export declare function createProject(storageEngine: StorageEngine, localState: LocalState, args: unknown): Promise<{
8
+ isError: boolean;
9
+ content: {
10
+ type: string;
11
+ text: string;
12
+ }[];
13
+ } | {
14
+ content: {
15
+ type: string;
16
+ text: string;
17
+ }[];
18
+ isError?: undefined;
19
+ }>;
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ import { describeCodeSourceConfig, resolveCodeSourceInput, saveCodeSource } from "./code-sources.js";
3
+ const CreateProjectArgsSchema = z.object({
4
+ project: z
5
+ .string()
6
+ .trim()
7
+ .min(1)
8
+ .refine((name) => name !== "." && name !== ".." && !/[\\/:]/.test(name), {
9
+ message: 'Project names cannot be "." or "..", nor contain "/", "\\" or ":".',
10
+ })
11
+ .describe("Name of the project to create."),
12
+ code_source: z
13
+ .object({
14
+ kind: z.enum(["local", "github"]),
15
+ repo: z.string().optional(),
16
+ branch: z.string().optional(),
17
+ })
18
+ .optional()
19
+ .describe("Where the project's code is read from, as with set_code_source. Omitted: the local working copy."),
20
+ });
21
+ /**
22
+ * Creates an empty project, with its code source when one is given. The source is checked before
23
+ * anything is written, so an unknown repository or branch creates nothing.
24
+ */
25
+ export async function createProject(storageEngine, localState, args) {
26
+ try {
27
+ const { project, code_source } = CreateProjectArgsSchema.parse(args);
28
+ if (storageEngine.getConfig().projects.includes(project)) {
29
+ return { isError: true, content: [{ type: "text", text: `Project "${project}" already exists.` }] };
30
+ }
31
+ const config = code_source ? await resolveCodeSourceInput(code_source) : undefined;
32
+ storageEngine.ensureProject(project);
33
+ if (config)
34
+ saveCodeSource(storageEngine, localState, project, config);
35
+ const source = config ? ` It reads its code from ${describeCodeSourceConfig(config)}.` : "";
36
+ return { content: [{ type: "text", text: `Project "${project}" created.${source}` }] };
37
+ }
38
+ catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ return { isError: true, content: [{ type: "text", text: `Error creating project: ${message}` }] };
41
+ }
42
+ }
@@ -0,0 +1,16 @@
1
+ import type { StorageEngine } from "../core/storage-engine.js";
2
+ import type { LocalState } from "../core/local-state.js";
3
+ /** Deletes an empty project, along with this machine's code root for it. */
4
+ export declare function deleteProject(storageEngine: StorageEngine, localState: LocalState, args: unknown): Promise<{
5
+ content: {
6
+ type: string;
7
+ text: string;
8
+ }[];
9
+ isError?: undefined;
10
+ } | {
11
+ isError: boolean;
12
+ content: {
13
+ type: string;
14
+ text: string;
15
+ }[];
16
+ }>;
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ import { invalidateCodeSources } from "../core/code-source.js";
3
+ import { assertProject, scopeErrorResult } from "../core/project-scope.js";
4
+ const DeleteProjectArgsSchema = z.object({
5
+ project: z.string().min(1).describe("Project to delete. It must be empty: no doc, no image, no other file."),
6
+ });
7
+ /** Deletes an empty project, along with this machine's code root for it. */
8
+ export async function deleteProject(storageEngine, localState, args) {
9
+ try {
10
+ const { project } = DeleteProjectArgsSchema.parse(args);
11
+ const projectError = assertProject(storageEngine, project);
12
+ if (projectError)
13
+ return scopeErrorResult(projectError);
14
+ storageEngine.deleteProject(project);
15
+ localState.setProjectCodeRoot(project, undefined);
16
+ invalidateCodeSources(project);
17
+ return {
18
+ content: [{ type: "text", text: `Project "${project}" deleted.` }],
19
+ };
20
+ }
21
+ catch (err) {
22
+ const message = err instanceof Error ? err.message : String(err);
23
+ return { isError: true, content: [{ type: "text", text: `Error deleting project: ${message}` }] };
24
+ }
25
+ }
@@ -1,117 +1,144 @@
1
- import { execSync } from "child_process";
1
+ import { execFileSync } from "child_process";
2
2
  import path from "path";
3
+ import { createTwoFilesPatch } from "diff";
3
4
  import { GitDiffDocArgsSchema } from "../../shared/schemas/doc.schema.js";
4
5
  import { computeFreshness } from "./check-freshness.js";
5
6
  import { toAbsolutePath } from "../core/path-resolver.js";
6
- import { getCodeSource, getGitHubApi, toStoredPathForMatching } from "../core/code-source.js";
7
+ import { getCodeSource, getGitHubSource, toStoredPathForMatching } from "../core/code-source.js";
8
+ import { bytesToFileContent, fileContentMatches, findAndHashSection } from "../core/section-tracker.js";
9
+ import { mapWithConcurrency } from "../core/concurrency.js";
7
10
  import { assertProject, scopeErrorResult } from "../core/project-scope.js";
8
- /** Diff of one tracked file against the local working copy, via the git CLI. */
9
- function getLocalGitDiff(tracked, project) {
10
- const filePath = toAbsolutePath(tracked.path, project);
11
- if (!filePath) {
12
- return ` [INFO] Cannot resolve "${tracked.path}" on this machine (no code root configured) — source absent.`;
11
+ /** Tracked files of one doc diffed at once. */
12
+ const FILE_CONCURRENCY = 4;
13
+ const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
14
+ const short = (sha) => sha.slice(0, 7);
15
+ /**
16
+ * Snapshot content on GitHub, by decreasing reliability: the recorded blob (independent of any
17
+ * commit, so it survives a rebase), the file at the recorded commit, or the file at the newest
18
+ * commit on the branch at or before the snapshot date.
19
+ */
20
+ async function githubSnapshotBase(source, tracked) {
21
+ const repo = source.api.label;
22
+ if (tracked.sourceBlobId) {
23
+ try {
24
+ return { content: await source.readBlob(tracked.sourceBlobId, tracked.path), origin: `snapshot blob ${short(tracked.sourceBlobId)}`, inferred: false };
25
+ }
26
+ catch (err) {
27
+ return { error: `The snapshot content (blob ${short(tracked.sourceBlobId)}) cannot be read on ${repo}: ${errorMessage(err)}` };
28
+ }
13
29
  }
14
- const fileDir = path.dirname(filePath);
15
- let repoRoot;
30
+ let commit = tracked.sourceRevision;
31
+ const inferred = !commit;
32
+ if (!commit) {
33
+ try {
34
+ commit = await source.api.resolveCommitAtDate(tracked.lastModifiedAt);
35
+ }
36
+ catch (err) {
37
+ return { error: `No source commit recorded, and the commit at the snapshot date (${tracked.lastModifiedAt}) could not be resolved on ${repo}: ${errorMessage(err)}` };
38
+ }
39
+ if (!commit) {
40
+ return { error: `No source commit recorded, and ${repo} has no commit at or before the snapshot date (${tracked.lastModifiedAt}).` };
41
+ }
42
+ }
43
+ const origin = inferred ? `commit ${short(commit)} inferred from the snapshot date` : `snapshot commit ${short(commit)}`;
16
44
  try {
17
- repoRoot = execSync(`git -C "${fileDir}" rev-parse --show-toplevel`, { encoding: "utf-8" }).trim();
45
+ const content = await source.readAt(tracked.path, commit);
46
+ if (!content)
47
+ return { error: `The file does not exist at ${origin} on ${repo}.` };
48
+ return { content, origin, inferred };
18
49
  }
19
- catch {
20
- return ` [ERROR] Not a git repository: ${filePath}`;
50
+ catch (err) {
51
+ return { error: `The ${origin} cannot be read on ${repo}: ${errorMessage(err)}` };
21
52
  }
22
- const relativeFilePath = path.relative(repoRoot, filePath).replace(/\\/g, "/");
23
- let commitHash;
53
+ }
54
+ function git(repoRoot, args) {
55
+ return execFileSync("git", ["-C", repoRoot, ...args], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
56
+ }
57
+ /** Snapshot content in the local clone, with the same order of preference as on GitHub. */
58
+ function localSnapshotBase(tracked, project) {
59
+ const filePath = toAbsolutePath(tracked.path, project);
60
+ if (!filePath)
61
+ return { error: `Cannot resolve "${tracked.path}" on this machine (no code root configured).` };
62
+ let repoRoot;
24
63
  try {
25
- commitHash = execSync(`git -C "${repoRoot}" log --before="${tracked.lastModifiedAt}" -1 --format=%H -- "${relativeFilePath}"`, { encoding: "utf-8" }).trim();
64
+ repoRoot = git(path.dirname(filePath), ["rev-parse", "--show-toplevel"]).toString("utf-8").trim();
26
65
  }
27
66
  catch {
28
- return ` [ERROR] Could not retrieve git log for: ${filePath}`;
67
+ return { error: `Not a git repository: ${filePath}` };
29
68
  }
30
- if (!commitHash) {
31
- return ` [INFO] No commit found before snapshot date (${tracked.lastModifiedAt}) for: ${filePath}`;
69
+ const relativeFilePath = path.relative(repoRoot, filePath).replace(/\\/g, "/");
70
+ if (tracked.sourceBlobId) {
71
+ try {
72
+ return { content: bytesToFileContent(git(repoRoot, ["cat-file", "blob", tracked.sourceBlobId])), origin: `snapshot blob ${short(tracked.sourceBlobId)}`, inferred: false };
73
+ }
74
+ catch {
75
+ return { error: `The snapshot content (blob ${short(tracked.sourceBlobId)}) is not in the local repository ${repoRoot}.` };
76
+ }
32
77
  }
33
- let diff;
78
+ let commit = tracked.sourceRevision;
79
+ const inferred = !commit;
80
+ if (!commit) {
81
+ try {
82
+ commit = git(repoRoot, ["log", `--before=${tracked.lastModifiedAt}`, "-1", "--format=%H", "--", relativeFilePath]).toString("utf-8").trim();
83
+ }
84
+ catch {
85
+ return { error: `Could not read the git log of ${filePath}.` };
86
+ }
87
+ if (!commit)
88
+ return { error: `No commit touches ${relativeFilePath} at or before the snapshot date (${tracked.lastModifiedAt}).` };
89
+ }
90
+ const origin = inferred ? `commit ${short(commit)} inferred from the snapshot date` : `snapshot commit ${short(commit)}`;
34
91
  try {
35
- diff = execSync(`git -C "${repoRoot}" diff ${commitHash} -- "${relativeFilePath}"`, { encoding: "utf-8" }).trim();
92
+ return { content: bytesToFileContent(git(repoRoot, ["show", `${commit}:${relativeFilePath}`])), origin, inferred };
36
93
  }
37
94
  catch {
38
- return ` [ERROR] Could not compute diff for: ${filePath}`;
39
- }
40
- if (!diff) {
41
- return ` [INFO] No changes since snapshot (commit ${commitHash.slice(0, 7)}) for: ${filePath}`;
95
+ return { error: `The file does not exist at ${origin} in ${repoRoot}, or that commit is not in the local repository.` };
42
96
  }
43
- return diff;
44
97
  }
45
98
  /**
46
- * Diffs tracked files against the branch they are tracked on, using the commit each snapshot
47
- * was taken at as the base.
48
- *
49
- * A snapshot with no recorded commit — taken before branch tracking, or from a local working copy —
50
- * is anchored on the newest commit at or before its date instead, so it diffs like any other rather
51
- * than demanding a refresh first.
52
- *
53
- * Files are grouped by that base commit so one comparison covers every file snapshotted at the
54
- * same revision — which, for a doc refreshed in one go, means a single API call.
99
+ * Whether a content is the one the snapshot recorded, judged by its stored hashes. Undefined when
100
+ * the entry holds no hash to judge by.
55
101
  */
56
- async function getGitHubDiffs(api, trackedFiles) {
57
- const diffs = new Map();
58
- const byBase = new Map();
59
- const inferredBases = new Set();
60
- for (const tracked of trackedFiles) {
61
- let base = tracked.sourceRevision;
62
- if (!base) {
63
- try {
64
- base = await api.resolveCommitAtDate(tracked.lastModifiedAt);
65
- }
66
- catch (err) {
67
- const message = err instanceof Error ? err.message : String(err);
68
- diffs.set(tracked.path, ` [ERROR] No source commit recorded, and the commit at the snapshot date (${tracked.lastModifiedAt}) could not be resolved on ${api.label}: ${message}`);
69
- continue;
70
- }
71
- if (!base) {
72
- diffs.set(tracked.path, ` [INFO] No source commit recorded, and ${api.label} has no commit at or before the snapshot date (${tracked.lastModifiedAt}) — nothing to diff against.\n` +
73
- ` → Call refresh_file_snapshot to re-anchor this file on ${api.label}.`);
74
- continue;
75
- }
76
- inferredBases.add(base);
77
- }
78
- const group = byBase.get(base);
79
- if (group)
80
- group.push(tracked);
81
- else
82
- byBase.set(base, [tracked]);
83
- }
84
- if (byBase.size === 0)
85
- return diffs;
86
- const { commitSha: head } = await api.resolveRef();
87
- for (const [base, files] of byBase) {
88
- // An inferred base is the branch state at the snapshot date, not a commit the snapshot was
89
- // provably read from — worth saying, since the diff can then include changes already documented.
90
- const note = inferredBases.has(base)
91
- ? ` [INFO] Base commit ${base.slice(0, 7)} inferred from the snapshot date (no source commit recorded).\n\n`
92
- : "";
93
- if (base === head) {
94
- for (const file of files) {
95
- diffs.set(file.path, `${note} [INFO] ${api.label} is still at the snapshot commit ${base.slice(0, 7)} — no changes.`);
96
- }
97
- continue;
98
- }
99
- let patches;
100
- try {
101
- patches = await api.comparePatches(base, head);
102
- }
103
- catch (err) {
104
- const message = err instanceof Error ? err.message : String(err);
105
- for (const file of files)
106
- diffs.set(file.path, `${note} [ERROR] Could not compare ${base.slice(0, 7)}...${head.slice(0, 7)}: ${message}`);
107
- continue;
108
- }
109
- for (const file of files) {
110
- const patch = patches.get(file.path);
111
- diffs.set(file.path, note + (patch ?? ` [INFO] No changes to this file between ${base.slice(0, 7)} and ${head.slice(0, 7)}.`));
112
- }
102
+ function matchesSnapshot(content, tracked) {
103
+ if (tracked.sections && tracked.sections.length > 0) {
104
+ return tracked.sections.every((section) => {
105
+ const result = findAndHashSection(content, section);
106
+ return result.found && result.matchesStored;
107
+ });
108
+ }
109
+ if (tracked.contentHash)
110
+ return fileContentMatches(content, tracked.contentHash);
111
+ return undefined;
112
+ }
113
+ /** The diff section of one tracked file: snapshot content against the current one at the source. */
114
+ async function diffTrackedFile(tracked, project, source) {
115
+ const github = getGitHubSource(project);
116
+ const base = github ? await githubSnapshotBase(github, tracked) : localSnapshotBase(tracked, project);
117
+ const impossible = (reason) => ` [ERROR] Diff impossible: ${reason}\n` +
118
+ ` → Re-read the file to check the doc against the current code, then call refresh_file_snapshot.`;
119
+ if ("error" in base)
120
+ return impossible(base.error);
121
+ const verdict = matchesSnapshot(base.content, tracked);
122
+ if (verdict === false) {
123
+ return impossible(`the content at ${base.origin} does not match the hash recorded at the snapshot.`);
124
+ }
125
+ const unverifiedNote = verdict === undefined ? ` [INFO] No hash recorded for this entry: ${base.origin} could not be checked against the snapshot.\n\n` : "";
126
+ const inferredNote = base.inferred && verdict ? ` [INFO] Base: ${base.origin} (no source commit recorded), checked against the snapshot hash.\n\n` : "";
127
+ let current;
128
+ try {
129
+ current = await source.read(tracked.path);
130
+ }
131
+ catch (err) {
132
+ return `${unverifiedNote}${inferredNote} [ERROR] The current content cannot be read from ${source.label}: ${errorMessage(err)}`;
113
133
  }
114
- return diffs;
134
+ if (!current)
135
+ return `${unverifiedNote}${inferredNote} [INFO] The file no longer exists at ${source.label}.`;
136
+ const before = base.content.lines.join("\n");
137
+ const after = current.content.lines.join("\n");
138
+ if (before === after)
139
+ return `${unverifiedNote}${inferredNote} [INFO] No changes since the snapshot.`;
140
+ const patch = createTwoFilesPatch(`a/${tracked.path}`, `b/${tracked.path}`, before, after, base.origin, "current", { context: 3 });
141
+ return unverifiedNote + inferredNote + patch.replace(/^=+\n/, "").trimEnd();
115
142
  }
116
143
  export async function gitDiffDoc(storageEngine, args) {
117
144
  try {
@@ -121,7 +148,6 @@ export async function gitDiffDoc(storageEngine, args) {
121
148
  if (projectError)
122
149
  return scopeErrorResult(projectError);
123
150
  const source = getCodeSource(project);
124
- const githubApi = getGitHubApi(project);
125
151
  const wanted = validated.file_paths
126
152
  ? new Set(await Promise.all(validated.file_paths.map((p) => toStoredPathForMatching(project, p))))
127
153
  : undefined;
@@ -165,11 +191,10 @@ export async function gitDiffDoc(storageEngine, args) {
165
191
  docSections.push(`## ${doc.title} [${docPath}]\n\n[INFO] All tracked files already shown in a previous documentation section.`);
166
192
  continue;
167
193
  }
168
- const diffs = githubApi ? await getGitHubDiffs(githubApi, fresh) : undefined;
169
- const fileSections = fresh.map((tf) => {
170
- const diff = diffs ? diffs.get(tf.path) : getLocalGitDiff(tf, project);
171
- const base = tf.sourceRevision ? `\nSnapshot commit: ${tf.sourceRevision.slice(0, 7)}` : "";
172
- return `### ${tf.path}\nSnapshot date: ${tf.lastModifiedAt}${base}\n\n${diff}`;
194
+ const diffs = await mapWithConcurrency(fresh, FILE_CONCURRENCY, (tf) => diffTrackedFile(tf, project, source));
195
+ const fileSections = fresh.map((tf, i) => {
196
+ const base = tf.sourceRevision ? `\nSnapshot commit: ${short(tf.sourceRevision)}` : "";
197
+ return `### ${tf.path}\nSnapshot date: ${tf.lastModifiedAt}${base}\n\n${diffs[i]}`;
173
198
  });
174
199
  docSections.push(`## ${doc.title} [${docPath}]\n\nCode source: ${source.label}\n\n${fileSections.join("\n\n---\n\n")}`);
175
200
  }