@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 +46 -1
- package/dist/index.js +25 -1
- package/dist/tools/generate-dependency-graph.js +162 -0
- package/dist/tools/ingest-source.js +34 -4
- package/dist/tools/lint-wiki.js +18 -22
- package/dist/tools/list-wiki.js +25 -4
- package/dist/tools/preview-generation.js +90 -74
- package/dist/tools/read-specs.js +5 -1
- package/dist/tools/save-answer-as-page.js +7 -1
- package/dist/tools/update-index.js +7 -1
- package/dist/tools/wiki-search.js +7 -1
- package/package.json +1 -1
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 =
|
|
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\
|
|
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
|
|
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) {
|
package/dist/tools/lint-wiki.js
CHANGED
|
@@ -74,11 +74,9 @@ function extractLinks(content) {
|
|
|
74
74
|
}
|
|
75
75
|
return links;
|
|
76
76
|
}
|
|
77
|
-
function findOrphanPages(
|
|
77
|
+
function findOrphanPages(pageMap) {
|
|
78
78
|
const issues = [];
|
|
79
|
-
for (const pageId of
|
|
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(
|
|
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
|
|
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(
|
|
121
|
+
function findMissingReferences(pageMap) {
|
|
125
122
|
const issues = [];
|
|
126
|
-
for (const pageId of
|
|
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 (!
|
|
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(
|
|
144
|
+
function findMetadataGaps(pageMap) {
|
|
149
145
|
const issues = [];
|
|
150
|
-
for (const pageId of
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
309
|
+
issues.push(...findStalePages(pageMap));
|
|
314
310
|
}
|
|
315
311
|
if (check_type === "all" || check_type === "metadata") {
|
|
316
|
-
issues.push(...findMissingReferences(
|
|
317
|
-
issues.push(...findMetadataGaps(
|
|
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:
|
|
325
|
+
total_pages: pageMap.size,
|
|
330
326
|
issues_found: issues,
|
|
331
327
|
metrics,
|
|
332
328
|
recommendations: [],
|
package/dist/tools/list-wiki.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
30
|
-
const
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
"✓ Extract entities and concepts",
|
|
38
|
-
"✓ Generate
|
|
39
|
-
"✓ Create cross-references",
|
|
40
|
-
"✓ Update index.md with new
|
|
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:
|
|
47
|
-
example: "entities/ServiceName.md, concepts/Pattern.md, syntheses/
|
|
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: "
|
|
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: "
|
|
57
|
-
example:
|
|
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
|
-
|
|
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:
|
|
85
|
-
example:
|
|
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
|
|
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:
|
|
102
|
-
|
|
103
|
-
|
|
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:
|
|
119
|
-
example: "
|
|
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: "
|
|
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:
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
"✓
|
|
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:
|
|
152
|
-
example: ".docuflow/wiki/syntheses/
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/read-specs.js
CHANGED
|
@@ -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
|
-
|
|
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}\`](
|
|
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(
|
|
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(
|
|
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) {
|