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 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
+ }