@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.
- package/README.md +55 -2
- package/package.json +4 -1
- package/src/cli/commands/ask.ts +13 -0
- package/src/cli/commands/models/use.ts +1 -0
- package/src/cli/commands/query.ts +3 -2
- package/src/cli/pager.ts +1 -1
- package/src/cli/program.ts +107 -0
- package/src/config/types.ts +2 -0
- package/src/core/links.ts +92 -20
- package/src/ingestion/sync.ts +267 -23
- package/src/ingestion/types.ts +2 -0
- package/src/ingestion/walker.ts +2 -1
- package/src/llm/nodeLlamaCpp/generation.ts +3 -1
- package/src/llm/registry.ts +1 -0
- package/src/llm/types.ts +2 -0
- package/src/mcp/tools/index.ts +34 -1
- package/src/mcp/tools/query.ts +26 -2
- package/src/mcp/tools/search.ts +10 -0
- package/src/mcp/tools/vsearch.ts +10 -0
- package/src/pipeline/answer.ts +324 -7
- package/src/pipeline/expansion.ts +282 -11
- package/src/pipeline/explain.ts +93 -5
- package/src/pipeline/hybrid.ts +273 -70
- package/src/pipeline/intent.ts +152 -0
- package/src/pipeline/query-modes.ts +125 -0
- package/src/pipeline/rerank.ts +109 -51
- package/src/pipeline/search.ts +58 -4
- package/src/pipeline/temporal.ts +257 -0
- package/src/pipeline/types.ts +67 -0
- package/src/pipeline/vsearch.ts +121 -10
- package/src/serve/public/app.tsx +1 -3
- package/src/serve/public/globals.built.css +2 -2
- package/src/serve/public/lib/retrieval-filters.ts +174 -0
- package/src/serve/public/pages/Ask.tsx +378 -109
- package/src/serve/public/pages/Browse.tsx +71 -5
- package/src/serve/public/pages/DocView.tsx +2 -21
- package/src/serve/public/pages/Search.tsx +561 -120
- package/src/serve/routes/api.ts +247 -2
- package/src/store/migrations/006-document-metadata.ts +104 -0
- package/src/store/migrations/007-document-date-fields.ts +24 -0
- package/src/store/migrations/index.ts +3 -1
- package/src/store/sqlite/adapter.ts +218 -5
- 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
|
+
}
|
package/src/pipeline/types.ts
CHANGED
|
@@ -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;
|
package/src/pipeline/vsearch.ts
CHANGED
|
@@ -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(
|
|
76
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
336
|
-
|
|
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,
|
package/src/serve/public/app.tsx
CHANGED
|
@@ -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">
|