@doquflow/server 0.3.0 → 0.4.0

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/dist/extractor.js CHANGED
@@ -48,6 +48,35 @@ const RE_PROCESS_ENV = /\bprocess\.env\.([A-Z_][A-Z0-9_]*)/g;
48
48
  const RE_DOTNET_ENV = /\bEnvironment\.GetEnvironmentVariable\(\s*["']([^"']+)["']/g;
49
49
  const RE_ICONFIG = /\bIConfiguration\b/g;
50
50
  const RE_APPSETTINGS = /\bappsettings(?:\.[A-Za-z0-9]+)?\.json\b/g;
51
+ // ─── Go ─────────────────────────────────────────────────────────────────────
52
+ // type FooBar struct | type FooBar interface
53
+ const RE_GO_TYPE = /\btype\s+([A-Z]\w*)\s+(?:struct|interface)/g;
54
+ // func FuncName( or func (recv *Type) MethodName(
55
+ const RE_GO_FUNC = /\bfunc\s+(?:\([^)]+\)\s+)?([A-Za-z_]\w*)\s*\(/g;
56
+ // import "pkg" or import ( "pkg" ) — individual quoted paths inside import blocks
57
+ const RE_GO_IMPORT = /^\s+["']([^"'\s]+)["']\s*(?:\/\/.*)?$/gm;
58
+ // os.Getenv("KEY") or os.LookupEnv("KEY")
59
+ const RE_GO_ENV = /\bos\.(?:Getenv|LookupEnv)\s*\(\s*["']([A-Z_][A-Z0-9_]*)["']/g;
60
+ // gorilla/mux, gin, chi, echo, stdlib: router.GET("/path", ...)
61
+ const RE_GO_HTTP = /\b(?:r|router|e|mux|g|app)\s*\.\s*(?:HandleFunc|GET|POST|PUT|DELETE|PATCH|Handle|Any)\s*\(\s*["']([^"']+)["']/g;
62
+ // GORM explicit table: db.Table("name")
63
+ const RE_GO_GORM_TABLE = /\bdb\s*\.\s*Table\s*\(\s*["']([^"']+)["']/g;
64
+ // ─── Ruby / Rails ────────────────────────────────────────────────────────────
65
+ // class Foo or module Bar
66
+ const RE_RUBY_CLASS = /\b(?:class|module)\s+([A-Z]\w*(?:::[A-Z]\w*)*)/g;
67
+ // def method_name or def self.method_name
68
+ const RE_RUBY_DEF = /^\s*def\s+(?:self\.)?([a-z_]\w*)/gm;
69
+ // require 'gem' or require_relative '../path'
70
+ const RE_RUBY_REQUIRE = /\brequire(?:_relative)?\s+["']([^"']+)["']/g;
71
+ // ENV['KEY'] or ENV["KEY"]
72
+ const RE_RUBY_ENV = /\bENV\s*\[\s*["']([A-Z_][A-Z0-9_]*)["']\s*\]/g;
73
+ // Rails routes: get '/path', post '/path', resources :users, root 'home#index'
74
+ const RE_RAILS_ROUTE = /^\s*(?:get|post|put|delete|patch|resources|resource|root)\s+["']([^"']+)["']/gm;
75
+ // ActiveRecord associations: has_many :users, belongs_to :user
76
+ const RE_AR_HAS = /\bhas_(?:many|one)\s+:(\w+)/g;
77
+ const RE_AR_BELONGS = /\bbelongs_to\s+:(\w+)/g;
78
+ // Explicit table name: self.table_name = 'table'
79
+ const RE_AR_TABLE = /\bself\.table_name\s*=\s*["']([^"']+)["']/g;
51
80
  // SQL keywords and noise that the regex may pick up as table names.
52
81
  // Also strips single-letter hits (LINQ aliases: `from u in _db.Users` → u).
53
82
  const SQL_KEYWORD_NOISE = new Set([
@@ -77,11 +106,17 @@ function cleanTableNames(names) {
77
106
  });
78
107
  }
79
108
  function extract(content) {
80
- const classes = collect(new RegExp(RE_CLASS.source, "g"), content);
109
+ const classes = uniq([
110
+ ...collect(new RegExp(RE_CLASS.source, "g"), content),
111
+ ...collect(new RegExp(RE_GO_TYPE.source, "g"), content),
112
+ ...collect(new RegExp(RE_RUBY_CLASS.source, "g"), content),
113
+ ]);
81
114
  const functions = uniq([
82
115
  ...collect(new RegExp(RE_FUNC_KW.source, "g"), content),
83
116
  ...collect(new RegExp(RE_FUNC_ARROW.source, "g"), content),
84
117
  ...collect(new RegExp(RE_METHOD_TS.source, "gm"), content),
118
+ ...collect(new RegExp(RE_GO_FUNC.source, "g"), content),
119
+ ...collect(new RegExp(RE_RUBY_DEF.source, "gm"), content),
85
120
  ]).filter((n) => !["if", "for", "while", "switch", "catch", "return", "function"].includes(n));
86
121
  const dependencies = uniq([
87
122
  ...collect(new RegExp(RE_USING.source, "g"), content),
@@ -89,6 +124,8 @@ function extract(content) {
89
124
  ...collect(new RegExp(RE_REQUIRE.source, "g"), content),
90
125
  ...collect(new RegExp(RE_DECORATOR.source, "g"), content),
91
126
  ...collect(new RegExp(RE_NEW_CLASS.source, "g"), content),
127
+ ...collect(new RegExp(RE_GO_IMPORT.source, "gm"), content),
128
+ ...collect(new RegExp(RE_RUBY_REQUIRE.source, "g"), content),
92
129
  ]);
93
130
  const db_tables = uniq(cleanTableNames([
94
131
  ...collect(new RegExp(RE_SQL_TABLE.source, "gi"), content),
@@ -96,6 +133,10 @@ function extract(content) {
96
133
  ...collect(new RegExp(RE_TABLE_ATTR.source, "g"), content),
97
134
  ...collect(new RegExp(RE_TABLE_FLUENT.source, "g"), content),
98
135
  ...collect(new RegExp(RE_EF_PROP.source, "g"), content),
136
+ ...collect(new RegExp(RE_GO_GORM_TABLE.source, "g"), content),
137
+ ...collect(new RegExp(RE_AR_HAS.source, "g"), content),
138
+ ...collect(new RegExp(RE_AR_BELONGS.source, "g"), content),
139
+ ...collect(new RegExp(RE_AR_TABLE.source, "g"), content),
99
140
  ]));
100
141
  const endpoints = uniq([
101
142
  ...collect(new RegExp(RE_DOTNET_ROUTE.source, "g"), content),
@@ -106,11 +147,15 @@ function extract(content) {
106
147
  ...collect(new RegExp(RE_FLASK.source, "g"), content),
107
148
  ...collect(new RegExp(RE_FASTAPI.source, "g"), content),
108
149
  ...collect(new RegExp(RE_DJANGO.source, "g"), content),
150
+ ...collect(new RegExp(RE_GO_HTTP.source, "g"), content),
151
+ ...collect(new RegExp(RE_RAILS_ROUTE.source, "gm"), content),
109
152
  ]);
110
153
  const config_refs = [];
111
154
  config_refs.push(...collect(new RegExp(RE_CFG_CONNSTR.source, "g"), content).map((s) => `ConnectionStrings:${s}`));
112
155
  config_refs.push(...collect(new RegExp(RE_PROCESS_ENV.source, "g"), content).map((s) => `process.env.${s}`));
113
156
  config_refs.push(...collect(new RegExp(RE_DOTNET_ENV.source, "g"), content));
157
+ config_refs.push(...collect(new RegExp(RE_GO_ENV.source, "g"), content).map((s) => `os.Getenv(${s})`));
158
+ config_refs.push(...collect(new RegExp(RE_RUBY_ENV.source, "g"), content).map((s) => `ENV[${s}]`));
114
159
  if (RE_ICONFIG.test(content))
115
160
  config_refs.push("IConfiguration");
116
161
  if (RE_APPSETTINGS.test(content))
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ const save_answer_as_page_1 = require("./tools/save-answer-as-page");
18
18
  const lint_wiki_1 = require("./tools/lint-wiki");
19
19
  const get_schema_guidance_1 = require("./tools/get-schema-guidance");
20
20
  const preview_generation_1 = require("./tools/preview-generation");
21
+ const generate_dependency_graph_1 = require("./tools/generate-dependency-graph");
21
22
  const server = new index_js_1.Server({ name: "docuflow", version: "0.1.0" }, { capabilities: { tools: {} } });
22
23
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
23
24
  tools: [
@@ -104,7 +105,7 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
104
105
  },
105
106
  {
106
107
  name: "list_wiki",
107
- description: "List all wiki pages in .docuflow/wiki/, optionally filtered by category. Returns metadata (title, created_at, sources, tags) and page counts by category.",
108
+ description: "List all wiki pages in .docuflow/wiki/, optionally filtered by category. Returns metadata (title, created_at, sources, tags, stale) and page counts by category. Pages not updated in 30+ days are flagged stale:true.",
108
109
  inputSchema: {
109
110
  type: "object",
110
111
  properties: {
@@ -240,6 +241,26 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
240
241
  required: ["tool_name", "project_path", "params"],
241
242
  },
242
243
  },
244
+ {
245
+ name: "generate_dependency_graph",
246
+ description: "Scan a project and build a dependency graph showing how modules import each other, which DB tables are shared, and which files are most connected. Returns nodes, edges, shared tables, and the top 10 most-connected modules. Use this to understand coupling and identify risky files to change.",
247
+ inputSchema: {
248
+ type: "object",
249
+ properties: {
250
+ project_path: { type: "string", description: "Root directory of the project to analyse." },
251
+ extensions: {
252
+ type: "array",
253
+ items: { type: "string" },
254
+ description: "Optional extension filter e.g. [\".ts\",\".go\"]. If omitted, all non-binary files are scanned.",
255
+ },
256
+ focus: {
257
+ type: "string",
258
+ description: "Optional: filter graph to neighbours of a specific file or module name (partial match, e.g. 'user-service').",
259
+ },
260
+ },
261
+ required: ["project_path"],
262
+ },
263
+ },
243
264
  ],
244
265
  }));
245
266
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
@@ -288,6 +309,9 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
288
309
  else if (name === "preview_generation") {
289
310
  result = await (0, preview_generation_1.previewGeneration)(args);
290
311
  }
312
+ else if (name === "generate_dependency_graph") {
313
+ result = await (0, generate_dependency_graph_1.generateDependencyGraph)(args);
314
+ }
291
315
  else {
292
316
  result = { error: `Unknown tool: ${name}` };
293
317
  }
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.generateDependencyGraph = generateDependencyGraph;
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const list_modules_1 = require("./list-modules");
9
+ async function generateDependencyGraph(input) {
10
+ try {
11
+ const projectPath = node_path_1.default.resolve(input.project_path);
12
+ // Scan all modules
13
+ const listResult = await (0, list_modules_1.listModules)({
14
+ path: projectPath,
15
+ extensions: input.extensions,
16
+ });
17
+ const modules = listResult.modules ?? [];
18
+ if (modules.length === 0) {
19
+ return {
20
+ nodes: [],
21
+ edges: [],
22
+ shared_tables: {},
23
+ shared_endpoints: {},
24
+ most_connected: [],
25
+ summary: "No source files found in the project.",
26
+ };
27
+ }
28
+ // Build id from relative path (normalised for use as node id)
29
+ const relPath = (filePath) => node_path_1.default.relative(projectPath, filePath).replace(/\\/g, "/");
30
+ // Build nodes map
31
+ const nodeMap = new Map();
32
+ for (const mod of modules) {
33
+ const id = relPath(mod.path);
34
+ nodeMap.set(id, {
35
+ id,
36
+ label: node_path_1.default.basename(mod.path),
37
+ language: mod.language,
38
+ classes: mod.classes.length,
39
+ functions: mod.functions.length,
40
+ db_tables: mod.db_tables,
41
+ endpoints: mod.endpoints,
42
+ });
43
+ }
44
+ // Build import edges: for each module's dependency list, look for matches in node IDs
45
+ const edges = [];
46
+ const edgeSet = new Set(); // avoid duplicates
47
+ for (const mod of modules) {
48
+ const fromId = relPath(mod.path);
49
+ for (const dep of mod.dependencies) {
50
+ // Try to resolve dep to a known node (relative path match or basename match)
51
+ for (const [nodeId] of nodeMap) {
52
+ // Match if the dependency string is contained in the node path
53
+ // (e.g., dep = "./user-service" matches "src/user-service.ts")
54
+ const depNorm = dep.replace(/^[./]+/, "").replace(/\\/g, "/").toLowerCase();
55
+ const nodeNorm = nodeId.replace(/\\/g, "/").toLowerCase();
56
+ if (depNorm.length > 2 &&
57
+ (nodeNorm.includes(depNorm) || nodeNorm.replace(/\.\w+$/, "").endsWith(depNorm))) {
58
+ const key = `${fromId}->${nodeId}`;
59
+ if (fromId !== nodeId && !edgeSet.has(key)) {
60
+ edgeSet.add(key);
61
+ edges.push({ from: fromId, to: nodeId, type: "imports" });
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ // Shared DB tables
68
+ const tableToModules = new Map();
69
+ for (const mod of modules) {
70
+ for (const table of mod.db_tables) {
71
+ const key = table.toLowerCase();
72
+ if (!tableToModules.has(key))
73
+ tableToModules.set(key, []);
74
+ tableToModules.get(key).push(relPath(mod.path));
75
+ }
76
+ }
77
+ const shared_tables = {};
78
+ for (const [table, mods] of tableToModules) {
79
+ if (mods.length > 1) {
80
+ shared_tables[table] = mods;
81
+ // Add shared_table edges
82
+ for (let i = 0; i < mods.length; i++) {
83
+ for (let j = i + 1; j < mods.length; j++) {
84
+ const key = `${mods[i]}<>${mods[j]}:${table}`;
85
+ if (!edgeSet.has(key)) {
86
+ edgeSet.add(key);
87
+ edges.push({ from: mods[i], to: mods[j], type: "shared_table" });
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ // Shared endpoints
94
+ const endpointToModules = new Map();
95
+ for (const mod of modules) {
96
+ for (const ep of mod.endpoints) {
97
+ if (!endpointToModules.has(ep))
98
+ endpointToModules.set(ep, []);
99
+ endpointToModules.get(ep).push(relPath(mod.path));
100
+ }
101
+ }
102
+ const shared_endpoints = {};
103
+ for (const [ep, mods] of endpointToModules) {
104
+ if (mods.length > 1)
105
+ shared_endpoints[ep] = mods;
106
+ }
107
+ // Most connected nodes (by total edge count)
108
+ const connectionCount = new Map();
109
+ for (const edge of edges) {
110
+ connectionCount.set(edge.from, (connectionCount.get(edge.from) ?? 0) + 1);
111
+ connectionCount.set(edge.to, (connectionCount.get(edge.to) ?? 0) + 1);
112
+ }
113
+ const most_connected = Array.from(connectionCount.entries())
114
+ .map(([id, count]) => ({
115
+ id,
116
+ label: nodeMap.get(id)?.label ?? id,
117
+ connection_count: count,
118
+ }))
119
+ .sort((a, b) => b.connection_count - a.connection_count)
120
+ .slice(0, 10);
121
+ // Apply focus filter: if focus is set, only include nodes/edges reachable from it
122
+ let nodes = Array.from(nodeMap.values());
123
+ let filteredEdges = edges;
124
+ if (input.focus) {
125
+ const focusNorm = input.focus.toLowerCase();
126
+ const focusId = nodes.find((n) => n.id.toLowerCase().includes(focusNorm))?.id;
127
+ if (focusId) {
128
+ const reachable = new Set([focusId]);
129
+ // BFS: include direct neighbours
130
+ for (const e of edges) {
131
+ if (e.from === focusId)
132
+ reachable.add(e.to);
133
+ if (e.to === focusId)
134
+ reachable.add(e.from);
135
+ }
136
+ nodes = nodes.filter((n) => reachable.has(n.id));
137
+ filteredEdges = edges.filter((e) => reachable.has(e.from) && reachable.has(e.to));
138
+ }
139
+ }
140
+ const sharedTableCount = Object.keys(shared_tables).length;
141
+ const summary = [
142
+ `${nodes.length} modules, ${filteredEdges.length} dependencies`,
143
+ sharedTableCount > 0 ? `${sharedTableCount} shared DB table(s)` : null,
144
+ most_connected.length > 0
145
+ ? `Most connected: ${most_connected[0].label} (${most_connected[0].connection_count} links)`
146
+ : null,
147
+ ]
148
+ .filter(Boolean)
149
+ .join(" · ");
150
+ return {
151
+ nodes,
152
+ edges: filteredEdges,
153
+ shared_tables,
154
+ shared_endpoints,
155
+ most_connected,
156
+ summary,
157
+ };
158
+ }
159
+ catch (e) {
160
+ return { error: e?.message ?? String(e) };
161
+ }
162
+ }
@@ -6,6 +6,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.ingestSource = ingestSource;
7
7
  const node_path_1 = __importDefault(require("node:path"));
8
8
  const filesystem_1 = require("../filesystem");
9
+ /**
10
+ * Find the first paragraph in the source that mentions the given name.
11
+ * Returns cleaned text (stripped of markdown syntax), up to 400 chars.
12
+ */
13
+ function findContextParagraph(content, name) {
14
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
+ const re = new RegExp(`\\b${escaped}\\b`, "i");
16
+ const paragraphs = content.split(/\n{2,}/);
17
+ for (const para of paragraphs) {
18
+ if (!re.test(para))
19
+ continue;
20
+ const clean = para
21
+ .replace(/^#+\s*/gm, "")
22
+ .replace(/\*\*?([^*]+)\*\*?/g, "$1")
23
+ .replace(/`([^`]+)`/g, "$1")
24
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
25
+ .trim();
26
+ if (clean.length > 20)
27
+ return clean.substring(0, 400);
28
+ }
29
+ return null;
30
+ }
9
31
  /**
10
32
  * Simple markdown-based entity extraction.
11
33
  * Looks for patterns like:
@@ -88,7 +110,7 @@ function extractFromMarkdown(content) {
88
110
  * Convert a source document into a collection of wiki pages.
89
111
  * For each distinct entity/concept, create a page.
90
112
  */
91
- function generateWikiPages(sourceId, sourceTitle, extracted) {
113
+ function generateWikiPages(sourceId, sourceTitle, extracted, sourceContent) {
92
114
  const now = new Date().toISOString();
93
115
  const pages = [];
94
116
  // Create summary page (synthesis)
@@ -111,11 +133,15 @@ function generateWikiPages(sourceId, sourceTitle, extracted) {
111
133
  // Create entity pages
112
134
  for (const entity of extracted.entities) {
113
135
  const entityId = `entity_${entity.name.replace(/[^a-z0-9]/gi, "_").toLowerCase()}`;
136
+ const contextPara = findContextParagraph(sourceContent, entity.name);
137
+ const overview = contextPara
138
+ ? `${contextPara}\n\n*Introduced in: ${sourceTitle}*`
139
+ : `Introduced in: ${sourceTitle}`;
114
140
  const entityPage = {
115
141
  id: entityId,
116
142
  title: entity.name,
117
143
  category: "entity",
118
- content: `# ${entity.name}\n\n**Type:** ${entity.type}\n\n## Overview\n\nIntroduced in: ${sourceTitle}`,
144
+ content: `# ${entity.name}\n\n**Type:** ${entity.type}\n\n## Overview\n\n${overview}`,
119
145
  frontmatter: {
120
146
  created_at: now,
121
147
  updated_at: now,
@@ -130,11 +156,15 @@ function generateWikiPages(sourceId, sourceTitle, extracted) {
130
156
  // Create concept pages
131
157
  for (const concept of extracted.concepts) {
132
158
  const conceptId = `concept_${concept.replace(/[^a-z0-9]/gi, "_").toLowerCase()}`;
159
+ const contextPara = findContextParagraph(sourceContent, concept);
160
+ const definition = contextPara
161
+ ? `${contextPara}\n\n*Introduced in: ${sourceTitle}*`
162
+ : `To be expanded with additional sources.\n\n*Introduced in: ${sourceTitle}*`;
133
163
  const conceptPage = {
134
164
  id: conceptId,
135
165
  title: concept,
136
166
  category: "concept",
137
- content: `# ${concept}\n\n**Introduced in:** ${sourceTitle}\n\n## Definition\n\nTo be expanded with additional sources.`,
167
+ content: `# ${concept}\n\n## Definition\n\n${definition}`,
138
168
  frontmatter: {
139
169
  created_at: now,
140
170
  updated_at: now,
@@ -172,7 +202,7 @@ async function ingestSource(input) {
172
202
  // Extract information
173
203
  const extracted = extractFromMarkdown(sourceContent);
174
204
  // Generate wiki pages
175
- const wikiPages = generateWikiPages(sourceTitle, sourceTitle, extracted);
205
+ const wikiPages = generateWikiPages(sourceTitle, sourceTitle, extracted, sourceContent);
176
206
  // Write all pages
177
207
  const pagesCreated = [];
178
208
  for (const page of wikiPages) {
@@ -74,11 +74,9 @@ function extractLinks(content) {
74
74
  }
75
75
  return links;
76
76
  }
77
- function findOrphanPages(wikiPath, allPageIds) {
77
+ function findOrphanPages(pageMap) {
78
78
  const issues = [];
79
- for (const pageId of allPageIds) {
80
- // Pages with no inbound links are orphans
81
- const filePath = path.join(wikiPath, `${pageId}.md`);
79
+ for (const [pageId, filePath] of pageMap) {
82
80
  if (!fs.existsSync(filePath))
83
81
  continue;
84
82
  const content = fs.readFileSync(filePath, "utf-8");
@@ -96,11 +94,10 @@ function findOrphanPages(wikiPath, allPageIds) {
96
94
  }
97
95
  return issues;
98
96
  }
99
- function findStalePages(wikiPath, allPageIds) {
97
+ function findStalePages(pageMap) {
100
98
  const issues = [];
101
99
  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
102
- for (const pageId of allPageIds) {
103
- const filePath = path.join(wikiPath, `${pageId}.md`);
100
+ for (const [pageId, filePath] of pageMap) {
104
101
  if (!fs.existsSync(filePath))
105
102
  continue;
106
103
  const content = fs.readFileSync(filePath, "utf-8");
@@ -121,17 +118,16 @@ function findStalePages(wikiPath, allPageIds) {
121
118
  }
122
119
  return issues;
123
120
  }
124
- function findMissingReferences(wikiPath, allPageIds) {
121
+ function findMissingReferences(pageMap) {
125
122
  const issues = [];
126
- for (const pageId of allPageIds) {
127
- const filePath = path.join(wikiPath, `${pageId}.md`);
123
+ for (const [pageId, filePath] of pageMap) {
128
124
  if (!fs.existsSync(filePath))
129
125
  continue;
130
126
  const content = fs.readFileSync(filePath, "utf-8");
131
127
  const { body } = parsePageMetadata(content);
132
128
  const links = extractLinks(body);
133
129
  for (const link of links) {
134
- if (!allPageIds.has(link)) {
130
+ if (!pageMap.has(link)) {
135
131
  issues.push({
136
132
  type: "missing_ref",
137
133
  page_id: pageId,
@@ -145,10 +141,9 @@ function findMissingReferences(wikiPath, allPageIds) {
145
141
  }
146
142
  return issues;
147
143
  }
148
- function findMetadataGaps(wikiPath, allPageIds) {
144
+ function findMetadataGaps(pageMap) {
149
145
  const issues = [];
150
- for (const pageId of allPageIds) {
151
- const filePath = path.join(wikiPath, `${pageId}.md`);
146
+ for (const [pageId, filePath] of pageMap) {
152
147
  if (!fs.existsSync(filePath))
153
148
  continue;
154
149
  const content = fs.readFileSync(filePath, "utf-8");
@@ -289,32 +284,33 @@ async function lintWiki(params) {
289
284
  if (!fs.existsSync(wikiPath)) {
290
285
  throw new Error(`Wiki not found at ${wikiPath}`);
291
286
  }
292
- // Collect all page IDs
293
- const allPageIds = new Set();
287
+ // Collect all page IDs mapped to their full file paths
288
+ const pageMap = new Map();
294
289
  for (const categoryDir of fs.readdirSync(wikiPath)) {
295
290
  const categoryPath = path.join(wikiPath, categoryDir);
296
291
  if (!fs.statSync(categoryPath).isDirectory())
297
292
  continue;
298
293
  for (const file of fs.readdirSync(categoryPath)) {
299
294
  if (file.endsWith(".md")) {
300
- allPageIds.add(file.replace(".md", ""));
295
+ const pageId = file.replace(".md", "");
296
+ pageMap.set(pageId, path.join(categoryPath, file));
301
297
  }
302
298
  }
303
299
  }
304
300
  // Run checks
305
301
  let issues = [];
306
302
  if (check_type === "all" || check_type === "orphans") {
307
- issues.push(...findOrphanPages(wikiPath, allPageIds));
303
+ issues.push(...findOrphanPages(pageMap));
308
304
  }
309
305
  if (check_type === "all" || check_type === "contradictions") {
310
306
  issues.push(...findContradictions(wikiPath));
311
307
  }
312
308
  if (check_type === "all" || check_type === "stale") {
313
- issues.push(...findStalePages(wikiPath, allPageIds));
309
+ issues.push(...findStalePages(pageMap));
314
310
  }
315
311
  if (check_type === "all" || check_type === "metadata") {
316
- issues.push(...findMissingReferences(wikiPath, allPageIds));
317
- issues.push(...findMetadataGaps(wikiPath, allPageIds));
312
+ issues.push(...findMissingReferences(pageMap));
313
+ issues.push(...findMetadataGaps(pageMap));
318
314
  }
319
315
  // Calculate metrics
320
316
  const metrics = {
@@ -326,7 +322,7 @@ async function lintWiki(params) {
326
322
  };
327
323
  // Build result
328
324
  const result = {
329
- total_pages: allPageIds.size,
325
+ total_pages: pageMap.size,
330
326
  issues_found: issues,
331
327
  metrics,
332
328
  recommendations: [],
@@ -7,6 +7,19 @@ exports.listWiki = listWiki;
7
7
  const node_path_1 = __importDefault(require("node:path"));
8
8
  const promises_1 = __importDefault(require("node:fs/promises"));
9
9
  const filesystem_1 = require("../filesystem");
10
+ const PLURAL_TO_SINGULAR = {
11
+ entities: "entity",
12
+ concepts: "concept",
13
+ timelines: "timeline",
14
+ syntheses: "synthesis",
15
+ };
16
+ const SINGULAR_TO_PLURAL = {
17
+ entity: "entities",
18
+ concept: "concepts",
19
+ timeline: "timelines",
20
+ synthesis: "syntheses",
21
+ };
22
+ const STALE_DAYS = 30;
10
23
  /**
11
24
  * Parse frontmatter from markdown
12
25
  */
@@ -49,15 +62,17 @@ async function listWiki(input) {
49
62
  const wikiDir = node_path_1.default.join(docuDir, "wiki");
50
63
  const pages = [];
51
64
  const categories = {};
52
- // Build list of categories to scan
65
+ const now = Date.now();
66
+ // Build list of categories to scan — always use correct plural directory names
53
67
  let categoriesToScan = ["entities", "concepts", "timelines", "syntheses"];
54
68
  if (input.category) {
55
- categoriesToScan = [`${input.category}s`];
69
+ const pluralDir = SINGULAR_TO_PLURAL[input.category] ?? `${input.category}s`;
70
+ categoriesToScan = [pluralDir];
56
71
  }
57
72
  // Scan each category
58
73
  for (const categoryDir of categoriesToScan) {
59
74
  const fullCategoryPath = node_path_1.default.join(wikiDir, categoryDir);
60
- const category = categoryDir.replace("s", ""); // entities → entity
75
+ const category = PLURAL_TO_SINGULAR[categoryDir] ?? categoryDir;
61
76
  try {
62
77
  const files = await promises_1.default.readdir(fullCategoryPath);
63
78
  let categoryCount = 0;
@@ -71,15 +86,19 @@ async function listWiki(input) {
71
86
  const fm = parseFrontmatter(read.content);
72
87
  const title = extractTitle(read.content);
73
88
  const pageId = file.replace(".md", "");
89
+ const updatedAt = fm.updated_at ?? new Date().toISOString();
90
+ const updatedMs = new Date(updatedAt).getTime();
91
+ const stale = !isNaN(updatedMs) && (now - updatedMs) > STALE_DAYS * 86_400_000;
74
92
  pages.push({
75
93
  id: pageId,
76
94
  title,
77
95
  category,
78
96
  path: node_path_1.default.relative(docuDir, filePath),
79
97
  created_at: fm.created_at ?? new Date().toISOString(),
80
- updated_at: fm.updated_at ?? new Date().toISOString(),
98
+ updated_at: updatedAt,
81
99
  sources: fm.sources ?? [],
82
100
  tags: fm.tags ?? [],
101
+ stale,
83
102
  });
84
103
  categoryCount++;
85
104
  }
@@ -93,6 +112,7 @@ async function listWiki(input) {
93
112
  }
94
113
  return {
95
114
  total_pages: pages.length,
115
+ stale_pages: pages.filter((p) => p.stale).length,
96
116
  pages: pages.sort((a, b) => a.title.localeCompare(b.title)),
97
117
  categories,
98
118
  };
@@ -100,6 +120,7 @@ async function listWiki(input) {
100
120
  catch (e) {
101
121
  return {
102
122
  total_pages: 0,
123
+ stale_pages: 0,
103
124
  pages: [],
104
125
  categories: {},
105
126
  error: e?.message ?? String(e),
@@ -5,6 +5,35 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.previewGeneration = previewGeneration;
7
7
  const node_path_1 = __importDefault(require("node:path"));
8
+ const promises_1 = __importDefault(require("node:fs/promises"));
9
+ async function countWikiPages(wikiDir) {
10
+ let total = 0;
11
+ for (const cat of ["entities", "concepts", "timelines", "syntheses"]) {
12
+ try {
13
+ const files = await promises_1.default.readdir(node_path_1.default.join(wikiDir, cat));
14
+ total += files.filter((f) => f.endsWith(".md")).length;
15
+ }
16
+ catch {
17
+ // directory may not exist
18
+ }
19
+ }
20
+ return total;
21
+ }
22
+ async function getSourceFileSize(sourcesDir, filename) {
23
+ try {
24
+ const stat = await promises_1.default.stat(node_path_1.default.join(sourcesDir, filename));
25
+ return stat.size;
26
+ }
27
+ catch {
28
+ return 0;
29
+ }
30
+ }
31
+ function predictPageCount(fileSizeBytes) {
32
+ // Rough heuristic: ~1 wiki page per 800 bytes of source
33
+ const low = Math.max(1, Math.floor(fileSizeBytes / 1200));
34
+ const high = Math.max(2, Math.ceil(fileSizeBytes / 600));
35
+ return `${low}–${high}`;
36
+ }
8
37
  /**
9
38
  * preview_generation
10
39
  *
@@ -24,37 +53,45 @@ const node_path_1 = __importDefault(require("node:path"));
24
53
  */
25
54
  async function previewGeneration(input) {
26
55
  const projectPath = node_path_1.default.resolve(input.project_path);
56
+ const docuDir = node_path_1.default.join(projectPath, ".docuflow");
57
+ const wikiDir = node_path_1.default.join(docuDir, "wiki");
58
+ const sourcesDir = node_path_1.default.join(docuDir, "sources");
27
59
  const toolName = input.tool_name;
28
60
  const params = input.params;
29
- // Define preview for each tool
30
- const previews = {
31
- ingest_source: (projectPath, params) => ({
61
+ // Read real wiki state upfront
62
+ const existingPageCount = await countWikiPages(wikiDir);
63
+ if (toolName === "ingest_source") {
64
+ const filename = params.source_filename ?? "";
65
+ const fileSize = await getSourceFileSize(sourcesDir, filename);
66
+ const predictedNew = predictPageCount(fileSize);
67
+ const sizeLabel = fileSize > 0 ? `${Math.round(fileSize / 1024)}KB` : "unknown size";
68
+ return {
32
69
  tool_name: "ingest_source",
33
70
  tool_description: "Process a new source and integrate it into the wiki",
34
71
  input_provided: params,
35
72
  predicted_actions: [
36
- "✓ Read the source file(s)",
37
- "✓ Extract entities and concepts",
38
- "✓ Generate or update wiki pages",
39
- "✓ Create cross-references",
40
- "✓ Update index.md with new entities",
73
+ `✓ Read ${filename} (${sizeLabel})`,
74
+ "✓ Extract entities and concepts from markdown",
75
+ "✓ Generate wiki pages (one per entity/concept found)",
76
+ "✓ Create cross-references between pages",
77
+ "✓ Update index.md with new entries",
41
78
  "✓ Append entry to log.md",
42
79
  ],
43
80
  predicted_outputs: [
44
81
  {
45
82
  type: "Wiki Pages",
46
- description: "5-20 new or updated wiki pages depending on source size",
47
- example: "entities/ServiceName.md, concepts/Pattern.md, syntheses/Integration.md",
83
+ description: `Estimated ${predictedNew} new pages (wiki currently has ${existingPageCount})`,
84
+ example: "entities/ServiceName.md, concepts/Pattern.md, syntheses/source_name.md",
48
85
  },
49
86
  {
50
87
  type: "Index Update",
51
- description: "Updated index.md with new pages and metadata",
88
+ description: "index.md gets new page entries with metadata",
52
89
  example: "index.md entry with source, date, page count",
53
90
  },
54
91
  {
55
92
  type: "Log Entry",
56
- description: "New line in log.md documenting the ingest",
57
- example: "[2026-04-17 14:23] ingest | source_name.md12 pages created, 5 updated",
93
+ description: "log.md records this ingest",
94
+ example: `[${new Date().toISOString().slice(0, 10)}] ingest | ${filename}N pages created`,
58
95
  },
59
96
  ],
60
97
  data_modified: true,
@@ -67,8 +104,10 @@ async function previewGeneration(input) {
67
104
  ],
68
105
  estimated_impact: "high",
69
106
  proceed_recommendation: "✓ Safe to proceed. Source will be integrated and wiki will grow. This is expected behavior.",
70
- }),
71
- query_wiki: (projectPath, params) => ({
107
+ };
108
+ }
109
+ if (toolName === "query_wiki") {
110
+ return {
72
111
  tool_name: "query_wiki",
73
112
  tool_description: "Search and synthesize answers from the wiki",
74
113
  input_provided: params,
@@ -81,8 +120,8 @@ async function previewGeneration(input) {
81
120
  predicted_outputs: [
82
121
  {
83
122
  type: "Answer",
84
- description: "Synthesized answer to your question",
85
- example: 'The system uses MCP protocol to communicate with tools...',
123
+ description: `Synthesized answer from up to ${existingPageCount} wiki pages`,
124
+ example: "The system uses MCP protocol to communicate with tools...",
86
125
  },
87
126
  {
88
127
  type: "Source Pages",
@@ -92,15 +131,19 @@ async function previewGeneration(input) {
92
131
  {
93
132
  type: "Confidence",
94
133
  description: "How well the question was answered (0-100)",
95
- example: "85 (good coverage, some gaps possible)",
134
+ example: existingPageCount > 10 ? "85 (good coverage)" : "50 (sparse wiki — add more sources)",
96
135
  },
97
136
  ],
98
137
  data_modified: false,
99
138
  files_affected: [],
100
139
  estimated_impact: "low",
101
- proceed_recommendation: "✓ Safe to proceed. This is a read-only operation. No wiki data will be changed.",
102
- }),
103
- lint_wiki: (projectPath, params) => ({
140
+ proceed_recommendation: existingPageCount === 0
141
+ ? "⚠ Wiki is empty — ingest sources first for useful answers."
142
+ : "✓ Safe to proceed. This is a read-only operation. No wiki data will be changed.",
143
+ };
144
+ }
145
+ if (toolName === "lint_wiki") {
146
+ return {
104
147
  tool_name: "lint_wiki",
105
148
  tool_description: "Health check the wiki for issues",
106
149
  input_provided: params,
@@ -115,13 +158,13 @@ async function previewGeneration(input) {
115
158
  predicted_outputs: [
116
159
  {
117
160
  type: "Issues",
118
- description: "Potential problems found in the wiki",
119
- example: "5 orphan pages, 2 stale (>30 days), 3 broken references",
161
+ description: `Scanning ${existingPageCount} wiki pages for problems`,
162
+ example: "N orphan pages, N stale (>30 days), N broken references",
120
163
  },
121
164
  {
122
165
  type: "Health Score",
123
166
  description: "Overall wiki health (0-100)",
124
- example: "78/100 (Good, some maintenance recommended)",
167
+ example: existingPageCount > 0 ? "Score will vary — run to find out" : "N/A (no pages yet)",
125
168
  },
126
169
  {
127
170
  type: "Recommendations",
@@ -132,15 +175,18 @@ async function previewGeneration(input) {
132
175
  data_modified: false,
133
176
  files_affected: [],
134
177
  estimated_impact: "low",
135
- proceed_recommendation: "✓ Safe to proceed. This is a read-only diagnostic. It will report issues but not fix them.",
136
- }),
137
- save_answer_as_page: (projectPath, params) => ({
178
+ proceed_recommendation: existingPageCount === 0
179
+ ? "⚠ Wiki is empty — nothing to lint yet. Ingest sources first."
180
+ : "✓ Safe to proceed. This is a read-only diagnostic. It will report issues but not fix them.",
181
+ };
182
+ }
183
+ if (toolName === "save_answer_as_page") {
184
+ return {
138
185
  tool_name: "save_answer_as_page",
139
186
  tool_description: "Save an answer as a new wiki page (enables knowledge compounding)",
140
187
  input_provided: params,
141
188
  predicted_actions: [
142
- "✓ Extract the answer from conversation",
143
- "✓ Create markdown file with frontmatter metadata",
189
+ "✓ Format the answer as markdown with frontmatter",
144
190
  "✓ Place in appropriate category (synthesis or concept)",
145
191
  "✓ Update index.md with new page",
146
192
  "✓ Add log entry",
@@ -148,13 +194,13 @@ async function previewGeneration(input) {
148
194
  predicted_outputs: [
149
195
  {
150
196
  type: "New Wiki Page",
151
- description: "New markdown file in wiki/syntheses/ or wiki/concepts/",
152
- example: ".docuflow/wiki/syntheses/query_result_20260417.md",
197
+ description: `New markdown file wiki will have ${existingPageCount + 1} pages`,
198
+ example: ".docuflow/wiki/syntheses/query_result_<date>.md",
153
199
  },
154
200
  {
155
201
  type: "Metadata",
156
202
  description: "YAML frontmatter with creation date, tags, sources",
157
- example: "created_at: 2026-04-17, sources: [5 pages], tags: [architecture]",
203
+ example: `created_at: ${new Date().toISOString().slice(0, 10)}, tags: [synthesis]`,
158
204
  },
159
205
  ],
160
206
  data_modified: true,
@@ -165,48 +211,18 @@ async function previewGeneration(input) {
165
211
  ],
166
212
  estimated_impact: "medium",
167
213
  proceed_recommendation: "✓ This compounds knowledge in your wiki! Proceed to file interesting discoveries.",
168
- }),
169
- lint_wiki_with_check: (projectPath, params) => ({
170
- tool_name: "lint_wiki",
171
- tool_description: "Run specific lint check on the wiki",
172
- input_provided: params,
173
- predicted_actions: [
174
- `✓ Run ${params.check_type || "all"} lint checks`,
175
- "✓ Analyze wiki for specific issues",
176
- "✓ Generate targeted recommendations",
177
- ],
178
- predicted_outputs: [
179
- {
180
- type: "Check Results",
181
- description: `Results for ${params.check_type || "all checks"}`,
182
- example: "5 issues found of this type",
183
- },
184
- {
185
- type: "Recommendations",
186
- description: "How to fix the issues",
187
- example: "Delete orphan pages, add missing references",
188
- },
189
- ],
190
- data_modified: false,
191
- files_affected: [],
192
- estimated_impact: "low",
193
- proceed_recommendation: "✓ Safe to proceed. This is diagnostic and won't modify wiki data.",
194
- }),
195
- };
196
- // Get preview based on tool name
197
- const previewFn = previews[toolName];
198
- if (!previewFn) {
199
- return {
200
- tool_name: toolName,
201
- tool_description: "Unknown tool",
202
- input_provided: params,
203
- predicted_actions: ["❌ Tool not found"],
204
- predicted_outputs: [],
205
- data_modified: false,
206
- files_affected: [],
207
- estimated_impact: "low",
208
- proceed_recommendation: "❌ Unknown tool. Check tool name and try again.",
209
214
  };
210
215
  }
211
- return previewFn(projectPath, params);
216
+ // Unknown tool fallback
217
+ return {
218
+ tool_name: toolName,
219
+ tool_description: "Unknown tool",
220
+ input_provided: params,
221
+ predicted_actions: ["❌ Tool not found"],
222
+ predicted_outputs: [],
223
+ data_modified: false,
224
+ files_affected: [],
225
+ estimated_impact: "low",
226
+ proceed_recommendation: "❌ Unknown tool. Check tool name and try again.",
227
+ };
212
228
  }
@@ -7,6 +7,7 @@ exports.readSpecs = readSpecs;
7
7
  const node_path_1 = __importDefault(require("node:path"));
8
8
  const promises_1 = __importDefault(require("node:fs/promises"));
9
9
  const filesystem_1 = require("../filesystem");
10
+ const STALE_DAYS = 30;
10
11
  async function readSpecs(input) {
11
12
  const projectPath = node_path_1.default.resolve(input.project_path);
12
13
  const docuDir = node_path_1.default.join(projectPath, ".docuflow");
@@ -20,12 +21,15 @@ async function readSpecs(input) {
20
21
  const needle = input.module_name.replace(/\.md$/i, "").toLowerCase();
21
22
  entries = entries.filter((s) => s.filename.replace(/\.md$/i, "").toLowerCase() === needle);
22
23
  }
24
+ const now = Date.now();
23
25
  const specs = [];
24
26
  for (const entry of entries) {
25
27
  const filePath = node_path_1.default.join(docuDir, "specs", entry.filename);
26
28
  try {
27
29
  const content = await promises_1.default.readFile(filePath, "utf8");
28
- specs.push({ filename: entry.filename, written_at: entry.written_at, content });
30
+ const writtenMs = entry.written_at ? new Date(entry.written_at).getTime() : NaN;
31
+ const stale = !isNaN(writtenMs) && (now - writtenMs) > STALE_DAYS * 86_400_000;
32
+ specs.push({ filename: entry.filename, written_at: entry.written_at, stale, content });
29
33
  }
30
34
  catch {
31
35
  // spec entry exists in index but file missing — skip silently
@@ -34,6 +34,12 @@ inbound_links: ${JSON.stringify([])}
34
34
  outbound_links: ${JSON.stringify(sources)}
35
35
  ---
36
36
  `;
37
+ const CATEGORY_DIR = {
38
+ synthesis: "syntheses",
39
+ entity: "entities",
40
+ concept: "concepts",
41
+ timeline: "timelines",
42
+ };
37
43
  // Build page content
38
44
  const pageContent = `${frontmatterYaml}
39
45
  # ${input.page_title}
@@ -49,7 +55,7 @@ ${input.answer}
49
55
  ## Related Pages
50
56
 
51
57
  ${sources.length > 0
52
- ? sources.map((s) => `- [\`${s}\`](../CATEGORY/${s}.md)`).join("\n")
58
+ ? sources.map((s) => `- [\`${s}\`](../${CATEGORY_DIR[category] ?? category + "s"}/${s}.md)`).join("\n")
53
59
  : "No source pages linked."}
54
60
 
55
61
  ---
@@ -58,6 +58,12 @@ async function updateIndex(input) {
58
58
  // Scan all wiki pages
59
59
  const entries = [];
60
60
  const categories = ["entities", "concepts", "timelines", "syntheses"];
61
+ const PLURAL_TO_SINGULAR = {
62
+ entities: "entity",
63
+ concepts: "concept",
64
+ timelines: "timeline",
65
+ syntheses: "synthesis",
66
+ };
61
67
  for (const category of categories) {
62
68
  const categoryDir = node_path_1.default.join(wikiDir, category);
63
69
  try {
@@ -75,7 +81,7 @@ async function updateIndex(input) {
75
81
  entries.push({
76
82
  id: pageId,
77
83
  title,
78
- category: category.replace("s", ""), // entities → entity
84
+ category: PLURAL_TO_SINGULAR[category] ?? category.replace(/s$/, ""),
79
85
  path: node_path_1.default.relative(docuDir, filePath),
80
86
  created_at: fm.created_at ?? new Date().toISOString(),
81
87
  });
@@ -93,10 +93,16 @@ async function wikiSearch(input) {
93
93
  // Category may not exist
94
94
  }
95
95
  }
96
+ const PLURAL_TO_SINGULAR = {
97
+ entities: "entity",
98
+ concepts: "concept",
99
+ timelines: "timeline",
100
+ syntheses: "synthesis",
101
+ };
96
102
  // Second pass: search all pages
97
103
  for (const categoryDir of categoriesToScan) {
98
104
  const fullCategoryPath = node_path_1.default.join(wikiDir, categoryDir);
99
- const category = categoryDir.replace("s", ""); // entities → entity
105
+ const category = PLURAL_TO_SINGULAR[categoryDir] ?? categoryDir.replace(/s$/, "");
100
106
  try {
101
107
  const files = await promises_1.default.readdir(fullCategoryPath);
102
108
  for (const file of files) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doquflow/server",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Docuflow MCP server — lets AI agents read codebases and persist living specs",
5
5
  "author": "Docuflow <hello@doquflows.dev>",
6
6
  "license": "MIT",