@imenam/mcp-documentor 1.0.56 → 1.0.58
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.
- package/README.md +69 -0
- package/dist/shared/read-only.js +3 -0
- package/dist/shared/schemas/doc.schema.d.ts +1 -0
- package/dist/shared/schemas/doc.schema.js +5 -5
- package/dist/src/core/code-ref-resolver.d.ts +2 -2
- package/dist/src/core/code-ref-resolver.js +14 -9
- package/dist/src/core/git-sync.d.ts +56 -0
- package/dist/src/core/git-sync.js +215 -0
- package/dist/src/core/path-resolver.d.ts +20 -0
- package/dist/src/core/path-resolver.js +82 -0
- package/dist/src/core/storage-engine.d.ts +22 -0
- package/dist/src/core/storage-engine.js +62 -3
- package/dist/src/index.js +94 -7
- package/dist/src/tools/check-freshness.d.ts +1 -1
- package/dist/src/tools/check-freshness.js +44 -12
- package/dist/src/tools/create-doc.js +2 -2
- package/dist/src/tools/get-doc-metadata.js +7 -3
- package/dist/src/tools/get-doc.js +6 -2
- package/dist/src/tools/git-diff-doc.js +11 -6
- package/dist/src/tools/list-docs.js +2 -2
- package/dist/src/tools/patch-doc.js +1 -1
- package/dist/src/tools/refresh-file-snapshot.js +10 -7
- package/dist/src/tools/sync.d.ts +17 -0
- package/dist/src/tools/sync.js +113 -0
- package/dist/src/tools/update-doc.js +2 -2
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +4 -2
- package/scripts/migrate-paths-to-relative.js +192 -0
|
@@ -5,6 +5,17 @@ interface GlobalConfig {
|
|
|
5
5
|
projects: string[];
|
|
6
6
|
foldersMigrated?: boolean;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* GitHub sync settings. Sourced exclusively from environment variables — the
|
|
10
|
+
* repository, branch and token are administration concerns and must never be
|
|
11
|
+
* settable by the agent at runtime.
|
|
12
|
+
*/
|
|
13
|
+
export interface SyncConfig {
|
|
14
|
+
remoteUrl: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
/** PAT for HTTPS auth (MCP_DOCUMENTOR_SYNC_TOKEN). */
|
|
17
|
+
token?: string;
|
|
18
|
+
}
|
|
8
19
|
export declare class StorageEngine {
|
|
9
20
|
private rootDir;
|
|
10
21
|
private historyStore?;
|
|
@@ -13,8 +24,19 @@ export declare class StorageEngine {
|
|
|
13
24
|
getDataDir(): string;
|
|
14
25
|
getProjectDir(project?: string): string;
|
|
15
26
|
private getConfigPath;
|
|
27
|
+
private getLocalStatePath;
|
|
28
|
+
private readLocalState;
|
|
29
|
+
private writeLocalState;
|
|
30
|
+
/**
|
|
31
|
+
* Returns the merged config. The shared part (projects list, migration flags)
|
|
32
|
+
* comes from config.json (versioned), while currentProject is a machine-local
|
|
33
|
+
* preference sourced from local-state.json (gitignored). Legacy config.json
|
|
34
|
+
* files that still embed currentProject are migrated transparently.
|
|
35
|
+
*/
|
|
16
36
|
getConfig(): GlobalConfig;
|
|
17
37
|
saveConfig(config: GlobalConfig): void;
|
|
38
|
+
/** Reads sync settings from environment variables (admin-controlled, not agent-settable). */
|
|
39
|
+
getSyncConfig(): SyncConfig | undefined;
|
|
18
40
|
ensureProject(project: string): void;
|
|
19
41
|
private walkMdFiles;
|
|
20
42
|
private docIdToFilePath;
|
|
@@ -27,22 +27,81 @@ export class StorageEngine {
|
|
|
27
27
|
getConfigPath() {
|
|
28
28
|
return path.join(this.getDataDir(), "config.json");
|
|
29
29
|
}
|
|
30
|
+
getLocalStatePath() {
|
|
31
|
+
return path.join(this.getDataDir(), "local-state.json");
|
|
32
|
+
}
|
|
33
|
+
readLocalState() {
|
|
34
|
+
const p = this.getLocalStatePath();
|
|
35
|
+
if (fs.existsSync(p)) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
logger.warn("Failed to parse local-state.json, using defaults");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
writeLocalState(state) {
|
|
46
|
+
const p = this.getLocalStatePath();
|
|
47
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
48
|
+
fs.writeFileSync(p, JSON.stringify(state, null, 2), "utf-8");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Returns the merged config. The shared part (projects list, migration flags)
|
|
52
|
+
* comes from config.json (versioned), while currentProject is a machine-local
|
|
53
|
+
* preference sourced from local-state.json (gitignored). Legacy config.json
|
|
54
|
+
* files that still embed currentProject are migrated transparently.
|
|
55
|
+
*/
|
|
30
56
|
getConfig() {
|
|
31
57
|
const configPath = this.getConfigPath();
|
|
58
|
+
let shared = {};
|
|
32
59
|
if (fs.existsSync(configPath)) {
|
|
33
60
|
try {
|
|
34
|
-
|
|
61
|
+
shared = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
35
62
|
}
|
|
36
63
|
catch {
|
|
37
64
|
logger.warn("Failed to parse config.json, using defaults");
|
|
38
65
|
}
|
|
39
66
|
}
|
|
40
|
-
|
|
67
|
+
const local = this.readLocalState();
|
|
68
|
+
const projects = shared.projects ?? ["default"];
|
|
69
|
+
// Migrate a legacy currentProject embedded in config.json into local-state.
|
|
70
|
+
if (shared.currentProject !== undefined && local.currentProject === undefined) {
|
|
71
|
+
local.currentProject = shared.currentProject;
|
|
72
|
+
this.writeLocalState(local);
|
|
73
|
+
delete shared.currentProject;
|
|
74
|
+
fs.writeFileSync(configPath, JSON.stringify(shared, null, 2), "utf-8");
|
|
75
|
+
logger.info("Migrated currentProject from config.json to local-state.json");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
currentProject: local.currentProject ?? projects[0] ?? "default",
|
|
79
|
+
projects,
|
|
80
|
+
foldersMigrated: shared.foldersMigrated,
|
|
81
|
+
};
|
|
41
82
|
}
|
|
42
83
|
saveConfig(config) {
|
|
84
|
+
// Shared, versioned part → config.json (currentProject is intentionally excluded).
|
|
43
85
|
const configPath = this.getConfigPath();
|
|
86
|
+
const shared = {
|
|
87
|
+
projects: config.projects,
|
|
88
|
+
foldersMigrated: config.foldersMigrated,
|
|
89
|
+
};
|
|
44
90
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
45
|
-
fs.writeFileSync(configPath, JSON.stringify(
|
|
91
|
+
fs.writeFileSync(configPath, JSON.stringify(shared, null, 2), "utf-8");
|
|
92
|
+
// Machine-local part → local-state.json.
|
|
93
|
+
const local = this.readLocalState();
|
|
94
|
+
local.currentProject = config.currentProject;
|
|
95
|
+
this.writeLocalState(local);
|
|
96
|
+
}
|
|
97
|
+
/** Reads sync settings from environment variables (admin-controlled, not agent-settable). */
|
|
98
|
+
getSyncConfig() {
|
|
99
|
+
const remoteUrl = process.env.MCP_DOCUMENTOR_SYNC_REMOTE?.trim();
|
|
100
|
+
if (!remoteUrl)
|
|
101
|
+
return undefined;
|
|
102
|
+
const branch = process.env.MCP_DOCUMENTOR_SYNC_BRANCH?.trim() || "main";
|
|
103
|
+
const token = process.env.MCP_DOCUMENTOR_SYNC_TOKEN?.trim() || undefined;
|
|
104
|
+
return { remoteUrl, branch, token };
|
|
46
105
|
}
|
|
47
106
|
ensureProject(project) {
|
|
48
107
|
const projectDir = path.join(this.getDataDir(), "projects", project);
|
package/dist/src/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { updateDoc } from "./tools/update-doc.js";
|
|
|
18
18
|
import { searchDocs } from "./tools/search-docs.js";
|
|
19
19
|
import { checkFreshness, computeFreshness } from "./tools/check-freshness.js";
|
|
20
20
|
import { findAndHashSection } from "./core/section-tracker.js";
|
|
21
|
+
import { toAbsolutePath } from "./core/path-resolver.js";
|
|
21
22
|
import { setProject } from "./tools/set-project.js";
|
|
22
23
|
import { listProjects } from "./tools/list-projects.js";
|
|
23
24
|
import { refreshFileSnapshot } from "./tools/refresh-file-snapshot.js";
|
|
@@ -39,6 +40,8 @@ import { prepareExportBundle } from "./tools/prepare-export-bundle.js";
|
|
|
39
40
|
import { deleteExportBundle } from "./tools/delete-export-bundle.js";
|
|
40
41
|
import { uploadImage } from "./tools/upload-image.js";
|
|
41
42
|
import { getImage } from "./tools/get-image.js";
|
|
43
|
+
import { SyncEngine } from "./core/git-sync.js";
|
|
44
|
+
import { syncStatus, pullDocs, pushDocs, syncDocs, resolveSyncConflict } from "./tools/sync.js";
|
|
42
45
|
import { isReadOnly, WRITE_TOOLS } from "../shared/read-only.js";
|
|
43
46
|
const __filename = fileURLToPath(import.meta.url);
|
|
44
47
|
const __dirname = path.dirname(__filename);
|
|
@@ -75,6 +78,7 @@ embeddingStore.init(storageEngine.getDataDir(), openaiModel);
|
|
|
75
78
|
const historyStore = new HistoryStore();
|
|
76
79
|
historyStore.init(storageEngine.getDataDir());
|
|
77
80
|
storageEngine.setHistoryStore(historyStore);
|
|
81
|
+
const syncEngine = new SyncEngine(storageEngine);
|
|
78
82
|
const server = new Server({ name: "mcp-documentor", version: APP_VERSION }, { capabilities: { tools: {} } });
|
|
79
83
|
let guiLauncher;
|
|
80
84
|
const sseControllers = new Set();
|
|
@@ -155,7 +159,7 @@ async function handleGetInitialState(msg) {
|
|
|
155
159
|
for (const doc of docs) {
|
|
156
160
|
const full = await storageEngine.getDoc(doc.id); // doc.id is now the full path
|
|
157
161
|
if (full && full.tracked_files.length > 0) {
|
|
158
|
-
const report = computeFreshness(full.tracked_files);
|
|
162
|
+
const report = computeFreshness(full.tracked_files, config.currentProject);
|
|
159
163
|
doc.isStale = report.status === "stale";
|
|
160
164
|
}
|
|
161
165
|
}
|
|
@@ -193,7 +197,7 @@ async function handleUpdateProject(msg) {
|
|
|
193
197
|
for (const doc of docs) {
|
|
194
198
|
const full = await storageEngine.getDoc(doc.id);
|
|
195
199
|
if (full && full.tracked_files.length > 0) {
|
|
196
|
-
const report = computeFreshness(full.tracked_files);
|
|
200
|
+
const report = computeFreshness(full.tracked_files, project);
|
|
197
201
|
doc.isStale = report.status === "stale";
|
|
198
202
|
}
|
|
199
203
|
}
|
|
@@ -229,11 +233,12 @@ async function handleListProjects(msg) {
|
|
|
229
233
|
}
|
|
230
234
|
async function handleListDocs(msg) {
|
|
231
235
|
try {
|
|
236
|
+
const currentProject = storageEngine.getConfig().currentProject;
|
|
232
237
|
const docs = await storageEngine.listDocs();
|
|
233
238
|
for (const doc of docs) {
|
|
234
239
|
const full = await storageEngine.getDoc(doc.id);
|
|
235
240
|
if (full && full.tracked_files.length > 0) {
|
|
236
|
-
const report = computeFreshness(full.tracked_files);
|
|
241
|
+
const report = computeFreshness(full.tracked_files, currentProject);
|
|
237
242
|
doc.isStale = report.status === "stale";
|
|
238
243
|
}
|
|
239
244
|
}
|
|
@@ -341,15 +346,15 @@ async function handleCreateDocIPC(msg) {
|
|
|
341
346
|
data: { success: true, id },
|
|
342
347
|
timestamp: new Date().toISOString(),
|
|
343
348
|
});
|
|
349
|
+
const config = storageEngine.getConfig();
|
|
344
350
|
const docs = await storageEngine.listDocs();
|
|
345
351
|
for (const doc of docs) {
|
|
346
352
|
const full = await storageEngine.getDoc(doc.id);
|
|
347
353
|
if (full && full.tracked_files.length > 0) {
|
|
348
|
-
const report = computeFreshness(full.tracked_files);
|
|
354
|
+
const report = computeFreshness(full.tracked_files, config.currentProject);
|
|
349
355
|
doc.isStale = report.status === "stale";
|
|
350
356
|
}
|
|
351
357
|
}
|
|
352
|
-
const config = storageEngine.getConfig();
|
|
353
358
|
broadcastToGUI({
|
|
354
359
|
type: "INITIAL_STATE",
|
|
355
360
|
data: { config, docs, projects: config.projects },
|
|
@@ -417,7 +422,9 @@ async function handleGetFileSection(msg) {
|
|
|
417
422
|
const trackedFile = resolved.doc.tracked_files.find((f) => f.path === filePath);
|
|
418
423
|
if (!trackedFile || !trackedFile.sections?.length)
|
|
419
424
|
throw new Error("No sections for this file");
|
|
420
|
-
const absolutePath =
|
|
425
|
+
const absolutePath = toAbsolutePath(trackedFile.path, resolved.project);
|
|
426
|
+
if (!absolutePath)
|
|
427
|
+
throw new Error("Cannot resolve file on this machine (no code root configured)");
|
|
421
428
|
const fileLines = fs.readFileSync(absolutePath, "utf-8").split("\n");
|
|
422
429
|
const sections = trackedFile.sections.map((section) => {
|
|
423
430
|
const result = findAndHashSection(absolutePath, section);
|
|
@@ -459,7 +466,7 @@ async function handleCheckFreshness(msg) {
|
|
|
459
466
|
});
|
|
460
467
|
return;
|
|
461
468
|
}
|
|
462
|
-
const report = computeFreshness(doc.tracked_files);
|
|
469
|
+
const report = computeFreshness(doc.tracked_files, resolved.project);
|
|
463
470
|
broadcastToGUI({
|
|
464
471
|
type: "CHECK_FRESHNESS_RESPONSE",
|
|
465
472
|
correlationId: msg.correlationId,
|
|
@@ -1030,6 +1037,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1030
1037
|
required: ["uri"],
|
|
1031
1038
|
},
|
|
1032
1039
|
},
|
|
1040
|
+
{
|
|
1041
|
+
name: "sync_status",
|
|
1042
|
+
description: "Show the synchronization status: current branch, how many commits the local repo is ahead/behind the remote, conflicted files, and uncommitted changes. The sync target (repository, branch, token) is fixed by the server administrator via environment variables and cannot be changed from here.",
|
|
1043
|
+
inputSchema: { type: "object", properties: {} },
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
name: "pull_docs",
|
|
1047
|
+
description: "Pull documentation updates from the GitHub remote. Defensive: only fast-forwards a clean working tree, never auto-merges a divergent history (local data is never overwritten). Reindexes embeddings for new documents after a successful update.",
|
|
1048
|
+
inputSchema: { type: "object", properties: {} },
|
|
1049
|
+
},
|
|
1050
|
+
{
|
|
1051
|
+
name: "push_docs",
|
|
1052
|
+
description: "Commit local documentation changes (markdown + assets + project list) and push them to the GitHub remote.",
|
|
1053
|
+
inputSchema: {
|
|
1054
|
+
type: "object",
|
|
1055
|
+
properties: {
|
|
1056
|
+
message: { type: "string", description: "Optional commit message (a default with machine name and timestamp is used otherwise)" },
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
name: "sync_docs",
|
|
1062
|
+
description: "Full synchronization: pull (defensive) then push. If the pull reveals a divergence or conflict, the push is skipped so it can be resolved first.",
|
|
1063
|
+
inputSchema: {
|
|
1064
|
+
type: "object",
|
|
1065
|
+
properties: {
|
|
1066
|
+
message: { type: "string", description: "Optional commit message for the push step" },
|
|
1067
|
+
},
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
name: "resolve_sync_conflict",
|
|
1072
|
+
description: "Bulk-resolve sync merge conflicts by keeping one side for the conflicted files, then stage them. For nuanced resolution, edit the conflicted markdown files directly instead. After resolving, run push_docs.",
|
|
1073
|
+
inputSchema: {
|
|
1074
|
+
type: "object",
|
|
1075
|
+
properties: {
|
|
1076
|
+
strategy: { type: "string", enum: ["mine", "theirs"], description: "'mine' keeps the local version, 'theirs' keeps the incoming remote version" },
|
|
1077
|
+
paths: { type: "array", items: { type: "string" }, description: "Specific conflicted file paths to resolve (defaults to all conflicted files)" },
|
|
1078
|
+
},
|
|
1079
|
+
required: ["strategy"],
|
|
1080
|
+
},
|
|
1081
|
+
},
|
|
1033
1082
|
];
|
|
1034
1083
|
return {
|
|
1035
1084
|
tools: isReadOnly() ? tools.filter((t) => !WRITE_TOOLS.has(t.name)) : tools,
|
|
@@ -1111,6 +1160,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1111
1160
|
return await uploadImage(storageEngine, args);
|
|
1112
1161
|
case "get_image":
|
|
1113
1162
|
return await getImage(storageEngine, args);
|
|
1163
|
+
case "sync_status":
|
|
1164
|
+
return await syncStatus(syncEngine);
|
|
1165
|
+
case "pull_docs":
|
|
1166
|
+
return await pullDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore);
|
|
1167
|
+
case "push_docs":
|
|
1168
|
+
return await pushDocs(syncEngine, args);
|
|
1169
|
+
case "sync_docs":
|
|
1170
|
+
return await syncDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore, args);
|
|
1171
|
+
case "resolve_sync_conflict":
|
|
1172
|
+
return await resolveSyncConflict(syncEngine, args);
|
|
1114
1173
|
default:
|
|
1115
1174
|
throw new Error(`Tool not found: ${name}`);
|
|
1116
1175
|
}
|
|
@@ -1122,9 +1181,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1122
1181
|
};
|
|
1123
1182
|
}
|
|
1124
1183
|
});
|
|
1184
|
+
async function syncPullOnStartup() {
|
|
1185
|
+
if (!syncEngine.isConfigured())
|
|
1186
|
+
return;
|
|
1187
|
+
if ((process.env.SYNC_PULL_ON_START ?? "").trim().toLowerCase() === "false")
|
|
1188
|
+
return;
|
|
1189
|
+
try {
|
|
1190
|
+
const res = await syncEngine.pull();
|
|
1191
|
+
if (res.status === "updated") {
|
|
1192
|
+
logger.info(`Startup sync: pulled ${res.changed.length} changed file(s).`);
|
|
1193
|
+
if (embeddingEngine.isConfigured()) {
|
|
1194
|
+
await reindexEmbeddings(storageEngine, embeddingEngine, embeddingStore, {});
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
else if (res.status === "diverged") {
|
|
1198
|
+
logger.warn(`Startup sync: local history diverged (ahead ${res.ahead}, behind ${res.behind}). Started on local data — run sync_status / sync_docs to reconcile.`);
|
|
1199
|
+
}
|
|
1200
|
+
else if (res.status === "conflict") {
|
|
1201
|
+
logger.warn(`Startup sync: merge conflict in ${res.conflicted.length} file(s). Started on local data — resolve with resolve_sync_conflict.`);
|
|
1202
|
+
}
|
|
1203
|
+
else {
|
|
1204
|
+
logger.info("Startup sync: already up to date.");
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
catch (err) {
|
|
1208
|
+
logger.error(`Startup sync failed (non-fatal): ${err?.message ?? err}`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1125
1211
|
async function main() {
|
|
1126
1212
|
await migrateFolders(storageEngine);
|
|
1127
1213
|
await migrateEmbeddings(embeddingStore, storageEngine);
|
|
1214
|
+
await syncPullOnStartup();
|
|
1128
1215
|
const transport = new StdioServerTransport();
|
|
1129
1216
|
await server.connect(transport);
|
|
1130
1217
|
logger.info("MCP Server connected and running on stdio");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { TrackedFile, FreshnessReport } from "../../shared/schemas/doc.schema.js";
|
|
2
2
|
import type { StorageEngine } from "../core/storage-engine.js";
|
|
3
3
|
export declare function computeFileHash(absolutePath: string): string;
|
|
4
|
-
export declare function computeFreshness(trackedFiles: TrackedFile[]): FreshnessReport;
|
|
4
|
+
export declare function computeFreshness(trackedFiles: TrackedFile[], project: string): FreshnessReport;
|
|
5
5
|
export declare function formatFreshnessReport(title: string, report: FreshnessReport): string;
|
|
6
6
|
export declare function checkFreshness(storageEngine: StorageEngine, args: unknown): Promise<{
|
|
7
7
|
isError: boolean;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import fs from "fs";
|
|
3
|
-
import path from "path";
|
|
4
3
|
import { CheckFreshnessArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
5
4
|
import { findAndHashSection } from "../core/section-tracker.js";
|
|
5
|
+
import { toAbsolutePath } from "../core/path-resolver.js";
|
|
6
6
|
export function computeFileHash(absolutePath) {
|
|
7
7
|
return crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex");
|
|
8
8
|
}
|
|
9
|
-
export function computeFreshness(trackedFiles) {
|
|
9
|
+
export function computeFreshness(trackedFiles, project) {
|
|
10
10
|
if (!trackedFiles || trackedFiles.length === 0) {
|
|
11
11
|
return { status: "no_tracking", modified: [], missing: [], unchanged: [], anchors_lost: [], total: 0 };
|
|
12
12
|
}
|
|
@@ -15,8 +15,11 @@ export function computeFreshness(trackedFiles) {
|
|
|
15
15
|
const unchanged = [];
|
|
16
16
|
const anchors_lost = [];
|
|
17
17
|
for (const tracked of trackedFiles) {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// A relative path with no code root configured on this machine is not
|
|
19
|
+
// resolvable here — count it as absent so we end up "unverifiable" rather
|
|
20
|
+
// than crying wolf with "stale".
|
|
21
|
+
const absolutePath = toAbsolutePath(tracked.path, project);
|
|
22
|
+
if (!absolutePath || !fs.existsSync(absolutePath)) {
|
|
20
23
|
missing.push(tracked.path);
|
|
21
24
|
continue;
|
|
22
25
|
}
|
|
@@ -68,6 +71,15 @@ export function computeFreshness(trackedFiles) {
|
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
}
|
|
74
|
+
// When every tracked file is absent, the source code simply isn't present on
|
|
75
|
+
// this machine (e.g. docs cloned without the codebase) — we can't verify
|
|
76
|
+
// freshness, so report "unverifiable" rather than crying wolf with "stale".
|
|
77
|
+
// A partial absence (some files missing, others present) is treated as a real
|
|
78
|
+
// change signal (likely deletions) and still counts as stale.
|
|
79
|
+
const allMissing = missing.length === trackedFiles.length;
|
|
80
|
+
if (allMissing) {
|
|
81
|
+
return { status: "unverifiable", modified, missing, unchanged, anchors_lost, total: trackedFiles.length };
|
|
82
|
+
}
|
|
71
83
|
const isStale = modified.length > 0 || missing.length > 0 || anchors_lost.length > 0;
|
|
72
84
|
return {
|
|
73
85
|
status: isStale ? "stale" : "up_to_date",
|
|
@@ -82,6 +94,9 @@ export function formatFreshnessReport(title, report) {
|
|
|
82
94
|
if (report.status === "no_tracking") {
|
|
83
95
|
return `No tracked files for "${title}". Use tracked_files when creating or updating the doc to enable staleness detection.`;
|
|
84
96
|
}
|
|
97
|
+
if (report.status === "unverifiable") {
|
|
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.`;
|
|
99
|
+
}
|
|
85
100
|
let text = report.status === "stale"
|
|
86
101
|
? `STALE: "${title}" — tracked files have changed since the documentation was last updated.\n`
|
|
87
102
|
: `UP TO DATE: "${title}" — no tracked files have been modified.\n`;
|
|
@@ -100,6 +115,15 @@ export function formatFreshnessReport(title, report) {
|
|
|
100
115
|
}
|
|
101
116
|
return text;
|
|
102
117
|
}
|
|
118
|
+
/** Cap on how many "not tracking any file" doc titles are listed before collapsing into a count. */
|
|
119
|
+
const UNTRACKED_LIST_LIMIT = 10;
|
|
120
|
+
/** Renders the untracked-docs list, truncated past UNTRACKED_LIST_LIMIT to avoid flooding the context. */
|
|
121
|
+
function renderUntrackedList(titles, heading) {
|
|
122
|
+
const shown = titles.slice(0, UNTRACKED_LIST_LIMIT).map((t) => ` - ${t}`);
|
|
123
|
+
const remaining = titles.length - UNTRACKED_LIST_LIMIT;
|
|
124
|
+
const suffix = remaining > 0 ? `\n … and ${remaining} more` : "";
|
|
125
|
+
return `\n\n${heading}\n${shown.join("\n")}${suffix}`;
|
|
126
|
+
}
|
|
103
127
|
export async function checkFreshness(storageEngine, args) {
|
|
104
128
|
try {
|
|
105
129
|
const validated = CheckFreshnessArgsSchema.parse(args);
|
|
@@ -108,8 +132,8 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
108
132
|
if (!resolved) {
|
|
109
133
|
return { isError: true, content: [{ type: "text", text: `Documentation entry "${validated.path}" not found in any project.` }] };
|
|
110
134
|
}
|
|
111
|
-
const { doc } = resolved;
|
|
112
|
-
const report = computeFreshness(doc.tracked_files);
|
|
135
|
+
const { doc, project } = resolved;
|
|
136
|
+
const report = computeFreshness(doc.tracked_files, project);
|
|
113
137
|
return { content: [{ type: "text", text: formatFreshnessReport(doc.title, report) }] };
|
|
114
138
|
}
|
|
115
139
|
if (validated.project) {
|
|
@@ -120,14 +144,18 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
120
144
|
const stale = [];
|
|
121
145
|
const noTracking = [];
|
|
122
146
|
let upToDate = 0;
|
|
147
|
+
let unverifiable = 0;
|
|
123
148
|
for (const summary of summaries) {
|
|
124
149
|
const doc = await storageEngine.getDoc(summary.id, validated.project);
|
|
125
150
|
if (!doc)
|
|
126
151
|
continue;
|
|
127
|
-
const report = computeFreshness(doc.tracked_files);
|
|
152
|
+
const report = computeFreshness(doc.tracked_files, validated.project);
|
|
128
153
|
if (report.status === "no_tracking") {
|
|
129
154
|
noTracking.push(doc.title);
|
|
130
155
|
}
|
|
156
|
+
else if (report.status === "unverifiable") {
|
|
157
|
+
unverifiable++;
|
|
158
|
+
}
|
|
131
159
|
else if (report.status === "stale") {
|
|
132
160
|
const details = [
|
|
133
161
|
...report.modified.map((f) => ` • modified: ${f}`),
|
|
@@ -141,12 +169,12 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
141
169
|
}
|
|
142
170
|
}
|
|
143
171
|
let text = `# Freshness report — project "${validated.project}"\n\n`;
|
|
144
|
-
text += `${summaries.length} doc(s) scanned: **${stale.length} stale**, ${upToDate} up to date, ${noTracking.length}
|
|
172
|
+
text += `${summaries.length} doc(s) scanned: **${stale.length} stale**, ${upToDate} up to date, ${unverifiable} unverifiable (source absent), ${noTracking.length} not tracking any file.\n`;
|
|
145
173
|
if (stale.length > 0) {
|
|
146
174
|
text += `\n## Stale docs\n\n${stale.join("\n\n")}`;
|
|
147
175
|
}
|
|
148
176
|
if (noTracking.length > 0) {
|
|
149
|
-
text +=
|
|
177
|
+
text += renderUntrackedList(noTracking, "## Docs not tracking any file (no staleness detection)");
|
|
150
178
|
}
|
|
151
179
|
return { content: [{ type: "text", text }] };
|
|
152
180
|
}
|
|
@@ -161,14 +189,18 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
161
189
|
const stale = [];
|
|
162
190
|
const noTracking = [];
|
|
163
191
|
let upToDate = 0;
|
|
192
|
+
let unverifiable = 0;
|
|
164
193
|
for (const summary of summaries) {
|
|
165
194
|
const doc = await storageEngine.getDoc(summary.id, proj);
|
|
166
195
|
if (!doc)
|
|
167
196
|
continue;
|
|
168
|
-
const report = computeFreshness(doc.tracked_files);
|
|
197
|
+
const report = computeFreshness(doc.tracked_files, proj);
|
|
169
198
|
if (report.status === "no_tracking") {
|
|
170
199
|
noTracking.push(doc.title);
|
|
171
200
|
}
|
|
201
|
+
else if (report.status === "unverifiable") {
|
|
202
|
+
unverifiable++;
|
|
203
|
+
}
|
|
172
204
|
else if (report.status === "stale") {
|
|
173
205
|
const details = [
|
|
174
206
|
...report.modified.map((f) => ` • modified: ${f}`),
|
|
@@ -184,12 +216,12 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
184
216
|
totalDocs += summaries.length;
|
|
185
217
|
totalStale += stale.length;
|
|
186
218
|
let block = `## ${proj}\n\n`;
|
|
187
|
-
block += `${summaries.length} doc(s): **${stale.length} stale**, ${upToDate} up to date, ${noTracking.length}
|
|
219
|
+
block += `${summaries.length} doc(s): **${stale.length} stale**, ${upToDate} up to date, ${unverifiable} unverifiable (source absent), ${noTracking.length} not tracking any file.`;
|
|
188
220
|
if (stale.length > 0) {
|
|
189
221
|
block += `\n\n### Stale docs\n\n${stale.join("\n\n")}`;
|
|
190
222
|
}
|
|
191
223
|
if (noTracking.length > 0) {
|
|
192
|
-
block +=
|
|
224
|
+
block += renderUntrackedList(noTracking, "### Docs not tracking any file");
|
|
193
225
|
}
|
|
194
226
|
projectBlocks.push(block);
|
|
195
227
|
}
|
|
@@ -31,11 +31,12 @@ export async function createDoc(storageEngine, embeddingEngine, args) {
|
|
|
31
31
|
if (project) {
|
|
32
32
|
storageEngine.ensureProject(project);
|
|
33
33
|
}
|
|
34
|
+
const resolvedProject = project ?? storageEngine.getConfig().currentProject;
|
|
34
35
|
const allTrackedInputs = [
|
|
35
36
|
...(validated.tracked_files ?? []),
|
|
36
37
|
...additionalTrackedFiles,
|
|
37
38
|
];
|
|
38
|
-
const trackedFiles = allTrackedInputs.length > 0 ? snapshotTrackedFiles(allTrackedInputs) : [];
|
|
39
|
+
const trackedFiles = allTrackedInputs.length > 0 ? snapshotTrackedFiles(allTrackedInputs, resolvedProject) : [];
|
|
39
40
|
const folder = validated.folder;
|
|
40
41
|
const doc = {
|
|
41
42
|
id,
|
|
@@ -48,7 +49,6 @@ export async function createDoc(storageEngine, embeddingEngine, args) {
|
|
|
48
49
|
createdAt: now,
|
|
49
50
|
updatedAt: now,
|
|
50
51
|
};
|
|
51
|
-
const resolvedProject = project ?? storageEngine.getConfig().currentProject;
|
|
52
52
|
await storageEngine.saveDoc(doc, resolvedProject, "create");
|
|
53
53
|
if (embeddingEngine.isConfigured()) {
|
|
54
54
|
const embedding = await embeddingEngine.generateEmbedding(content);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import fs from "fs";
|
|
1
2
|
import { GetDocMetadataArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
2
3
|
import { computeFreshness } from "./check-freshness.js";
|
|
3
4
|
import { findAndHashSection } from "../core/section-tracker.js";
|
|
5
|
+
import { toAbsolutePath } from "../core/path-resolver.js";
|
|
4
6
|
export async function getDocMetadata(storageEngine, args) {
|
|
5
7
|
try {
|
|
6
8
|
const validated = GetDocMetadataArgsSchema.parse(args);
|
|
@@ -8,8 +10,8 @@ export async function getDocMetadata(storageEngine, args) {
|
|
|
8
10
|
if (!resolved) {
|
|
9
11
|
return { isError: true, content: [{ type: "text", text: `Documentation entry "${validated.path}" not found in any project.` }] };
|
|
10
12
|
}
|
|
11
|
-
const { doc } = resolved;
|
|
12
|
-
const report = computeFreshness(doc.tracked_files);
|
|
13
|
+
const { doc, project } = resolved;
|
|
14
|
+
const report = computeFreshness(doc.tracked_files, project);
|
|
13
15
|
const fileStatusIcon = (filePath) => {
|
|
14
16
|
if (report.modified.includes(filePath))
|
|
15
17
|
return "✗ modified";
|
|
@@ -28,8 +30,10 @@ export async function getDocMetadata(storageEngine, args) {
|
|
|
28
30
|
text += doc.tracked_files.map((f) => {
|
|
29
31
|
let entry = ` ${fileStatusIcon(f.path)} — ${f.path}\n File mtime: ${f.lastModifiedAt}`;
|
|
30
32
|
if (f.sections && f.sections.length > 0) {
|
|
33
|
+
const abs = toAbsolutePath(f.path, project);
|
|
34
|
+
const readable = abs && fs.existsSync(abs);
|
|
31
35
|
const sectionLines = f.sections.map((section, idx) => {
|
|
32
|
-
const result = findAndHashSection(
|
|
36
|
+
const result = readable ? findAndHashSection(abs, section) : { found: false };
|
|
33
37
|
const location = result.found
|
|
34
38
|
? `lines ${result.lines.start}–${result.lines.end}`
|
|
35
39
|
: "section introuvable (ancres modifiées)";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import fs from "fs";
|
|
1
2
|
import { GetDocArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
2
3
|
import { computeFreshness } from "./check-freshness.js";
|
|
3
4
|
import { findAndHashSection } from "../core/section-tracker.js";
|
|
5
|
+
import { toAbsolutePath } from "../core/path-resolver.js";
|
|
4
6
|
export async function getDoc(storageEngine, args) {
|
|
5
7
|
try {
|
|
6
8
|
const validated = GetDocArgsSchema.parse(args);
|
|
@@ -12,8 +14,10 @@ export async function getDoc(storageEngine, args) {
|
|
|
12
14
|
const trackedFilesText = doc.tracked_files.length > 0
|
|
13
15
|
? doc.tracked_files.map((f) => {
|
|
14
16
|
if (f.sections && f.sections.length > 0) {
|
|
17
|
+
const abs = toAbsolutePath(f.path, resolvedProject);
|
|
18
|
+
const readable = abs && fs.existsSync(abs);
|
|
15
19
|
const sectionLines = f.sections.map((section, idx) => {
|
|
16
|
-
const result = findAndHashSection(
|
|
20
|
+
const result = readable ? findAndHashSection(abs, section) : { found: false };
|
|
17
21
|
const location = result.found
|
|
18
22
|
? `lines ${result.lines.start}–${result.lines.end}`
|
|
19
23
|
: "section introuvable";
|
|
@@ -24,7 +28,7 @@ export async function getDoc(storageEngine, args) {
|
|
|
24
28
|
return ` - ${f.path} (snapshot: ${f.lastModifiedAt})`;
|
|
25
29
|
}).join("\n")
|
|
26
30
|
: " (none)";
|
|
27
|
-
const freshnessReport = computeFreshness(doc.tracked_files);
|
|
31
|
+
const freshnessReport = computeFreshness(doc.tracked_files, resolvedProject);
|
|
28
32
|
const stalenessBanner = freshnessReport.status === "stale"
|
|
29
33
|
? [
|
|
30
34
|
`> [!WARNING]`,
|
|
@@ -2,8 +2,12 @@ import { execSync } from "child_process";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { GitDiffDocArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
4
4
|
import { computeFreshness } from "./check-freshness.js";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import { toAbsolutePath, toStoredPath } from "../core/path-resolver.js";
|
|
6
|
+
function getGitDiffForFile(tracked, project) {
|
|
7
|
+
const filePath = toAbsolutePath(tracked.path, project);
|
|
8
|
+
if (!filePath) {
|
|
9
|
+
return ` [INFO] Cannot resolve "${tracked.path}" on this machine (no code root configured) — source absent.`;
|
|
10
|
+
}
|
|
7
11
|
const fileDir = path.dirname(filePath);
|
|
8
12
|
let repoRoot;
|
|
9
13
|
try {
|
|
@@ -46,17 +50,18 @@ export async function gitDiffDoc(storageEngine, args) {
|
|
|
46
50
|
docSections.push(`## ${docPath}\n\n[ERROR] Documentation entry not found in any project.`);
|
|
47
51
|
continue;
|
|
48
52
|
}
|
|
49
|
-
const { doc } = resolved;
|
|
53
|
+
const { doc, project } = resolved;
|
|
50
54
|
if (!doc.tracked_files || doc.tracked_files.length === 0) {
|
|
51
55
|
docSections.push(`## ${doc.title}\n\n[INFO] No tracked files.`);
|
|
52
56
|
continue;
|
|
53
57
|
}
|
|
54
58
|
let filesToDiff;
|
|
55
59
|
if (validated.file_paths) {
|
|
56
|
-
|
|
60
|
+
const wanted = new Set(validated.file_paths.map((p) => toStoredPath(p, project)));
|
|
61
|
+
filesToDiff = doc.tracked_files.filter((tf) => wanted.has(tf.path));
|
|
57
62
|
}
|
|
58
63
|
else {
|
|
59
|
-
const freshness = computeFreshness(doc.tracked_files);
|
|
64
|
+
const freshness = computeFreshness(doc.tracked_files, project);
|
|
60
65
|
const changedPaths = new Set([...freshness.modified, ...freshness.missing]);
|
|
61
66
|
filesToDiff = doc.tracked_files.filter((tf) => changedPaths.has(tf.path));
|
|
62
67
|
}
|
|
@@ -75,7 +80,7 @@ export async function gitDiffDoc(storageEngine, args) {
|
|
|
75
80
|
return true;
|
|
76
81
|
})
|
|
77
82
|
.map((tf) => {
|
|
78
|
-
const diff = getGitDiffForFile(tf);
|
|
83
|
+
const diff = getGitDiffForFile(tf, project);
|
|
79
84
|
return `### ${tf.path}\nSnapshot date: ${tf.lastModifiedAt}\n\n${diff}`;
|
|
80
85
|
});
|
|
81
86
|
if (fileSections.length === 0) {
|
|
@@ -41,7 +41,7 @@ export async function listDocs(storageEngine, args) {
|
|
|
41
41
|
for (const summary of summaries) {
|
|
42
42
|
const doc = await storageEngine.getDoc(summary.id, proj);
|
|
43
43
|
if (doc && doc.tracked_files.length > 0) {
|
|
44
|
-
const report = computeFreshness(doc.tracked_files);
|
|
44
|
+
const report = computeFreshness(doc.tracked_files, proj);
|
|
45
45
|
summary.isStale = report.status === "stale";
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -59,7 +59,7 @@ export async function listDocs(storageEngine, args) {
|
|
|
59
59
|
for (const summary of summaries) {
|
|
60
60
|
const doc = await storageEngine.getDoc(summary.id, proj);
|
|
61
61
|
if (doc && doc.tracked_files.length > 0) {
|
|
62
|
-
const report = computeFreshness(doc.tracked_files);
|
|
62
|
+
const report = computeFreshness(doc.tracked_files, proj);
|
|
63
63
|
summary.isStale = report.status === "stale";
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -28,7 +28,7 @@ export async function patchDoc(storageEngine, embeddingEngine, args) {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
doc.content = doc.content.replace(search, replacement);
|
|
31
|
-
doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles);
|
|
31
|
+
doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles, resolvedProject);
|
|
32
32
|
doc.updatedAt = new Date().toISOString();
|
|
33
33
|
await storageEngine.saveDoc(doc, resolvedProject, "patch");
|
|
34
34
|
if (embeddingEngine.isConfigured()) {
|