@gmickel/gno 0.16.0 → 0.18.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.
Files changed (43) hide show
  1. package/README.md +55 -2
  2. package/package.json +4 -1
  3. package/src/cli/commands/ask.ts +13 -0
  4. package/src/cli/commands/models/use.ts +1 -0
  5. package/src/cli/commands/query.ts +3 -2
  6. package/src/cli/pager.ts +1 -1
  7. package/src/cli/program.ts +107 -0
  8. package/src/config/types.ts +2 -0
  9. package/src/core/links.ts +92 -20
  10. package/src/ingestion/sync.ts +267 -23
  11. package/src/ingestion/types.ts +2 -0
  12. package/src/ingestion/walker.ts +2 -1
  13. package/src/llm/nodeLlamaCpp/generation.ts +3 -1
  14. package/src/llm/registry.ts +1 -0
  15. package/src/llm/types.ts +2 -0
  16. package/src/mcp/tools/index.ts +34 -1
  17. package/src/mcp/tools/query.ts +26 -2
  18. package/src/mcp/tools/search.ts +10 -0
  19. package/src/mcp/tools/vsearch.ts +10 -0
  20. package/src/pipeline/answer.ts +324 -7
  21. package/src/pipeline/expansion.ts +282 -11
  22. package/src/pipeline/explain.ts +93 -5
  23. package/src/pipeline/hybrid.ts +273 -70
  24. package/src/pipeline/intent.ts +152 -0
  25. package/src/pipeline/query-modes.ts +125 -0
  26. package/src/pipeline/rerank.ts +109 -51
  27. package/src/pipeline/search.ts +58 -4
  28. package/src/pipeline/temporal.ts +257 -0
  29. package/src/pipeline/types.ts +67 -0
  30. package/src/pipeline/vsearch.ts +121 -10
  31. package/src/serve/public/app.tsx +1 -3
  32. package/src/serve/public/globals.built.css +2 -2
  33. package/src/serve/public/lib/retrieval-filters.ts +174 -0
  34. package/src/serve/public/pages/Ask.tsx +378 -109
  35. package/src/serve/public/pages/Browse.tsx +71 -5
  36. package/src/serve/public/pages/DocView.tsx +2 -21
  37. package/src/serve/public/pages/Search.tsx +561 -120
  38. package/src/serve/routes/api.ts +247 -2
  39. package/src/store/migrations/006-document-metadata.ts +104 -0
  40. package/src/store/migrations/007-document-date-fields.ts +24 -0
  41. package/src/store/migrations/index.ts +3 -1
  42. package/src/store/sqlite/adapter.ts +218 -5
  43. package/src/store/types.ts +46 -0
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Temporal range parsing helpers for retrieval filters.
3
+ *
4
+ * @module src/pipeline/temporal
5
+ */
6
+
7
+ const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
8
+ const RECENCY_SORT_RE =
9
+ /\b(latest|newest|most recent|recent|today|yesterday|this week|last week|this month|last month)\b/;
10
+
11
+ export interface TemporalRange {
12
+ since?: string;
13
+ until?: string;
14
+ }
15
+
16
+ type BoundKind = "since" | "until";
17
+
18
+ function startOfDay(d: Date): Date {
19
+ const out = new Date(d);
20
+ out.setUTCHours(0, 0, 0, 0);
21
+ return out;
22
+ }
23
+
24
+ function endOfDay(d: Date): Date {
25
+ const out = new Date(d);
26
+ out.setUTCHours(23, 59, 59, 999);
27
+ return out;
28
+ }
29
+
30
+ function startOfWeekUtc(d: Date): Date {
31
+ const out = startOfDay(d);
32
+ const day = out.getUTCDay(); // 0 = Sunday
33
+ const mondayOffset = day === 0 ? -6 : 1 - day;
34
+ out.setUTCDate(out.getUTCDate() + mondayOffset);
35
+ return out;
36
+ }
37
+
38
+ function endOfWeekUtc(d: Date): Date {
39
+ const start = startOfWeekUtc(d);
40
+ const out = new Date(start);
41
+ out.setUTCDate(out.getUTCDate() + 6);
42
+ return endOfDay(out);
43
+ }
44
+
45
+ function startOfMonthUtc(d: Date): Date {
46
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0));
47
+ }
48
+
49
+ function endOfMonthUtc(d: Date): Date {
50
+ return new Date(
51
+ Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0, 23, 59, 59, 999)
52
+ );
53
+ }
54
+
55
+ function normalizeParsedDate(value: string, kind: BoundKind): string | null {
56
+ const parsed = new Date(value);
57
+ if (Number.isNaN(parsed.getTime())) {
58
+ return null;
59
+ }
60
+
61
+ if (DATE_ONLY_RE.test(value)) {
62
+ return (
63
+ kind === "since" ? startOfDay(parsed) : endOfDay(parsed)
64
+ ).toISOString();
65
+ }
66
+
67
+ return parsed.toISOString();
68
+ }
69
+
70
+ function parseRelative(
71
+ value: string,
72
+ kind: BoundKind,
73
+ now: Date
74
+ ): string | null {
75
+ const v = value.trim().toLowerCase();
76
+
77
+ if (v === "today") {
78
+ return (kind === "since" ? startOfDay(now) : endOfDay(now)).toISOString();
79
+ }
80
+ if (v === "yesterday") {
81
+ const d = new Date(now);
82
+ d.setUTCDate(d.getUTCDate() - 1);
83
+ return (kind === "since" ? startOfDay(d) : endOfDay(d)).toISOString();
84
+ }
85
+ if (v === "this week") {
86
+ return (kind === "since" ? startOfWeekUtc(now) : now).toISOString();
87
+ }
88
+ if (v === "last week") {
89
+ const d = new Date(now);
90
+ d.setUTCDate(d.getUTCDate() - 7);
91
+ return (
92
+ kind === "since" ? startOfWeekUtc(d) : endOfWeekUtc(d)
93
+ ).toISOString();
94
+ }
95
+ if (v === "this month") {
96
+ return (kind === "since" ? startOfMonthUtc(now) : now).toISOString();
97
+ }
98
+ if (v === "last month") {
99
+ const d = new Date(now);
100
+ d.setUTCMonth(d.getUTCMonth() - 1);
101
+ return (
102
+ kind === "since" ? startOfMonthUtc(d) : endOfMonthUtc(d)
103
+ ).toISOString();
104
+ }
105
+ if (v === "recent") {
106
+ if (kind === "until") {
107
+ return now.toISOString();
108
+ }
109
+ const d = new Date(now);
110
+ d.setUTCDate(d.getUTCDate() - 30);
111
+ return d.toISOString();
112
+ }
113
+
114
+ return null;
115
+ }
116
+
117
+ function parseBound(
118
+ input: string | undefined,
119
+ kind: BoundKind,
120
+ now: Date
121
+ ): string | undefined {
122
+ if (!input) {
123
+ return undefined;
124
+ }
125
+ const relative = parseRelative(input, kind, now);
126
+ if (relative) {
127
+ return relative;
128
+ }
129
+ return normalizeParsedDate(input, kind) ?? undefined;
130
+ }
131
+
132
+ function inferFromQuery(query: string, now: Date): TemporalRange {
133
+ const q = query.toLowerCase();
134
+
135
+ if (/\btoday\b/.test(q)) {
136
+ return {
137
+ since: parseRelative("today", "since", now) ?? undefined,
138
+ until: parseRelative("today", "until", now) ?? undefined,
139
+ };
140
+ }
141
+ if (/\byesterday\b/.test(q)) {
142
+ return {
143
+ since: parseRelative("yesterday", "since", now) ?? undefined,
144
+ until: parseRelative("yesterday", "until", now) ?? undefined,
145
+ };
146
+ }
147
+ if (/\bthis week\b/.test(q)) {
148
+ return {
149
+ since: parseRelative("this week", "since", now) ?? undefined,
150
+ until: parseRelative("this week", "until", now) ?? undefined,
151
+ };
152
+ }
153
+ if (/\blast week\b/.test(q)) {
154
+ return {
155
+ since: parseRelative("last week", "since", now) ?? undefined,
156
+ until: parseRelative("last week", "until", now) ?? undefined,
157
+ };
158
+ }
159
+ if (/\bthis month\b/.test(q)) {
160
+ return {
161
+ since: parseRelative("this month", "since", now) ?? undefined,
162
+ until: parseRelative("this month", "until", now) ?? undefined,
163
+ };
164
+ }
165
+ if (/\blast month\b/.test(q)) {
166
+ return {
167
+ since: parseRelative("last month", "since", now) ?? undefined,
168
+ until: parseRelative("last month", "until", now) ?? undefined,
169
+ };
170
+ }
171
+ if (/\brecent\b/.test(q)) {
172
+ return {
173
+ since: parseRelative("recent", "since", now) ?? undefined,
174
+ until: parseRelative("recent", "until", now) ?? undefined,
175
+ };
176
+ }
177
+
178
+ return {};
179
+ }
180
+
181
+ /**
182
+ * Resolve temporal bounds from explicit flags or query text.
183
+ */
184
+ export function resolveTemporalRange(
185
+ query: string,
186
+ sinceInput?: string,
187
+ untilInput?: string,
188
+ now = new Date()
189
+ ): TemporalRange {
190
+ const since = parseBound(sinceInput, "since", now);
191
+ const until = parseBound(untilInput, "until", now);
192
+
193
+ if (since || until) {
194
+ return { since, until };
195
+ }
196
+
197
+ return inferFromQuery(query, now);
198
+ }
199
+
200
+ /**
201
+ * Return true when timestamp falls inside optional range.
202
+ */
203
+ export function isWithinTemporalRange(
204
+ timestamp: string | undefined,
205
+ range: TemporalRange
206
+ ): boolean {
207
+ if (!timestamp) {
208
+ return true;
209
+ }
210
+ const t = new Date(timestamp).getTime();
211
+ if (Number.isNaN(t)) {
212
+ return true;
213
+ }
214
+ if (range.since) {
215
+ const since = new Date(range.since).getTime();
216
+ if (!Number.isNaN(since) && t < since) {
217
+ return false;
218
+ }
219
+ }
220
+ if (range.until) {
221
+ const until = new Date(range.until).getTime();
222
+ if (!Number.isNaN(until) && t > until) {
223
+ return false;
224
+ }
225
+ }
226
+ return true;
227
+ }
228
+
229
+ /**
230
+ * Return true when query intent implies newest-first ordering.
231
+ */
232
+ export function shouldSortByRecency(query: string): boolean {
233
+ return RECENCY_SORT_RE.test(query.toLowerCase());
234
+ }
235
+
236
+ /**
237
+ * Prefer canonical doc date; fallback to source modified time.
238
+ * Returns 0 when neither value is valid.
239
+ */
240
+ export function resolveRecencyTimestamp(
241
+ docDate?: string | null,
242
+ sourceModifiedAt?: string | null
243
+ ): number {
244
+ if (docDate) {
245
+ const parsed = new Date(docDate).getTime();
246
+ if (!Number.isNaN(parsed)) {
247
+ return parsed;
248
+ }
249
+ }
250
+ if (sourceModifiedAt) {
251
+ const parsed = new Date(sourceModifiedAt).getTime();
252
+ if (!Number.isNaN(parsed)) {
253
+ return parsed;
254
+ }
255
+ }
256
+ return 0;
257
+ }
@@ -18,6 +18,7 @@ export interface SearchResultSource {
18
18
  mime: string;
19
19
  ext: string;
20
20
  modifiedAt?: string;
21
+ documentDate?: string;
21
22
  sizeBytes?: number;
22
23
  sourceHash?: string;
23
24
  }
