@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.
@@ -0,0 +1,157 @@
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
+ const PLURAL_TO_SINGULAR = {
97
+ entities: "entity",
98
+ concepts: "concept",
99
+ timelines: "timeline",
100
+ syntheses: "synthesis",
101
+ };
102
+ // Second pass: search all pages
103
+ for (const categoryDir of categoriesToScan) {
104
+ const fullCategoryPath = node_path_1.default.join(wikiDir, categoryDir);
105
+ const category = PLURAL_TO_SINGULAR[categoryDir] ?? categoryDir.replace(/s$/, "");
106
+ try {
107
+ const files = await promises_1.default.readdir(fullCategoryPath);
108
+ for (const file of files) {
109
+ if (!file.endsWith(".md"))
110
+ continue;
111
+ const filePath = node_path_1.default.join(fullCategoryPath, file);
112
+ const read = await (0, filesystem_1.safeReadFile)(filePath);
113
+ if (read.error || read.binary || !read.content)
114
+ continue;
115
+ // Extract title from content (first H1)
116
+ const titleMatch = read.content.match(/^#\s+(.+?)$/m);
117
+ const title = titleMatch ? titleMatch[1].trim() : file.replace(".md", "");
118
+ const pageId = file.replace(".md", "");
119
+ // Score the match
120
+ const { score, matched_terms } = scoreMatch(input.query, read.content, title, category, totalPages);
121
+ // Only include if there's at least one match
122
+ if (score > 0 && matched_terms.length > 0) {
123
+ const preview = extractPreview(read.content, matched_terms);
124
+ allResults.push({
125
+ page_id: pageId,
126
+ title,
127
+ category,
128
+ path: node_path_1.default.relative(docuDir, filePath),
129
+ relevance_score: Math.round(score * 10) / 10, // Round to 1 decimal
130
+ preview,
131
+ matched_terms,
132
+ });
133
+ }
134
+ }
135
+ }
136
+ catch (e) {
137
+ // Category directory may not exist
138
+ }
139
+ }
140
+ // Sort by relevance score (descending) and return top N
141
+ allResults.sort((a, b) => b.relevance_score - a.relevance_score);
142
+ const results = allResults.slice(0, limit);
143
+ return {
144
+ query: input.query,
145
+ results,
146
+ total_results: allResults.length,
147
+ };
148
+ }
149
+ catch (e) {
150
+ return {
151
+ query: input.query,
152
+ results: [],
153
+ total_results: 0,
154
+ error: e?.message ?? String(e),
155
+ };
156
+ }
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doquflow/server",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Docuflow MCP server — lets AI agents read codebases and persist living specs",
5
5
  "author": "Docuflow <hello@doquflows.dev>",
6
6
  "license": "MIT",