@wei840222/qmd 2026.8.28 → 2026.9.25

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.
@@ -0,0 +1,215 @@
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
+ import YAML from "yaml";
23
+ // =============================================================================
24
+ // Limits
25
+ // =============================================================================
26
+ /**
27
+ * Bump when extraction or normalization semantics change so existing rows are
28
+ * re-extracted on the next `qmd update`.
29
+ */
30
+ export const METADATA_EXTRACTION_VERSION = 1;
31
+ /** Defensive limits for metadata from untrusted repositories. */
32
+ export const METADATA_LIMITS = {
33
+ maxFrontmatterBytes: 64 * 1024,
34
+ maxKeys: 64,
35
+ maxKeyBytes: 128,
36
+ maxStringLength: 1024,
37
+ maxArrayLength: 128,
38
+ maxYamlAliasCount: 100,
39
+ maxErrorLength: 200,
40
+ };
41
+ /** File extensions parsed for frontmatter metadata. */
42
+ const FRONTMATTER_FILE_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]);
43
+ // =============================================================================
44
+ // Extraction
45
+ // =============================================================================
46
+ /**
47
+ * Extract `qmd.metadata` from a document's leading YAML frontmatter.
48
+ *
49
+ * Never throws. A document without frontmatter, without a `qmd` namespace, or
50
+ * with a non-frontmatter extension yields empty metadata with no error.
51
+ * Invalid frontmatter or invalid metadata yields empty metadata plus a bounded
52
+ * extraction error.
53
+ */
54
+ export function extractDocumentMetadata(content, path) {
55
+ const success = (metadata) => ({ metadata, extractionVersion: METADATA_EXTRACTION_VERSION });
56
+ const failure = (message) => ({
57
+ metadata: {},
58
+ error: truncateErrorMessage(message),
59
+ extractionVersion: METADATA_EXTRACTION_VERSION,
60
+ });
61
+ if (!hasFrontmatterFileExtension(path))
62
+ return success({});
63
+ const frontmatterYaml = getFrontmatterYaml(content);
64
+ if (frontmatterYaml === null)
65
+ return success({});
66
+ if (Buffer.byteLength(frontmatterYaml, "utf-8") > METADATA_LIMITS.maxFrontmatterBytes) {
67
+ return failure(`frontmatter exceeds ${METADATA_LIMITS.maxFrontmatterBytes} bytes`);
68
+ }
69
+ let frontmatter;
70
+ try {
71
+ frontmatter = YAML.parse(frontmatterYaml, { maxAliasCount: METADATA_LIMITS.maxYamlAliasCount });
72
+ }
73
+ catch (err) {
74
+ return failure(`invalid frontmatter YAML: ${err instanceof Error ? err.message : String(err)}`);
75
+ }
76
+ if (!isPlainObject(frontmatter))
77
+ return success({});
78
+ const qmdNamespace = frontmatter["qmd"];
79
+ if (qmdNamespace === undefined)
80
+ return success({});
81
+ if (!isPlainObject(qmdNamespace)) {
82
+ return failure("frontmatter 'qmd' must be a mapping");
83
+ }
84
+ const rawMetadata = qmdNamespace["metadata"];
85
+ if (rawMetadata === undefined)
86
+ return success({});
87
+ if (!isPlainObject(rawMetadata)) {
88
+ return failure("frontmatter 'qmd.metadata' must be a mapping");
89
+ }
90
+ try {
91
+ return success(normalizeMetadata(rawMetadata));
92
+ }
93
+ catch (err) {
94
+ return failure(err instanceof Error ? err.message : String(err));
95
+ }
96
+ }
97
+ function hasFrontmatterFileExtension(path) {
98
+ const dotIndex = path.lastIndexOf(".");
99
+ if (dotIndex < 0)
100
+ return false;
101
+ return FRONTMATTER_FILE_EXTENSIONS.has(path.slice(dotIndex).toLowerCase());
102
+ }
103
+ /**
104
+ * Slice the YAML between a leading `---` line and a closing `---` or `...`
105
+ * line. Tolerates a UTF-8 BOM and CRLF line endings. Returns null when the
106
+ * document has no complete leading frontmatter block.
107
+ */
108
+ function getFrontmatterYaml(content) {
109
+ const body = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
110
+ const openMatch = body.match(/^---[ \t]*\r?\n/);
111
+ if (!openMatch)
112
+ return null;
113
+ const yamlStart = openMatch[0].length;
114
+ const closePattern = /^(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/m;
115
+ const closeMatch = body.slice(yamlStart).match(closePattern);
116
+ if (!closeMatch || closeMatch.index === undefined)
117
+ return null;
118
+ return body.slice(yamlStart, yamlStart + closeMatch.index);
119
+ }
120
+ // =============================================================================
121
+ // Normalization
122
+ // =============================================================================
123
+ /**
124
+ * Normalize raw `qmd.metadata` into canonical `DocumentMetadata`.
125
+ * Throws on any unsupported key or value — extraction is all-or-nothing per
126
+ * document so stale partial metadata can never persist.
127
+ */
128
+ function normalizeMetadata(rawMetadata) {
129
+ const keys = Object.keys(rawMetadata);
130
+ if (keys.length > METADATA_LIMITS.maxKeys) {
131
+ throw new Error(`metadata has ${keys.length} keys (max ${METADATA_LIMITS.maxKeys})`);
132
+ }
133
+ const entries = [];
134
+ for (const key of keys) {
135
+ validateMetadataKey(key);
136
+ entries.push([key, normalizeMetadataValue(key, rawMetadata[key])]);
137
+ }
138
+ // Object.fromEntries defines "__proto__" as ordinary data instead of
139
+ // invoking Object.prototype's legacy setter. Metadata keys are unrestricted
140
+ // user data, so prototype-shaped names must round-trip like every other key.
141
+ return Object.fromEntries(entries);
142
+ }
143
+ function validateMetadataKey(key) {
144
+ if (key.length === 0) {
145
+ throw new Error("metadata keys must be non-empty strings");
146
+ }
147
+ if (Buffer.byteLength(key, "utf-8") > METADATA_LIMITS.maxKeyBytes) {
148
+ throw new Error(`metadata key exceeds ${METADATA_LIMITS.maxKeyBytes} bytes`);
149
+ }
150
+ // eslint-disable-next-line no-control-regex
151
+ if (/[\u0000-\u001f\u007f]/.test(key)) {
152
+ throw new Error("metadata keys must not contain control characters");
153
+ }
154
+ }
155
+ function normalizeMetadataValue(key, rawValue) {
156
+ if (Array.isArray(rawValue)) {
157
+ return normalizeMetadataArray(key, rawValue);
158
+ }
159
+ return normalizeMetadataScalar(key, rawValue);
160
+ }
161
+ function normalizeMetadataScalar(key, rawValue) {
162
+ if (typeof rawValue === "string") {
163
+ if (rawValue.length > METADATA_LIMITS.maxStringLength) {
164
+ throw new Error(`metadata key "${key}": string exceeds ${METADATA_LIMITS.maxStringLength} characters`);
165
+ }
166
+ return rawValue;
167
+ }
168
+ if (typeof rawValue === "number") {
169
+ if (!Number.isFinite(rawValue)) {
170
+ throw new Error(`metadata key "${key}": numbers must be finite`);
171
+ }
172
+ return rawValue;
173
+ }
174
+ if (typeof rawValue === "boolean")
175
+ return rawValue;
176
+ if (rawValue === null) {
177
+ throw new Error(`metadata key "${key}": null is not supported — omit the key instead`);
178
+ }
179
+ throw new Error(`metadata key "${key}": unsupported value type — use strings, numbers, booleans, or flat arrays of one of those`);
180
+ }
181
+ function normalizeMetadataArray(key, rawValues) {
182
+ if (rawValues.length === 0) {
183
+ throw new Error(`metadata key "${key}": empty arrays are not supported — omit the key instead`);
184
+ }
185
+ if (rawValues.length > METADATA_LIMITS.maxArrayLength) {
186
+ throw new Error(`metadata key "${key}": array exceeds ${METADATA_LIMITS.maxArrayLength} values`);
187
+ }
188
+ const scalars = rawValues.map(rawValue => {
189
+ if (Array.isArray(rawValue)) {
190
+ throw new Error(`metadata key "${key}": nested arrays are not supported`);
191
+ }
192
+ return normalizeMetadataScalar(key, rawValue);
193
+ });
194
+ // Narrow each homogeneous case explicitly so the public array union remains
195
+ // precise without discarding type evidence through chained assertions.
196
+ if (scalars.every((scalar) => typeof scalar === "string")) {
197
+ return Array.from(new Set(scalars));
198
+ }
199
+ if (scalars.every((scalar) => typeof scalar === "number")) {
200
+ return Array.from(new Set(scalars));
201
+ }
202
+ if (scalars.every((scalar) => typeof scalar === "boolean")) {
203
+ return Array.from(new Set(scalars));
204
+ }
205
+ throw new Error(`metadata key "${key}": mixed-type arrays are not supported`);
206
+ }
207
+ function isPlainObject(value) {
208
+ return typeof value === "object" && value !== null && !Array.isArray(value);
209
+ }
210
+ function truncateErrorMessage(message) {
211
+ const singleLine = message.replace(/\s+/g, " ").trim();
212
+ if (singleLine.length <= METADATA_LIMITS.maxErrorLength)
213
+ return singleLine;
214
+ return singleLine.slice(0, METADATA_LIMITS.maxErrorLength - 3) + "...";
215
+ }
@@ -179849,6 +179849,7 @@ c++ 3 nz
179849
179849
  實際性 4 N
179850
179850
  實際數 3 N
179851
179851
  實際節 1 N
179852
+ 實際耗時 1000000 nz
179852
179853
  實際面 6 N
179853
179854
  實領 4 N
179854
179855
  實馬 1 N
@@ -403350,6 +403351,7 @@ c++ 3 nz
403350
403351
  真實度 3 N
403351
403352
  真實性 86 N
403352
403353
  真實感 20 N
403354
+ 真實時間 1000000 nz
403353
403355
  真實模式 1000000 nz
403354
403356
  真實版 16 N
403355
403357
  真實面 7 N
@@ -437457,6 +437459,7 @@ c++ 3 nz
437457
437459
  總編輯 112 N
437458
437460
  總署 191 N
437459
437461
  總而言之 20 ADV
437462
+ 總耗時 1000000 nz
437460
437463
  總膽管 3 N
437461
437464
  總荷 1 N
437462
437465
  總菌 3 N
package/dist/store.d.ts CHANGED
@@ -18,6 +18,8 @@ import type { LLM } from "./llm.js";
18
18
  import { LlamaCpp, formatQueryForEmbedding, formatDocForEmbedding, type ILLMSession } from "./llm.js";
19
19
  import type { NamedCollection, Collection, CollectionConfig } from "./collections.js";
20
20
  import type { IndexDiagnostics } from "./diagnostics.js";
21
+ import { type DocumentMetadata } from "./metadata.js";
22
+ import { type MetadataFilter } from "./metadata-filter.js";
21
23
  export declare const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
22
24
  export declare const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
23
25
  export declare const DEFAULT_QUERY_MODEL = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
@@ -310,9 +312,9 @@ export type Store = {
310
312
  isVirtualPath: typeof isVirtualPath;
311
313
  resolveVirtualPath: (virtualPath: string) => string | null;
312
314
  toVirtualPath: (absolutePath: string) => string | null;
313
- searchCharFTS: (query: string, limit?: number, collectionFilter?: CollectionFilter) => SearchResult[];
314
- searchFTS: (query: string, limit?: number, collectionFilter?: CollectionFilter) => SearchResult[];
315
- searchVec: (query: string, model: string, limit?: number, collectionFilter?: CollectionFilter, session?: ILLMSession, precomputedEmbedding?: number[]) => Promise<SearchResult[]>;
315
+ searchCharFTS: (query: string, limit?: number, collectionFilter?: CollectionFilter, filter?: MetadataFilter) => SearchResult[];
316
+ searchFTS: (query: string, limit?: number, collectionFilter?: CollectionFilter, filter?: MetadataFilter) => SearchResult[];
317
+ searchVec: (query: string, model: string, limit?: number, collectionFilter?: CollectionFilter, session?: ILLMSession, precomputedEmbedding?: number[], filter?: MetadataFilter) => Promise<SearchResult[]>;
316
318
  expandQuery: (query: string, model?: string, expansionContext?: string, options?: QueryExpansionOptions) => Promise<ExpandedQuery[]>;
317
319
  /** Drop the cached expansion for a query so the next call regenerates. */
318
320
  invalidateExpansionCache: (query: string, expansionContext?: string, options?: QueryExpansionOptions) => void;
@@ -347,7 +349,7 @@ export type Store = {
347
349
  hash: string;
348
350
  } | null;
349
351
  insertContent: (hash: string, content: string, createdAt: string) => void;
350
- insertDocument: (collectionName: string, path: string, title: string, hash: string, createdAt: string, modifiedAt: string) => void;
352
+ insertDocument: (collectionName: string, path: string, title: string, hash: string, createdAt: string, modifiedAt: string) => number;
351
353
  findActiveDocument: (collectionName: string, path: string) => {
352
354
  id: number;
353
355
  hash: string;
@@ -367,6 +369,7 @@ export type Store = {
367
369
  body: string;
368
370
  path: string;
369
371
  }[];
372
+ insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, totalChunks?: number, fingerprint?: string, lease?: EmbeddingBuildLease) => void;
370
373
  };
371
374
  export type ReindexProgress = {
372
375
  file: string;
@@ -385,6 +388,8 @@ export type ReindexResult = {
385
388
  orphanedCleaned: number;
386
389
  skipped: number;
387
390
  skippedFiles: ReindexSkippedFile[];
391
+ /** Documents whose qmd.metadata frontmatter failed extraction this pass. */
392
+ metadataErrors: number;
388
393
  };
389
394
  /**
390
395
  * Re-index a single collection by scanning the filesystem and updating the database.
@@ -501,6 +506,7 @@ export declare function handelize(path: string): string;
501
506
  * Search result extends DocumentResult with score and source info
502
507
  */
503
508
  export type SearchResult = DocumentResult & {
509
+ metadata: DocumentMetadata;
504
510
  score: number;
505
511
  source: "fts" | "vec";
506
512
  chunkPos?: number;
@@ -612,6 +618,8 @@ export type IndexStatus = {
612
618
  totalDocuments: number;
613
619
  needsEmbedding: number;
614
620
  hasVectorIndex: boolean;
621
+ /** Active documents without current, error-free metadata extraction. */
622
+ pendingMetadata: number;
615
623
  collections: CollectionInfo[];
616
624
  /** Additive diagnostics populated by high-level composition roots. */
617
625
  diagnostics?: IndexDiagnostics;
@@ -707,10 +715,11 @@ export declare function extractTitle(content: string, filename: string): string;
707
715
  export declare function insertContent(db: Database, hash: string, content: string, createdAt: string): void;
708
716
  /**
709
717
  * Insert a new document into the documents table.
718
+ * Returns the document's id so callers can attach document-scoped state.
710
719
  */
711
- export declare function insertDocument(db: Database, collectionName: string, path: string, title: string, hash: string, createdAt: string, modifiedAt: string): void;
720
+ export declare function insertDocument(db: Database, collectionName: string, path: string, title: string, hash: string, createdAt: string, modifiedAt: string): number;
712
721
  /** Insert immutable content and its document row in the same lexical transaction. */
713
- export declare function insertDocumentWithContent(db: Database, hash: string, content: string, contentCreatedAt: string, collectionName: string, path: string, title: string, documentCreatedAt: string, modifiedAt: string): void;
722
+ export declare function insertDocumentWithContent(db: Database, hash: string, content: string, contentCreatedAt: string, collectionName: string, path: string, title: string, documentCreatedAt: string, modifiedAt: string): number;
714
723
  /**
715
724
  * Find an active document by collection name and path.
716
725
  */
@@ -778,13 +787,6 @@ export declare function chunkDocumentAsync(content: string, maxChars?: number, o
778
787
  text: string;
779
788
  pos: number;
780
789
  }[]>;
781
- /**
782
- * Chunk a document by actual token count using the LLM tokenizer.
783
- * More accurate than character-based chunking but requires async.
784
- *
785
- * When filepath and chunkStrategy are provided, uses AST-aware break points
786
- * for supported code files.
787
- */
788
790
  export declare function chunkDocumentByTokens(content: string, maxTokens?: number, overlapTokens?: number, windowTokens?: number, filepath?: string, chunkStrategy?: ChunkStrategy, signal?: AbortSignal): Promise<{
789
791
  text: string;
790
792
  pos: number;
@@ -929,9 +931,9 @@ export declare const CJK_LEXICAL_CANDIDATE_DEPTH = 60;
929
931
  export declare const CJK_LEXICAL_RRF_WEIGHTS: Readonly<Record<CjkLexicalChannel, number>>;
930
932
  export type CollectionFilter = string | readonly string[];
931
933
  export type CollectionScope = string | readonly string[] | undefined;
932
- export declare function searchCharFTS(db: Database, query: string, limit?: number, collectionFilter?: CollectionFilter): SearchResult[];
933
- export declare function searchFTS(db: Database, query: string, limit?: number, collectionFilter?: CollectionFilter): SearchResult[];
934
- export declare function searchVec(db: Database, query: string, model: string, limit?: number, collectionFilter?: CollectionFilter, session?: ILLMSession, precomputedEmbedding?: number[], provider?: EmbeddingProvider, authorizeRemoteRequest?: Store["authorizeRemoteRequest"], llmOverride?: LLM): Promise<SearchResult[]>;
934
+ export declare function searchCharFTS(db: Database, query: string, limit?: number, collectionFilter?: CollectionFilter, filter?: MetadataFilter): SearchResult[];
935
+ export declare function searchFTS(db: Database, query: string, limit?: number, collectionFilter?: CollectionScope, filter?: MetadataFilter): SearchResult[];
936
+ export declare function searchVec(db: Database, query: string, model: string, limit?: number, collectionFilter?: CollectionFilter, session?: ILLMSession, precomputedEmbedding?: number[], providerOrLlm?: EmbeddingProvider | LLM, authorizeRemoteRequestOrFilter?: Store["authorizeRemoteRequest"] | MetadataFilter, llmOverride?: LLM, metadataFilter?: MetadataFilter): Promise<SearchResult[]>;
935
937
  /**
936
938
  * Get all unique content hashes that need embeddings (from active documents).
937
939
  * Returns hash, document body, and a sample path for display purposes.
@@ -1104,6 +1106,7 @@ export type ExpansionErrorEvent = {
1104
1106
  export interface HybridQueryOptions {
1105
1107
  collection?: string | readonly string[];
1106
1108
  collections?: readonly string[];
1109
+ filter?: MetadataFilter;
1107
1110
  limit?: number;
1108
1111
  minScore?: number;
1109
1112
  candidateLimit?: number;
@@ -1128,6 +1131,7 @@ export interface HybridQueryResult {
1128
1131
  score: number;
1129
1132
  context: string | null;
1130
1133
  docid: string;
1134
+ metadata: DocumentMetadata;
1131
1135
  explain?: HybridQueryExplain;
1132
1136
  }
1133
1137
  export type RankedListMeta = {
@@ -1163,6 +1167,7 @@ export declare function getHybridRrfWeights(rankedListMeta: RankedListMeta[]): n
1163
1167
  export declare function hybridQuery(store: Store, query: string, options?: HybridQueryOptions): Promise<HybridQueryResult[]>;
1164
1168
  export interface VectorSearchOptions {
1165
1169
  collection?: CollectionFilter;
1170
+ filter?: MetadataFilter;
1166
1171
  limit?: number;
1167
1172
  minScore?: number;
1168
1173
  /** Additional context used only while generating query expansions. */
@@ -1179,6 +1184,7 @@ export interface VectorSearchResult {
1179
1184
  score: number;
1180
1185
  context: string | null;
1181
1186
  docid: string;
1187
+ metadata: DocumentMetadata;
1182
1188
  }
1183
1189
  /**
1184
1190
  * Vector-only semantic search with query expansion.
@@ -1196,6 +1202,7 @@ export declare function vectorSearchQuery(store: Store, query: string, options?:
1196
1202
  */
1197
1203
  export interface StructuredSearchOptions {
1198
1204
  collections?: string[];
1205
+ filter?: MetadataFilter;
1199
1206
  limit?: number;
1200
1207
  minScore?: number;
1201
1208
  candidateLimit?: number;