@@ -61,10 +62,23 @@ export interface SearchMeta {
61
62
  reranked?: boolean;
62
63
  vectorsUsed?: boolean;
63
64
  totalResults: number;
65
+ intent?: string;
64
66
  collection?: string;
65
67
  lang?: string;
66
68
  /** Detected/overridden query language for prompt selection (typically BCP-47; may be user-provided via --lang) */
67
69
  queryLanguage?: string;
70
+ /** Summary of structured query modes applied (if provided) */
71
+ queryModes?: QueryModeSummary;
72
+ /** Temporal filter lower bound (ISO 8601) */
73
+ since?: string;
74
+ /** Temporal filter upper bound (ISO 8601) */
75
+ until?: string;
76
+ /** Category filters applied */
77
+ categories?: string[];
78
+ /** Author filter applied */
79
+ author?: string;
80
+ /** Rerank candidate limit used */
81
+ candidateLimit?: number;
68
82
  /** Explain data (when --explain is used) */
69
83
  explain?: {
70
84
  lines: ExplainLine[];
@@ -100,6 +114,32 @@ export interface SearchOptions {
100
114
  tagsAll?: string[];
101
115
  /** Filter to docs with ANY of these tags (OR) */
102
116
  tagsAny?: string[];
117
+ /** Filter by modified time lower bound (ISO 8601 or relative token) */
118
+ since?: string;
119
+ /** Filter by modified time upper bound (ISO 8601 or relative token) */
120
+ until?: string;
121
+ /** Filter to docs matching ANY category */
122
+ categories?: string[];
123
+ /** Filter by author value */
124
+ author?: string;
125
+ /** Optional disambiguating context that steers scoring/snippets, but is not searched directly */
126
+ intent?: string;
127
+ }
128
+
129
+ /** Structured query mode identifier */
130
+ export type QueryMode = "term" | "intent" | "hyde";
131
+
132
+ /** Structured query mode entry */
133
+ export interface QueryModeInput {
134
+ mode: QueryMode;
135
+ text: string;
136
+ }
137
+
138
+ /** Structured query mode summary for metadata/explain */
139
+ export interface QueryModeSummary {
140
+ term: number;
141
+ intent: number;
142
+ hyde: boolean;
103
143
  }
104
144
 
105
145
  /** Options for hybrid search (gno query) */
@@ -108,6 +148,10 @@ export type HybridSearchOptions = SearchOptions & {
108
148
  noExpand?: boolean;
109
149
  /** Disable reranking */
110
150
  noRerank?: boolean;
151
+ /** Optional structured mode entries; when set, used as expansion inputs */
152
+ queryModes?: QueryModeInput[];
153
+ /** Max candidates passed to reranking */
154
+ candidateLimit?: number;
111
155
  /** Enable explain output */
112
156
  explain?: boolean;
113
157
  /** Language hint for prompt selection (does NOT filter retrieval, only affects expansion prompts) */
@@ -247,13 +291,35 @@ export interface Citation {
247
291
  endLine?: number;
248
292
  }
249
293
 
294
+ /** Source selection entry for answer-generation explain */
295
+ export interface AnswerContextEntry {
296
+ docid: string;
297
+ uri: string;
298
+ score: number;
299
+ queryTokenHits: number;
300
+ facetHits: number;
301
+ reason: string;
302
+ }
303
+
304
+ /** Answer-generation context selection explain payload */
305
+ export interface AnswerContextExplain {
306
+ strategy: "adaptive_coverage_v1";
307
+ targetSources: number;
308
+ facets: string[];
309
+ selected: AnswerContextEntry[];
310
+ dropped: AnswerContextEntry[];
311
+ }
312
+
250
313
  /** Ask result metadata */
251
314
  export interface AskMeta {
252
315
  expanded: boolean;
253
316
  reranked: boolean;
254
317
  vectorsUsed: boolean;
318
+ intent?: string;
319
+ candidateLimit?: number;
255
320
  answerGenerated?: boolean;
256
321
  totalResults?: number;
322
+ answerContext?: AnswerContextExplain;
257
323
  }
258
324
 
259
325
  /** Ask command result */
@@ -323,6 +389,7 @@ export interface ExplainResult {
323
389
  rank: number;
324
390
  docid: string;
325
391
  score: number;
392
+ fusionScore?: number;
326
393
  bm25Score?: number;
327
394
  vecScore?: number;
328
395
  rerankScore?: number;
@@ -14,7 +14,15 @@ import type { SearchOptions, SearchResult, SearchResults } from "./types";
14
14
  import { err, ok } from "../store/types";
15
15
  import { createChunkLookup } from "./chunk-lookup";
16
16
  import { formatQueryForEmbedding } from "./contextual";
17
+ import { selectBestChunkForSteering } from "./intent";
17
18
  import { detectQueryLanguage } from "./query-language";
19
+ import {
20
+ resolveRecencyTimestamp,
21
+ isWithinTemporalRange,
22
+ resolveTemporalRange,
23
+ shouldSortByRecency,
24
+ type TemporalRange,
25
+ } from "./temporal";
18
26
 
19
27
  // ─────────────────────────────────────────────────────────────────────────────
20
28
  // Score Normalization
@@ -58,6 +66,13 @@ export async function searchVectorWithEmbedding(
58
66
  const { store, vectorIndex } = deps;
59
67
  const limit = options.limit ?? 20;
60
68
  const minScore = options.minScore ?? 0;
69
+ const recencySort = shouldSortByRecency(query);
70
+ const retrievalLimit = recencySort ? limit * 3 : limit;
71
+ const temporalRange = resolveTemporalRange(
72
+ query,
73
+ options.since,
74
+ options.until
75
+ );
61
76
 
62
77
  // Detect query language for metadata (DOES NOT affect retrieval filtering)
63
78
  const detection = detectQueryLanguage(query);
@@ -72,15 +87,20 @@ export async function searchVectorWithEmbedding(
72
87
  }
73
88
 
74
89
  // Search nearest neighbors
75
- const searchResult = await vectorIndex.searchNearest(queryEmbedding, limit, {
76
- minScore,
77
- });
90
+ const searchResult = await vectorIndex.searchNearest(
91
+ queryEmbedding,
92
+ retrievalLimit,
93
+ {
94
+ minScore,
95
+ }
96
+ );
78
97
 
79
98
  if (!searchResult.ok) {
80
99
  return err("QUERY_FAILED", searchResult.error.message);
81
100
  }
82
101
 
83
102
  const vecResults = searchResult.value;
103
+ const uniqueHashes = [...new Set(vecResults.map((v) => v.mirrorHash))];
84
104
 
85
105
  // Get collection paths for absPath resolution
86
106
  const collectionsResult = await store.getCollections();
@@ -96,10 +116,14 @@ export async function searchVectorWithEmbedding(
96
116
  collection: options.collection,
97
117
  tagsAll: options.tagsAll,
98
118
  tagsAny: options.tagsAny,
119
+ since: temporalRange.since,
120
+ until: temporalRange.until,
121
+ categories: options.categories,
122
+ author: options.author,
123
+ mirrorHashes: uniqueHashes,
99
124
  });
100
125
 
101
126
  // Pre-fetch all chunks in one batch query (eliminates N+1)
102
- const uniqueHashes = [...new Set(vecResults.map((v) => v.mirrorHash))];
103
127
  const chunksMapResult = await store.getChunksBatch(uniqueHashes);
104
128
  if (!chunksMapResult.ok) {
105
129
  return err("QUERY_FAILED", chunksMapResult.error.message);
@@ -123,7 +147,18 @@ export async function searchVectorWithEmbedding(
123
147
  }
124
148
 
125
149
  // Get chunk via O(1) lookup
126
- const chunk = getChunk(vec.mirrorHash, vec.seq);
150
+ const rawChunk = getChunk(vec.mirrorHash, vec.seq);
151
+ const chunk = options.intent
152
+ ? (selectBestChunkForSteering(
153
+ chunksMap.get(vec.mirrorHash) ?? [],
154
+ query,
155
+ options.intent,
156
+ {
157
+ preferredSeq: rawChunk?.seq ?? vec.seq,
158
+ intentWeight: 0.3,
159
+ }
160
+ ) ?? rawChunk)
161
+ : rawChunk;
127
162
  if (!chunk) {
128
163
  continue;
129
164
  }
@@ -178,6 +213,7 @@ export async function searchVectorWithEmbedding(
178
213
  mime: doc.sourceMime,
179
214
  ext: doc.sourceExt,
180
215
  modifiedAt: doc.sourceMtime,
216
+ documentDate: doc.frontmatterDate ?? undefined,
181
217
  sizeBytes: doc.sourceSize,
182
218
  sourceHash: doc.sourceHash,
183
219
  },
@@ -223,6 +259,7 @@ export async function searchVectorWithEmbedding(
223
259
  mime: doc.sourceMime,
224
260
  ext: doc.sourceExt,
225
261
  modifiedAt: doc.sourceMtime,
262
+ documentDate: doc.frontmatterDate ?? undefined,
226
263
  sizeBytes: doc.sourceSize,
227
264
  sourceHash: doc.sourceHash,
228
265
  },
@@ -237,15 +274,39 @@ export async function searchVectorWithEmbedding(
237
274
  }
238
275
  }
239
276
 
277
+ if (recencySort) {
278
+ results.sort((a, b) => {
279
+ const aTs = resolveRecencyTimestamp(
280
+ a.source.documentDate,
281
+ a.source.modifiedAt
282
+ );
283
+ const bTs = resolveRecencyTimestamp(
284
+ b.source.documentDate,
285
+ b.source.modifiedAt
286
+ );
287
+ if (aTs !== bTs) {
288
+ return bTs - aTs;
289
+ }
290
+ return b.score - a.score;
291
+ });
292
+ }
293
+
294
+ const finalResults = results.slice(0, limit);
295
+
240
296
  return ok({
241
- results,
297
+ results: finalResults,
242
298
  meta: {
243
299
  query,
244
300
  mode: "vector",
245
301
  vectorsUsed: true,
246
- totalResults: results.length,
302
+ totalResults: finalResults.length,
303
+ intent: options.intent,
247
304
  collection: options.collection,
248
305
  lang: options.lang,
306
+ since: temporalRange.since,
307
+ until: temporalRange.until,
308
+ categories: options.categories,
309
+ author: options.author,
249
310
  queryLanguage,
250
311
  },
251
312
  });
@@ -305,6 +366,7 @@ interface DocumentInfo {
305
366
  sourceMime: string;
306
367
  sourceExt: string;
307
368
  sourceMtime: string;
369
+ frontmatterDate?: string | null;
308
370
  sourceSize: number;
309
371
  mirrorHash: string | null;
310
372
  converterId: string | null;
@@ -319,6 +381,25 @@ interface DocumentMapOptions {
319
381
  collection?: string;
320
382
  tagsAll?: string[];
321
383
  tagsAny?: string[];
384
+ since?: string;
385
+ until?: string;
386
+ categories?: string[];
387
+ author?: string;
388
+ mirrorHashes?: string[];
389
+ }
390
+
391
+ function matchesCategoryFilter(
392
+ doc: { contentType?: string | null; categories?: string[] | null },
393
+ categories?: string[]
394
+ ): boolean {
395
+ if (!categories || categories.length === 0) {
396
+ return true;
397
+ }
398
+ const allowed = new Set(categories.map((c) => c.toLowerCase()));
399
+ if (doc.contentType && allowed.has(doc.contentType.toLowerCase())) {
400
+ return true;
401
+ }
402
+ return (doc.categories ?? []).some((c) => allowed.has(c.toLowerCase()));
322
403
  }
323
404
 
324
405
  async function buildDocumentMap(
@@ -327,13 +408,29 @@ async function buildDocumentMap(
327
408
  ): Promise<Map<string, DocumentInfo>> {
328
409
  const result = new Map<string, DocumentInfo>();
329
410
 
330
- const docs = await store.listDocuments(options.collection);
411
+ if (options.mirrorHashes && options.mirrorHashes.length === 0) {
412
+ return result;
413
+ }
414
+
415
+ const docs = options.mirrorHashes
416
+ ? await store.getDocumentsByMirrorHashes(options.mirrorHashes, {
417
+ collection: options.collection,
418
+ activeOnly: true,
419
+ })
420
+ : await store.listDocuments(options.collection);
331
421
  if (!docs.ok) {
332
422
  return result;
333
423
  }
334
424
 
335
- // Filter active docs with mirrorHash
336
- const activeDocs = docs.value.filter((d) => d.mirrorHash && d.active);
425
+ // Filter docs with mirrorHash.
426
+ // listDocuments path still needs explicit active filter.
427
+ const activeDocs = options.mirrorHashes
428
+ ? docs.value.filter((d) => d.mirrorHash)
429
+ : docs.value.filter((d) => d.mirrorHash && d.active);
430
+ const temporalRange: TemporalRange = {
431
+ since: options.since,
432
+ until: options.until,
433
+ };
337
434
 
338
435
  // Apply tag filters if specified (batch fetch to avoid N+1)
339
436
  const needsTagFilter = options.tagsAll?.length || options.tagsAny?.length;
@@ -370,6 +467,19 @@ async function buildDocumentMap(
370
467
  }
371
468
 
372
469
  for (const doc of activeDocs) {
470
+ if (!isWithinTemporalRange(doc.sourceMtime, temporalRange)) {
471
+ continue;
472
+ }
473
+ if (!matchesCategoryFilter(doc, options.categories)) {
474
+ continue;
475
+ }
476
+ if (
477
+ options.author &&
478
+ !doc.author?.toLowerCase().includes(options.author.toLowerCase())
479
+ ) {
480
+ continue;
481
+ }
482
+
373
483
  // Skip if tag filter excluded this doc
374
484
  if (allowedDocIds !== null && !allowedDocIds.has(doc.id)) {
375
485
  continue;
@@ -385,6 +495,7 @@ async function buildDocumentMap(
385
495
  sourceMime: doc.sourceMime,
386
496
  sourceExt: doc.sourceExt,
387
497
  sourceMtime: doc.sourceMtime,
498
+ frontmatterDate: doc.frontmatterDate,
388
499
  sourceSize: doc.sourceSize,
389
500
  mirrorHash: doc.mirrorHash,
390
501
  converterId: doc.converterId,
@@ -58,8 +58,6 @@ function App() {
58
58
  }
59
59
  window.history.pushState({}, "", to);
60
60
  setLocation(to);
61
- // Dispatch event for components that need to react to URL changes
62
- window.dispatchEvent(new CustomEvent("locationchange", { detail: to }));
63
61
  }, []);
64
62
 
65
63
  // Global keyboard shortcuts (single-key, GitHub/Gmail pattern)
@@ -98,7 +96,7 @@ function App() {
98
96
  <CaptureModalProvider>
99
97
  <div className="flex min-h-screen flex-col">
100
98
  <div className="flex-1">
101
- <Page navigate={navigate} />
99
+ <Page key={location} navigate={navigate} />
102
100
  </div>
103
101
  <footer className="border-t border-border/50 bg-background/80 py-4 text-center text-muted-foreground text-sm">
104
102
  <div className="flex items-center justify-center gap-4">