@doquflow/server 0.2.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
@@ -8,6 +8,17 @@ const read_module_1 = require("./tools/read-module");
8
8
  const list_modules_1 = require("./tools/list-modules");
9
9
  const write_spec_1 = require("./tools/write-spec");
10
10
  const read_specs_1 = require("./tools/read-specs");
11
+ const ingest_source_1 = require("./tools/ingest-source");
12
+ const update_index_1 = require("./tools/update-index");
13
+ const list_wiki_1 = require("./tools/list-wiki");
14
+ const wiki_search_1 = require("./tools/wiki-search");
15
+ const answer_synthesis_1 = require("./tools/answer-synthesis");
16
+ const query_wiki_1 = require("./tools/query-wiki");
17
+ const save_answer_as_page_1 = require("./tools/save-answer-as-page");
18
+ const lint_wiki_1 = require("./tools/lint-wiki");
19
+ const get_schema_guidance_1 = require("./tools/get-schema-guidance");
20
+ const preview_generation_1 = require("./tools/preview-generation");
21
+ const generate_dependency_graph_1 = require("./tools/generate-dependency-graph");
11
22
  const server = new index_js_1.Server({ name: "docuflow", version: "0.1.0" }, { capabilities: { tools: {} } });
12
23
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
13
24
  tools: [
@@ -66,6 +77,190 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
66
77
  required: ["project_path"],
67
78
  },
68
79
  },
