ko-qmd 2.8.3-ko.10
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 +1937 -0
- package/LICENSE +21 -0
- package/README.en.md +184 -0
- package/README.md +292 -0
- package/bin/qmd +192 -0
- package/dist/ast.d.ts +65 -0
- package/dist/ast.js +51 -0
- package/dist/bench/bench.d.ts +41 -0
- package/dist/bench/bench.js +12 -0
- package/dist/bench/score.d.ts +33 -0
- package/dist/bench/score.js +1 -0
- package/dist/bench/types.d.ts +101 -0
- package/dist/bench/types.js +1 -0
- package/dist/cli/build-info.json +4 -0
- package/dist/cli/embed-lock.d.ts +24 -0
- package/dist/cli/embed-lock.js +2 -0
- package/dist/cli/formatter.d.ts +120 -0
- package/dist/cli/formatter.js +60 -0
- package/dist/cli/mcp-pid.d.ts +33 -0
- package/dist/cli/mcp-pid.js +1 -0
- package/dist/cli/qmd.d.ts +101 -0
- package/dist/cli/qmd.js +165 -0
- package/dist/cli/version.d.ts +42 -0
- package/dist/cli/version.js +1 -0
- package/dist/collections.d.ts +162 -0
- package/dist/collections.js +1 -0
- package/dist/db.d.ts +63 -0
- package/dist/db.js +2 -0
- package/dist/filters.d.ts +46 -0
- package/dist/filters.js +1 -0
- package/dist/grep.d.ts +85 -0
- package/dist/grep.js +9 -0
- package/dist/hangul.d.ts +102 -0
- package/dist/hangul.js +1 -0
- package/dist/index.d.ts +270 -0
- package/dist/index.js +1 -0
- package/dist/llm.d.ts +584 -0
- package/dist/llm.js +40 -0
- package/dist/maintenance.d.ts +33 -0
- package/dist/maintenance.js +1 -0
- package/dist/mcp/daemon-health.d.ts +17 -0
- package/dist/mcp/daemon-health.js +1 -0
- package/dist/mcp/origin-guard.d.ts +67 -0
- package/dist/mcp/origin-guard.js +1 -0
- package/dist/mcp/server.d.ts +131 -0
- package/dist/mcp/server.js +35 -0
- package/dist/memory.d.ts +74 -0
- package/dist/memory.js +13 -0
- package/dist/metadata-filter.d.ts +74 -0
- package/dist/metadata-filter.js +1 -0
- package/dist/metadata-store.d.ts +45 -0
- package/dist/metadata-store.js +68 -0
- package/dist/metadata.d.ts +61 -0
- package/dist/metadata.js +1 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +1 -0
- package/dist/query-log.d.ts +156 -0
- package/dist/query-log.js +2 -0
- package/dist/refresh.d.ts +60 -0
- package/dist/refresh.js +1 -0
- package/dist/rerank-input.d.ts +46 -0
- package/dist/rerank-input.js +5 -0
- package/dist/store.d.ts +1267 -0
- package/dist/store.js +421 -0
- package/dist/trust.d.ts +152 -0
- package/dist/trust.js +2 -0
- package/package.json +124 -0
- package/scripts/build.mjs +130 -0
- package/scripts/check-package-grammars.mjs +29 -0
- package/scripts/package-smoke.mjs +65 -0
- package/scripts/test-all.mjs +42 -0
- package/skills/qmd/SKILL.md +333 -0
- package/skills/qmd/references/mcp-setup.md +122 -0
- package/skills/release/SKILL.md +141 -0
- package/skills/release/scripts/install-hooks.sh +38 -0
- package/skills/release/scripts/release-context.sh +129 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QMD Metadata Filter - Recursive filter AST, strict runtime validation, and
|
|
3
|
+
* parameterized SQL compilation.
|
|
4
|
+
*
|
|
5
|
+
* The filter has one canonical, `operator`-discriminated recursive shape shared
|
|
6
|
+
* by every public search surface (CLI, SDK, MCP, HTTP):
|
|
7
|
+
*
|
|
8
|
+
* { "operator": "and", "operands": [ ... ] }
|
|
9
|
+
* { "operator": "not", "operand": { ... } }
|
|
10
|
+
* { "key": "status", "operator": "eq", "value": "published" }
|
|
11
|
+
*
|
|
12
|
+
* Compilation emits correlated EXISTS/NOT EXISTS subqueries over
|
|
13
|
+
* `document_metadata_values` with every user value bound as a parameter —
|
|
14
|
+
* metadata keys and values are data, never SQL.
|
|
15
|
+
*/
|
|
16
|
+
import type { MetadataScalar, MetadataScalarArray } from "./metadata.js";
|
|
17
|
+
export type MetadataFilter = MetadataFilterGroup | MetadataFilterNegation | MetadataCondition;
|
|
18
|
+
export interface MetadataFilterGroup {
|
|
19
|
+
operator: "and" | "or";
|
|
20
|
+
operands: readonly MetadataFilter[];
|
|
21
|
+
}
|
|
22
|
+
export interface MetadataFilterNegation {
|
|
23
|
+
operator: "not";
|
|
24
|
+
operand: MetadataFilter;
|
|
25
|
+
}
|
|
26
|
+
export type MetadataCondition = {
|
|
27
|
+
key: string;
|
|
28
|
+
operator: "eq" | "ne";
|
|
29
|
+
value: MetadataScalar;
|
|
30
|
+
} | {
|
|
31
|
+
key: string;
|
|
32
|
+
operator: "gt" | "gte" | "lt" | "lte";
|
|
33
|
+
value: string | number;
|
|
34
|
+
} | {
|
|
35
|
+
key: string;
|
|
36
|
+
operator: "in" | "nin" | "all";
|
|
37
|
+
value: MetadataScalarArray;
|
|
38
|
+
} | {
|
|
39
|
+
key: string;
|
|
40
|
+
operator: "exists";
|
|
41
|
+
value: boolean;
|
|
42
|
+
};
|
|
43
|
+
export interface CompiledMetadataFilter {
|
|
44
|
+
sql: string;
|
|
45
|
+
params: (string | number)[];
|
|
46
|
+
}
|
|
47
|
+
/** Raised by parseMetadataFilter with the JSON path of the failing node. */
|
|
48
|
+
export declare class MetadataFilterError extends Error {
|
|
49
|
+
readonly path: string;
|
|
50
|
+
constructor(path: string, message: string);
|
|
51
|
+
}
|
|
52
|
+
/** Defensive limits for recursive filters from untrusted callers. */
|
|
53
|
+
export declare const METADATA_FILTER_LIMITS: {
|
|
54
|
+
readonly maxDepth: 16;
|
|
55
|
+
readonly maxNodes: 256;
|
|
56
|
+
readonly maxGroupOperands: 32;
|
|
57
|
+
readonly maxMembershipValues: 64;
|
|
58
|
+
readonly maxKeyBytes: 128;
|
|
59
|
+
readonly maxStringLength: 1024;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Strictly validate an untrusted value as a MetadataFilter.
|
|
63
|
+
* Rejects unknown operators, unknown properties, operator-incompatible values,
|
|
64
|
+
* and inputs exceeding METADATA_FILTER_LIMITS. Canonicalizes membership value
|
|
65
|
+
* arrays by de-duplicating while preserving order.
|
|
66
|
+
*/
|
|
67
|
+
export declare function parseMetadataFilter(input: unknown): MetadataFilter;
|
|
68
|
+
/**
|
|
69
|
+
* Compile a validated filter into one parameterized SQL predicate correlated
|
|
70
|
+
* against a documents-table alias (e.g. `d`). All keys and values are bound
|
|
71
|
+
* parameters. The caller is responsible for restricting the surrounding query
|
|
72
|
+
* to active documents with current, error-free metadata extraction.
|
|
73
|
+
*/
|
|
74
|
+
export declare function compileMetadataFilter(filter: MetadataFilter, documentsAlias: string): CompiledMetadataFilter;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var h=Object.defineProperty;var a=(e,n)=>h(e,"name",{value:n,configurable:!0});import{METADATA_LIMITS as d}from"./metadata.js";class u extends Error{static{a(this,"MetadataFilterError")}path;constructor(n,r){super(`Invalid metadata filter at ${n}: ${r}`),this.name="MetadataFilterError",this.path=n}}const i={maxDepth:16,maxNodes:256,maxGroupOperands:32,maxMembershipValues:64,maxKeyBytes:d.maxKeyBytes,maxStringLength:d.maxStringLength},x=new Set(["and","or"]),N=new Set(["eq","ne","gt","gte","lt","lte"]),b=new Set(["gt","gte","lt","lte"]),A=new Set(["in","nin","all"]),g=new Set([...N,...A,"exists"]),O=[...x,"not",...g];function R(e){return $(e,"$",1,{nodes:0})}a(R,"parseMetadataFilter");function $(e,n,r,t){if(r>i.maxDepth)throw new u(n,`exceeds maximum nesting depth of ${i.maxDepth}`);if(t.nodes+=1,t.nodes>i.maxNodes)throw new u(n,`exceeds maximum of ${i.maxNodes} nodes`);if(typeof e!="object"||e===null||Array.isArray(e))throw new u(n,"each filter node must be an object");const o=e,s=o.operator;if(typeof s!="string")throw new u(n,"missing 'operator' property");if(x.has(s))return k(o,s,n,r,t);if(s==="not")return D(o,n,r,t);if(g.has(s))return q(o,s,n);throw new u(n,`unknown operator '${s}' \u2014 expected one of: ${O.join(", ")}`)}a($,"parseFilterNode");function k(e,n,r,t,o){w(e,["operator","operands"],r);const s=e.operands;if(!Array.isArray(s))throw new u(r,`'${n}' requires an 'operands' array`);if(s.length===0)throw new u(r,`'${n}' requires a non-empty 'operands' array`);if(s.length>i.maxGroupOperands)throw new u(r,`'${n}' exceeds maximum of ${i.maxGroupOperands} operands`);return{operator:n,operands:s.map((c,p)=>$(c,`${r}.operands[${p}]`,t+1,o))}}a(k,"parseFilterGroup");function D(e,n,r,t){if(w(e,["operator","operand"],n),!("operand"in e))throw new u(n,"'not' requires exactly one 'operand'");return{operator:"not",operand:$(e.operand,`${n}.operand`,r+1,t)}}a(D,"parseFilterNegation");function q(e,n,r){w(e,["key","operator","value"],r);const t=e.key;if(typeof t!="string"||t.length===0)throw new u(r,`'${n}' requires a non-empty string 'key'`);if(Buffer.byteLength(t,"utf-8")>i.maxKeyBytes)throw new u(r,`'key' exceeds ${i.maxKeyBytes} bytes`);if(!("value"in e))throw new u(r,`'${n}' requires a 'value'`);const o=e.value;if(n==="exists"){if(typeof o!="boolean")throw new u(`${r}.value`,"'exists' requires a boolean value");return{key:t,operator:n,value:o}}if(A.has(n))return{key:t,operator:n,value:T(o,n,r)};const s=S(o,`${r}.value`);if(b.has(n)&&typeof s=="boolean")throw new u(`${r}.value`,`'${n}' requires a string or number value`);return{key:t,operator:n,value:s}}a(q,"parseFilterCondition");function T(e,n,r){if(!Array.isArray(e))throw new u(`${r}.value`,`'${n}' requires an array value`);if(e.length===0)throw new u(`${r}.value`,`'${n}' requires a non-empty array value`);if(e.length>i.maxMembershipValues)throw new u(`${r}.value`,`'${n}' exceeds maximum of ${i.maxMembershipValues} values`);const t=e.map((o,s)=>S(o,`${r}.value[${s}]`));if(t.every(o=>typeof o=="string"))return Array.from(new Set(t));if(t.every(o=>typeof o=="number"))return Array.from(new Set(t));if(t.every(o=>typeof o=="boolean"))return Array.from(new Set(t));throw new u(`${r}.value`,`'${n}' requires a homogeneous array of one scalar type`)}a(T,"parseMembershipValues");function S(e,n){if(typeof e=="string"){if(e.length>i.maxStringLength)throw new u(n,`string exceeds ${i.maxStringLength} characters`);return e}if(typeof e=="number"){if(!Number.isFinite(e))throw new u(n,"numbers must be finite");return e}if(typeof e=="boolean")return e;throw new u(n,"expected a string, number, or boolean")}a(S,"parseScalarValue");function w(e,n,r){for(const t of Object.keys(e))if(!n.includes(t))throw new u(r,`unknown property '${t}' \u2014 allowed: ${n.join(", ")}`)}a(w,"rejectUnknownProperties");function M(e,n){const r=[];return{sql:f(e,n,r),params:r}}a(M,"compileMetadataFilter");function f(e,n,r){switch(e.operator){case"and":case"or":{const t=e.operator==="and"?" AND ":" OR ";return`(${e.operands.map(o=>f(o,n,r)).join(t)})`}case"not":return`NOT ${f(e.operand,n,r)}`;case"exists":return r.push(e.key),e.value?m(n,"mv.key = ?"):`NOT ${m(n,"mv.key = ?")}`;case"eq":case"gt":case"gte":case"lt":case"lte":{const t={eq:"=",gt:">",gte:">=",lt:"<",lte:"<="}[e.operator];return r.push(e.key,y(e.value)),m(n,`mv.key = ? AND mv.value_type = '${l(e.value)}' AND mv.${v(e.value)} ${t} ?`)}case"ne":{const t=l(e.value);r.push(e.key);const o=m(n,`mv.key = ? AND mv.value_type = '${t}'`);r.push(e.key,y(e.value));const s=m(n,`mv.key = ? AND mv.value_type = '${t}' AND mv.${v(e.value)} = ?`);return`(${o} AND NOT ${s})`}case"in":case"nin":{const t=l(e.value[0]),o=v(e.value[0]),s=e.value.map(()=>"?").join(", ");if(e.operator==="in")return r.push(e.key,...e.value.map(y)),m(n,`mv.key = ? AND mv.value_type = '${t}' AND mv.${o} IN (${s})`);r.push(e.key);const c=m(n,`mv.key = ? AND mv.value_type = '${t}'`);r.push(e.key,...e.value.map(y));const p=m(n,`mv.key = ? AND mv.value_type = '${t}' AND mv.${o} IN (${s})`);return`(${c} AND NOT ${p})`}case"all":{const t=l(e.value[0]),o=v(e.value[0]);return`(${e.value.map(c=>(r.push(e.key,y(c)),m(n,`mv.key = ? AND mv.value_type = '${t}' AND mv.${o} = ?`))).join(" AND ")})`}}}a(f,"compileFilterNode");function m(e,n){return`EXISTS (SELECT 1 FROM document_metadata_values mv WHERE mv.document_id = ${e}.id AND ${n})`}a(m,"buildValueExistsSql");function l(e){return typeof e}a(l,"valueTypeOf");function v(e){return typeof e=="string"?"text_value":typeof e=="number"?"number_value":"boolean_value"}a(v,"valueColumnOf");function y(e){return typeof e=="boolean"?e?1:0:e}a(y,"bindScalar");export{i as METADATA_FILTER_LIMITS,u as MetadataFilterError,M as compileMetadataFilter,R as parseMetadataFilter};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QMD Metadata Store - Schema, persistence, and batch loading for document
|
|
3
|
+
* metadata.
|
|
4
|
+
*
|
|
5
|
+
* Metadata attaches to document identity (`documents.id`), not content
|
|
6
|
+
* identity: two paths can share one content hash while carrying different
|
|
7
|
+
* metadata. SQLite stays a derived index — metadata is rebuilt from source
|
|
8
|
+
* documents on `qmd update`, never mutated in place.
|
|
9
|
+
*
|
|
10
|
+
* `document_metadata` records extraction state per document (including
|
|
11
|
+
* successful-but-empty extraction), so filtered search can distinguish
|
|
12
|
+
* "extracted with no metadata" from "not yet extracted" and "extraction
|
|
13
|
+
* failed". `document_metadata_values` holds one indexed row per scalar value
|
|
14
|
+
* for filtering.
|
|
15
|
+
*/
|
|
16
|
+
import type { Database } from "./db.js";
|
|
17
|
+
import { type DocumentMetadata, type MetadataExtractionResult } from "./metadata.js";
|
|
18
|
+
export declare function initializeMetadataSchema(db: Database): void;
|
|
19
|
+
/**
|
|
20
|
+
* Extract and persist metadata for one document, replacing any prior rows.
|
|
21
|
+
*
|
|
22
|
+
* With `onlyIfStale`, extraction is skipped when the document already has a
|
|
23
|
+
* current-version extraction row — the cheap path for unchanged documents
|
|
24
|
+
* during re-index. Returns the extraction result, or null when skipped.
|
|
25
|
+
*/
|
|
26
|
+
export declare function syncDocumentMetadata(db: Database, documentId: number, content: string, path: string, options?: {
|
|
27
|
+
onlyIfStale?: boolean;
|
|
28
|
+
}): MetadataExtractionResult | null;
|
|
29
|
+
/**
|
|
30
|
+
* Replace a document's metadata rows atomically. A failed extraction persists
|
|
31
|
+
* empty metadata plus the error, so stale metadata never survives a bad edit.
|
|
32
|
+
*/
|
|
33
|
+
export declare function replaceDocumentMetadata(db: Database, documentId: number, extraction: MetadataExtractionResult): void;
|
|
34
|
+
/**
|
|
35
|
+
* Count active documents without a current, error-free metadata extraction.
|
|
36
|
+
* These documents are excluded from filtered search until `qmd update` runs.
|
|
37
|
+
*/
|
|
38
|
+
export declare function countDocumentsPendingMetadata(db: Database): number;
|
|
39
|
+
/**
|
|
40
|
+
* Batch-load canonical metadata for a set of result filepaths
|
|
41
|
+
* (`qmd://collection/path`). One query — never per-result lookups.
|
|
42
|
+
*/
|
|
43
|
+
export declare function getMetadataByFilepath(db: Database, filepaths: readonly string[]): Map<string, DocumentMetadata>;
|
|
44
|
+
/** Parse a stored `metadata_json` column value, tolerating absent rows. */
|
|
45
|
+
export declare function parseMetadataJson(metadataJson: string | null | undefined): DocumentMetadata;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
var m=Object.defineProperty;var n=(e,t)=>m(e,"name",{value:t,configurable:!0});import{extractDocumentMetadata as i,METADATA_EXTRACTION_VERSION as _}from"./metadata.js";function v(e){e.exec(`
|
|
2
|
+
CREATE TABLE IF NOT EXISTS document_metadata (
|
|
3
|
+
document_id INTEGER PRIMARY KEY,
|
|
4
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
5
|
+
extraction_version INTEGER NOT NULL,
|
|
6
|
+
extraction_error TEXT,
|
|
7
|
+
extracted_at TEXT NOT NULL,
|
|
8
|
+
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
|
9
|
+
)
|
|
10
|
+
`),e.exec(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS document_metadata_values (
|
|
12
|
+
document_id INTEGER NOT NULL,
|
|
13
|
+
key TEXT NOT NULL,
|
|
14
|
+
ordinal INTEGER NOT NULL,
|
|
15
|
+
value_type TEXT NOT NULL,
|
|
16
|
+
text_value TEXT,
|
|
17
|
+
number_value REAL,
|
|
18
|
+
boolean_value INTEGER,
|
|
19
|
+
PRIMARY KEY (document_id, key, ordinal),
|
|
20
|
+
FOREIGN KEY (document_id)
|
|
21
|
+
REFERENCES document_metadata(document_id)
|
|
22
|
+
ON DELETE CASCADE,
|
|
23
|
+
CHECK (value_type IN ('string', 'number', 'boolean')),
|
|
24
|
+
CHECK (
|
|
25
|
+
(value_type = 'string' AND text_value IS NOT NULL AND number_value IS NULL AND boolean_value IS NULL)
|
|
26
|
+
OR (value_type = 'number' AND number_value IS NOT NULL AND text_value IS NULL AND boolean_value IS NULL)
|
|
27
|
+
OR (value_type = 'boolean' AND boolean_value IN (0, 1) AND text_value IS NULL AND number_value IS NULL)
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
`),e.exec(`
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_metadata_text_lookup
|
|
32
|
+
ON document_metadata_values(key, text_value, document_id)
|
|
33
|
+
WHERE value_type = 'string'
|
|
34
|
+
`),e.exec(`
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_metadata_number_lookup
|
|
36
|
+
ON document_metadata_values(key, number_value, document_id)
|
|
37
|
+
WHERE value_type = 'number'
|
|
38
|
+
`),e.exec(`
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_metadata_boolean_lookup
|
|
40
|
+
ON document_metadata_values(key, boolean_value, document_id)
|
|
41
|
+
WHERE value_type = 'boolean'
|
|
42
|
+
`)}n(v,"initializeMetadataSchema");function O(e,t,a,d,u){if(u?.onlyIfStale&&N(e,t))return null;const o=i(a,d);return l(e,t,o),o}n(O,"syncDocumentMetadata");function l(e,t,a){e.transaction(()=>{e.prepare(`
|
|
43
|
+
INSERT INTO document_metadata (document_id, metadata_json, extraction_version, extraction_error, extracted_at)
|
|
44
|
+
VALUES (?, ?, ?, ?, ?)
|
|
45
|
+
ON CONFLICT(document_id) DO UPDATE SET
|
|
46
|
+
metadata_json = excluded.metadata_json,
|
|
47
|
+
extraction_version = excluded.extraction_version,
|
|
48
|
+
extraction_error = excluded.extraction_error,
|
|
49
|
+
extracted_at = excluded.extracted_at
|
|
50
|
+
`).run(t,JSON.stringify(a.metadata),a.extractionVersion,a.error??null,new Date().toISOString()),e.prepare("DELETE FROM document_metadata_values WHERE document_id = ?").run(t);const u=e.prepare(`
|
|
51
|
+
INSERT INTO document_metadata_values (document_id, key, ordinal, value_type, text_value, number_value, boolean_value)
|
|
52
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
53
|
+
`);for(const[o,E]of Object.entries(a.metadata))(Array.isArray(E)?E:[E]).forEach((r,c)=>{u.run(t,o,c,typeof r,typeof r=="string"?r:null,typeof r=="number"?r:null,typeof r=="boolean"?r?1:0:null)})})()}n(l,"replaceDocumentMetadata");function N(e,t){return e.prepare("SELECT extraction_version FROM document_metadata WHERE document_id = ?").get(t)?.extraction_version===_}n(N,"isDocumentMetadataCurrent");function I(e){return e.prepare(`
|
|
54
|
+
SELECT COUNT(*) as c FROM documents d
|
|
55
|
+
WHERE d.active = 1
|
|
56
|
+
AND NOT EXISTS (
|
|
57
|
+
SELECT 1 FROM document_metadata dm
|
|
58
|
+
WHERE dm.document_id = d.id
|
|
59
|
+
AND dm.extraction_version = ?
|
|
60
|
+
AND dm.extraction_error IS NULL
|
|
61
|
+
)
|
|
62
|
+
`).get(_).c}n(I,"countDocumentsPendingMetadata");function S(e,t){const a=new Map;if(t.length===0)return a;const d=t.map(()=>"?").join(", "),u=e.prepare(`
|
|
63
|
+
SELECT 'qmd://' || d.collection || '/' || d.path AS filepath, dm.metadata_json
|
|
64
|
+
FROM documents d
|
|
65
|
+
JOIN document_metadata dm ON dm.document_id = d.id
|
|
66
|
+
WHERE d.active = 1
|
|
67
|
+
AND 'qmd://' || d.collection || '/' || d.path IN (${d})
|
|
68
|
+
`).all(...t);for(const o of u)a.set(o.filepath,T(o.metadata_json));return a}n(S,"getMetadataByFilepath");function T(e){if(!e)return{};try{return JSON.parse(e)}catch{return{}}}n(T,"parseMetadataJson");export{I as countDocumentsPendingMetadata,S as getMetadataByFilepath,v as initializeMetadataSchema,T as parseMetadataJson,l as replaceDocumentMetadata,O as syncDocumentMetadata};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QMD Metadata - Public metadata types and frontmatter extraction.
|
|
3
|
+
*
|
|
4
|
+
* Documents opt into metadata through a namespaced Markdown frontmatter block:
|
|
5
|
+
*
|
|
6
|
+
* ---
|
|
7
|
+
* qmd:
|
|
8
|
+
* metadata:
|
|
9
|
+
* topics:
|
|
10
|
+
* - typescript
|
|
11
|
+
* - programming
|
|
12
|
+
* status: published
|
|
13
|
+
* ---
|
|
14
|
+
*
|
|
15
|
+
* Extraction is source-agnostic at the persistence boundary: this module
|
|
16
|
+
* produces a canonical `MetadataExtractionResult`, and future non-frontmatter
|
|
17
|
+
* sources can produce the same shape without touching storage or filtering.
|
|
18
|
+
*
|
|
19
|
+
* The raw document is never modified — frontmatter stays part of the stored,
|
|
20
|
+
* indexed, chunked, and embedded content.
|
|
21
|
+
*/
|
|
22
|
+
export type MetadataScalar = string | number | boolean;
|
|
23
|
+
export type MetadataScalarArray = readonly string[] | readonly number[] | readonly boolean[];
|
|
24
|
+
export type MetadataValue = MetadataScalar | MetadataScalarArray;
|
|
25
|
+
export type DocumentMetadata = Record<string, MetadataValue>;
|
|
26
|
+
/**
|
|
27
|
+
* Result of extracting metadata from one document.
|
|
28
|
+
*
|
|
29
|
+
* `error` is set when the document opted into `qmd.metadata` but the value was
|
|
30
|
+
* invalid — the document still indexes normally, but it is excluded from
|
|
31
|
+
* filtered search until the metadata is corrected and re-indexed.
|
|
32
|
+
*/
|
|
33
|
+
export interface MetadataExtractionResult {
|
|
34
|
+
metadata: DocumentMetadata;
|
|
35
|
+
error?: string;
|
|
36
|
+
extractionVersion: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Bump when extraction or normalization semantics change so existing rows are
|
|
40
|
+
* re-extracted on the next `qmd update`.
|
|
41
|
+
*/
|
|
42
|
+
export declare const METADATA_EXTRACTION_VERSION = 1;
|
|
43
|
+
/** Defensive limits for metadata from untrusted repositories. */
|
|
44
|
+
export declare const METADATA_LIMITS: {
|
|
45
|
+
readonly maxFrontmatterBytes: number;
|
|
46
|
+
readonly maxKeys: 64;
|
|
47
|
+
readonly maxKeyBytes: 128;
|
|
48
|
+
readonly maxStringLength: 1024;
|
|
49
|
+
readonly maxArrayLength: 128;
|
|
50
|
+
readonly maxYamlAliasCount: 100;
|
|
51
|
+
readonly maxErrorLength: 200;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Extract `qmd.metadata` from a document's leading YAML frontmatter.
|
|
55
|
+
*
|
|
56
|
+
* Never throws. A document without frontmatter, without a `qmd` namespace, or
|
|
57
|
+
* with a non-frontmatter extension yields empty metadata with no error.
|
|
58
|
+
* Invalid frontmatter or invalid metadata yields empty metadata plus a bounded
|
|
59
|
+
* extraction error.
|
|
60
|
+
*/
|
|
61
|
+
export declare function extractDocumentMetadata(content: string, path: string): MetadataExtractionResult;
|
package/dist/metadata.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var h=Object.defineProperty;var a=(t,r)=>h(t,"name",{value:r,configurable:!0});import l from"yaml";const y=1,o={maxFrontmatterBytes:64*1024,maxKeys:64,maxKeyBytes:128,maxStringLength:1024,maxArrayLength:128,maxYamlAliasCount:100,maxErrorLength:200},x=new Set([".md",".markdown",".mdx"]);function S(t,r){const n=a(s=>({metadata:s,extractionVersion:y}),"success"),e=a(s=>({metadata:{},error:L(s),extractionVersion:y}),"failure");if(!g(r))return n({});const m=p(t);if(m===null)return n({});if(Buffer.byteLength(m,"utf-8")>o.maxFrontmatterBytes)return e(`frontmatter exceeds ${o.maxFrontmatterBytes} bytes`);let i;try{i=l.parse(m,{maxAliasCount:o.maxYamlAliasCount})}catch(s){return e(`invalid frontmatter YAML: ${s instanceof Error?s.message:String(s)}`)}if(!u(i))return n({});const f=i.qmd;if(f===void 0)return n({});if(!u(f))return e("frontmatter 'qmd' must be a mapping");const c=f.metadata;if(c===void 0)return n({});if(!u(c))return e("frontmatter 'qmd.metadata' must be a mapping");try{return n(E(c))}catch(s){return e(s instanceof Error?s.message:String(s))}}a(S,"extractDocumentMetadata");function g(t){const r=t.lastIndexOf(".");return r<0?!1:x.has(t.slice(r).toLowerCase())}a(g,"hasFrontmatterFileExtension");function p(t){const r=t.charCodeAt(0)===65279?t.slice(1):t,n=r.match(/^---[ \t]*\r?\n/);if(!n)return null;const e=n[0].length,m=/^(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/m,i=r.slice(e).match(m);return!i||i.index===void 0?null:r.slice(e,e+i.index)}a(p,"getFrontmatterYaml");function E(t){const r=Object.keys(t);if(r.length>o.maxKeys)throw new Error(`metadata has ${r.length} keys (max ${o.maxKeys})`);const n=[];for(const e of r)A(e),n.push([e,b(e,t[e])]);return Object.fromEntries(n)}a(E,"normalizeMetadata");function A(t){if(t.length===0)throw new Error("metadata keys must be non-empty strings");if(Buffer.byteLength(t,"utf-8")>o.maxKeyBytes)throw new Error(`metadata key exceeds ${o.maxKeyBytes} bytes`);if(/[\u0000-\u001f\u007f]/.test(t))throw new Error("metadata keys must not contain control characters")}a(A,"validateMetadataKey");function b(t,r){return Array.isArray(r)?w(t,r):d(t,r)}a(b,"normalizeMetadataValue");function d(t,r){if(typeof r=="string"){if(r.length>o.maxStringLength)throw new Error(`metadata key "${t}": string exceeds ${o.maxStringLength} characters`);return r}if(typeof r=="number"){if(!Number.isFinite(r))throw new Error(`metadata key "${t}": numbers must be finite`);return r}if(typeof r=="boolean")return r;throw r===null?new Error(`metadata key "${t}": null is not supported \u2014 omit the key instead`):new Error(`metadata key "${t}": unsupported value type \u2014 use strings, numbers, booleans, or flat arrays of one of those`)}a(d,"normalizeMetadataScalar");function w(t,r){if(r.length===0)throw new Error(`metadata key "${t}": empty arrays are not supported \u2014 omit the key instead`);if(r.length>o.maxArrayLength)throw new Error(`metadata key "${t}": array exceeds ${o.maxArrayLength} values`);const n=r.map(e=>{if(Array.isArray(e))throw new Error(`metadata key "${t}": nested arrays are not supported`);return d(t,e)});if(n.every(e=>typeof e=="string"))return Array.from(new Set(n));if(n.every(e=>typeof e=="number"))return Array.from(new Set(n));if(n.every(e=>typeof e=="boolean"))return Array.from(new Set(n));throw new Error(`metadata key "${t}": mixed-type arrays are not supported`)}a(w,"normalizeMetadataArray");function u(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}a(u,"isPlainObject");function L(t){const r=t.replace(/\s+/g," ").trim();return r.length<=o.maxErrorLength?r:r.slice(0,o.maxErrorLength-3)+"..."}a(L,"truncateErrorMessage");export{y as METADATA_EXTRACTION_VERSION,o as METADATA_LIMITS,S as extractDocumentMetadata};
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function qmdHomedir(): string;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var m=Object.defineProperty;var o=(r,e)=>m(r,"name",{value:e,configurable:!0});import{homedir as s}from"node:os";function p(){return process.env.HOME||process.env.USERPROFILE||s()||"/tmp"}o(p,"qmdHomedir");export{p as qmdHomedir};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon query log — an opt-in JSONL record of searches served by the HTTP
|
|
3
|
+
* daemon: the REST `/query`·`/search` endpoints and the MCP `query` tool, so a
|
|
4
|
+
* user dogfooding a build can mine their real queries for misses. Only HTTP
|
|
5
|
+
* traffic is logged; a stdio MCP connection has no request to annotate.
|
|
6
|
+
*
|
|
7
|
+
* - Off unless `QMD_QUERY_LOG` is `1`, `true` or `yes` (case-insensitive).
|
|
8
|
+
* CLI searches never call this module.
|
|
9
|
+
* - One file per local month: `$XDG_CACHE_HOME/qmd/queries-YYYY-MM.jsonl`
|
|
10
|
+
* (default `~/.cache/qmd`), created 0600. Nothing is ever deleted.
|
|
11
|
+
* - Rows hold queries, result paths and scores — never snippets or bodies.
|
|
12
|
+
* - Fail-open: callers do not await and nothing here throws; a failure never
|
|
13
|
+
* reaches the search response. The first failure per process prints one
|
|
14
|
+
* stderr line; `queryLogStatus()` keeps the latest failure until the next
|
|
15
|
+
* successful write.
|
|
16
|
+
* - Clients annotate rows with `X-QMD-Tag` / `X-QMD-Qid` / `X-QMD-Role`, and
|
|
17
|
+
* skip logging with `X-QMD-No-Log: 1`. Header values are untrusted.
|
|
18
|
+
*
|
|
19
|
+
* Env and paths are read at call time so tests can redirect XDG_CACHE_HOME.
|
|
20
|
+
*/
|
|
21
|
+
import type { IncomingHttpHeaders } from "node:http";
|
|
22
|
+
import type { IndexStatus } from "./store.js";
|
|
23
|
+
export declare const QUERY_LOG_SCHEMA_VERSION = 1;
|
|
24
|
+
type HeaderSource = Headers | IncomingHttpHeaders;
|
|
25
|
+
type StatusSource = {
|
|
26
|
+
getStatus(): Promise<IndexStatus>;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* One search hit. `vec_score` / `fts_score` are the raw backend scores the
|
|
30
|
+
* daemon attaches on `rerank:false` responses; absent when that backend did
|
|
31
|
+
* not return the hit (absent, never 0).
|
|
32
|
+
*/
|
|
33
|
+
export type QueryLogResult = {
|
|
34
|
+
file: string;
|
|
35
|
+
score: number;
|
|
36
|
+
vec_score?: number;
|
|
37
|
+
fts_score?: number;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Milliseconds each stage of one search took. A stage that did not run is
|
|
41
|
+
* absent, never 0: `refresh` is the pre-query re-index check, `expand` the LLM
|
|
42
|
+
* query expansion, `fts` the BM25 lookups, `embed` the query embeddings, `vec`
|
|
43
|
+
* the sqlite-vec lookups, `chunk` splitting candidates to pick each one's best
|
|
44
|
+
* chunk, `rerank` the LLM reranker, and `serialize` snippet extraction (plus
|
|
45
|
+
* writing the response on REST). RRF fusion and metadata lookups are not a
|
|
46
|
+
* stage, so the stages need not sum to `ms`.
|
|
47
|
+
*/
|
|
48
|
+
export type QueryLogTimeout = "expand" | "rerank";
|
|
49
|
+
export type QueryLogStages = {
|
|
50
|
+
refresh?: number;
|
|
51
|
+
load?: number;
|
|
52
|
+
expand?: number;
|
|
53
|
+
fts?: number;
|
|
54
|
+
embed?: number;
|
|
55
|
+
vec?: number;
|
|
56
|
+
chunk?: number;
|
|
57
|
+
rerank?: number;
|
|
58
|
+
serialize?: number;
|
|
59
|
+
};
|
|
60
|
+
export type QueryLogEntry = {
|
|
61
|
+
via: "rest" | "mcp";
|
|
62
|
+
tool: string;
|
|
63
|
+
headers: HeaderSource;
|
|
64
|
+
searches: {
|
|
65
|
+
type: string;
|
|
66
|
+
query: string;
|
|
67
|
+
}[];
|
|
68
|
+
collections: string[] | null;
|
|
69
|
+
limit: number;
|
|
70
|
+
/** null = the request left reranking to the daemon default */
|
|
71
|
+
rerank: boolean | null;
|
|
72
|
+
results: QueryLogResult[];
|
|
73
|
+
ms: number;
|
|
74
|
+
stages?: QueryLogStages;
|
|
75
|
+
/** Stages that ran past their deadline; the search went on without them */
|
|
76
|
+
timeouts?: QueryLogTimeout[];
|
|
77
|
+
/** Injectable clock for tests; defaults to the time of the call. */
|
|
78
|
+
now?: Date;
|
|
79
|
+
};
|
|
80
|
+
export type QueryLogStatus = {
|
|
81
|
+
enabled: boolean;
|
|
82
|
+
path: string;
|
|
83
|
+
lastWrite: string | null;
|
|
84
|
+
lastError: string | null;
|
|
85
|
+
};
|
|
86
|
+
/** Path of the log file for the local month containing `date`. */
|
|
87
|
+
export declare const queryLogPath: (date?: Date) => string;
|
|
88
|
+
/** ISO-8601 in local time with the UTC offset, e.g. 2026-09-17T16:05:03.123+09:00. */
|
|
89
|
+
export declare const isoWithOffset: (date: Date) => string;
|
|
90
|
+
/**
|
|
91
|
+
* Queue one row. Returns immediately; the write happens after the response.
|
|
92
|
+
* No-op unless the flag is on and the request did not ask to be skipped.
|
|
93
|
+
* Never throws — the REST handler calls this after the response has ended.
|
|
94
|
+
*/
|
|
95
|
+
export declare const logQuery: (entry: QueryLogEntry, store: StatusSource) => void;
|
|
96
|
+
/** Resolves once every queued row has been written or has failed. */
|
|
97
|
+
export declare const flushQueryLog: () => Promise<void>;
|
|
98
|
+
/** In-process view (the daemon's own `status` tool). */
|
|
99
|
+
export declare const queryLogStatus: () => QueryLogStatus;
|
|
100
|
+
export type QueryLogFileState = {
|
|
101
|
+
kind: "none";
|
|
102
|
+
} | {
|
|
103
|
+
kind: "file";
|
|
104
|
+
path: string;
|
|
105
|
+
modified: Date;
|
|
106
|
+
} | {
|
|
107
|
+
kind: "error";
|
|
108
|
+
message: string;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Newest log file on disk, for `qmd status` — which runs in a different
|
|
112
|
+
* process than the daemon and so cannot see the daemon's env or state.
|
|
113
|
+
* A missing cache dir is "none"; any other failure yields kind "error" with
|
|
114
|
+
* the error code only (status output carries no filesystem paths).
|
|
115
|
+
*/
|
|
116
|
+
export declare const latestQueryLogFile: () => QueryLogFileState;
|
|
117
|
+
/** Map a REST `/query` request the same way the handler does. */
|
|
118
|
+
export declare const entryFromRest: (input: {
|
|
119
|
+
path: string;
|
|
120
|
+
headers: IncomingHttpHeaders;
|
|
121
|
+
/** The handler's already-mapped searches. */
|
|
122
|
+
searches: {
|
|
123
|
+
type: string;
|
|
124
|
+
query: string;
|
|
125
|
+
}[];
|
|
126
|
+
/** Raw JSON body fields the handler reads with typeof checks. */
|
|
127
|
+
params: {
|
|
128
|
+
limit?: unknown;
|
|
129
|
+
rerank?: unknown;
|
|
130
|
+
};
|
|
131
|
+
/** The collections actually searched (the handler's defaults applied). */
|
|
132
|
+
collections: string[];
|
|
133
|
+
results: QueryLogResult[];
|
|
134
|
+
ms: number;
|
|
135
|
+
stages?: QueryLogStages;
|
|
136
|
+
timeouts?: QueryLogTimeout[];
|
|
137
|
+
}) => QueryLogEntry;
|
|
138
|
+
/** Map an MCP `query` tool call. Only the HTTP transport supplies headers. */
|
|
139
|
+
export declare const entryFromMcp: (input: {
|
|
140
|
+
headers: Headers;
|
|
141
|
+
/** The tool's typed sub-queries, or the plain `query` it auto-expanded. */
|
|
142
|
+
searches: {
|
|
143
|
+
type: string;
|
|
144
|
+
query: string;
|
|
145
|
+
}[];
|
|
146
|
+
/** The collections actually searched (the tool's defaults applied). */
|
|
147
|
+
collections: string[];
|
|
148
|
+
limit: number;
|
|
149
|
+
rerank: boolean;
|
|
150
|
+
results: QueryLogResult[];
|
|
151
|
+
ms: number;
|
|
152
|
+
stages?: QueryLogStages;
|
|
153
|
+
timeouts?: QueryLogTimeout[];
|
|
154
|
+
}) => QueryLogEntry;
|
|
155
|
+
export declare const _resetQueryLogForTesting: () => void;
|
|
156
|
+
export {};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var x=Object.defineProperty;var o=(e,t)=>x(e,"name",{value:t,configurable:!0});import{appendFile as y,mkdir as E}from"node:fs/promises";import{readFileSync as F,readdirSync as L,statSync as M}from"node:fs";import{dirname as w,join as l}from"node:path";import{fileURLToPath as R}from"node:url";import{qmdHomedir as O}from"./paths.js";import{resolveCommit as C}from"./cli/version.js";const D=1,T=["primary","probe","canary","replay"],b=32,Q=64,A=6e4,k=o(e=>e!==void 0&&["1","true","yes"].includes(e.trim().toLowerCase()),"isTruthyFlag"),S=o(()=>k(process.env.QMD_QUERY_LOG),"isQueryLogEnabled"),_=o(()=>l(process.env.XDG_CACHE_HOME||l(O(),".cache"),"qmd"),"queryLogDir"),a=o(e=>String(e).padStart(2,"0"),"pad2"),h=o((e=new Date)=>l(_(),`queries-${e.getFullYear()}-${a(e.getMonth()+1)}.jsonl`),"queryLogPath"),H=o(e=>{const t=-e.getTimezoneOffset(),s=t>=0?"+":"-",n=Math.abs(t),i=String(e.getMilliseconds()).padStart(3,"0");return`${e.getFullYear()}-${a(e.getMonth()+1)}-${a(e.getDate())}T${a(e.getHours())}:${a(e.getMinutes())}:${a(e.getSeconds())}.${i}${s}${a(Math.floor(n/60))}:${a(n%60)}`},"isoWithOffset"),u=o((e,t)=>{if(e instanceof Headers)return e.get(t);const s=e[t.toLowerCase()];return(Array.isArray(s)?s[0]:s)??null},"readHeader"),v=o((e,t)=>e===null||e===""?null:e.slice(0,t),"clip"),N=o(e=>T.find(t=>t===e)??null,"parseRole"),I=o(e=>(e.startsWith("qmd://")?e.slice(6):e).split("/").map(s=>{try{return decodeURIComponent(s)}catch{return s}}).join("/"),"normalizeResultFile");let m;const j=o(()=>{if(m!==void 0)return m;const e=w(R(import.meta.url)),t=l(e,"..");try{const s=JSON.parse(F(l(t,"package.json"),"utf-8")),n=C(l(e,"cli"),t);m=n?`${s.version??"unknown"} (${n})`:s.version??"unknown"}catch{m="unknown"}return m},"qmdVersion");let c=null;const G=o(async e=>{const t=Date.now();if(c&&c.store===e&&t-c.at<A)return c.value;let s;try{const n=await e.getStatus(),i=n.collections.map(r=>r.lastUpdated).filter(Boolean).sort().at(-1)??null;s={docs:n.totalDocuments,updated:i}}catch{s={docs:null,updated:null}}return c={store:e,at:t,value:s},s},"indexFingerprint");let d=Promise.resolve(),p=null,g=null,q=!1;const $=o((e,t)=>{g=t,!q&&(q=!0,console.error(`qmd: query log write failed (${e}): ${t} \u2014 searches are unaffected`))},"recordFailure"),K=o((e,t)=>{try{P(e,t)}catch(s){$(h(),s instanceof Error?s.message:String(s))}},"logQuery"),P=o((e,t)=>{if(!S()||k(u(e.headers,"x-qmd-no-log")??void 0))return;const s=e.now??new Date,n=h(s),i={v:D,ts:H(s),qmd:j(),via:e.via,tool:e.tool,searches:e.searches.map(({type:r,query:f})=>({type:r,query:f})),collections:e.collections,limit:e.limit,rerank:e.timeouts?.includes("rerank")?"timeout":e.rerank,results:e.results.map((r,f)=>({file:I(r.file),score:r.score,...r.vec_score!==void 0&&{vec_score:r.vec_score},...r.fts_score!==void 0&&{fts_score:r.fts_score},rank:f+1})),ms:e.ms,...e.stages&&{stages:e.stages},...e.timeouts&&e.timeouts.length>0&&{timeouts:e.timeouts},client:{tag:v(u(e.headers,"x-qmd-tag"),b),qid:v(u(e.headers,"x-qmd-qid"),Q),role:N(u(e.headers,"x-qmd-role"))}};d=d.then(async()=>{try{const r=JSON.stringify({...i,index:await G(t)})+`
|
|
2
|
+
`;await E(w(n),{recursive:!0}),await y(n,r,{mode:384}),p=new Date().toISOString(),g=null}catch(r){$(n,r instanceof Error?r.message:String(r))}})},"queueRow"),Z=o(()=>d,"flushQueryLog"),ee=o(()=>({enabled:S(),path:h(),lastWrite:p,lastError:g}),"queryLogStatus"),U=o(e=>e.code??e.message,"errorCode"),te=o(()=>{const e=_();try{const t=L(e).filter(n=>/^queries-\d{4}-\d{2}\.jsonl$/.test(n)).sort().at(-1);if(!t)return{kind:"none"};const s=l(e,t);return{kind:"file",path:s,modified:M(s).mtime}}catch(t){const s=U(t);return s==="ENOENT"?{kind:"none"}:{kind:"error",message:s}}},"latestQueryLogFile"),se=o(e=>({via:"rest",tool:e.path,headers:e.headers,searches:e.searches,collections:e.collections.length>0?e.collections:null,limit:typeof e.params.limit=="number"?e.params.limit:10,rerank:typeof e.params.rerank=="boolean"?e.params.rerank:null,results:e.results,ms:e.ms,stages:e.stages,timeouts:e.timeouts}),"entryFromRest"),oe=o(e=>({via:"mcp",tool:"query",headers:e.headers,searches:e.searches,collections:e.collections.length>0?e.collections:null,limit:e.limit,rerank:e.rerank,results:e.results,ms:e.ms,stages:e.stages,timeouts:e.timeouts}),"entryFromMcp"),re=o(()=>{d=Promise.resolve(),p=null,g=null,q=!1,c=null},"_resetQueryLogForTesting");export{D as QUERY_LOG_SCHEMA_VERSION,re as _resetQueryLogForTesting,oe as entryFromMcp,se as entryFromRest,Z as flushQueryLog,H as isoWithOffset,te as latestQueryLogFile,K as logQuery,h as queryLogPath,ee as queryLogStatus};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* refresh.ts - keep the text index current for the daemon, without `qmd update`.
|
|
3
|
+
*
|
|
4
|
+
* A note written a minute ago should be findable now. The index only moves when someone runs
|
|
5
|
+
* `qmd update`, and nobody does between writing and asking, so the daemon checks the
|
|
6
|
+
* collections a query is about to search and re-indexes the ones that changed on disk.
|
|
7
|
+
*
|
|
8
|
+
* Three things it deliberately does not do:
|
|
9
|
+
* - Run a collection's `update:` command. That is a shell hook (`git pull` in the documented
|
|
10
|
+
* example), gated on `qmd trust` for project-local configs; firing it because a query
|
|
11
|
+
* arrived would run commands nobody asked for.
|
|
12
|
+
* - Clear the LLM cache. `store.update()` does, and a refresh after every edit would then send
|
|
13
|
+
* every following query through expansion and rerank cold.
|
|
14
|
+
* - Embed. Embedding 232 documents takes minutes on a laptop GPU; a new note is findable by
|
|
15
|
+
* BM25 immediately and by vector search after the next `qmd embed`.
|
|
16
|
+
*/
|
|
17
|
+
import type { QMDStore } from "./index.js";
|
|
18
|
+
export type RefreshCollection = {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly path: string;
|
|
21
|
+
readonly pattern: string;
|
|
22
|
+
readonly ignore?: string[];
|
|
23
|
+
};
|
|
24
|
+
export type CollectionSnapshot = {
|
|
25
|
+
readonly fileCount: number;
|
|
26
|
+
readonly newestMtimeMs: number;
|
|
27
|
+
};
|
|
28
|
+
export type IndexRefresherDeps = {
|
|
29
|
+
/** Collections with the time the index last recorded a change in each, or null if never. */
|
|
30
|
+
readonly listCollections: () => ReadonlyArray<RefreshCollection & {
|
|
31
|
+
readonly lastIndexedMs: number | null;
|
|
32
|
+
}>;
|
|
33
|
+
readonly snapshot: (collection: RefreshCollection) => Promise<CollectionSnapshot>;
|
|
34
|
+
readonly reindex: (collection: RefreshCollection) => Promise<void>;
|
|
35
|
+
readonly now?: () => number;
|
|
36
|
+
readonly cooldownMs?: number;
|
|
37
|
+
readonly onError?: (collection: string, error: unknown) => void;
|
|
38
|
+
};
|
|
39
|
+
export type IndexRefresher = {
|
|
40
|
+
/** Re-index each named collection that changed on disk. Never rejects. */
|
|
41
|
+
readonly refreshIfStale: (collections: readonly string[]) => Promise<void>;
|
|
42
|
+
/** Re-check now, ignoring the cooldown — for a caller that just wrote into the collection. */
|
|
43
|
+
readonly refreshNow: (collections: readonly string[]) => Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
/** How long a collection's last check stays good. Bounds the directory walk, not the re-index. */
|
|
46
|
+
export declare const REFRESH_COOLDOWN_MS = 30000;
|
|
47
|
+
export declare const createIndexRefresher: (deps: IndexRefresherDeps) => IndexRefresher;
|
|
48
|
+
/**
|
|
49
|
+
* A refresher wired to a store's own collections.
|
|
50
|
+
*
|
|
51
|
+
* `configPath`, when given, is the config the daemon was started from. A project-local config
|
|
52
|
+
* arrives with `git clone`, so its collection paths outside the project are somebody else's
|
|
53
|
+
* choice until the user runs `qmd trust`; `qmd update` skips them for an untrusted config, and
|
|
54
|
+
* a refresh — which never asks — skips them always.
|
|
55
|
+
*/
|
|
56
|
+
export declare const createStoreIndexRefresher: (store: QMDStore, options?: {
|
|
57
|
+
configPath?: string;
|
|
58
|
+
cooldownMs?: number;
|
|
59
|
+
onError?: (collection: string, error: unknown) => void;
|
|
60
|
+
}) => IndexRefresher;
|
package/dist/refresh.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var g=Object.defineProperty;var o=(t,i)=>g(t,"name",{value:i,configurable:!0});import{statSync as M}from"node:fs";import{resolve as x}from"node:path";import{getStoreCollections as A,listCollectionFiles as S,listCollections as y,reindexCollection as P}from"./store.js";import{isCollectionPathInsideProject as I,isLocalConfigPath as E}from"./trust.js";const O=3e4,k=o(t=>{const i=t.now??Date.now,d=t.cooldownMs??O,f=new Map,h=new Map,s=new Set,a=o(async e=>{const n=i(),r=f.get(e.name);if(!s.delete(e.name)&&r&&n-r.checkedAt<d)return;const m=await t.snapshot(e);if(m.fileCount===0&&(r?.fileCount??0)>0){f.set(e.name,{...r,checkedAt:n});return}const w=Math.max(e.lastIndexedMs??0,r?.refreshedAt??0),p=m.newestMtimeMs>w,C=r!==void 0&&m.fileCount!==r.fileCount;if(!p&&!C){f.set(e.name,{checkedAt:n,refreshedAt:r?.refreshedAt??0,fileCount:m.fileCount});return}await t.reindex(e),f.set(e.name,{checkedAt:n,refreshedAt:n,fileCount:m.fileCount})},"refreshOne"),l=o(e=>{const n=h.get(e.name);if(n)return n;const r=a(e).catch(c=>{t.onError?.(e.name,c)}).finally(()=>{h.delete(e.name)});return h.set(e.name,r),r},"refreshShared"),u=o(async e=>{let n;try{const r=new Set(e);n=t.listCollections().filter(c=>r.has(c.name))}catch(r){for(const c of e)t.onError?.(c,r);return}await Promise.all(n.map(l))},"refreshIfStale");return{refreshIfStale:u,refreshNow:o(async e=>{for(const n of e)s.add(n);await Promise.all(e.map(n=>h.get(n))),await u(e)},"refreshNow")}},"createIndexRefresher"),v=o(async t=>{const i=await S(t.path,t.pattern,t.ignore);let d=0;for(const f of i)try{d=Math.max(d,M(x(t.path,f)).mtimeMs)}catch{}return{fileCount:i.length,newestMtimeMs:d}},"snapshotCollection"),j=o((t,i={})=>{const d=t.internal.db,f=i.configPath,h=o(s=>f!==void 0&&E(f)&&!I(f,s),"isOutsideLocalProject");return k({listCollections:o(()=>{const s=new Map(y(d).map(a=>[a.name,a.last_modified]));return A(d).filter(a=>!h(a.path)).map(a=>{const l=s.get(a.name);return{name:a.name,path:a.path,pattern:a.pattern||"**/*.md",ignore:a.ignore,lastIndexedMs:l?Date.parse(l):null}})},"listCollections"),snapshot:v,reindex:o(async s=>{await P(t.internal,s.path,s.pattern,s.name,{ignorePatterns:s.ignore})},"reindex"),cooldownMs:i.cooldownMs,onError:i.onError})},"createStoreIndexRefresher");export{O as REFRESH_COOLDOWN_MS,k as createIndexRefresher,j as createStoreIndexRefresher};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rerank-input.ts - what the cross-encoder reranker is shown.
|
|
3
|
+
*
|
|
4
|
+
* Both search paths (`hybridQuery`, `structuredSearch`) and `rerank()` build the reranker's
|
|
5
|
+
* input here and nowhere else: the query string, which chunk of a candidate document is sent,
|
|
6
|
+
* and the document text made from that chunk. The query string is also the rerank cache key,
|
|
7
|
+
* so a change here re-scores every cached pair.
|
|
8
|
+
*/
|
|
9
|
+
/** A chunk as the search paths cut it: text plus its character offset in the body. */
|
|
10
|
+
export type RerankChunk = {
|
|
11
|
+
text: string;
|
|
12
|
+
pos: number;
|
|
13
|
+
};
|
|
14
|
+
/** Candidate metadata a document text may draw on besides the chunk. */
|
|
15
|
+
export type RerankDocMeta = {
|
|
16
|
+
file: string;
|
|
17
|
+
title: string;
|
|
18
|
+
displayPath: string;
|
|
19
|
+
};
|
|
20
|
+
/** Weight of an intent term in chunk selection; a non-Hangul query word counts 2, a Hangul bigram 1 */
|
|
21
|
+
export declare const INTENT_WEIGHT_CHUNK = 1;
|
|
22
|
+
/** The reranker's query: intent, a blank line, then the query; the query alone without intent. */
|
|
23
|
+
export declare const formatRerankQuery: (query: string, intent?: string) => string;
|
|
24
|
+
/**
|
|
25
|
+
* Head start of the first chunk (title, summary); a later chunk must out-score it by more than this,
|
|
26
|
+
* which a typical short query cannot do, so its first chunk is always sent.
|
|
27
|
+
*/
|
|
28
|
+
export declare const FIRST_CHUNK_HEAD_START = 16;
|
|
29
|
+
/** The head start when intent is given: halved, so intent words can still steer selection to a later chunk. */
|
|
30
|
+
export declare const FIRST_CHUNK_HEAD_START_WITH_INTENT = 8;
|
|
31
|
+
/**
|
|
32
|
+
* Scorer for one chunk's text against `query`: two points per query word longer than two characters
|
|
33
|
+
* with no Hangul, one per query Hangul syllable bigram, and one per intent word (half a non-Hangul
|
|
34
|
+
* query word). Case-insensitive substring match.
|
|
35
|
+
*/
|
|
36
|
+
export declare const rerankChunkScorer: (query: string, intentTerms: string[]) => ((text: string) => number);
|
|
37
|
+
/**
|
|
38
|
+
* Index of the chunk to rerank: the highest `rerankChunkScorer` score, with the first chunk starting
|
|
39
|
+
* FIRST_CHUNK_HEAD_START ahead (FIRST_CHUNK_HEAD_START_WITH_INTENT with intent), so the note's lead is
|
|
40
|
+
* sent unless a later chunk matches far more of the query. A tie keeps the earlier chunk.
|
|
41
|
+
*/
|
|
42
|
+
export declare const selectRerankChunk: (chunks: RerankChunk[], query: string, intentTerms: string[]) => number;
|
|
43
|
+
/** Frontmatter longer than this drowns the first chunk in source URLs; short blocks stay put. */
|
|
44
|
+
export declare const LONG_FRONTMATTER_CHARS = 350;
|
|
45
|
+
/** The document text sent for one candidate: the selected chunk alone, without long frontmatter. */
|
|
46
|
+
export declare const formatRerankDoc: (chunkText: string, _meta: RerankDocMeta) => string;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
var g=Object.defineProperty;var s=(e,t)=>g(e,"name",{value:t,configurable:!0});const C=1,I=s((e,t)=>t?`${t}
|
|
2
|
+
|
|
3
|
+
${e}`:e,"formatRerankQuery"),i=s(e=>(e.match(/[가-힣]{3,}/g)??[]).flatMap(t=>Array.from({length:t.length-1},(c,l)=>t.slice(l,l+2))),"hangulBigrams"),T=16,_=8,u=s((e,t)=>{const c=e.toLowerCase(),l=c.split(/\s+/).filter(o=>o.length>2&&!/[가-힣]/.test(o)),p=i(c);return o=>{const a=o.toLowerCase();return l.reduce((r,n)=>r+(a.includes(n)?2:0),0)+p.reduce((r,n)=>r+(a.includes(n)?1:0),0)+t.reduce((r,n)=>r+(a.includes(n)?1:0),0)}},"rerankChunkScorer"),d=s((e,t,c)=>{const l=u(t,c),p=c.length===0?T:_;let o=0,a=-1;for(let r=0;r<e.length;r++){const n=l(e[r].text)+(r===0?p:0);n>a&&(a=n,o=r)}return o},"selectRerankChunk"),h=350,m=s(e=>{if(!/^---\r?\n/.test(e))return e;const t=e.match(/^---\r?\n[\s\S]{1,100000}?---\r?\n/);return t&&t[0].length>h?e.slice(t[0].length):e},"stripLongFrontmatter"),S=s(e=>e.replace(/!?\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)/g,"$1").replace(/https?:\/\/\S+/g,t=>t.match(/[.,;:!?)\]}>]+$/)?.[0]??""),"stripUrls"),N=s(e=>e.split(`
|
|
4
|
+
`).map(t=>t.replace(/^\s*(>\s*)+/,"")).join(`
|
|
5
|
+
`),"stripQuotes"),H=s(e=>e.replace(/<!--[\s\S]*?-->/g,"").replace(/<script\b[^<>]*>[\s\S]*?<\/script\s*>/gi,"").replace(/<style\b[^<>]*>[\s\S]*?<\/style\s*>/gi,"").replace(/<\/?[a-zA-Z][^<>]*>/g,""),"stripHtmlTags"),f=s((e,t)=>H(N(S(m(e)))),"formatRerankDoc");export{T as FIRST_CHUNK_HEAD_START,_ as FIRST_CHUNK_HEAD_START_WITH_INTENT,C as INTENT_WEIGHT_CHUNK,h as LONG_FRONTMATTER_CHARS,f as formatRerankDoc,I as formatRerankQuery,u as rerankChunkScorer,d as selectRerankChunk};
|