@imenam/mcp-documentor 1.0.62 → 1.0.64

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.
@@ -65,6 +65,8 @@ export interface DocSummary {
65
65
  updatedAt: string;
66
66
  tags: string[];
67
67
  isStale: boolean;
68
+ /** True when tracked entries have no snapshot and freshness cannot be computed. */
69
+ isBroken?: boolean;
68
70
  folder?: string;
69
71
  }
70
72
  export interface DocMatch {
@@ -81,11 +83,13 @@ export declare const FreshnessReportSchema: z.ZodObject<{
81
83
  stale: "stale";
82
84
  no_tracking: "no_tracking";
83
85
  unverifiable: "unverifiable";
86
+ broken: "broken";
84
87
  }>;
85
88
  modified: z.ZodArray<z.ZodString>;
86
89
  missing: z.ZodArray<z.ZodString>;
87
90
  unchanged: z.ZodArray<z.ZodString>;
88
91
  anchors_lost: z.ZodDefault<z.ZodArray<z.ZodString>>;
92
+ broken: z.ZodDefault<z.ZodArray<z.ZodString>>;
89
93
  total: z.ZodNumber;
90
94
  }, z.core.$strip>;
91
95
  export type FreshnessReport = z.infer<typeof FreshnessReportSchema>;
@@ -36,11 +36,13 @@ export const DocSchema = z.object({
36
36
  updatedAt: z.string(),
37
37
  });
38
38
  export const FreshnessReportSchema = z.object({
39
- status: z.enum(["up_to_date", "stale", "no_tracking", "unverifiable"]),
39
+ status: z.enum(["up_to_date", "stale", "no_tracking", "unverifiable", "broken"]),
40
40
  modified: z.array(z.string()),
41
41
  missing: z.array(z.string()),
42
42
  unchanged: z.array(z.string()),
43
43
  anchors_lost: z.array(z.string()).default([]),
44
+ /** Entries with neither sections nor contentHash (pre hash-based tracking) — cannot be checked until re-tracked. */
45
+ broken: z.array(z.string()).default([]),
44
46
  total: z.number(),
45
47
  });
