@tobilu/qmd 0.9.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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +615 -0
- package/package.json +80 -0
- package/qmd +55 -0
- package/src/collections.ts +390 -0
- package/src/formatter.ts +429 -0
- package/src/llm.ts +1208 -0
- package/src/mcp.ts +654 -0
- package/src/qmd.ts +2535 -0
- package/src/store.ts +3072 -0
package/src/formatter.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* formatter.ts - Output formatting utilities for QMD
|
|
3
|
+
*
|
|
4
|
+
* Provides methods to format search results and documents into various output formats:
|
|
5
|
+
* JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { extractSnippet } from "./store.js";
|
|
9
|
+
import type { SearchResult, MultiGetResult, DocumentResult } from "./store.js";
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Types
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
// Re-export store types for convenience
|
|
16
|
+
export type { SearchResult, MultiGetResult, DocumentResult };
|
|
17
|
+
|
|
18
|
+
// Flattened type for formatter convenience (extracts info from MultiGetResult)
|
|
19
|
+
export type MultiGetFile = {
|
|
20
|
+
filepath: string;
|
|
21
|
+
displayPath: string;
|
|
22
|
+
title: string;
|
|
23
|
+
body: string;
|
|
24
|
+
context?: string | null;
|
|
25
|
+
skipped: false;
|
|
26
|
+
} | {
|
|
27
|
+
filepath: string;
|
|
28
|
+
displayPath: string;
|
|
29
|
+
title: string;
|
|
30
|
+
body: string;
|
|
31
|
+
context?: string | null;
|
|
32
|
+
skipped: true;
|
|
33
|
+
skipReason: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type OutputFormat = "cli" | "csv" | "md" | "xml" | "files" | "json";
|
|
37
|
+
|
|
38
|
+
export type FormatOptions = {
|
|
39
|
+
full?: boolean; // Show full document content instead of snippet
|
|
40
|
+
query?: string; // Query for snippet extraction and highlighting
|
|
41
|
+
useColor?: boolean; // Enable terminal colors (default: false for non-CLI)
|
|
42
|
+
lineNumbers?: boolean;// Add line numbers to output
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// =============================================================================
|
|
46
|
+
// Helper Functions
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Add line numbers to text content.
|
|
51
|
+
* Each line becomes: "{lineNum}: {content}"
|
|
52
|
+
* @param text The text to add line numbers to
|
|
53
|
+
* @param startLine Optional starting line number (default: 1)
|
|
54
|
+
*/
|
|
55
|
+
export function addLineNumbers(text: string, startLine: number = 1): string {
|
|
56
|
+
const lines = text.split('\n');
|
|
57
|
+
return lines.map((line, i) => `${startLine + i}: ${line}`).join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extract short docid from a full hash (first 6 characters).
|
|
62
|
+
*/
|
|
63
|
+
export function getDocid(hash: string): string {
|
|
64
|
+
return hash.slice(0, 6);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// Escape Helpers
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
export function escapeCSV(value: string | null | number): string {
|
|
72
|
+
if (value === null || value === undefined) return "";
|
|
73
|
+
const str = String(value);
|
|
74
|
+
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
|
75
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
76
|
+
}
|
|
77
|
+
return str;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function escapeXml(str: string): string {
|
|
81
|
+
return str
|
|
82
|
+
.replace(/&/g, "&")
|
|
83
|
+
.replace(/</g, "<")
|
|
84
|
+
.replace(/>/g, ">")
|
|
85
|
+
.replace(/"/g, """)
|
|
86
|
+
.replace(/'/g, "'");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// =============================================================================
|
|
90
|
+
// Search Results Formatters
|
|
91
|
+
// =============================================================================
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Format search results as JSON
|
|
95
|
+
*/
|
|
96
|
+
export function searchResultsToJson(
|
|
97
|
+
results: SearchResult[],
|
|
98
|
+
opts: FormatOptions = {}
|
|
99
|
+
): string {
|
|
100
|
+
const query = opts.query || "";
|
|
101
|
+
const output = results.map(row => {
|
|
102
|
+
const bodyStr = row.body || "";
|
|
103
|
+
let body = opts.full ? bodyStr : undefined;
|
|
104
|
+
let snippet = !opts.full ? extractSnippet(bodyStr, query, 300, row.chunkPos).snippet : undefined;
|
|
105
|
+
|
|
106
|
+
if (opts.lineNumbers) {
|
|
107
|
+
if (body) body = addLineNumbers(body);
|
|
108
|
+
if (snippet) snippet = addLineNumbers(snippet);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
docid: `#${row.docid}`,
|
|
113
|
+
score: Math.round(row.score * 100) / 100,
|
|
114
|
+
file: row.displayPath,
|
|
115
|
+
title: row.title,
|
|
116
|
+
...(row.context && { context: row.context }),
|
|
117
|
+
...(body && { body }),
|
|
118
|
+
...(snippet && { snippet }),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
return JSON.stringify(output, null, 2);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Format search results as CSV
|
|
126
|
+
*/
|
|
127
|
+
export function searchResultsToCsv(
|
|
128
|
+
results: SearchResult[],
|
|
129
|
+
opts: FormatOptions = {}
|
|
130
|
+
): string {
|
|
131
|
+
const query = opts.query || "";
|
|
132
|
+
const header = "docid,score,file,title,context,line,snippet";
|
|
133
|
+
const rows = results.map(row => {
|
|
134
|
+
const bodyStr = row.body || "";
|
|
135
|
+
const { line, snippet } = extractSnippet(bodyStr, query, 500, row.chunkPos);
|
|
136
|
+
let content = opts.full ? bodyStr : snippet;
|
|
137
|
+
if (opts.lineNumbers && content) {
|
|
138
|
+
content = addLineNumbers(content);
|
|
139
|
+
}
|
|
140
|
+
return [
|
|
141
|
+
`#${row.docid}`,
|
|
142
|
+
row.score.toFixed(4),
|
|
143
|
+
escapeCSV(row.displayPath),
|
|
144
|
+
escapeCSV(row.title),
|
|
145
|
+
escapeCSV(row.context || ""),
|
|
146
|
+
line,
|
|
147
|
+
escapeCSV(content),
|
|
148
|
+
].join(",");
|
|
149
|
+
});
|
|
150
|
+
return [header, ...rows].join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Format search results as simple files list (docid,score,filepath,context)
|
|
155
|
+
*/
|
|
156
|
+
export function searchResultsToFiles(results: SearchResult[]): string {
|
|
157
|
+
return results.map(row => {
|
|
158
|
+
const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
|
|
159
|
+
return `#${row.docid},${row.score.toFixed(2)},${row.displayPath}${ctx}`;
|
|
160
|
+
}).join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Format search results as Markdown
|
|
165
|
+
*/
|
|
166
|
+
export function searchResultsToMarkdown(
|
|
167
|
+
results: SearchResult[],
|
|
168
|
+
opts: FormatOptions = {}
|
|
169
|
+
): string {
|
|
170
|
+
const query = opts.query || "";
|
|
171
|
+
return results.map(row => {
|
|
172
|
+
const heading = row.title || row.displayPath;
|
|
173
|
+
const bodyStr = row.body || "";
|
|
174
|
+
let content: string;
|
|
175
|
+
if (opts.full) {
|
|
176
|
+
content = bodyStr;
|
|
177
|
+
} else {
|
|
178
|
+
content = extractSnippet(bodyStr, query, 500, row.chunkPos).snippet;
|
|
179
|
+
}
|
|
180
|
+
if (opts.lineNumbers) {
|
|
181
|
+
content = addLineNumbers(content);
|
|
182
|
+
}
|
|
183
|
+
const contextLine = row.context ? `**context:** ${row.context}\n` : "";
|
|
184
|
+
return `---\n# ${heading}\n\n**docid:** \`#${row.docid}\`\n${contextLine}\n${content}\n`;
|
|
185
|
+
}).join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Format search results as XML
|
|
190
|
+
*/
|
|
191
|
+
export function searchResultsToXml(
|
|
192
|
+
results: SearchResult[],
|
|
193
|
+
opts: FormatOptions = {}
|
|
194
|
+
): string {
|
|
195
|
+
const query = opts.query || "";
|
|
196
|
+
const items = results.map(row => {
|
|
197
|
+
const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : "";
|
|
198
|
+
const bodyStr = row.body || "";
|
|
199
|
+
let content = opts.full ? bodyStr : extractSnippet(bodyStr, query, 500, row.chunkPos).snippet;
|
|
200
|
+
if (opts.lineNumbers) {
|
|
201
|
+
content = addLineNumbers(content);
|
|
202
|
+
}
|
|
203
|
+
const contextAttr = row.context ? ` context="${escapeXml(row.context)}"` : "";
|
|
204
|
+
return `<file docid="#${row.docid}" name="${escapeXml(row.displayPath)}"${titleAttr}${contextAttr}>\n${escapeXml(content)}\n</file>`;
|
|
205
|
+
});
|
|
206
|
+
return items.join("\n\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Format search results for MCP (simpler CSV format with pre-extracted snippets)
|
|
211
|
+
*/
|
|
212
|
+
export function searchResultsToMcpCsv(
|
|
213
|
+
results: { docid: string; file: string; title: string; score: number; context: string | null; snippet: string }[]
|
|
214
|
+
): string {
|
|
215
|
+
const header = "docid,file,title,score,context,snippet";
|
|
216
|
+
const rows = results.map(r =>
|
|
217
|
+
[`#${r.docid}`, r.file, r.title, r.score, r.context || "", r.snippet].map(escapeCSV).join(",")
|
|
218
|
+
);
|
|
219
|
+
return [header, ...rows].join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// =============================================================================
|
|
223
|
+
// Document Formatters (for multi-get using MultiGetFile from store)
|
|
224
|
+
// =============================================================================
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Format documents as JSON
|
|
228
|
+
*/
|
|
229
|
+
export function documentsToJson(results: MultiGetFile[]): string {
|
|
230
|
+
const output = results.map(r => ({
|
|
231
|
+
file: r.displayPath,
|
|
232
|
+
title: r.title,
|
|
233
|
+
...(r.context && { context: r.context }),
|
|
234
|
+
...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
|
|
235
|
+
}));
|
|
236
|
+
return JSON.stringify(output, null, 2);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Format documents as CSV
|
|
241
|
+
*/
|
|
242
|
+
export function documentsToCsv(results: MultiGetFile[]): string {
|
|
243
|
+
const header = "file,title,context,skipped,body";
|
|
244
|
+
const rows = results.map(r =>
|
|
245
|
+
[
|
|
246
|
+
r.displayPath,
|
|
247
|
+
r.title,
|
|
248
|
+
r.context || "",
|
|
249
|
+
r.skipped ? "true" : "false",
|
|
250
|
+
r.skipped ? (r.skipReason || "") : r.body
|
|
251
|
+
].map(escapeCSV).join(",")
|
|
252
|
+
);
|
|
253
|
+
return [header, ...rows].join("\n");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Format documents as files list
|
|
258
|
+
*/
|
|
259
|
+
export function documentsToFiles(results: MultiGetFile[]): string {
|
|
260
|
+
return results.map(r => {
|
|
261
|
+
const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
|
|
262
|
+
const status = r.skipped ? ",[SKIPPED]" : "";
|
|
263
|
+
return `${r.displayPath}${ctx}${status}`;
|
|
264
|
+
}).join("\n");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Format documents as Markdown
|
|
269
|
+
*/
|
|
270
|
+
export function documentsToMarkdown(results: MultiGetFile[]): string {
|
|
271
|
+
return results.map(r => {
|
|
272
|
+
let md = `## ${r.displayPath}\n\n`;
|
|
273
|
+
if (r.title && r.title !== r.displayPath) md += `**Title:** ${r.title}\n\n`;
|
|
274
|
+
if (r.context) md += `**Context:** ${r.context}\n\n`;
|
|
275
|
+
if (r.skipped) {
|
|
276
|
+
md += `> ${r.skipReason}\n`;
|
|
277
|
+
} else {
|
|
278
|
+
md += "```\n" + r.body + "\n```\n";
|
|
279
|
+
}
|
|
280
|
+
return md;
|
|
281
|
+
}).join("\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Format documents as XML
|
|
286
|
+
*/
|
|
287
|
+
export function documentsToXml(results: MultiGetFile[]): string {
|
|
288
|
+
const items = results.map(r => {
|
|
289
|
+
let xml = " <document>\n";
|
|
290
|
+
xml += ` <file>${escapeXml(r.displayPath)}</file>\n`;
|
|
291
|
+
xml += ` <title>${escapeXml(r.title)}</title>\n`;
|
|
292
|
+
if (r.context) xml += ` <context>${escapeXml(r.context)}</context>\n`;
|
|
293
|
+
if (r.skipped) {
|
|
294
|
+
xml += ` <skipped>true</skipped>\n`;
|
|
295
|
+
xml += ` <reason>${escapeXml(r.skipReason || "")}</reason>\n`;
|
|
296
|
+
} else {
|
|
297
|
+
xml += ` <body>${escapeXml(r.body)}</body>\n`;
|
|
298
|
+
}
|
|
299
|
+
xml += " </document>";
|
|
300
|
+
return xml;
|
|
301
|
+
});
|
|
302
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<documents>\n${items.join("\n")}\n</documents>`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// =============================================================================
|
|
306
|
+
// Single Document Formatters
|
|
307
|
+
// =============================================================================
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Format a single DocumentResult as JSON
|
|
311
|
+
*/
|
|
312
|
+
export function documentToJson(doc: DocumentResult): string {
|
|
313
|
+
return JSON.stringify({
|
|
314
|
+
file: doc.displayPath,
|
|
315
|
+
title: doc.title,
|
|
316
|
+
...(doc.context && { context: doc.context }),
|
|
317
|
+
hash: doc.hash,
|
|
318
|
+
modifiedAt: doc.modifiedAt,
|
|
319
|
+
bodyLength: doc.bodyLength,
|
|
320
|
+
...(doc.body !== undefined && { body: doc.body }),
|
|
321
|
+
}, null, 2);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Format a single DocumentResult as Markdown
|
|
326
|
+
*/
|
|
327
|
+
export function documentToMarkdown(doc: DocumentResult): string {
|
|
328
|
+
let md = `# ${doc.title || doc.displayPath}\n\n`;
|
|
329
|
+
if (doc.context) md += `**Context:** ${doc.context}\n\n`;
|
|
330
|
+
md += `**File:** ${doc.displayPath}\n`;
|
|
331
|
+
md += `**Modified:** ${doc.modifiedAt}\n\n`;
|
|
332
|
+
if (doc.body !== undefined) {
|
|
333
|
+
md += "---\n\n" + doc.body + "\n";
|
|
334
|
+
}
|
|
335
|
+
return md;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Format a single DocumentResult as XML
|
|
340
|
+
*/
|
|
341
|
+
export function documentToXml(doc: DocumentResult): string {
|
|
342
|
+
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<document>\n`;
|
|
343
|
+
xml += ` <file>${escapeXml(doc.displayPath)}</file>\n`;
|
|
344
|
+
xml += ` <title>${escapeXml(doc.title)}</title>\n`;
|
|
345
|
+
if (doc.context) xml += ` <context>${escapeXml(doc.context)}</context>\n`;
|
|
346
|
+
xml += ` <hash>${escapeXml(doc.hash)}</hash>\n`;
|
|
347
|
+
xml += ` <modifiedAt>${escapeXml(doc.modifiedAt)}</modifiedAt>\n`;
|
|
348
|
+
xml += ` <bodyLength>${doc.bodyLength}</bodyLength>\n`;
|
|
349
|
+
if (doc.body !== undefined) {
|
|
350
|
+
xml += ` <body>${escapeXml(doc.body)}</body>\n`;
|
|
351
|
+
}
|
|
352
|
+
xml += `</document>`;
|
|
353
|
+
return xml;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Format a single document to the specified format
|
|
358
|
+
*/
|
|
359
|
+
export function formatDocument(doc: DocumentResult, format: OutputFormat): string {
|
|
360
|
+
switch (format) {
|
|
361
|
+
case "json":
|
|
362
|
+
return documentToJson(doc);
|
|
363
|
+
case "md":
|
|
364
|
+
return documentToMarkdown(doc);
|
|
365
|
+
case "xml":
|
|
366
|
+
return documentToXml(doc);
|
|
367
|
+
default:
|
|
368
|
+
// Default to markdown for CLI and other formats
|
|
369
|
+
return documentToMarkdown(doc);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// =============================================================================
|
|
374
|
+
// Universal Format Function
|
|
375
|
+
// =============================================================================
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Format search results to the specified output format
|
|
379
|
+
*/
|
|
380
|
+
export function formatSearchResults(
|
|
381
|
+
results: SearchResult[],
|
|
382
|
+
format: OutputFormat,
|
|
383
|
+
opts: FormatOptions = {}
|
|
384
|
+
): string {
|
|
385
|
+
switch (format) {
|
|
386
|
+
case "json":
|
|
387
|
+
return searchResultsToJson(results, opts);
|
|
388
|
+
case "csv":
|
|
389
|
+
return searchResultsToCsv(results, opts);
|
|
390
|
+
case "files":
|
|
391
|
+
return searchResultsToFiles(results);
|
|
392
|
+
case "md":
|
|
393
|
+
return searchResultsToMarkdown(results, opts);
|
|
394
|
+
case "xml":
|
|
395
|
+
return searchResultsToXml(results, opts);
|
|
396
|
+
case "cli":
|
|
397
|
+
// CLI format should be handled separately with colors
|
|
398
|
+
// Return a simple text version as fallback
|
|
399
|
+
return searchResultsToMarkdown(results, opts);
|
|
400
|
+
default:
|
|
401
|
+
return searchResultsToJson(results, opts);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Format documents to the specified output format
|
|
407
|
+
*/
|
|
408
|
+
export function formatDocuments(
|
|
409
|
+
results: MultiGetFile[],
|
|
410
|
+
format: OutputFormat
|
|
411
|
+
): string {
|
|
412
|
+
switch (format) {
|
|
413
|
+
case "json":
|
|
414
|
+
return documentsToJson(results);
|
|
415
|
+
case "csv":
|
|
416
|
+
return documentsToCsv(results);
|
|
417
|
+
case "files":
|
|
418
|
+
return documentsToFiles(results);
|
|
419
|
+
case "md":
|
|
420
|
+
return documentsToMarkdown(results);
|
|
421
|
+
case "xml":
|
|
422
|
+
return documentsToXml(results);
|
|
423
|
+
case "cli":
|
|
424
|
+
// CLI format should be handled separately with colors
|
|
425
|
+
return documentsToMarkdown(results);
|
|
426
|
+
default:
|
|
427
|
+
return documentsToJson(results);
|
|
428
|
+
}
|
|
429
|
+
}
|