md-semantic-search 1.0.2 → 1.0.3
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 +154 -6
- package/bin/cli.mjs +403 -22
- package/dist/completions.d.ts +25 -0
- package/dist/completions.js +233 -0
- package/dist/core.d.ts +5 -0
- package/dist/core.js +17 -0
- package/dist/download-progress.d.ts +52 -0
- package/dist/download-progress.js +112 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +6 -1
- package/dist/open.d.ts +43 -0
- package/dist/open.js +84 -0
- package/dist/providers.d.ts +21 -0
- package/dist/providers.js +104 -0
- package/dist/query-cache-disk.d.ts +14 -0
- package/dist/query-cache-disk.js +175 -0
- package/dist/repair.d.ts +36 -0
- package/dist/repair.js +208 -0
- package/dist/rerank.js +3 -2
- package/dist/search.d.ts +34 -0
- package/dist/search.js +172 -5
- package/dist/serve.d.ts +126 -0
- package/dist/serve.js +579 -45
- package/dist/watcher.d.ts +16 -0
- package/dist/watcher.js +41 -0
- package/dist/webui.d.ts +13 -0
- package/dist/webui.js +231 -0
- package/package.json +1 -1
- package/src/completions.ts +250 -0
- package/src/core.ts +26 -0
- package/src/download-progress.ts +146 -0
- package/src/index.ts +4 -0
- package/src/mcp.ts +10 -3
- package/src/open.ts +119 -0
- package/src/providers.ts +134 -0
- package/src/query-cache-disk.ts +191 -0
- package/src/repair.ts +245 -0
- package/src/rerank.ts +3 -2
- package/src/search.ts +192 -6
- package/src/serve.ts +635 -42
- package/src/watcher.ts +46 -0
- package/src/webui.ts +234 -0
package/dist/search.js
CHANGED
|
@@ -11,6 +11,7 @@ import { buildRelationshipGraph, computePageRank, expandGraphNeighborhood } from
|
|
|
11
11
|
import { evaluateFilter } from './filter.js';
|
|
12
12
|
import { deserializeBinaryIndex } from './binary-format.js';
|
|
13
13
|
import { dequantizeFromInt8 } from './quantization.js';
|
|
14
|
+
import { buildDiskKey, diskQueryGet, diskQueryPut } from './query-cache-disk.js';
|
|
14
15
|
export { tokenize } from './lexical.js';
|
|
15
16
|
const chunkTokens = new WeakMap();
|
|
16
17
|
export const _stats = { corpusTokenizedChars: 0 };
|
|
@@ -150,7 +151,100 @@ export function getRuntimeGraph(runtime) {
|
|
|
150
151
|
runtime.pageRank = pageRank;
|
|
151
152
|
return { graph, pageRank };
|
|
152
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Issue #127 pure helpers — exported for deterministic unit tests.
|
|
156
|
+
*/
|
|
157
|
+
/** Frontmatter `created` → `updated` → file mtime; null when nothing parses. */
|
|
158
|
+
export function resolvePassageAgeMs(meta, absFile) {
|
|
159
|
+
for (const v of [meta?.created, meta?.updated]) {
|
|
160
|
+
if (typeof v === 'string') {
|
|
161
|
+
const t = Date.parse(v);
|
|
162
|
+
if (Number.isFinite(t))
|
|
163
|
+
return t;
|
|
164
|
+
}
|
|
165
|
+
else if (typeof v === 'number' && Number.isFinite(v)) {
|
|
166
|
+
return v;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (absFile) {
|
|
170
|
+
try {
|
|
171
|
+
return fs.statSync(absFile).mtimeMs;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* file gone — treat as unknown age */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
/** Multiplicative recency boost: 0.5^(ageDays / halfLifeDays); 1 when off/unknown/future. */
|
|
180
|
+
export function recencyBoost(ageMs, halfLifeDays, now = Date.now()) {
|
|
181
|
+
if (ageMs === null || !(halfLifeDays > 0))
|
|
182
|
+
return 1;
|
|
183
|
+
const ageDays = Math.max(0, (now - ageMs) / 86_400_000);
|
|
184
|
+
return Math.pow(0.5, ageDays / halfLifeDays);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* HyDE passage generation via an OpenAI-compatible chat endpoint (issue #125).
|
|
188
|
+
* Returns null on ANY failure — HyDE must degrade to PRF/baseline silently.
|
|
189
|
+
* Injectable fetch for tests.
|
|
190
|
+
*/
|
|
191
|
+
export async function hydePassage(endpoint, query, model, fetchImpl = globalThis.fetch) {
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetchImpl(endpoint.replace(/\/$/, '') + '/chat/completions', {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: { 'content-type': 'application/json' },
|
|
196
|
+
body: JSON.stringify({
|
|
197
|
+
...(model ? { model } : {}),
|
|
198
|
+
messages: [
|
|
199
|
+
{ role: 'system', content: 'Write one short passage (2-4 sentences) that would plausibly contain the answer. Output only the passage.' },
|
|
200
|
+
{ role: 'user', content: query },
|
|
201
|
+
],
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok)
|
|
205
|
+
return null;
|
|
206
|
+
const data = await res.json();
|
|
207
|
+
const text = data?.choices?.[0]?.message?.content ?? data?.response ?? null;
|
|
208
|
+
return typeof text === 'string' && text.trim() ? text.trim() : null;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
153
214
|
const runtimeIndexStates = new WeakMap();
|
|
215
|
+
/** Minimal EN+RU stopword set for PRF expansion (issue #125). */
|
|
216
|
+
const EXPANSION_STOPWORDS = new Set([
|
|
217
|
+
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'is', 'are',
|
|
218
|
+
'be', 'this', 'that', 'it', 'as', 'at', 'by', 'from', 'not', 'but', 'we', 'you',
|
|
219
|
+
'и', 'в', 'на', 'с', 'по', 'для', 'как', 'это', 'не', 'а', 'но', 'или', 'из',
|
|
220
|
+
]);
|
|
221
|
+
/**
|
|
222
|
+
* Salient-term extraction for PRF (issue #125): tokens that appear in the
|
|
223
|
+
* top-m feedback passages, are not query terms or stopwords, and are ranked
|
|
224
|
+
* by (tf-in-feedback × distinct-passage coverage). Pure + deterministic.
|
|
225
|
+
*/
|
|
226
|
+
export function extractExpansionTerms(topTexts, queryTerms, maxTerms) {
|
|
227
|
+
const qSet = new Set(queryTerms);
|
|
228
|
+
const tf = new Map();
|
|
229
|
+
const df = new Map();
|
|
230
|
+
for (const text of topTexts) {
|
|
231
|
+
const seen = new Set();
|
|
232
|
+
for (const tok of tokenize(text)) {
|
|
233
|
+
if (tok.length < 3 || qSet.has(tok) || EXPANSION_STOPWORDS.has(tok))
|
|
234
|
+
continue;
|
|
235
|
+
tf.set(tok, (tf.get(tok) ?? 0) + 1);
|
|
236
|
+
seen.add(tok);
|
|
237
|
+
}
|
|
238
|
+
for (const tok of seen)
|
|
239
|
+
df.set(tok, (df.get(tok) ?? 0) + 1);
|
|
240
|
+
}
|
|
241
|
+
return [...tf.entries()]
|
|
242
|
+
.filter(([, count]) => count >= 2 || topTexts.length === 1)
|
|
243
|
+
.map(([tok, count]) => [tok, count * Math.log(2 + (df.get(tok) ?? 0))])
|
|
244
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
245
|
+
.slice(0, maxTerms)
|
|
246
|
+
.map(([tok]) => tok);
|
|
247
|
+
}
|
|
154
248
|
function snapshotRuntimeIndex(index, validation) {
|
|
155
249
|
const dim = validation.dim || (index.chunks[0]?.vec?.length ?? 0);
|
|
156
250
|
const count = index.chunks.length;
|
|
@@ -470,20 +564,62 @@ export async function searchIndex(opts) {
|
|
|
470
564
|
return true;
|
|
471
565
|
});
|
|
472
566
|
}
|
|
567
|
+
// Issue #125: offline pseudo-relevance feedback (PRF) query expansion.
|
|
568
|
+
// A lexical-only first pass picks the top-m passages; salient terms from
|
|
569
|
+
// them enrich the query, which is then embedded ONCE (replacing the raw
|
|
570
|
+
// query embed — zero extra model calls). HyDE would swap the enriched text
|
|
571
|
+
// for an LLM-generated passage; without a configured endpoint it degrades
|
|
572
|
+
// to the baseline silently.
|
|
573
|
+
let effectiveQuery = query;
|
|
574
|
+
let expandedTerms = null;
|
|
575
|
+
if ((opts.expand === 'prf' || opts.expand === 'hyde') && candidates.length > 0) {
|
|
576
|
+
const qt0 = [...new Set(tokenize(query))];
|
|
577
|
+
if (qt0.length > 0 && lexicalState.kind === 'persisted-bm25') {
|
|
578
|
+
const firstPass = [
|
|
579
|
+
...bm25Scores(lexicalState.lexical, qt0, new Set(candidates.map((c) => c.idx))).entries(),
|
|
580
|
+
]
|
|
581
|
+
.sort((a, b) => b[1] - a[1])
|
|
582
|
+
.slice(0, Math.max(1, opts.expandPassages ?? 3));
|
|
583
|
+
const topTexts = firstPass.map(([idx]) => chunks[idx].text);
|
|
584
|
+
expandedTerms = extractExpansionTerms(topTexts, qt0, 8);
|
|
585
|
+
if (opts.expand === 'hyde' && opts.llmEndpoint) {
|
|
586
|
+
const hydeText = await hydePassage(opts.llmEndpoint, query, opts.llmModel);
|
|
587
|
+
if (hydeText)
|
|
588
|
+
effectiveQuery = hydeText;
|
|
589
|
+
else if (expandedTerms.length > 0)
|
|
590
|
+
effectiveQuery = `${query} ${expandedTerms.join(' ')}`;
|
|
591
|
+
}
|
|
592
|
+
else if (expandedTerms.length > 0) {
|
|
593
|
+
effectiveQuery = `${query} ${expandedTerms.join(' ')}`;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
473
597
|
let qVec;
|
|
474
598
|
if (opts.useQueryCache !== false && runtime.queryCache) {
|
|
475
|
-
const
|
|
599
|
+
const normQuery = effectiveQuery.trim().toLowerCase();
|
|
600
|
+
const cacheKey = `${runtime.model.id}:${runtime.model.revision || 'main'}:${expectedDim || 0}:${normQuery}`;
|
|
601
|
+
// L2 disk layer (issue #114): persists across processes under cacheDir;
|
|
602
|
+
// corrupt/missing files degrade silently to a normal embed.
|
|
603
|
+
const diskKey = buildDiskKey(runtime.model.id, runtime.model.revision || 'main', expectedDim, normQuery);
|
|
604
|
+
const useDisk = opts.queryDiskCache !== false;
|
|
476
605
|
qVec = await runtime.queryCache.getOrCompute(cacheKey, async () => {
|
|
477
|
-
|
|
606
|
+
if (useDisk) {
|
|
607
|
+
const fromDisk = diskQueryGet(cacheDir, diskKey, expectedDim);
|
|
608
|
+
if (fromDisk)
|
|
609
|
+
return fromDisk;
|
|
610
|
+
}
|
|
611
|
+
const [v] = await embedFn([effectiveQuery], 'query', runtime.model, cacheDir, offline);
|
|
478
612
|
if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
|
|
479
|
-
throw new Error(`query vector has ${v.length} dims, expected ${expectedDim}
|
|
613
|
+
throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} - run \`mdss index\` to rebuild`);
|
|
480
614
|
}
|
|
481
615
|
validateNumericVector(v, undefined, 'query vector');
|
|
616
|
+
if (useDisk)
|
|
617
|
+
diskQueryPut(cacheDir, diskKey, v);
|
|
482
618
|
return v;
|
|
483
619
|
});
|
|
484
620
|
}
|
|
485
621
|
else {
|
|
486
|
-
const [v] = await embedFn([
|
|
622
|
+
const [v] = await embedFn([effectiveQuery], 'query', runtime.model, cacheDir, offline);
|
|
487
623
|
if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
|
|
488
624
|
throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} — run \`mdss index\` to rebuild`);
|
|
489
625
|
}
|
|
@@ -580,6 +716,28 @@ export async function searchIndex(opts) {
|
|
|
580
716
|
.slice(0, pool);
|
|
581
717
|
}
|
|
582
718
|
}
|
|
719
|
+
// Issue #127: time-decay recency boost — multiply the fused score by
|
|
720
|
+
// 0.5^(ageDays / halfLifeDays), post-RRF and pre-collapse. Age source
|
|
721
|
+
// priority: frontmatter `created` → `updated` → file mtime. Missing dates
|
|
722
|
+
// get factor 1 (evergreen content stays reachable via lexical strength).
|
|
723
|
+
const recencyHalfLife = Number(opts.recency) > 0 ? Number(opts.recency) : 0;
|
|
724
|
+
if (recencyHalfLife > 0) {
|
|
725
|
+
const now = Date.now();
|
|
726
|
+
const ageCache = new Map();
|
|
727
|
+
for (const r of ranked) {
|
|
728
|
+
const c = chunks[r.idx];
|
|
729
|
+
const abs = runtime.db && !path.isAbsolute(c.file) ? path.join(runtime.db, c.file) : c.file;
|
|
730
|
+
const ageKey = `${c.file}|${c.meta?.updated ?? ''}|${c.meta?.created ?? ''}`;
|
|
731
|
+
if (!ageCache.has(ageKey))
|
|
732
|
+
ageCache.set(ageKey, resolvePassageAgeMs(c.meta, abs));
|
|
733
|
+
const ageMs = ageCache.get(ageKey) ?? null;
|
|
734
|
+
r.recencyFactor = recencyBoost(ageMs, recencyHalfLife, now);
|
|
735
|
+
r.recencyAgeDays = ageMs === null ? null : Math.max(0, (now - ageMs) / 86_400_000);
|
|
736
|
+
r.rawFscore = r.fscore;
|
|
737
|
+
r.fscore = r.fscore * r.recencyFactor;
|
|
738
|
+
}
|
|
739
|
+
ranked.sort((a, b) => b.fscore - a.fscore);
|
|
740
|
+
}
|
|
583
741
|
if (rerank && ranked.length > 0) {
|
|
584
742
|
const texts = ranked.map((r) => chunks[r.idx].text);
|
|
585
743
|
const scores = await rerankFn(query, texts, cacheDir, offline);
|
|
@@ -621,10 +779,19 @@ export async function searchIndex(opts) {
|
|
|
621
779
|
? {
|
|
622
780
|
explain: {
|
|
623
781
|
cosine: +r.cos.toFixed(4),
|
|
624
|
-
rrfScore: +r.fscore.toFixed(4),
|
|
782
|
+
rrfScore: +(r.rawFscore ?? r.fscore).toFixed(4),
|
|
625
783
|
bm25Weights: { title: 3.0, aliases: 3.0, headingPath: 1.8, body: 1.0 },
|
|
626
784
|
...(gScore !== undefined ? { graphScore: +gScore.toFixed(4) } : {}),
|
|
627
785
|
...(prScore !== undefined ? { pageRank: +prScore.toFixed(4) } : {}),
|
|
786
|
+
...(r.recencyFactor !== undefined
|
|
787
|
+
? {
|
|
788
|
+
recencyFactor: +r.recencyFactor.toFixed(4),
|
|
789
|
+
recencyAgeDays: r.recencyAgeDays === null || r.recencyAgeDays === undefined
|
|
790
|
+
? null
|
|
791
|
+
: +r.recencyAgeDays.toFixed(2),
|
|
792
|
+
}
|
|
793
|
+
: {}),
|
|
794
|
+
...(expandedTerms ? { expandedTerms } : {}),
|
|
628
795
|
},
|
|
629
796
|
}
|
|
630
797
|
: {}),
|
package/dist/serve.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ declare const DEFAULT_PORT = 8747;
|
|
|
9
9
|
declare const DEFAULT_HOST = "127.0.0.1";
|
|
10
10
|
declare const WATCH_INTERVAL_MS = 3000;
|
|
11
11
|
declare const MAX_BODY_BYTES: number;
|
|
12
|
+
/** Hosts a browser is allowed to name in the Host header (issue #120). */
|
|
13
|
+
export declare const DEFAULT_ALLOWED_HOSTS: string[];
|
|
14
|
+
/** Fairness defaults (issue #119): protective yet friction-free locally. */
|
|
15
|
+
export declare const DEFAULT_RATE_LIMIT_PER_MIN = 60;
|
|
16
|
+
export declare const DEFAULT_RATE_BURST = 10;
|
|
17
|
+
export declare const DEFAULT_MAX_CONCURRENCY: number;
|
|
12
18
|
export interface ServeState {
|
|
13
19
|
loaded: {
|
|
14
20
|
index: any;
|
|
@@ -23,6 +29,17 @@ export interface ServeState {
|
|
|
23
29
|
reindexCount?: number;
|
|
24
30
|
apiKey?: string;
|
|
25
31
|
healthPublic?: boolean;
|
|
32
|
+
/** issue #111: serve the built-in web UI at `/` (default on). */
|
|
33
|
+
ui?: boolean;
|
|
34
|
+
/** issue #123: mount the Streamable HTTP MCP transport at /mcp (default off). */
|
|
35
|
+
mcp?: boolean;
|
|
36
|
+
mcpSessionId?: string;
|
|
37
|
+
/** Extra Host-header values accepted beyond the loopback defaults (issue #120). */
|
|
38
|
+
allowedHosts?: string[];
|
|
39
|
+
/** Exact origins reflected as `Access-Control-Allow-Origin`; absent → CORS off (issue #120). */
|
|
40
|
+
corsOrigins?: string[];
|
|
41
|
+
/** Fairness gates for /search (issue #119); `0` disables a gate. */
|
|
42
|
+
limiter?: ServeLimiter;
|
|
26
43
|
}
|
|
27
44
|
export interface CreateServeOptions {
|
|
28
45
|
indexDir: string;
|
|
@@ -36,14 +53,123 @@ export interface CreateServeOptions {
|
|
|
36
53
|
watchDelay?: number;
|
|
37
54
|
apiKey?: string;
|
|
38
55
|
healthPublic?: boolean;
|
|
56
|
+
allowedHosts?: string[];
|
|
57
|
+
corsOrigins?: string[];
|
|
58
|
+
rateLimit?: number;
|
|
59
|
+
maxConcurrency?: number;
|
|
60
|
+
/** issue #116: verbose watch-loop trace (polls, retries, classifications). */
|
|
61
|
+
watchDebug?: boolean;
|
|
62
|
+
/** issue #116 test seam: override hashing/stat so fault-injection needs no real locks. */
|
|
63
|
+
_testFs?: {
|
|
64
|
+
hash?: (absPath: string) => string;
|
|
65
|
+
stat?: (absPath: string) => {
|
|
66
|
+
mtimeMs: number;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
ui?: boolean;
|
|
70
|
+
/** issue #123: mount Streamable HTTP MCP at /mcp (default off; stdio stays default). */
|
|
71
|
+
mcp?: boolean;
|
|
39
72
|
embedFn?: any;
|
|
40
73
|
rerankFn?: any;
|
|
41
74
|
log?: (msg: string) => void;
|
|
42
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Token bucket for per-client request budgeting (issue #119): starts full
|
|
78
|
+
* (`burst`), refills continuously at `limitPerMinute`/60 tokens per second.
|
|
79
|
+
* `take` is injective in time so tests can time-travel via the `now` argument.
|
|
80
|
+
*/
|
|
81
|
+
export declare class TokenBucket {
|
|
82
|
+
readonly limitPerMinute: number;
|
|
83
|
+
readonly burst: number;
|
|
84
|
+
private tokens;
|
|
85
|
+
/** Last refill timestamp — doubles as a staleness marker for map pruning. */
|
|
86
|
+
lastTouched: number;
|
|
87
|
+
constructor(limitPerMinute: number, burst: number);
|
|
88
|
+
take(now?: number): {
|
|
89
|
+
ok: true;
|
|
90
|
+
} | {
|
|
91
|
+
ok: false;
|
|
92
|
+
retryAfterSec: number;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** A granted slot's release handle — idempotent, so `finally` is always safe. */
|
|
96
|
+
type SlotRelease = () => void;
|
|
97
|
+
/**
|
|
98
|
+
* Counting semaphore with a bounded wait queue (issue #119): once `maxQueued`
|
|
99
|
+
* callers already wait, acquire() returns null instead of enqueueing, so an
|
|
100
|
+
* overloaded daemon sheds load with 503 rather than ballooning memory.
|
|
101
|
+
*/
|
|
102
|
+
export declare class BoundedSemaphore {
|
|
103
|
+
readonly size: number;
|
|
104
|
+
readonly maxQueued: number;
|
|
105
|
+
private available;
|
|
106
|
+
private readonly waiters;
|
|
107
|
+
constructor(size: number, maxQueued: number);
|
|
108
|
+
get executing(): number;
|
|
109
|
+
get queued(): number;
|
|
110
|
+
acquire(): Promise<SlotRelease | null>;
|
|
111
|
+
private releaseSlot;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Fairness gates for POST /search (issue #119):
|
|
115
|
+
* - per-client token bucket → 429 + Retry-After when a client exceeds its budget;
|
|
116
|
+
* - global concurrency cap around the embedding sweep; queue overflow → 503.
|
|
117
|
+
* Either gate disabled by passing `0`; counters are exposed via /health.
|
|
118
|
+
*/
|
|
119
|
+
export declare class ServeLimiter {
|
|
120
|
+
readonly rateLimitPerMin: number;
|
|
121
|
+
readonly rateBurst: number;
|
|
122
|
+
readonly maxConcurrency: number;
|
|
123
|
+
private readonly buckets;
|
|
124
|
+
private readonly sem;
|
|
125
|
+
rejected429: number;
|
|
126
|
+
rejected503: number;
|
|
127
|
+
constructor(rateLimitPerMin: number, rateBurst: number, maxConcurrency: number);
|
|
128
|
+
/** Burst is capped by the per-minute budget itself (--rate-limit 5 → burst 5). */
|
|
129
|
+
private effectiveBurst;
|
|
130
|
+
get rateEnabled(): boolean;
|
|
131
|
+
get concurrencyEnabled(): boolean;
|
|
132
|
+
get inFlight(): number;
|
|
133
|
+
get queued(): number;
|
|
134
|
+
get rejectedTotal(): number;
|
|
135
|
+
/** Cheap synchronous rate check. Null → proceed; otherwise reject with 429. */
|
|
136
|
+
gate(clientKey: string, now?: number): {
|
|
137
|
+
status: 429;
|
|
138
|
+
retryAfterSec: number;
|
|
139
|
+
} | null;
|
|
140
|
+
/**
|
|
141
|
+
* Acquire a global search slot. Resolves with a release handle, or null when
|
|
142
|
+
* the wait queue is saturated (caller replies 503).
|
|
143
|
+
*/
|
|
144
|
+
acquireSlot(): Promise<SlotRelease | null>;
|
|
145
|
+
}
|
|
43
146
|
export declare function createServe(opts: CreateServeOptions): Promise<{
|
|
44
147
|
server: http.Server;
|
|
45
148
|
state: ServeState;
|
|
46
149
|
close: () => Promise<void>;
|
|
47
150
|
}>;
|
|
48
151
|
export declare function isAuthorizedToken(token?: string | null, expectedKey?: string | null): boolean;
|
|
152
|
+
/** True when `host` binds to loopback only (issue #121). Empty/undefined means the loopback default. */
|
|
153
|
+
export declare function isLoopbackHost(host?: string | null): boolean;
|
|
154
|
+
/**
|
|
155
|
+
* Refuse to start an unauthenticated server on a non-loopback interface (issue #121):
|
|
156
|
+
* `mdss serve --host 0.0.0.0` without an API key silently serves the whole knowledge
|
|
157
|
+
* base to the LAN. Explicit `--allow-unsecured` opts in (banner still printed by CLI).
|
|
158
|
+
*/
|
|
159
|
+
export declare function validateBindSecurity(opts: {
|
|
160
|
+
host?: string;
|
|
161
|
+
hasApiKey: boolean;
|
|
162
|
+
allowUnsecured?: boolean;
|
|
163
|
+
}): void;
|
|
164
|
+
/**
|
|
165
|
+
* Read an API key from a file (issue #121): avoids shell-history / CI-log leaks of
|
|
166
|
+
* literal `--api-key` values. Trailing newlines are trimmed; on POSIX a group- or
|
|
167
|
+
* world-readable key file draws a loud warning.
|
|
168
|
+
*/
|
|
169
|
+
export declare function loadApiKeyFile(filePath: string, log?: (msg: string) => void): string;
|
|
170
|
+
/**
|
|
171
|
+
* Strip the port from a Host header value, keeping IPv6 bracket forms intact:
|
|
172
|
+
* "localhost:8747" → "localhost", "[::1]:8747" → "[::1]", "::1" → "::1" (issue #120).
|
|
173
|
+
*/
|
|
174
|
+
export declare function splitHostHeader(hostHeader: string): string;
|
|
49
175
|
export { DEFAULT_PORT, DEFAULT_HOST, MAX_BODY_BYTES, WATCH_INTERVAL_MS };
|