46
48
  export const CreateDocArgsSchema = z.object({
package/dist/src/index.js CHANGED
@@ -81,6 +81,9 @@ storageEngine.setHistoryStore(historyStore);
81
81
  const syncEngine = new SyncEngine(storageEngine);
82
82
  const server = new Server({ name: "mcp-documentor", version: APP_VERSION }, { capabilities: { tools: {} } });
83
83
  let guiLauncher;
84
+ let guiStatus = "disabled";
85
+ let guiUrl = null;
86
+ let guiStartWaiter = null;
84
87
  const sseControllers = new Set();
85
88
  function broadcastToGUI(message) {
86
89
  const child = guiLauncher.getProcess();
@@ -95,9 +98,15 @@ function broadcastToGUI(message) {
95
98
  }
96
99
  function handleIPCMessage(msg) {
97
100
  if (msg.type === "ALREADY_RUNNING") {
101
+ guiStatus = "already_running";
102
+ guiUrl = msg.data?.url ?? guiUrl;
103
+ guiStartWaiter?.({ outcome: "already_running", url: msg.data?.url });
98
104
  logger.info(`GUI is already running at: ${msg.data?.url || "unknown URL"}.`);
99
105
  }
100
106
  else if (msg.type === "READY") {
107
+ guiStatus = "ready";
108
+ guiUrl = msg.data?.url ?? null;
109
+ guiStartWaiter?.({ outcome: "ready", url: msg.data?.url });
101
110
  logger.info(`GUI is ready! Access it at: ${msg.data.url}`);
102
111
  }
103
112
  else if (msg.type === "GET_INITIAL_STATE") {
@@ -159,15 +168,9 @@ async function handleGetInitialState(msg) {
159
168
  for (const doc of docs) {
160
169
  const full = await storageEngine.getDoc(doc.id); // doc.id is now the full path
161
170
  if (full && full.tracked_files.length > 0) {
162
- // A doc whose freshness cannot be computed (legacy hash-less entries)
163
- // needs attention: surface it as stale rather than crashing the view.
164
- try {
165
- const report = computeFreshness(full.tracked_files, config.currentProject);
166
- doc.isStale = report.status === "stale";
167
- }
168
- catch {
169
- doc.isStale = true;
170
- }
171
+ const report = computeFreshness(full.tracked_files, config.currentProject);
172
+ doc.isStale = report.status === "stale" || report.status === "broken";
173
+ doc.isBroken = report.status === "broken";
171
174
  }
172
175
  }
173
176
  const bundles = storageEngine.getBundles();
@@ -204,13 +207,9 @@ async function handleUpdateProject(msg) {
204
207
  for (const doc of docs) {
205
208
  const full = await storageEngine.getDoc(doc.id);
206
209
  if (full && full.tracked_files.length > 0) {
207
- try {
208
- const report = computeFreshness(full.tracked_files, project);
209
- doc.isStale = report.status === "stale";
210
- }
211
- catch {
212
- doc.isStale = true;
213
- }
210
+ const report = computeFreshness(full.tracked_files, project);
211
+ doc.isStale = report.status === "stale" || report.status === "broken";
212
+ doc.isBroken = report.status === "broken";
214
213
  }
215
214
  }
216
215
  broadcastToGUI({
@@ -250,13 +249,9 @@ async function handleListDocs(msg) {
250
249
  for (const doc of docs) {
251
250
  const full = await storageEngine.getDoc(doc.id);
252
251
  if (full && full.tracked_files.length > 0) {
253
- try {
254
- const report = computeFreshness(full.tracked_files, currentProject);
255
- doc.isStale = report.status === "stale";
256
- }
257
- catch {
258
- doc.isStale = true;
259
- }
252
+ const report = computeFreshness(full.tracked_files, currentProject);
253
+ doc.isStale = report.status === "stale" || report.status === "broken";
254
+ doc.isBroken = report.status === "broken";
260
255
  }
261
256
  }
262
257
  broadcastToGUI({
@@ -368,15 +363,9 @@ async function handleCreateDocIPC(msg) {
368
363
  for (const doc of docs) {
369
364
  const full = await storageEngine.getDoc(doc.id);
370
365
  if (full && full.tracked_files.length > 0) {
371
- // A doc whose freshness cannot be computed (legacy hash-less entries)
372
- // needs attention: surface it as stale rather than crashing the view.
373
- try {
374
- const report = computeFreshness(full.tracked_files, config.currentProject);
375
- doc.isStale = report.status === "stale";
376
- }
377
- catch {
378
- doc.isStale = true;
379
- }
366
+ const report = computeFreshness(full.tracked_files, config.currentProject);
367
+ doc.isStale = report.status === "stale" || report.status === "broken";
368
+ doc.isBroken = report.status === "broken";
380
369
  }
381
370
  }
382
371
  broadcastToGUI({
@@ -661,13 +650,70 @@ guiLauncher = new GuiLauncher({
661
650
  handleIPCMessage(msg);
662
651
  },
663
652
  });
653
+ function startGuiWorker() {
654
+ guiStatus = "starting";
655
+ const child = guiLauncher.start();
656
+ child.on("exit", () => {
657
+ // Un start() ultérieur remplace le process courant : seul l'exit du process
658
+ // actif change l'état. ALREADY_RUNNING = la GUI vit dans une autre instance,
659
+ // l'exit du worker est alors attendu.
660
+ if (guiLauncher.getProcess() === child && guiStatus !== "already_running") {
661
+ guiStatus = "stopped";
662
+ }
663
+ });
664
+ return child;
665
+ }
664
666
  function launchGUI() {
665
667
  if (!process.env.PROXY_URL) {
666
668
  logger.info('[MASTER] PROXY_URL non défini — GUI désactivée.');
669
+ guiStatus = "disabled";
667
670
  return;
668
671
  }
669
672
  logger.info("Launching GUI worker...");
670
- guiLauncher.start();
673
+ startGuiWorker();
674
+ }
675
+ async function reconnectGui(args) {
676
+ getConfig(); // relit le .env au cas où PROXY_URL aurait été ajouté après le démarrage
677
+ if (!process.env.PROXY_URL) {
678
+ return {
679
+ content: [{ type: "text", text: "Error: PROXY_URL is not set — the GUI is disabled. Add PROXY_URL to the .env file, then call reconnect_gui again." }],
680
+ isError: true,
681
+ };
682
+ }
683
+ const child = guiLauncher.getProcess();
684
+ const alive = !!child && child.exitCode === null && !child.killed;
685
+ const force = args?.force === true;
686
+ if (alive && !force) {
687
+ if (guiStatus === "starting") {
688
+ return { content: [{ type: "text", text: "GUI worker is already starting (proxy registration in progress). Wait a few seconds, then call reconnect_gui again if needed." }] };
689
+ }
690
+ return { content: [{ type: "text", text: `GUI is already registered with the proxy${guiUrl ? ` at ${guiUrl}` : ""}. Nothing to do (use force=true to restart it anyway).` }] };
691
+ }
692
+ logger.info(`[MASTER] reconnect_gui: ${alive ? "restarting (force)" : "relaunching"} GUI worker...`);
693
+ const result = await new Promise((resolve) => {
694
+ const newChild = startGuiWorker();
695
+ const onExit = (code) => { cleanup(); resolve({ outcome: "exited", code }); };
696
+ const timer = setTimeout(() => { cleanup(); resolve({ outcome: "timeout" }); }, 15000);
697
+ const cleanup = () => { clearTimeout(timer); newChild.off("exit", onExit); guiStartWaiter = null; };
698
+ guiStartWaiter = (r) => { cleanup(); resolve(r); };
699
+ newChild.on("exit", onExit);
700
+ });
701
+ if (result.outcome === "ready") {
702
+ return { content: [{ type: "text", text: `GUI reconnected and registered with the proxy. URL: ${result.url}` }] };
703
+ }
704
+ if (result.outcome === "already_running") {
705
+ return { content: [{ type: "text", text: `GUI is already registered by another MCP instance. URL: ${result.url ?? "unknown"}` }] };
706
+ }
707
+ if (result.outcome === "exited") {
708
+ return {
709
+ content: [{ type: "text", text: `GUI worker exited before completing registration (code=${result.code}). The proxy server is probably still unreachable at ${process.env.PROXY_URL}. Start the proxy server, then call reconnect_gui again.` }],
710
+ isError: true,
711
+ };
712
+ }
713
+ return {
714
+ content: [{ type: "text", text: "GUI worker started but did not confirm proxy registration within 15s. Check the logs (.docs-data/logs), then retry." }],
715
+ isError: true,
716
+ };
671
717
  }
672
718
  server.setRequestHandler(ListToolsRequestSchema, async () => {
673
719
  const tools = [
@@ -1103,6 +1149,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1103
1149
  required: ["strategy"],
1104
1150
  },
1105
1151
  },
1152
+ {
1153
+ name: "reconnect_gui",
1154
+ description: "Relaunch the GUI worker and re-register it with the proxy server. Use this when the proxy server was not running when the MCP server started (so the GUI registration failed), to redeploy the GUI without restarting the MCP server. Does nothing if the GUI is already registered, unless force=true.",
1155
+ inputSchema: {
1156
+ type: "object",
1157
+ properties: {
1158
+ force: { type: "boolean", description: "Restart the GUI worker even if it is already running and registered (default: false)" },
1159
+ },
1160
+ },
1161
+ },
1106
1162
  ];
1107
1163
  return {
1108
1164
  tools: isReadOnly() ? tools.filter((t) => !WRITE_TOOLS.has(t.name)) : tools,
@@ -1194,6 +1250,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1194
1250
  return await syncDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore, args);
1195
1251
  case "resolve_sync_conflict":
1196
1252
  return await resolveSyncConflict(syncEngine, args);
1253
+ case "reconnect_gui":
1254
+ return await reconnectGui(args);
1197
1255
  default:
1198
1256
  throw new Error(`Tool not found: ${name}`);
1199
1257
  }
@@ -8,12 +8,13 @@ export function computeFileHash(absolutePath) {
8
8
  }
9
9
  export function computeFreshness(trackedFiles, project) {
10
10
  if (!trackedFiles || trackedFiles.length === 0) {
11
- return { status: "no_tracking", modified: [], missing: [], unchanged: [], anchors_lost: [], total: 0 };
11
+ return { status: "no_tracking", modified: [], missing: [], unchanged: [], anchors_lost: [], broken: [], total: 0 };
12
12
  }
13
13
  const modified = [];
14
14
  const missing = [];
15
15
  const unchanged = [];
16
16
  const anchors_lost = [];
17
+ const broken = [];
17
18
  for (const tracked of trackedFiles) {
18
19
  // A relative path with no code root configured on this machine is not
19
20
  // resolvable here — count it as absent so we end up "unverifiable" rather
@@ -61,8 +62,10 @@ export function computeFreshness(trackedFiles, project) {
61
62
  }
62
63
  else {
63
64
  // Hash comparison is the only freshness system. An entry with neither
64
- // sections nor contentHash predates it and cannot be checked.
65
- throw new Error(`Tracked file "${tracked.path}" has no contentHash and no sections — it predates hash-based tracking. Call refresh_file_snapshot on this doc to snapshot it, or re-declare it via update_doc.`);
65
+ // sections nor contentHash predates it and cannot be checked — it is
66
+ // reported as broken (never checked by any other means) so readers can
67
+ // still expose the doc and its tracked list to the agent for re-tracking.
68
+ broken.push(tracked.path);
66
69
  }
67
70
  }
68
71
  // When every tracked file is absent, the source code simply isn't present on
@@ -72,15 +75,18 @@ export function computeFreshness(trackedFiles, project) {
72
75
  // change signal (likely deletions) and still counts as stale.
73
76
  const allMissing = missing.length === trackedFiles.length;
74
77
  if (allMissing) {
75
- return { status: "unverifiable", modified, missing, unchanged, anchors_lost, total: trackedFiles.length };
78
+ return { status: "unverifiable", modified, missing, unchanged, anchors_lost, broken, total: trackedFiles.length };
76
79
  }
77
80
  const isStale = modified.length > 0 || missing.length > 0 || anchors_lost.length > 0;
81
+ // Broken tracking outranks everything: no freshness verdict is trustworthy
82
+ // until the broken entries are re-tracked.
78
83
  return {
79
- status: isStale ? "stale" : "up_to_date",
84
+ status: broken.length > 0 ? "broken" : isStale ? "stale" : "up_to_date",
80
85
  modified,
81
86
  missing,
82
87
  unchanged,
83
88
  anchors_lost,
89
+ broken,
84
90
  total: trackedFiles.length,
85
91
  };
86
92
  }
@@ -91,11 +97,23 @@ export function formatFreshnessReport(title, report) {
91
97
  if (report.status === "unverifiable") {
92
98
  return `UNVERIFIABLE: "${title}" — none of the ${report.total} tracked file(s) are present on this machine, so freshness cannot be checked here. This is expected when the documentation is cloned without its source codebase.`;
93
99
  }
94
- let text = report.status === "stale"
95
- ? `STALE: "${title}" — tracked files have changed since the documentation was last updated.\n`
96
- : `UP TO DATE: "${title}" — no tracked files have been modified.\n`;
100
+ let text;
101
+ if (report.status === "broken") {
102
+ text = `BROKEN TRACKING: "${title}" — some tracked entries have no content snapshot and cannot be checked.\n`;
103
+ }
104
+ else if (report.status === "stale") {
105
+ text = `STALE: "${title}" — tracked files have changed since the documentation was last updated.\n`;
106
+ }
107
+ else {
108
+ text = `UP TO DATE: "${title}" — no tracked files have been modified.\n`;
109
+ }
97
110
  const anchorLostCount = report.anchors_lost?.length ?? 0;
98
- text += `\nSummary: ${report.modified.length} modified, ${report.missing.length} missing, ${anchorLostCount} with lost anchors, ${report.unchanged.length} unchanged out of ${report.total} tracked file(s).`;
111
+ const brokenCount = report.broken?.length ?? 0;
112
+ text += `\nSummary: ${report.modified.length} modified, ${report.missing.length} missing, ${anchorLostCount} with lost anchors, ${brokenCount} with broken tracking, ${report.unchanged.length} unchanged out of ${report.total} tracked file(s).`;
113
+ if (brokenCount > 0) {
114
+ text += `\n\n⚠ Files with broken tracking — no snapshot to compare against:\n${report.broken.map((f) => ` - ${f}`).join("\n")}`;
115
+ text += `\n → These entries predate hash-based tracking. Re-read the source files and re-declare them in tracked_files via update_doc, or call refresh_file_snapshot if the doc content is still accurate.`;
116
+ }
99
117
  if (report.modified.length > 0) {
100
118
  text += `\n\nModified files (call refresh_file_snapshot to acknowledge):\n${report.modified.map((f) => ` - ${f}`).join("\n")}`;
101
119
  }
@@ -125,16 +143,12 @@ async function scanProjectFreshness(storageEngine, project) {
125
143
  const doc = await storageEngine.getDoc(summary.id, project);
126
144
  if (!doc)
127
145
  continue;
128
- let report;
129
- try {
130
- report = computeFreshness(doc.tracked_files, project);
131
- }
132
- catch (err) {
133
- const message = err instanceof Error ? err.message : String(err);
134
- scan.broken.push(` - **${doc.title}** [${doc.id}]: ${message}`);
135
- continue;
146
+ const report = computeFreshness(doc.tracked_files, project);
147
+ if (report.status === "broken") {
148
+ const files = report.broken.map((f) => ` • no snapshot: ${f}`).join("\n");
149
+ scan.broken.push(` - **${doc.title}** [${doc.id}]\n${files}`);
136
150
  }
137
- if (report.status === "no_tracking") {
151
+ else if (report.status === "no_tracking") {
138
152
  scan.noTracking.push(doc.title);
139
153
  }
140
154
  else if (report.status === "unverifiable") {
@@ -13,6 +13,8 @@ export async function getDocMetadata(storageEngine, args) {
13
13
  const { doc, project } = resolved;
14
14
  const report = computeFreshness(doc.tracked_files, project);
15
15
  const fileStatusIcon = (filePath) => {
16
+ if (report.broken.includes(filePath))
17
+ return "⚠ broken tracking (no snapshot — re-declare via update_doc or run refresh_file_snapshot)";
16
18
  if (report.modified.includes(filePath))
17
19
  return "✗ modified";
18
20
  if (report.missing.includes(filePath))
@@ -29,8 +29,20 @@ export async function getDoc(storageEngine, args) {
29
29
  }).join("\n")
30
30
  : " (none)";
31
31
  const freshnessReport = computeFreshness(doc.tracked_files, resolvedProject);
32
- const stalenessBanner = freshnessReport.status === "stale"
33
- ? [
32
+ let stalenessBanner = "";
33
+ if (freshnessReport.status === "broken") {
34
+ stalenessBanner = [
35
+ `> [!WARNING]`,
36
+ `> **Le suivi de fichiers de cette documentation est cassé.**`,
37
+ `> Les entrées suivantes n'ont aucun snapshot de contenu et ne peuvent pas être vérifiées :`,
38
+ ...freshnessReport.broken.map((f) => `> - \`${f}\` *(aucun snapshot)*`),
39
+ `>`,
40
+ `> La fraîcheur de ce document est invérifiable tant que ces entrées ne sont pas re-suivies. Relisez les fichiers sources et re-déclarez-les via \`update_doc\` (tracked_files), ou appelez \`refresh_file_snapshot\` si le contenu ci-dessous est encore exact.`,
41
+ ``,
42
+ ].join("\n");
43
+ }
44
+ else if (freshnessReport.status === "stale") {
45
+ stalenessBanner = [
34
46
  `> [!WARNING]`,
35
47
  `> **Cette documentation est potentiellement obsolète.**`,
36
48
  `> Les fichiers suivants ont été modifiés depuis la dernière mise à jour de ce document :`,
@@ -41,8 +53,8 @@ export async function getDoc(storageEngine, args) {
41
53
  `>`,
42
54
  `> Vérifiez si le contenu ci-dessous est toujours valide. Si nécessaire, utilisez \`git_diff_doc\` pour voir les changements, puis mettez à jour la documentation avec \`patch_doc\` ou \`update_doc\`.`,
43
55
  ``,
44
- ].join("\n")
45
- : "";
56
+ ].join("\n");
57
+ }
46
58
  const text = [
47
59
  stalenessBanner,
48
60
  `# ${doc.title}`,
@@ -62,7 +62,9 @@ export async function gitDiffDoc(storageEngine, args) {
62
62
  }
63
63
  else {
64
64
  const freshness = computeFreshness(doc.tracked_files, project);
65
- const changedPaths = new Set([...freshness.modified, ...freshness.missing]);
65
+ // Broken entries (no snapshot) can't be hash-compared, but the git diff
66
+ // is still the best signal the agent has to re-assess them — include them.
67
+ const changedPaths = new Set([...freshness.modified, ...freshness.missing, ...freshness.broken]);
66
68
  filesToDiff = doc.tracked_files.filter((tf) => changedPaths.has(tf.path));
67
69
  }
68
70
  if (filesToDiff.length === 0) {
@@ -21,7 +21,7 @@ function renderProjectDocs(summaries, totalLabel) {
21
21
  if (key)
22
22
  lines.push(`\n 📁 ${key}`);
23
23
  for (const s of groups.get(key)) {
24
- const staleTag = s.isStale ? " [STALE]" : "";
24
+ const staleTag = s.isBroken ? " [BROKEN TRACKING]" : s.isStale ? " [STALE]" : "";
25
25
  const tags = s.tags.length > 0 ? ` (${s.tags.join(", ")})` : "";
26
26
  const indent = key ? " " : " ";
27
27
  lines.push(`${indent}- ${s.title} [${s.id}]${tags}${staleTag} — updated ${s.updatedAt}`);
@@ -43,6 +43,7 @@ export async function listDocs(storageEngine, args) {
43
43
  if (doc && doc.tracked_files.length > 0) {
44
44
  const report = computeFreshness(doc.tracked_files, proj);
45
45
  summary.isStale = report.status === "stale";
46
+ summary.isBroken = report.status === "broken";
46
47
  }
47
48
  }
48
49
  if (summaries.length === 0) {
@@ -61,6 +62,7 @@ export async function listDocs(storageEngine, args) {
61
62
  if (doc && doc.tracked_files.length > 0) {
62
63
  const report = computeFreshness(doc.tracked_files, proj);
63
64
  summary.isStale = report.status === "stale";
65
+ summary.isBroken = report.status === "broken";
64
66
  }
65
67
  }
66
68
  grandTotal += summaries.length;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.62";
1
+ export declare const APP_VERSION = "1.0.64";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated version file - do not edit manually
2
- export const APP_VERSION = '1.0.62';
2
+ export const APP_VERSION = '1.0.64';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-documentor",
3
- "version": "1.0.62",
3
+ "version": "1.0.64",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",