contextqmd 0.1.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/README.md +87 -0
- package/dist/index.js +199 -0
- package/dist/lib/config.js +22 -0
- package/dist/lib/doc-indexer.js +270 -0
- package/dist/lib/local-cache.js +183 -0
- package/dist/lib/registry-client.js +100 -0
- package/dist/lib/service.js +776 -0
- package/dist/lib/types.js +1 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# contextqmd-cli
|
|
2
|
+
|
|
3
|
+
`contextqmd` is a standalone CLI for ContextQMD. It talks to the registry API directly and manages a local docs cache and search index powered by QMD (SQLite + BM25 + vector search).
|
|
4
|
+
|
|
5
|
+
Requires Node.js >= 22.0.0.
|
|
6
|
+
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
### Library Management
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
contextqmd libraries search "inertia rails"
|
|
13
|
+
contextqmd libraries install "inertia rails" --version 3.17.0
|
|
14
|
+
contextqmd libraries list
|
|
15
|
+
contextqmd libraries update inertiajs/inertia-rails
|
|
16
|
+
contextqmd libraries remove inertiajs/inertia-rails --version 3.17.0
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Documentation Search & Retrieval
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
contextqmd docs search "file uploads" --library inertiajs/inertia-rails --version 3.17.0 --mode hybrid
|
|
23
|
+
contextqmd docs get --library inertiajs/inertia-rails --version 3.17.0 --doc-path guides/forms.md --from-line 120 --max-lines 80
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Search modes: `fts` (full-text), `vector`, `hybrid`, `auto` (default — heuristic picks based on query shape).
|
|
27
|
+
|
|
28
|
+
## Global Flags
|
|
29
|
+
|
|
30
|
+
| Flag | Description |
|
|
31
|
+
|------|-------------|
|
|
32
|
+
| `--json` | Structured JSON output instead of human-readable text |
|
|
33
|
+
| `--registry <url>` | Registry URL (default: `https://contextqmd.com`) |
|
|
34
|
+
| `--token <token>` | Authentication token |
|
|
35
|
+
| `--cache-dir <path>` | Local cache directory (default: `~/.cache/contextqmd`) |
|
|
36
|
+
|
|
37
|
+
## Architecture
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
src/
|
|
41
|
+
index.ts CLI entrypoint (Commander.js argument parsing, IO abstraction)
|
|
42
|
+
lib/
|
|
43
|
+
service.ts Business logic — install, search, update, remove orchestration
|
|
44
|
+
registry-client.ts HTTP client for the ContextQMD registry API (uses built-in fetch)
|
|
45
|
+
local-cache.ts Filesystem cache with atomic installs and backup/restore
|
|
46
|
+
doc-indexer.ts Search index wrapping @tobilu/qmd (SQLite + BM25 + vector)
|
|
47
|
+
config.ts Config loader (~/.config/contextqmd/config.json with defaults)
|
|
48
|
+
types.ts Shared API contract types (Library, Version, Manifest, etc.)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Install pipeline:** Resolves library via registry → fetches manifest → tries bundle download (tar.gz with SHA-256 verification) → falls back to page-by-page API download → indexes pages for local search.
|
|
52
|
+
|
|
53
|
+
**Search pipeline:** Classifies query (code patterns → FTS, conceptual questions → vector/hybrid) → searches local QMD index → falls back to FTS if vector/hybrid returns empty.
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
Optional config file at `~/.config/contextqmd/config.json`:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"registry_url": "https://contextqmd.com",
|
|
62
|
+
"default_install_mode": "slim",
|
|
63
|
+
"preferred_search_mode": "auto",
|
|
64
|
+
"local_cache_dir": "~/.cache/contextqmd",
|
|
65
|
+
"allow_origin_fetch": true,
|
|
66
|
+
"allow_remote_bundles": true,
|
|
67
|
+
"verify_registry_signatures": true
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
All fields are optional — defaults are used for anything not specified.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install
|
|
77
|
+
npm run build # compile TypeScript to dist/
|
|
78
|
+
npm run check # type-check without emitting
|
|
79
|
+
npm test # run tests (vitest)
|
|
80
|
+
npm link # symlink the contextqmd binary globally
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Dependencies
|
|
84
|
+
|
|
85
|
+
- **[commander](https://github.com/tj/commander.js)** — CLI argument parsing
|
|
86
|
+
- **[@tobilu/qmd](https://github.com/tobilu/qmd)** — local search index (SQLite + BM25 + vector + LLM reranking)
|
|
87
|
+
- **[zod](https://github.com/colinhacks/zod)** — schema validation
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { Command, CommanderError } from "commander";
|
|
6
|
+
import { loadConfig } from "./lib/config.js";
|
|
7
|
+
import { DocIndexer } from "./lib/doc-indexer.js";
|
|
8
|
+
import { LocalCache } from "./lib/local-cache.js";
|
|
9
|
+
import { RegistryClient } from "./lib/registry-client.js";
|
|
10
|
+
import { getDoc, installDocs, listInstalledDocs, removeDocs, searchDocs, searchLibraries, updateDocs, } from "./lib/service.js";
|
|
11
|
+
function defaultIo() {
|
|
12
|
+
return {
|
|
13
|
+
env: process.env,
|
|
14
|
+
writeStdout: chunk => process.stdout.write(chunk),
|
|
15
|
+
writeStderr: chunk => process.stderr.write(chunk),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function resolveEntrypointPath(path) {
|
|
19
|
+
try {
|
|
20
|
+
return realpathSync(path);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return resolvePath(path);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function isCliEntrypoint(argvPath = process.argv[1], moduleUrl = import.meta.url) {
|
|
27
|
+
if (!argvPath)
|
|
28
|
+
return false;
|
|
29
|
+
return resolveEntrypointPath(argvPath) === resolveEntrypointPath(fileURLToPath(moduleUrl));
|
|
30
|
+
}
|
|
31
|
+
async function withDeps(env, options, io, run) {
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
const registryUrl = options.registry ?? config.registry_url;
|
|
34
|
+
const token = options.token ?? env.CONTEXTQMD_API_TOKEN;
|
|
35
|
+
const cacheDir = options.cacheDir ?? config.local_cache_dir;
|
|
36
|
+
const registryClient = new RegistryClient(registryUrl, token);
|
|
37
|
+
const cache = new LocalCache(cacheDir);
|
|
38
|
+
const indexer = new DocIndexer(join(cacheDir, "index.sqlite"), cache);
|
|
39
|
+
try {
|
|
40
|
+
return await run({
|
|
41
|
+
registryClient,
|
|
42
|
+
cache,
|
|
43
|
+
indexer,
|
|
44
|
+
reportProgress: message => io.writeStderr(`${message}\n`),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await indexer.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function renderResult(result, options, io) {
|
|
52
|
+
const output = options.json
|
|
53
|
+
? JSON.stringify(result.structuredContent ?? {}, null, 2)
|
|
54
|
+
: result.text;
|
|
55
|
+
const normalized = output.endsWith("\n") ? output : `${output}\n`;
|
|
56
|
+
if (result.isError) {
|
|
57
|
+
io.writeStderr(normalized);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
io.writeStdout(normalized);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
function addGlobalOptions(command) {
|
|
64
|
+
return command
|
|
65
|
+
.option("--json", "Print structured output as JSON")
|
|
66
|
+
.option("--registry <url>", "Registry URL override")
|
|
67
|
+
.option("--token <token>", "Registry token override")
|
|
68
|
+
.option("--cache-dir <path>", "Local cache directory override");
|
|
69
|
+
}
|
|
70
|
+
function globalOptionsFor(command) {
|
|
71
|
+
return command.optsWithGlobals();
|
|
72
|
+
}
|
|
73
|
+
function createProgram(io, onExitCode) {
|
|
74
|
+
const program = addGlobalOptions(new Command());
|
|
75
|
+
program
|
|
76
|
+
.name("contextqmd")
|
|
77
|
+
.description("CLI for ContextQMD")
|
|
78
|
+
.exitOverride()
|
|
79
|
+
.configureOutput({
|
|
80
|
+
writeOut: chunk => io.writeStdout(chunk),
|
|
81
|
+
writeErr: chunk => io.writeStderr(chunk),
|
|
82
|
+
});
|
|
83
|
+
const libraries = program.command("libraries").description("Library package workflows");
|
|
84
|
+
libraries
|
|
85
|
+
.command("search")
|
|
86
|
+
.argument("<query>", "Search query")
|
|
87
|
+
.option("--limit <count>", "Maximum libraries to return")
|
|
88
|
+
.action(async function (query, options) {
|
|
89
|
+
const global = globalOptionsFor(this);
|
|
90
|
+
const result = await withDeps(io.env, global, io, deps => searchLibraries(deps, { query, ...(options.limit ? { limit: Number(options.limit) } : {}) }));
|
|
91
|
+
onExitCode(renderResult(result, global, io));
|
|
92
|
+
});
|
|
93
|
+
libraries
|
|
94
|
+
.command("install")
|
|
95
|
+
.argument("<library>", "Library query or slug")
|
|
96
|
+
.option("--version <version>", "Version to install")
|
|
97
|
+
.action(async function (library, options) {
|
|
98
|
+
const global = globalOptionsFor(this);
|
|
99
|
+
const result = await withDeps(io.env, global, io, deps => installDocs(deps, { library, version: options.version }));
|
|
100
|
+
onExitCode(renderResult(result, global, io));
|
|
101
|
+
});
|
|
102
|
+
libraries
|
|
103
|
+
.command("list")
|
|
104
|
+
.action(async function () {
|
|
105
|
+
const global = globalOptionsFor(this);
|
|
106
|
+
const result = await withDeps(io.env, global, io, deps => listInstalledDocs(deps));
|
|
107
|
+
onExitCode(renderResult(result, global, io));
|
|
108
|
+
});
|
|
109
|
+
libraries
|
|
110
|
+
.command("update")
|
|
111
|
+
.argument("[library]", "Optional library slug (namespace/name)")
|
|
112
|
+
.action(async function (library) {
|
|
113
|
+
const global = globalOptionsFor(this);
|
|
114
|
+
const result = await withDeps(io.env, global, io, deps => updateDocs(deps, library ? { library } : {}));
|
|
115
|
+
onExitCode(renderResult(result, global, io));
|
|
116
|
+
});
|
|
117
|
+
libraries
|
|
118
|
+
.command("remove")
|
|
119
|
+
.argument("<library>", "Library slug (namespace/name)")
|
|
120
|
+
.option("--version <version>", "Installed version to remove")
|
|
121
|
+
.action(async function (library, options) {
|
|
122
|
+
const global = globalOptionsFor(this);
|
|
123
|
+
const result = await withDeps(io.env, global, io, deps => removeDocs(deps, { library, version: options.version }));
|
|
124
|
+
onExitCode(renderResult(result, global, io));
|
|
125
|
+
});
|
|
126
|
+
const docs = program.command("docs").description("Installed documentation operations");
|
|
127
|
+
docs
|
|
128
|
+
.command("search")
|
|
129
|
+
.argument("<query>", "Search query")
|
|
130
|
+
.option("--library <library>", "Filter to a specific library")
|
|
131
|
+
.option("--version <version>", "Filter to a specific version")
|
|
132
|
+
.option("--mode <mode>", "Search mode")
|
|
133
|
+
.option("--max-results <count>", "Maximum results to return")
|
|
134
|
+
.action(async function (query, options) {
|
|
135
|
+
const global = globalOptionsFor(this);
|
|
136
|
+
const result = await withDeps(io.env, global, io, deps => searchDocs(deps, {
|
|
137
|
+
query,
|
|
138
|
+
...(options.library ? { library: options.library } : {}),
|
|
139
|
+
...(options.version ? { version: options.version } : {}),
|
|
140
|
+
...(options.mode ? { mode: options.mode } : {}),
|
|
141
|
+
...(options.maxResults ? { max_results: Number(options.maxResults) } : {}),
|
|
142
|
+
}));
|
|
143
|
+
onExitCode(renderResult(result, global, io));
|
|
144
|
+
});
|
|
145
|
+
docs
|
|
146
|
+
.command("get")
|
|
147
|
+
.requiredOption("--library <library>", "Library slug (namespace/name)")
|
|
148
|
+
.requiredOption("--version <version>", "Installed version")
|
|
149
|
+
.option("--doc-path <docPath>", "Canonical document path")
|
|
150
|
+
.option("--page-uid <pageUid>", "Page UID fallback")
|
|
151
|
+
.option("--from-line <line>", "Start line")
|
|
152
|
+
.option("--max-lines <count>", "Maximum lines")
|
|
153
|
+
.option("--around-line <line>", "Anchor line")
|
|
154
|
+
.option("--before <count>", "Lines before around-line")
|
|
155
|
+
.option("--after <count>", "Lines after around-line")
|
|
156
|
+
.option("--line-numbers", "Include line numbers")
|
|
157
|
+
.action(async function (options) {
|
|
158
|
+
const global = globalOptionsFor(this);
|
|
159
|
+
const result = await withDeps(io.env, global, io, deps => getDoc(deps, {
|
|
160
|
+
library: options.library,
|
|
161
|
+
version: options.version,
|
|
162
|
+
...(options.docPath ? { doc_path: options.docPath } : {}),
|
|
163
|
+
...(options.pageUid ? { page_uid: options.pageUid } : {}),
|
|
164
|
+
...(options.fromLine ? { from_line: Number(options.fromLine) } : {}),
|
|
165
|
+
...(options.maxLines ? { max_lines: Number(options.maxLines) } : {}),
|
|
166
|
+
...(options.aroundLine ? { around_line: Number(options.aroundLine) } : {}),
|
|
167
|
+
...(options.before ? { before: Number(options.before) } : {}),
|
|
168
|
+
...(options.after ? { after: Number(options.after) } : {}),
|
|
169
|
+
...(options.lineNumbers ? { line_numbers: true } : {}),
|
|
170
|
+
}));
|
|
171
|
+
onExitCode(renderResult(result, global, io));
|
|
172
|
+
});
|
|
173
|
+
return program;
|
|
174
|
+
}
|
|
175
|
+
export async function runCli(argv, inputIo = {}) {
|
|
176
|
+
const io = { ...defaultIo(), ...inputIo };
|
|
177
|
+
let exitCode = 0;
|
|
178
|
+
const program = createProgram(io, code => {
|
|
179
|
+
exitCode = code;
|
|
180
|
+
});
|
|
181
|
+
try {
|
|
182
|
+
await program.parseAsync(argv, { from: "user" });
|
|
183
|
+
return exitCode;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (error instanceof CommanderError) {
|
|
187
|
+
if (error.code !== "commander.helpDisplayed") {
|
|
188
|
+
io.writeStderr(`${error.message}\n`);
|
|
189
|
+
}
|
|
190
|
+
return error.exitCode;
|
|
191
|
+
}
|
|
192
|
+
io.writeStderr(`${error.message}\n`);
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (isCliEntrypoint()) {
|
|
197
|
+
const exitCode = await runCli(process.argv.slice(2));
|
|
198
|
+
process.exit(exitCode);
|
|
199
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
const DEFAULT_CONFIG = {
|
|
5
|
+
registry_url: "https://contextqmd.com",
|
|
6
|
+
fallback_registries: [],
|
|
7
|
+
allow_origin_fetch: true,
|
|
8
|
+
allow_remote_bundles: true,
|
|
9
|
+
allow_public_fallback: false,
|
|
10
|
+
verify_registry_signatures: true,
|
|
11
|
+
default_install_mode: "slim",
|
|
12
|
+
preferred_search_mode: "auto",
|
|
13
|
+
local_cache_dir: join(homedir(), ".cache", "contextqmd"),
|
|
14
|
+
};
|
|
15
|
+
export function loadConfig(configPath) {
|
|
16
|
+
const path = configPath ?? join(homedir(), ".config", "contextqmd", "config.json");
|
|
17
|
+
if (!existsSync(path)) {
|
|
18
|
+
return { ...DEFAULT_CONFIG };
|
|
19
|
+
}
|
|
20
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
21
|
+
return { ...DEFAULT_CONFIG, ...raw };
|
|
22
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createStore, extractSnippet, } from "@tobilu/qmd";
|
|
3
|
+
import { normalizeDocPath } from "./local-cache.js";
|
|
4
|
+
export function classifyQuery(query) {
|
|
5
|
+
const q = query.trim();
|
|
6
|
+
if (q.split(/\s+/).length <= 2 && !q.includes("?")) {
|
|
7
|
+
return "fts";
|
|
8
|
+
}
|
|
9
|
+
const codePatterns = [
|
|
10
|
+
/[a-z][A-Z]/,
|
|
11
|
+
/[a-z]_[a-z]/,
|
|
12
|
+
/\w+\.\w+\.\w+/,
|
|
13
|
+
/`[^`]+`/,
|
|
14
|
+
/^[A-Z_]{3,}$/,
|
|
15
|
+
/\berr(or)?[:\s]+/i,
|
|
16
|
+
/\d+\.\d+\.\d+/,
|
|
17
|
+
/[{}()\[\]<>]/,
|
|
18
|
+
/^(get|set|use|create|delete|update)\w+/i,
|
|
19
|
+
/\w+::\w+/,
|
|
20
|
+
/\w+\/\w+/,
|
|
21
|
+
];
|
|
22
|
+
if (codePatterns.some(pattern => pattern.test(q))) {
|
|
23
|
+
return "fts";
|
|
24
|
+
}
|
|
25
|
+
const conceptualPatterns = [
|
|
26
|
+
/^(how|what|why|when|where|can|should|is it|does)\b/i,
|
|
27
|
+
/\b(best practice|pattern|approach|strategy|concept|overview|guide)\b/i,
|
|
28
|
+
/\b(difference between|compare|vs\.?|versus)\b/i,
|
|
29
|
+
/\b(explain|understand|learn|tutorial)\b/i,
|
|
30
|
+
];
|
|
31
|
+
if (conceptualPatterns.some(pattern => pattern.test(q))) {
|
|
32
|
+
if (q.split(/\s+/).length >= 6) {
|
|
33
|
+
return "hybrid";
|
|
34
|
+
}
|
|
35
|
+
return "vector";
|
|
36
|
+
}
|
|
37
|
+
if (q.split(/\s+/).length >= 8) {
|
|
38
|
+
return "hybrid";
|
|
39
|
+
}
|
|
40
|
+
return "fts";
|
|
41
|
+
}
|
|
42
|
+
export class DocIndexer {
|
|
43
|
+
storePromise;
|
|
44
|
+
cache;
|
|
45
|
+
constructor(dbPath, cache) {
|
|
46
|
+
this.storePromise = createStore({
|
|
47
|
+
dbPath,
|
|
48
|
+
config: { collections: {} },
|
|
49
|
+
});
|
|
50
|
+
this.cache = cache;
|
|
51
|
+
}
|
|
52
|
+
async close() {
|
|
53
|
+
const store = await this.storePromise;
|
|
54
|
+
await store.close();
|
|
55
|
+
}
|
|
56
|
+
async getStore() {
|
|
57
|
+
return (await this.storePromise).internal;
|
|
58
|
+
}
|
|
59
|
+
static collectionName(namespace, name, version) {
|
|
60
|
+
return `${namespace}__${name}__${version}`;
|
|
61
|
+
}
|
|
62
|
+
static parseCollectionName(collectionName) {
|
|
63
|
+
const parts = collectionName.split("__");
|
|
64
|
+
if (parts.length !== 3)
|
|
65
|
+
return null;
|
|
66
|
+
return { namespace: parts[0], name: parts[1], version: parts[2] };
|
|
67
|
+
}
|
|
68
|
+
async indexLibraryVersion(namespace, name, version) {
|
|
69
|
+
const store = await this.getStore();
|
|
70
|
+
const collectionName = DocIndexer.collectionName(namespace, name, version);
|
|
71
|
+
const pageUids = this.cache.listPageUids(namespace, name, version);
|
|
72
|
+
const desiredPaths = new Set();
|
|
73
|
+
let indexed = 0;
|
|
74
|
+
for (const pageUid of pageUids) {
|
|
75
|
+
const content = this.cache.readPage(namespace, name, version, pageUid);
|
|
76
|
+
if (!content)
|
|
77
|
+
continue;
|
|
78
|
+
const page = this.cache.findPageByUid(namespace, name, version, pageUid);
|
|
79
|
+
const docPath = page ? normalizeDocPath(page.path) : `${pageUid}.md`;
|
|
80
|
+
desiredPaths.add(docPath);
|
|
81
|
+
const title = page?.title ?? extractTitle(content, pageUid);
|
|
82
|
+
const hash = await hashContent(content);
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
const legacyPath = `${pageUid}.md`;
|
|
85
|
+
const existing = store.findActiveDocument(collectionName, docPath);
|
|
86
|
+
const legacyExisting = docPath === legacyPath
|
|
87
|
+
? existing
|
|
88
|
+
: store.findActiveDocument(collectionName, legacyPath);
|
|
89
|
+
if (existing && existing.hash === hash)
|
|
90
|
+
continue;
|
|
91
|
+
if (!existing && legacyExisting && legacyExisting.hash === hash) {
|
|
92
|
+
store.insertDocument(collectionName, docPath, title, legacyExisting.hash, now, now);
|
|
93
|
+
if (legacyPath !== docPath) {
|
|
94
|
+
store.deactivateDocument(collectionName, legacyPath);
|
|
95
|
+
}
|
|
96
|
+
indexed++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
store.insertContent(hash, content, now);
|
|
100
|
+
if (existing) {
|
|
101
|
+
store.updateDocument(existing.id, title, hash, now);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
store.insertDocument(collectionName, docPath, title, hash, now, now);
|
|
105
|
+
if (legacyExisting && legacyPath !== docPath) {
|
|
106
|
+
store.deactivateDocument(collectionName, legacyPath);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
indexed++;
|
|
110
|
+
}
|
|
111
|
+
for (const activePath of store.getActiveDocumentPaths(collectionName)) {
|
|
112
|
+
if (!desiredPaths.has(activePath)) {
|
|
113
|
+
store.deactivateDocument(collectionName, activePath);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return indexed;
|
|
117
|
+
}
|
|
118
|
+
async removeLibraryVersion(namespace, name, version) {
|
|
119
|
+
const store = await this.getStore();
|
|
120
|
+
const collectionName = DocIndexer.collectionName(namespace, name, version);
|
|
121
|
+
const paths = store.getActiveDocumentPaths(collectionName);
|
|
122
|
+
for (const path of paths) {
|
|
123
|
+
store.deactivateDocument(collectionName, path);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async search(query, options = {}) {
|
|
127
|
+
if (options.version && !options.library) {
|
|
128
|
+
console.warn(`[contextqmd] version filter "${options.version}" specified without library — version will be applied as a post-query filter`);
|
|
129
|
+
}
|
|
130
|
+
const requestedMode = options.mode ?? "auto";
|
|
131
|
+
const effectiveMode = requestedMode === "auto" ? classifyQuery(query) : requestedMode;
|
|
132
|
+
const searchMode = !options.library && effectiveMode !== "fts" ? "fts" : effectiveMode;
|
|
133
|
+
if (searchMode === "vector") {
|
|
134
|
+
const results = await this.searchVector(query, options);
|
|
135
|
+
if (results.length > 0)
|
|
136
|
+
return results;
|
|
137
|
+
return (await this.searchFTS(query, options)).map(result => ({ ...result, searchMode: "fts" }));
|
|
138
|
+
}
|
|
139
|
+
if (searchMode === "hybrid") {
|
|
140
|
+
const results = await this.searchHybrid(query, options);
|
|
141
|
+
if (results.length > 0)
|
|
142
|
+
return results;
|
|
143
|
+
return (await this.searchFTS(query, options)).map(result => ({ ...result, searchMode: "fts" }));
|
|
144
|
+
}
|
|
145
|
+
return this.searchFTS(query, options);
|
|
146
|
+
}
|
|
147
|
+
async searchFTS(query, options = {}) {
|
|
148
|
+
const store = await this.getStore();
|
|
149
|
+
const limit = options.maxResults ?? 10;
|
|
150
|
+
let collectionFilter;
|
|
151
|
+
if (options.library && options.version) {
|
|
152
|
+
const [namespace, name] = options.library.split("/");
|
|
153
|
+
collectionFilter = DocIndexer.collectionName(namespace, name, options.version);
|
|
154
|
+
}
|
|
155
|
+
const results = store.searchFTS(query, limit * 2, collectionFilter);
|
|
156
|
+
return this.mapAnyResults(results, query, options, "fts").slice(0, limit);
|
|
157
|
+
}
|
|
158
|
+
async searchVector(query, options = {}) {
|
|
159
|
+
const store = await this.storePromise;
|
|
160
|
+
const limit = options.maxResults ?? 10;
|
|
161
|
+
let collectionFilter;
|
|
162
|
+
if (options.library && options.version) {
|
|
163
|
+
const [namespace, name] = options.library.split("/");
|
|
164
|
+
collectionFilter = DocIndexer.collectionName(namespace, name, options.version);
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const results = await withTimeout(store.searchVector(query, {
|
|
168
|
+
collection: collectionFilter,
|
|
169
|
+
limit: limit * 2,
|
|
170
|
+
}), 10_000);
|
|
171
|
+
return this.mapAnyResults(results, query, options, "vector").slice(0, limit);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async searchHybrid(query, options = {}) {
|
|
178
|
+
const store = await this.storePromise;
|
|
179
|
+
const limit = options.maxResults ?? 10;
|
|
180
|
+
let collectionFilter;
|
|
181
|
+
if (options.library && options.version) {
|
|
182
|
+
const [namespace, name] = options.library.split("/");
|
|
183
|
+
collectionFilter = DocIndexer.collectionName(namespace, name, options.version);
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const results = await withTimeout(store.search({ query, collection: collectionFilter, limit }), 10_000);
|
|
187
|
+
return this.mapAnyResults(results, query, options, "hybrid", result => this.extractSnippetInfo(result.body ?? "", query, result.bestChunkPos)).slice(0, limit);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
mapAnyResults(results, query, options, mode, snippetFn = result => this.extractSnippetInfo(result.body ?? "", query, result.chunkPos)) {
|
|
194
|
+
return results
|
|
195
|
+
.map(result => {
|
|
196
|
+
const collectionName = result.collectionName ?? result.displayPath.split("/")[0];
|
|
197
|
+
const parsed = DocIndexer.parseCollectionName(collectionName);
|
|
198
|
+
const library = parsed ? `${parsed.namespace}/${parsed.name}` : collectionName;
|
|
199
|
+
let docPath = result.displayPath;
|
|
200
|
+
if (docPath.startsWith(`${collectionName}/`)) {
|
|
201
|
+
docPath = docPath.slice(collectionName.length + 1);
|
|
202
|
+
}
|
|
203
|
+
docPath = normalizeDocPath(docPath);
|
|
204
|
+
const page = parsed
|
|
205
|
+
? this.cache.findPageByPath(parsed.namespace, parsed.name, parsed.version, docPath)
|
|
206
|
+
: null;
|
|
207
|
+
const pageUid = page?.page_uid ?? docPath.replace(/\.md$/, "");
|
|
208
|
+
const contentMd = parsed
|
|
209
|
+
? (this.cache.readPage(parsed.namespace, parsed.name, parsed.version, pageUid) ?? result.body ?? "")
|
|
210
|
+
: (result.body ?? "");
|
|
211
|
+
const snippet = snippetFn(result);
|
|
212
|
+
return {
|
|
213
|
+
pageUid,
|
|
214
|
+
title: page?.title ?? result.title,
|
|
215
|
+
path: result.displayPath,
|
|
216
|
+
docPath,
|
|
217
|
+
contentMd,
|
|
218
|
+
score: result.score,
|
|
219
|
+
snippet: snippet.snippet,
|
|
220
|
+
library,
|
|
221
|
+
searchMode: mode,
|
|
222
|
+
_version: parsed?.version,
|
|
223
|
+
lineStart: snippet.lineStart,
|
|
224
|
+
lineEnd: snippet.lineEnd,
|
|
225
|
+
url: page?.url,
|
|
226
|
+
};
|
|
227
|
+
})
|
|
228
|
+
.filter(result => {
|
|
229
|
+
if (options.library && result.library !== options.library)
|
|
230
|
+
return false;
|
|
231
|
+
if (options.version && result._version !== options.version)
|
|
232
|
+
return false;
|
|
233
|
+
return true;
|
|
234
|
+
})
|
|
235
|
+
.map(({ _version, ...result }) => ({ ...result, version: _version ?? "unknown" }));
|
|
236
|
+
}
|
|
237
|
+
extractSnippetInfo(body, query, chunkPos) {
|
|
238
|
+
if (!body.trim()) {
|
|
239
|
+
return { snippet: "", lineStart: null, lineEnd: null };
|
|
240
|
+
}
|
|
241
|
+
const { snippet, line, snippetLines } = extractSnippet(body, query, 500, chunkPos);
|
|
242
|
+
if (!line || line < 1) {
|
|
243
|
+
return { snippet, lineStart: null, lineEnd: null };
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
snippet,
|
|
247
|
+
lineStart: line,
|
|
248
|
+
lineEnd: line + Math.max(snippetLines - 1, 0),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function hashContent(content) {
|
|
253
|
+
return createHash("sha256").update(content).digest("hex");
|
|
254
|
+
}
|
|
255
|
+
function withTimeout(promise, ms) {
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
const timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
|
|
258
|
+
promise.then(value => {
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
resolve(value);
|
|
261
|
+
}, error => {
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
reject(error);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
function extractTitle(content, fallback) {
|
|
268
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
269
|
+
return match ? match[1].trim() : fallback;
|
|
270
|
+
}
|