80
+ {
81
+ name: "ingest_source",
82
+ description: "Ingest a markdown source document from .docuflow/sources/ and generate wiki pages (entities, concepts) with cross-references. Returns pages created and entities discovered.",
83
+ inputSchema: {
84
+ type: "object",
85
+ properties: {
86
+ project_path: { type: "string", description: "Root of the project." },
87
+ source_filename: {
88
+ type: "string",
89
+ description: "Filename in .docuflow/sources/ to ingest (e.g., 'overview.md').",
90
+ },
91
+ },
92
+ required: ["project_path", "source_filename"],
93
+ },
94
+ },
95
+ {
96
+ name: "update_index",
97
+ description: "Scan all wiki pages in .docuflow/wiki/ and regenerate .docuflow/index.md organized by category. Appends operation to log.md.",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ project_path: { type: "string", description: "Root of the project." },
102
+ },
103
+ required: ["project_path"],
104
+ },
105
+ },
106
+ {
107
+ name: "list_wiki",
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.",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ project_path: { type: "string", description: "Root of the project." },
113
+ category: {
114
+ type: "string",
115
+ enum: ["entity", "concept", "timeline", "synthesis"],
116
+ description: "Optional: filter to a specific category.",
117
+ },
118
+ },
119
+ required: ["project_path"],
120
+ },
121
+ },
122
+ {
123
+ name: "wiki_search",
124
+ description: "Search the wiki for pages matching a query using relevance scoring. Returns ranked results with preview snippets and matched terms. BM25-inspired ranking weights entity pages higher.",
125
+ inputSchema: {
126
+ type: "object",
127
+ properties: {
128
+ project_path: { type: "string", description: "Root of the project." },
129
+ query: { type: "string", description: "Search query (e.g., 'MCP protocol design')." },
130
+ limit: {
131
+ type: "number",
132
+ description: "Optional: max results to return (default: 10).",
133
+ },
134
+ category: {
135
+ type: "string",
136
+ enum: ["entity", "concept", "timeline", "synthesis"],
137
+ description: "Optional: filter to a specific category.",
138
+ },
139
+ },
140
+ required: ["project_path", "query"],
141
+ },
142
+ },
143
+ {
144
+ name: "synthesize_answer",
145
+ description: "Generate a synthesis answer from multiple wiki pages. Extracts relevant sentences, key concepts, and builds a markdown answer with citations.",
146
+ inputSchema: {
147
+ type: "object",
148
+ properties: {
149
+ project_path: { type: "string", description: "Root of the project." },
150
+ query: { type: "string", description: "The question being answered." },
151
+ source_page_ids: {
152
+ type: "array",
153
+ items: { type: "string" },
154
+ description: "List of wiki page IDs to synthesize from.",
155
+ },
156
+ },
157
+ required: ["project_path", "query", "source_page_ids"],
158
+ },
159
+ },
160
+ {
161
+ name: "query_wiki",
162
+ description: "Ask a question against the wiki. Automatically searches for relevant pages, synthesizes an answer, and returns source pages with confidence score. One-stop tool for querying accumulated knowledge.",
163
+ inputSchema: {
164
+ type: "object",
165
+ properties: {
166
+ project_path: { type: "string", description: "Root of the project." },
167
+ question: { type: "string", description: "The question to ask (e.g., 'How does the MCP protocol work?')." },
168
+ max_sources: {
169
+ type: "number",
170
+ description: "Optional: max source pages to use in synthesis (default: 5).",
171
+ },
172
+ },
173
+ required: ["project_path", "question"],
174
+ },
175
+ },
176
+ {
177
+ name: "save_answer_as_page",
178
+ description: "Save a generated answer as a new wiki page. Allows query results to compound back into the knowledge base, growing the wiki with new synthesis pages. Automatically adds frontmatter and updates log.md.",
179
+ inputSchema: {
180
+ type: "object",
181
+ properties: {
182
+ project_path: { type: "string", description: "Root of the project." },
183
+ question: { type: "string", description: "The original question that was answered." },
184
+ answer: { type: "string", description: "The markdown answer text to save." },
185
+ page_title: { type: "string", description: "Title for the new page (e.g., 'How MCP Protocol Works')." },
186
+ category: {
187
+ type: "string",
188
+ enum: ["synthesis", "entity", "concept", "timeline"],
189
+ description: "Optional: wiki category for the page (default: synthesis).",
190
+ },
191
+ source_page_ids: {
192
+ type: "array",
193
+ items: { type: "string" },
194
+ description: "Optional: list of source wiki page IDs used to generate this answer.",
195
+ },
196
+ },
197
+ required: ["project_path", "question", "answer", "page_title"],
198
+ },
199
+ },
200
+ {
201
+ name: "lint_wiki",
202
+ description: "Health check wiki for quality issues: orphan pages, broken references, stale content, metadata gaps, and contradictions. Returns issues found, metrics, health score, and recommendations.",
203
+ inputSchema: {
204
+ type: "object",
205
+ properties: {
206
+ project_path: { type: "string", description: "Root of the project." },
207
+ check_type: {
208
+ type: "string",
209
+ enum: ["all", "orphans", "contradictions", "stale", "metadata"],
210
+ description: "Optional: type of check to run (default: all).",
211
+ },
212
+ },
213
+ required: ["project_path"],
214
+ },
215
+ },
216
+ {
217
+ name: "get_schema_guidance",
218
+ description: "Analyze what documents should exist based on project schema and current wiki. Removes decision fatigue by suggesting what to create next.",
219
+ inputSchema: {
220
+ type: "object",
221
+ properties: {
222
+ project_path: { type: "string", description: "Root of the project." },
223
+ domain: {
224
+ type: "string",
225
+ description: "Optional: domain hint (Code/Architecture, Research, Business, Personal). Auto-detected if not provided.",
226
+ },
227
+ },
228
+ required: ["project_path"],
229
+ },
230
+ },
231
+ {
232
+ name: "preview_generation",
233
+ description: "Preview what a tool will generate before running it. Removes black-box feeling by showing predicted actions, outputs, and impact.",
234
+ inputSchema: {
235
+ type: "object",
236
+ properties: {
237
+ tool_name: { type: "string", description: "Name of the tool to preview (e.g., ingest_source, query_wiki)." },
238
+ project_path: { type: "string", description: "Root of the project." },
239
+ params: { type: "object", description: "Parameters you would pass to that tool." },
240
+ },
241
+ required: ["tool_name", "project_path", "params"],
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
+ },
69
264
  ],
70
265
  }));
71
266
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
@@ -84,6 +279,39 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
84
279
  else if (name === "read_specs") {
85
280
  result = await (0, read_specs_1.readSpecs)(args);
86
281
  }
