@doquflow/server 0.2.0 → 0.3.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/index.js +204 -0
- package/dist/tools/answer-synthesis.js +189 -0
- package/dist/tools/get-schema-guidance.js +213 -0
- package/dist/tools/ingest-source.js +222 -0
- package/dist/tools/lint-wiki.js +346 -0
- package/dist/tools/list-wiki.js +108 -0
- package/dist/tools/preview-generation.js +212 -0
- package/dist/tools/query-wiki.js +67 -0
- package/dist/tools/save-answer-as-page.js +96 -0
- package/dist/tools/update-index.js +151 -0
- package/dist/tools/wiki-search.js +151 -0
- package/package.json +1 -1
|
@@ -0,0 +1,212 @@
|
|
|
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.previewGeneration = previewGeneration;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
/**
|
|
9
|
+
* preview_generation
|
|
10
|
+
*
|
|
11
|
+
* Shows what a tool will generate before it actually runs.
|
|
12
|
+
* Removes black-box feeling by providing transparency.
|
|
13
|
+
*
|
|
14
|
+
* Input:
|
|
15
|
+
* - tool_name: string (the tool you want to run)
|
|
16
|
+
* - project_path: string
|
|
17
|
+
* - params: Record<string, any> (the parameters you'd pass to the tool)
|
|
18
|
+
*
|
|
19
|
+
* Output:
|
|
20
|
+
* - Predicted actions and outputs
|
|
21
|
+
* - Files that will be affected
|
|
22
|
+
* - Impact level (low/medium/high)
|
|
23
|
+
* - Recommendation on whether to proceed
|
|
24
|
+
*/
|
|
25
|
+
async function previewGeneration(input) {
|
|
26
|
+
const projectPath = node_path_1.default.resolve(input.project_path);
|
|
27
|
+
const toolName = input.tool_name;
|
|
28
|
+
const params = input.params;
|
|
29
|
+
// Define preview for each tool
|
|
30
|
+
const previews = {
|
|
31
|
+
ingest_source: (projectPath, params) => ({
|
|
32
|
+
tool_name: "ingest_source",
|
|
33
|
+
tool_description: "Process a new source and integrate it into the wiki",
|
|
34
|
+
input_provided: params,
|
|
35
|
+
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",
|
|
41
|
+
"✓ Append entry to log.md",
|
|
42
|
+
],
|
|
43
|
+
predicted_outputs: [
|
|
44
|
+
{
|
|
45
|
+
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",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: "Index Update",
|
|
51
|
+
description: "Updated index.md with new pages and metadata",
|
|
52
|
+
example: "index.md entry with source, date, page count",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
type: "Log Entry",
|
|
56
|
+
description: "New line in log.md documenting the ingest",
|
|
57
|
+
example: "[2026-04-17 14:23] ingest | source_name.md → 12 pages created, 5 updated",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
data_modified: true,
|
|
61
|
+
files_affected: [
|
|
62
|
+
".docuflow/wiki/entities/*.md",
|
|
63
|
+
".docuflow/wiki/concepts/*.md",
|
|
64
|
+
".docuflow/wiki/syntheses/*.md",
|
|
65
|
+
".docuflow/index.md",
|
|
66
|
+
".docuflow/log.md",
|
|
67
|
+
],
|
|
68
|
+
estimated_impact: "high",
|
|
69
|
+
proceed_recommendation: "✓ Safe to proceed. Source will be integrated and wiki will grow. This is expected behavior.",
|
|
70
|
+
}),
|
|
71
|
+
query_wiki: (projectPath, params) => ({
|
|
72
|
+
tool_name: "query_wiki",
|
|
73
|
+
tool_description: "Search and synthesize answers from the wiki",
|
|
74
|
+
input_provided: params,
|
|
75
|
+
predicted_actions: [
|
|
76
|
+
"✓ Search index.md for relevant pages",
|
|
77
|
+
"✓ Read matching wiki pages",
|
|
78
|
+
"✓ Synthesize answer with citations",
|
|
79
|
+
"✓ Return answer with source pages",
|
|
80
|
+
],
|
|
81
|
+
predicted_outputs: [
|
|
82
|
+
{
|
|
83
|
+
type: "Answer",
|
|
84
|
+
description: "Synthesized answer to your question",
|
|
85
|
+
example: 'The system uses MCP protocol to communicate with tools...',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
type: "Source Pages",
|
|
89
|
+
description: "Wiki pages used to create the answer",
|
|
90
|
+
example: "concepts/MCP_Protocol.md, entities/Server.md",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
type: "Confidence",
|
|
94
|
+
description: "How well the question was answered (0-100)",
|
|
95
|
+
example: "85 (good coverage, some gaps possible)",
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
data_modified: false,
|
|
99
|
+
files_affected: [],
|
|
100
|
+
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) => ({
|
|
104
|
+
tool_name: "lint_wiki",
|
|
105
|
+
tool_description: "Health check the wiki for issues",
|
|
106
|
+
input_provided: params,
|
|
107
|
+
predicted_actions: [
|
|
108
|
+
"✓ Scan for orphan pages (no incoming links)",
|
|
109
|
+
"✓ Find stale pages (30+ days old)",
|
|
110
|
+
"✓ Check for broken cross-references",
|
|
111
|
+
"✓ Detect contradictions in content",
|
|
112
|
+
"✓ Look for metadata gaps",
|
|
113
|
+
"✓ Calculate overall health score",
|
|
114
|
+
],
|
|
115
|
+
predicted_outputs: [
|
|
116
|
+
{
|
|
117
|
+
type: "Issues",
|
|
118
|
+
description: "Potential problems found in the wiki",
|
|
119
|
+
example: "5 orphan pages, 2 stale (>30 days), 3 broken references",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
type: "Health Score",
|
|
123
|
+
description: "Overall wiki health (0-100)",
|
|
124
|
+
example: "78/100 (Good, some maintenance recommended)",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
type: "Recommendations",
|
|
128
|
+
description: "Actionable suggestions to improve wiki",
|
|
129
|
+
example: "Delete orphans, update stale pages, add missing cross-refs",
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
data_modified: false,
|
|
133
|
+
files_affected: [],
|
|
134
|
+
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) => ({
|
|
138
|
+
tool_name: "save_answer_as_page",
|
|
139
|
+
tool_description: "Save an answer as a new wiki page (enables knowledge compounding)",
|
|
140
|
+
input_provided: params,
|
|
141
|
+
predicted_actions: [
|
|
142
|
+
"✓ Extract the answer from conversation",
|
|
143
|
+
"✓ Create markdown file with frontmatter metadata",
|
|
144
|
+
"✓ Place in appropriate category (synthesis or concept)",
|
|
145
|
+
"✓ Update index.md with new page",
|
|
146
|
+
"✓ Add log entry",
|
|
147
|
+
],
|
|
148
|
+
predicted_outputs: [
|
|
149
|
+
{
|
|
150
|
+
type: "New Wiki Page",
|
|
151
|
+
description: "New markdown file in wiki/syntheses/ or wiki/concepts/",
|
|
152
|
+
example: ".docuflow/wiki/syntheses/query_result_20260417.md",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
type: "Metadata",
|
|
156
|
+
description: "YAML frontmatter with creation date, tags, sources",
|
|
157
|
+
example: "created_at: 2026-04-17, sources: [5 pages], tags: [architecture]",
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
data_modified: true,
|
|
161
|
+
files_affected: [
|
|
162
|
+
".docuflow/wiki/syntheses/*.md or concepts/*.md",
|
|
163
|
+
".docuflow/index.md",
|
|
164
|
+
".docuflow/log.md",
|
|
165
|
+
],
|
|
166
|
+
estimated_impact: "medium",
|
|
167
|
+
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
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return previewFn(projectPath, params);
|
|
212
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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.queryWiki = queryWiki;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const wiki_search_1 = require("./wiki-search");
|
|
9
|
+
const answer_synthesis_1 = require("./answer-synthesis");
|
|
10
|
+
async function queryWiki(input) {
|
|
11
|
+
try {
|
|
12
|
+
const projectPath = node_path_1.default.resolve(input.project_path);
|
|
13
|
+
const maxSources = input.max_sources ?? 5;
|
|
14
|
+
// Step 1: Search the wiki for relevant pages
|
|
15
|
+
const searchResult = await (0, wiki_search_1.wikiSearch)({
|
|
16
|
+
project_path: projectPath,
|
|
17
|
+
query: input.question,
|
|
18
|
+
limit: Math.max(10, maxSources * 2), // Get extra to filter
|
|
19
|
+
});
|
|
20
|
+
if (searchResult.error || !searchResult.results.length) {
|
|
21
|
+
return {
|
|
22
|
+
question: input.question,
|
|
23
|
+
answer: `No relevant wiki pages found for: ${input.question}`,
|
|
24
|
+
source_pages: [],
|
|
25
|
+
search_results: 0,
|
|
26
|
+
confidence: 0,
|
|
27
|
+
error: searchResult.error,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// Step 2: Select top N results for synthesis
|
|
31
|
+
const topResults = searchResult.results.slice(0, maxSources);
|
|
32
|
+
const sourcePageIds = topResults.map((r) => r.page_id);
|
|
33
|
+
// Step 3: Synthesize answer from selected pages
|
|
34
|
+
const synthesisResult = await (0, answer_synthesis_1.synthesizeAnswer)({
|
|
35
|
+
project_path: projectPath,
|
|
36
|
+
query: input.question,
|
|
37
|
+
source_page_ids: sourcePageIds,
|
|
38
|
+
});
|
|
39
|
+
if (synthesisResult.error) {
|
|
40
|
+
return {
|
|
41
|
+
question: input.question,
|
|
42
|
+
answer: `Error synthesizing answer: ${synthesisResult.error}`,
|
|
43
|
+
source_pages: synthesisResult.source_pages,
|
|
44
|
+
search_results: searchResult.total_results,
|
|
45
|
+
confidence: 0,
|
|
46
|
+
error: synthesisResult.error,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
question: input.question,
|
|
51
|
+
answer: synthesisResult.answer,
|
|
52
|
+
source_pages: synthesisResult.source_pages,
|
|
53
|
+
search_results: searchResult.total_results,
|
|
54
|
+
confidence: synthesisResult.confidence,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
return {
|
|
59
|
+
question: input.question,
|
|
60
|
+
answer: `Query failed: ${e?.message ?? String(e)}`,
|
|
61
|
+
source_pages: [],
|
|
62
|
+
search_results: 0,
|
|
63
|
+
confidence: 0,
|
|
64
|
+
error: e?.message ?? String(e),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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.saveAnswerAsPage = saveAnswerAsPage;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
9
|
+
const filesystem_1 = require("../filesystem");
|
|
10
|
+
async function saveAnswerAsPage(input) {
|
|
11
|
+
try {
|
|
12
|
+
const projectPath = node_path_1.default.resolve(input.project_path);
|
|
13
|
+
const docuDir = node_path_1.default.join(projectPath, ".docuflow");
|
|
14
|
+
const wikiDir = node_path_1.default.join(docuDir, "wiki");
|
|
15
|
+
// Use provided category or default to synthesis
|
|
16
|
+
const category = input.category ?? "synthesis";
|
|
17
|
+
const categoryDir = node_path_1.default.join(wikiDir, category + "s");
|
|
18
|
+
await (0, filesystem_1.ensureDir)(categoryDir);
|
|
19
|
+
// Generate page ID from title
|
|
20
|
+
const pageId = `query_${input.page_title
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9]/g, "_")
|
|
23
|
+
.replace(/_+/g, "_")
|
|
24
|
+
.substring(0, 50)}`;
|
|
25
|
+
// Generate frontmatter
|
|
26
|
+
const now = new Date().toISOString();
|
|
27
|
+
const sources = input.source_page_ids ?? [];
|
|
28
|
+
const frontmatterYaml = `---
|
|
29
|
+
created_at: ${now}
|
|
30
|
+
updated_at: ${now}
|
|
31
|
+
sources: ${JSON.stringify(sources)}
|
|
32
|
+
tags: ["query_result","synthesis"]
|
|
33
|
+
inbound_links: ${JSON.stringify([])}
|
|
34
|
+
outbound_links: ${JSON.stringify(sources)}
|
|
35
|
+
---
|
|
36
|
+
`;
|
|
37
|
+
// Build page content
|
|
38
|
+
const pageContent = `${frontmatterYaml}
|
|
39
|
+
# ${input.page_title}
|
|
40
|
+
|
|
41
|
+
**Generated from query**: ${input.question}
|
|
42
|
+
|
|
43
|
+
**Synthesis timestamp**: ${now}
|
|
44
|
+
|
|
45
|
+
## Answer
|
|
46
|
+
|
|
47
|
+
${input.answer}
|
|
48
|
+
|
|
49
|
+
## Related Pages
|
|
50
|
+
|
|
51
|
+
${sources.length > 0
|
|
52
|
+
? sources.map((s) => `- [\`${s}\`](../CATEGORY/${s}.md)`).join("\n")
|
|
53
|
+
: "No source pages linked."}
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
*This page was generated by synthesizing answers from multiple wiki pages.*
|
|
58
|
+
*To refine further, add more source documents and re-ingest.*
|
|
59
|
+
`;
|
|
60
|
+
// Write the page file
|
|
61
|
+
const pageFile = node_path_1.default.join(categoryDir, `${pageId}.md`);
|
|
62
|
+
const bytes = await (0, filesystem_1.writeFileAtomic)(pageFile, pageContent);
|
|
63
|
+
// Also update log.md to record this
|
|
64
|
+
const logFile = node_path_1.default.join(docuDir, "log.md");
|
|
65
|
+
try {
|
|
66
|
+
let logContent = "";
|
|
67
|
+
try {
|
|
68
|
+
const logRead = await promises_1.default.readFile(logFile, "utf-8");
|
|
69
|
+
logContent = logRead;
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
logContent = "# Operation Log\n\n";
|
|
73
|
+
}
|
|
74
|
+
const timestamp = now.split("T")[0]; // YYYY-MM-DD
|
|
75
|
+
const logEntry = `## [${timestamp}] query-result | Saved answer as ${pageId}\n\n`;
|
|
76
|
+
logContent += logEntry;
|
|
77
|
+
await (0, filesystem_1.writeFileAtomic)(logFile, logContent);
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
// Log update failure is not critical
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
saved_page_id: pageId,
|
|
84
|
+
saved_path: node_path_1.default.relative(docuDir, pageFile),
|
|
85
|
+
category,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
return {
|
|
90
|
+
saved_page_id: "",
|
|
91
|
+
saved_path: "",
|
|
92
|
+
category: input.category ?? "synthesis",
|
|
93
|
+
error: e?.message ?? String(e),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
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.updateIndex = updateIndex;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
9
|
+
const filesystem_1 = require("../filesystem");
|
|
10
|
+
/**
|
|
11
|
+
* Parse frontmatter from a markdown file.
|
|
12
|
+
* Expects YAML format between --- markers.
|
|
13
|
+
*/
|
|
14
|
+
function parseFrontmatter(content) {
|
|
15
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
16
|
+
if (!match)
|
|
17
|
+
return {};
|
|
18
|
+
const yaml = match[1];
|
|
19
|
+
const result = {};
|
|
20
|
+
for (const line of yaml.split("\n")) {
|
|
21
|
+
if (!line.trim())
|
|
22
|
+
continue;
|
|
23
|
+
const [key, ...valueParts] = line.split(":");
|
|
24
|
+
const value = valueParts.join(":").trim();
|
|
25
|
+
try {
|
|
26
|
+
// Try to parse as JSON (arrays, objects)
|
|
27
|
+
if (value.startsWith("[") || value.startsWith("{")) {
|
|
28
|
+
result[key.trim()] = JSON.parse(value);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
result[key.trim()] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
result[key.trim()] = value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Extract title from markdown content.
|
|
42
|
+
* Looks for first H1 header.
|
|
43
|
+
*/
|
|
44
|
+
function extractTitle(content) {
|
|
45
|
+
const match = content.match(/^#\s+(.+?)$/m);
|
|
46
|
+
return match ? match[1].trim() : "Untitled";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Scan all wiki pages and regenerate index.md
|
|
50
|
+
*/
|
|
51
|
+
async function updateIndex(input) {
|
|
52
|
+
try {
|
|
53
|
+
const projectPath = node_path_1.default.resolve(input.project_path);
|
|
54
|
+
const docuDir = node_path_1.default.join(projectPath, ".docuflow");
|
|
55
|
+
const wikiDir = node_path_1.default.join(docuDir, "wiki");
|
|
56
|
+
const indexFile = node_path_1.default.join(docuDir, "index.md");
|
|
57
|
+
const logFile = node_path_1.default.join(docuDir, "log.md");
|
|
58
|
+
// Scan all wiki pages
|
|
59
|
+
const entries = [];
|
|
60
|
+
const categories = ["entities", "concepts", "timelines", "syntheses"];
|
|
61
|
+
for (const category of categories) {
|
|
62
|
+
const categoryDir = node_path_1.default.join(wikiDir, category);
|
|
63
|
+
try {
|
|
64
|
+
const files = await promises_1.default.readdir(categoryDir);
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
if (!file.endsWith(".md"))
|
|
67
|
+
continue;
|
|
68
|
+
const filePath = node_path_1.default.join(categoryDir, file);
|
|
69
|
+
const read = await (0, filesystem_1.safeReadFile)(filePath);
|
|
70
|
+
if (read.error || read.binary || !read.content)
|
|
71
|
+
continue;
|
|
72
|
+
const fm = parseFrontmatter(read.content);
|
|
73
|
+
const title = extractTitle(read.content);
|
|
74
|
+
const pageId = file.replace(".md", "");
|
|
75
|
+
entries.push({
|
|
76
|
+
id: pageId,
|
|
77
|
+
title,
|
|
78
|
+
category: category.replace("s", ""), // entities → entity
|
|
79
|
+
path: node_path_1.default.relative(docuDir, filePath),
|
|
80
|
+
created_at: fm.created_at ?? new Date().toISOString(),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
// Category dir may not exist yet
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Generate index.md content
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
let indexContent = `# Wiki Index
|
|
91
|
+
|
|
92
|
+
Generated: ${now}
|
|
93
|
+
|
|
94
|
+
## Overview
|
|
95
|
+
|
|
96
|
+
Total pages: ${entries.length}
|
|
97
|
+
|
|
98
|
+
## By Category
|
|
99
|
+
|
|
100
|
+
`;
|
|
101
|
+
// Group by category
|
|
102
|
+
const byCategory = {};
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!byCategory[entry.category])
|
|
105
|
+
byCategory[entry.category] = [];
|
|
106
|
+
byCategory[entry.category].push(entry);
|
|
107
|
+
}
|
|
108
|
+
// Add each category section
|
|
109
|
+
for (const category of ["entity", "concept", "timeline", "synthesis"]) {
|
|
110
|
+
const categoryEntries = byCategory[category] || [];
|
|
111
|
+
if (categoryEntries.length === 0)
|
|
112
|
+
continue;
|
|
113
|
+
indexContent += `### ${category.charAt(0).toUpperCase() + category.slice(1)} Pages (${categoryEntries.length})\n\n`;
|
|
114
|
+
for (const entry of categoryEntries.sort((a, b) => a.title.localeCompare(b.title))) {
|
|
115
|
+
indexContent += `- [\`${entry.id}\`](./${entry.path}) — **${entry.title}**\n`;
|
|
116
|
+
}
|
|
117
|
+
indexContent += "\n";
|
|
118
|
+
}
|
|
119
|
+
// Write index.md
|
|
120
|
+
await (0, filesystem_1.ensureDir)(docuDir);
|
|
121
|
+
await (0, filesystem_1.writeFileAtomic)(indexFile, indexContent);
|
|
122
|
+
// Append to log.md
|
|
123
|
+
let logUpdated = false;
|
|
124
|
+
try {
|
|
125
|
+
const logRead = await (0, filesystem_1.safeReadFile)(logFile);
|
|
126
|
+
let logContent = logRead.content ?? "# Operation Log\n\n";
|
|
127
|
+
// Add entry
|
|
128
|
+
const timestamp = now.split("T")[0]; // YYYY-MM-DD
|
|
129
|
+
const logEntry = `## [${timestamp}] index-update | ${entries.length} pages indexed\n\n`;
|
|
130
|
+
logContent += logEntry;
|
|
131
|
+
await (0, filesystem_1.writeFileAtomic)(logFile, logContent);
|
|
132
|
+
logUpdated = true;
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
// Log may not exist, that's ok
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
entries_indexed: entries.length,
|
|
139
|
+
index_file: indexFile,
|
|
140
|
+
log_appended: logUpdated,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
return {
|
|
145
|
+
entries_indexed: 0,
|
|
146
|
+
index_file: "",
|
|
147
|
+
log_appended: false,
|
|
148
|
+
error: e?.message ?? String(e),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
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.wikiSearch = wikiSearch;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
9
|
+
const filesystem_1 = require("../filesystem");
|
|
10
|
+
/**
|
|
11
|
+
* Simple BM25-inspired scoring with term frequency and document frequency.
|
|
12
|
+
* Weights entity pages more heavily than concepts, titles match higher.
|
|
13
|
+
*/
|
|
14
|
+
function scoreMatch(query, content, title, category, totalPages) {
|
|
15
|
+
const queryTerms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 2);
|
|
16
|
+
let score = 0;
|
|
17
|
+
const matched_terms = new Set();
|
|
18
|
+
for (const term of queryTerms) {
|
|
19
|
+
// Title match (highest weight)
|
|
20
|
+
if (title.toLowerCase().includes(term)) {
|
|
21
|
+
score += 50;
|
|
22
|
+
matched_terms.add(term);
|
|
23
|
+
}
|
|
24
|
+
// Content match with term frequency (TF)
|
|
25
|
+
const contentLower = content.toLowerCase();
|
|
26
|
+
const termRegex = new RegExp(`\\b${term}\\b`, "gi");
|
|
27
|
+
const matches = contentLower.match(termRegex);
|
|
28
|
+
if (matches) {
|
|
29
|
+
const tf = matches.length;
|
|
30
|
+
// Inverse document frequency approximation
|
|
31
|
+
const idf = Math.log(totalPages / Math.max(1, matches.length));
|
|
32
|
+
score += tf * idf * 2;
|
|
33
|
+
matched_terms.add(term);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Category boost: entities score higher than concepts
|
|
37
|
+
if (category === "entity") {
|
|
38
|
+
score *= 1.3;
|
|
39
|
+
}
|
|
40
|
+
else if (category === "synthesis") {
|
|
41
|
+
score *= 1.1;
|
|
42
|
+
}
|
|
43
|
+
return { score, matched_terms: Array.from(matched_terms) };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Extract a preview snippet from content around matched terms
|
|
47
|
+
*/
|
|
48
|
+
function extractPreview(content, matchedTerms, maxLength = 150) {
|
|
49
|
+
if (!matchedTerms.length) {
|
|
50
|
+
// Return first non-empty paragraph
|
|
51
|
+
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#") && !l.startsWith("---"));
|
|
52
|
+
return lines.slice(0, 2).join(" ").substring(0, maxLength);
|
|
53
|
+
}
|
|
54
|
+
// Find first occurrence of any matched term
|
|
55
|
+
const contentLower = content.toLowerCase();
|
|
56
|
+
const term = matchedTerms[0];
|
|
57
|
+
const index = contentLower.indexOf(term);
|
|
58
|
+
if (index === -1) {
|
|
59
|
+
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
|
60
|
+
return lines.slice(0, 2).join(" ").substring(0, maxLength);
|
|
61
|
+
}
|
|
62
|
+
const start = Math.max(0, index - 60);
|
|
63
|
+
const end = Math.min(content.length, index + 150);
|
|
64
|
+
let preview = content.substring(start, end);
|
|
65
|
+
// Trim to word boundary
|
|
66
|
+
const lastSpace = preview.lastIndexOf(" ");
|
|
67
|
+
if (lastSpace > 0) {
|
|
68
|
+
preview = preview.substring(0, lastSpace) + "...";
|
|
69
|
+
}
|
|
70
|
+
return preview;
|
|
71
|
+
}
|
|
72
|
+
async function wikiSearch(input) {
|
|
73
|
+
try {
|
|
74
|
+
const projectPath = node_path_1.default.resolve(input.project_path);
|
|
75
|
+
const docuDir = node_path_1.default.join(projectPath, ".docuflow");
|
|
76
|
+
const wikiDir = node_path_1.default.join(docuDir, "wiki");
|
|
77
|
+
const limit = input.limit ?? 10;
|
|
78
|
+
const allResults = [];
|
|
79
|
+
// Build list of categories to search
|
|
80
|
+
let categoriesToScan = ["entities", "concepts", "timelines", "syntheses"];
|
|
81
|
+
if (input.category) {
|
|
82
|
+
categoriesToScan = [`${input.category}s`];
|
|
83
|
+
}
|
|
84
|
+
// First pass: count total pages (for IDF calculation)
|
|
85
|
+
let totalPages = 0;
|
|
86
|
+
for (const categoryDir of categoriesToScan) {
|
|
87
|
+
const fullCategoryPath = node_path_1.default.join(wikiDir, categoryDir);
|
|
88
|
+
try {
|
|
89
|
+
const files = await promises_1.default.readdir(fullCategoryPath);
|
|
90
|
+
totalPages += files.filter((f) => f.endsWith(".md")).length;
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
// Category may not exist
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Second pass: search all pages
|
|
97
|
+
for (const categoryDir of categoriesToScan) {
|
|
98
|
+
const fullCategoryPath = node_path_1.default.join(wikiDir, categoryDir);
|
|
99
|
+
const category = categoryDir.replace("s", ""); // entities → entity
|
|
100
|
+
try {
|
|
101
|
+
const files = await promises_1.default.readdir(fullCategoryPath);
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
if (!file.endsWith(".md"))
|
|
104
|
+
continue;
|
|
105
|
+
const filePath = node_path_1.default.join(fullCategoryPath, file);
|
|
106
|
+
const read = await (0, filesystem_1.safeReadFile)(filePath);
|
|
107
|
+
if (read.error || read.binary || !read.content)
|
|
108
|
+
continue;
|
|
109
|
+
// Extract title from content (first H1)
|
|
110
|
+
const titleMatch = read.content.match(/^#\s+(.+?)$/m);
|
|
111
|
+
const title = titleMatch ? titleMatch[1].trim() : file.replace(".md", "");
|
|
112
|
+
const pageId = file.replace(".md", "");
|
|
113
|
+
// Score the match
|
|
114
|
+
const { score, matched_terms } = scoreMatch(input.query, read.content, title, category, totalPages);
|
|
115
|
+
// Only include if there's at least one match
|
|
116
|
+
if (score > 0 && matched_terms.length > 0) {
|
|
117
|
+
const preview = extractPreview(read.content, matched_terms);
|
|
118
|
+
allResults.push({
|
|
119
|
+
page_id: pageId,
|
|
120
|
+
title,
|
|
121
|
+
category,
|
|
122
|
+
path: node_path_1.default.relative(docuDir, filePath),
|
|
123
|
+
relevance_score: Math.round(score * 10) / 10, // Round to 1 decimal
|
|
124
|
+
preview,
|
|
125
|
+
matched_terms,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
// Category directory may not exist
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Sort by relevance score (descending) and return top N
|
|
135
|
+
allResults.sort((a, b) => b.relevance_score - a.relevance_score);
|
|
136
|
+
const results = allResults.slice(0, limit);
|
|
137
|
+
return {
|
|
138
|
+
query: input.query,
|
|
139
|
+
results,
|
|
140
|
+
total_results: allResults.length,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
return {
|
|
145
|
+
query: input.query,
|
|
146
|
+
results: [],
|
|
147
|
+
total_results: 0,
|
|
148
|
+
error: e?.message ?? String(e),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|