282
+ else if (name === "ingest_source") {
283
+ result = await (0, ingest_source_1.ingestSource)(args);
284
+ }
285
+ else if (name === "update_index") {
286
+ result = await (0, update_index_1.updateIndex)(args);
287
+ }
288
+ else if (name === "list_wiki") {
289
+ result = await (0, list_wiki_1.listWiki)(args);
290
+ }
291
+ else if (name === "wiki_search") {
292
+ result = await (0, wiki_search_1.wikiSearch)(args);
293
+ }
294
+ else if (name === "synthesize_answer") {
295
+ result = await (0, answer_synthesis_1.synthesizeAnswer)(args);
296
+ }
297
+ else if (name === "query_wiki") {
298
+ result = await (0, query_wiki_1.queryWiki)(args);
299
+ }
300
+ else if (name === "save_answer_as_page") {
301
+ result = await (0, save_answer_as_page_1.saveAnswerAsPage)(args);
302
+ }
303
+ else if (name === "lint_wiki") {
304
+ result = await (0, lint_wiki_1.lintWiki)(args);
305
+ }
306
+ else if (name === "get_schema_guidance") {
307
+ result = await (0, get_schema_guidance_1.getSchemataGuidance)(args);
308
+ }
309
+ else if (name === "preview_generation") {
310
+ result = await (0, preview_generation_1.previewGeneration)(args);
311
+ }
312
+ else if (name === "generate_dependency_graph") {
313
+ result = await (0, generate_dependency_graph_1.generateDependencyGraph)(args);
314
+ }
87
315
  else {
88
316
  result = { error: `Unknown tool: ${name}` };
89
317
  }
@@ -0,0 +1,189 @@
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.synthesizeAnswer = synthesizeAnswer;
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const filesystem_1 = require("../filesystem");
9
+ /**
10
+ * Extract key sentences from content that relate to the query
11
+ */
12
+ function extractRelevantSentences(content, queryTerms, maxSentences = 3) {
13
+ const sentences = content
14
+ .split(/[.!?]+/)
15
+ .map((s) => s.trim())
16
+ .filter((s) => s.length > 10 && !s.startsWith("#") && !s.startsWith("---"));
17
+ const scoredSentences = [];
18
+ for (const sentence of sentences) {
19
+ let score = 0;
20
+ const sentenceLower = sentence.toLowerCase();
21
+ for (const term of queryTerms) {
22
+ if (sentenceLower.includes(term)) {
23
+ // Count occurrences
24
+ const matches = sentenceLower.split(term).length - 1;
25
+ score += matches * 2;
26
+ }
27
+ }
28
+ if (score > 0) {
29
+ scoredSentences.push({ text: sentence, score });
30
+ }
31
+ }
32
+ // Sort by score and take top N
33
+ return scoredSentences
34
+ .sort((a, b) => b.score - a.score)
35
+ .slice(0, maxSentences)
36
+ .map((s) => s.text);
37
+ }
38
+ /**
39
+ * Extract key concepts from page content (from H3 headers, bold text)
40
+ */
41
+ function extractKeyConcepts(content) {
42
+ const concepts = new Set();
43
+ // Extract from H3 headers
44
+ const h3Matches = content.matchAll(/^###\s+(.+?)$/gm);
45
+ for (const match of h3Matches) {
46
+ const concept = match[1].trim();
47
+ if (concept && concept.length < 80) {
48
+ concepts.add(concept);
49
+ }
50
+ }
51
+ // Extract from bold text (but not arrays/JSON)
52
+ const boldMatches = content.matchAll(/\*\*([^*]+)\*\*/g);
53
+ for (const match of boldMatches) {
54
+ const text = match[1].trim();
55
+ if (text.length > 3 &&
56
+ text.length < 60 &&
57
+ !text.includes("[") &&
58
+ !text.includes("{") &&
59
+ !text.includes('"')) {
60
+ concepts.add(text);
61
+ }
62
+ }
63
+ return Array.from(concepts).slice(0, 5);
64
+ }
65
+ /**
66
+ * Build a synthesis answer from multiple source pages
67
+ */
68
+ function buildSynthesis(query, sourcePages, queryTerms) {
69
+ if (!sourcePages.length) {
70
+ return {
71
+ answer: `No information found related to: ${query}`,
72
+ concepts: [],
73
+ };
74
+ }
75
+ const allConcepts = new Set();
76
+ const sections = [];
77
+ // Add introduction
78
+ sections.push(`## Synthesis: ${query}\n`);
79
+ sections.push(`Based on ${sourcePages.length} source page(s):\n`);
80
+ // Add content from each source
81
+ for (const page of sourcePages) {
82
+ sections.push(`### ${page.title}`);
83
+ sections.push(`*Category: ${page.category} | Relevance: ${Math.round(page.relevance * 100)}%*\n`);
84
+ // Extract relevant sentences
85
+ const relevantSentences = extractRelevantSentences(page.content, queryTerms, 2);
86
+ if (relevantSentences.length > 0) {
87
+ sections.push(relevantSentences.map((s) => `- ${s.trim()}`).join("\n"));
88
+ }
89
+ else {
90
+ // Fallback: use first paragraph
91
+ const firstPara = page.content
92
+ .split("\n")
93
+ .filter((l) => l.trim() && !l.startsWith("#") && !l.startsWith("---"))
94
+ .slice(0, 1);
95
+ sections.push(firstPara.join("\n"));
96
+ }
97
+ // Collect concepts
98
+ const pageConcepts = extractKeyConcepts(page.content);
99
+ pageConcepts.forEach((c) => allConcepts.add(c));
100
+ sections.push("");
101
+ }
102
+ // Add summary
103
+ sections.push("\n## Key Concepts Found");
104
+ Array.from(allConcepts).forEach((c) => {
105
+ sections.push(`- ${c}`);
106
+ });
107
+ sections.push("\n## How to Extend This\n");
108
+ sections.push(`This synthesis was generated from ${sourcePages.length} page(s) containing the key terms: `);
109
+ sections.push(queryTerms.join(", "));
110
+ sections.push("\n\nAdd more source pages to deepen this synthesis.");
111
+ return {
112
+ answer: sections.join("\n"),
113
+ concepts: Array.from(allConcepts),
114
+ };
115
+ }
116
+ async function synthesizeAnswer(input) {
117
+ try {
118
+ const projectPath = node_path_1.default.resolve(input.project_path);
119
+ const docuDir = node_path_1.default.join(projectPath, ".docuflow");
120
+ const wikiDir = node_path_1.default.join(docuDir, "wiki");
121
+ // Load each source page
122
+ const sourcePages = [];
123
+ for (const pageId of input.source_page_ids) {
124
+ // Try to find the page (scan all category directories)
125
+ let found = false;
126
+ for (const categoryDir of ["entities", "concepts", "timelines", "syntheses"]) {
127
+ const filePath = node_path_1.default.join(wikiDir, categoryDir, `${pageId}.md`);
128
+ try {
129
+ const read = await (0, filesystem_1.safeReadFile)(filePath);
130
+ if (!read.error && !read.binary && read.content) {
131
+ const titleMatch = read.content.match(/^#\s+(.+?)$/m);
132
+ const title = titleMatch ? titleMatch[1].trim() : pageId;
133
+ const category = categoryDir.replace("s", "");
134
+ sourcePages.push({
135
+ page_id: pageId,
136
+ title,
137
+ category,
138
+ content: read.content,
139
+ relevance: 1.0 - (sourcePages.length * 0.1), // Slight decay for order
140
+ });
141
+ found = true;
142
+ break;
143
+ }
144
+ }
145
+ catch (e) {
146
+ // Try next category
147
+ }
148
+ }
149
+ if (!found && sourcePages.length === 0) {
150
+ // At least one page should be found
151
+ return {
152
+ query: input.query,
153
+ answer: `Could not find source page: ${pageId}`,
154
+ source_pages: [],
155
+ confidence: 0,
156
+ key_concepts: [],
157
+ error: `Page not found: ${pageId}`,
158
+ };
159
+ }
160
+ }
161
+ // Build query terms
162
+ const queryTerms = input.query.toLowerCase().split(/\s+/).filter((t) => t.length > 2);
163
+ // Generate synthesis
164
+ const { answer, concepts } = buildSynthesis(input.query, sourcePages, queryTerms);
165
+ // Calculate confidence based on number and relevance of sources
166
+ const confidence = Math.min(1.0, Math.max(0.3, sourcePages.length * 0.25));
167
+ return {
168
+ query: input.query,
169
+ answer,
170
+ source_pages: sourcePages.map((p) => ({
171
+ page_id: p.page_id,
172
+ title: p.title,
173
+ category: p.category,
174
+ })),
175
+ confidence,
176
+ key_concepts: concepts,
177
+ };
178
+ }
179
+ catch (e) {
180
+ return {
181
+ query: input.query,
182
+ answer: `Error synthesizing answer: ${e?.message ?? String(e)}`,
183
+ source_pages: [],
184
+ confidence: 0,
185
+ key_concepts: [],
186
+ error: e?.message ?? String(e),
187
+ };
188
+ }
189
+ }