@gmickel/gno 0.16.0 → 0.17.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 (37) hide show
  1. package/README.md +36 -1
  2. package/package.json +4 -1
  3. package/src/cli/commands/ask.ts +9 -0
  4. package/src/cli/commands/query.ts +3 -2
  5. package/src/cli/pager.ts +1 -1
  6. package/src/cli/program.ts +89 -0
  7. package/src/core/links.ts +92 -20
  8. package/src/ingestion/sync.ts +267 -23
  9. package/src/ingestion/types.ts +2 -0
  10. package/src/ingestion/walker.ts +2 -1
  11. package/src/mcp/tools/index.ts +30 -1
  12. package/src/mcp/tools/query.ts +22 -2
  13. package/src/mcp/tools/search.ts +8 -0
  14. package/src/mcp/tools/vsearch.ts +8 -0
  15. package/src/pipeline/answer.ts +324 -7
  16. package/src/pipeline/expansion.ts +243 -7
  17. package/src/pipeline/explain.ts +93 -5
  18. package/src/pipeline/hybrid.ts +240 -57
  19. package/src/pipeline/query-modes.ts +125 -0
  20. package/src/pipeline/rerank.ts +34 -13
  21. package/src/pipeline/search.ts +41 -3
  22. package/src/pipeline/temporal.ts +257 -0
  23. package/src/pipeline/types.ts +58 -0
  24. package/src/pipeline/vsearch.ts +107 -9
  25. package/src/serve/public/app.tsx +1 -3
  26. package/src/serve/public/globals.built.css +2 -2
  27. package/src/serve/public/lib/retrieval-filters.ts +167 -0
  28. package/src/serve/public/pages/Ask.tsx +339 -109
  29. package/src/serve/public/pages/Browse.tsx +71 -5
  30. package/src/serve/public/pages/DocView.tsx +2 -21
  31. package/src/serve/public/pages/Search.tsx +507 -120
  32. package/src/serve/routes/api.ts +202 -2
  33. package/src/store/migrations/006-document-metadata.ts +104 -0
  34. package/src/store/migrations/007-document-date-fields.ts +24 -0
  35. package/src/store/migrations/index.ts +3 -1
  36. package/src/store/sqlite/adapter.ts +218 -5
  37. package/src/store/types.ts +46 -0
@@ -6,7 +6,12 @@
6
6
  */
7
7
 
8
8
  import type { Config, ModelPreset } from "../../config/types";
9
- import type { AskResult, Citation, SearchOptions } from "../../pipeline/types";
9
+ import type {
10
+ AskResult,
11
+ Citation,
12
+ QueryModeInput,
13
+ SearchOptions,
14
+ } from "../../pipeline/types";
10
15
  import type { SqliteAdapter } from "../../store/sqlite/adapter";
11
16
  import type { EmbedScheduler } from "../embed-scheduler";
12
17
 
@@ -61,6 +66,11 @@ export interface SearchRequestBody {
61
66
  limit?: number;
62
67
  minScore?: number;
63
68
  collection?: string;
69
+ since?: string;
70
+ until?: string;
71
+ /** Comma-separated category filters */
72
+ category?: string;
73
+ author?: string;
64
74
  /** Comma-separated tags - filter to docs having ALL (AND) */
65
75
  tagsAll?: string;
66
76
  /** Comma-separated tags - filter to docs having ANY (OR) */
@@ -73,6 +83,12 @@ export interface QueryRequestBody {
73
83
  minScore?: number;
74
84
  collection?: string;
75
85
  lang?: string;
86
+ since?: string;
87
+ until?: string;
88
+ /** Comma-separated category filters */
89
+ category?: string;
90
+ author?: string;
91
+ queryModes?: QueryModeInput[];
76
92
  noExpand?: boolean;
77
93
  noRerank?: boolean;
78
94
  /** Comma-separated tags - filter to docs having ALL (AND) */
@@ -86,6 +102,11 @@ export interface AskRequestBody {
86
102
  limit?: number;
87
103
  collection?: string;
88
104
  lang?: string;
105
+ since?: string;
106
+ until?: string;
107
+ /** Comma-separated category filters */
108
+ category?: string;
109
+ author?: string;
89
110
  maxAnswerTokens?: number;
90
111
  noExpand?: boolean;
91
112
  noRerank?: boolean;
@@ -137,6 +158,17 @@ function errorResponse(code: string, message: string, status = 400): Response {
137
158
  return jsonResponse({ error: { code, message } }, status);
138
159
  }
139
160
 
161
+ function parseCommaSeparatedValues(input: string): string[] {
162
+ return Array.from(
163
+ new Set(
164
+ input
165
+ .split(",")
166
+ .map((value) => value.trim().toLowerCase())
167
+ .filter(Boolean)
168
+ )
169
+ );
170
+ }
171
+
140
172
  // ─────────────────────────────────────────────────────────────────────────────
141
173
  // Route Handlers
142
174
  // ─────────────────────────────────────────────────────────────────────────────
@@ -431,6 +463,12 @@ export async function handleDocs(
431
463
  url: URL
432
464
  ): Promise<Response> {
433
465
  const collection = url.searchParams.get("collection") || undefined;
466
+ const sortFieldRaw = (url.searchParams.get("sortField") ?? "modified")
467
+ .trim()
468
+ .toLowerCase();
469
+ const sortOrderRaw = (url.searchParams.get("sortOrder") ?? "desc")
470
+ .trim()
471
+ .toLowerCase();
434
472
 
435
473
  // Validate limit: positive integer, max 100
436
474
  const limitParam = Number(url.searchParams.get("limit"));
@@ -452,6 +490,18 @@ export async function handleDocs(
452
490
  }
453
491
  const offset = offsetParam || 0;
454
492
 
493
+ if (sortFieldRaw !== "modified" && !/^[a-z0-9_]+$/.test(sortFieldRaw)) {
494
+ return errorResponse(
495
+ "VALIDATION",
496
+ "sortField must be 'modified' or a lowercase frontmatter date key"
497
+ );
498
+ }
499
+
500
+ if (sortOrderRaw !== "asc" && sortOrderRaw !== "desc") {
501
+ return errorResponse("VALIDATION", "sortOrder must be 'asc' or 'desc'");
502
+ }
503
+ const sortOrder: "asc" | "desc" = sortOrderRaw === "asc" ? "asc" : "desc";
504
+
455
505
  // Parse tag filters
456
506
  let tagsAll: string[] | undefined;
457
507
  let tagsAny: string[] | undefined;
@@ -480,12 +530,30 @@ export async function handleDocs(
480
530
  }
481
531
  }
482
532
 
533
+ const dateFieldsResult = await store.getCollectionDateFields(collection);
534
+ if (!dateFieldsResult.ok) {
535
+ return errorResponse("RUNTIME", dateFieldsResult.error.message, 500);
536
+ }
537
+ const availableDateFields = dateFieldsResult.value;
538
+
539
+ if (
540
+ sortFieldRaw !== "modified" &&
541
+ !availableDateFields.includes(sortFieldRaw)
542
+ ) {
543
+ return errorResponse(
544
+ "VALIDATION",
545
+ `Unknown sortField: ${sortFieldRaw} for current collection`
546
+ );
547
+ }
548
+
483
549
  const result = await store.listDocumentsPaginated({
484
550
  collection,
485
551
  limit,
486
552
  offset,
487
553
  tagsAll,
488
554
  tagsAny,
555
+ sortField: sortFieldRaw,
556
+ sortOrder,
489
557
  });
490
558
 
491
559
  if (!result.ok) {
@@ -508,6 +576,9 @@ export async function handleDocs(
508
576
  total,
509
577
  limit,
510
578
  offset,
579
+ availableDateFields,
580
+ sortField: sortFieldRaw,
581
+ sortOrder,
511
582
  });
512
583
  }
513
584
 
@@ -1016,6 +1087,22 @@ export async function handleSearch(
1016
1087
  );
1017
1088
  }
1018
1089
 
1090
+ if (body.since !== undefined && typeof body.since !== "string") {
1091
+ return errorResponse("VALIDATION", "since must be a string");
1092
+ }
1093
+ if (body.until !== undefined && typeof body.until !== "string") {
1094
+ return errorResponse("VALIDATION", "until must be a string");
1095
+ }
1096
+ if (body.category !== undefined && typeof body.category !== "string") {
1097
+ return errorResponse(
1098
+ "VALIDATION",
1099
+ "category must be a comma-separated string"
1100
+ );
1101
+ }
1102
+ if (body.author !== undefined && typeof body.author !== "string") {
1103
+ return errorResponse("VALIDATION", "author must be a string");
1104
+ }
1105
+
1019
1106
  // Parse tag filters
1020
1107
  let tagsAll: string[] | undefined;
1021
1108
  let tagsAny: string[] | undefined;
@@ -1042,6 +1129,11 @@ export async function handleSearch(
1042
1129
  }
1043
1130
  }
1044
1131
 
1132
+ const categories = body.category
1133
+ ? parseCommaSeparatedValues(body.category)
1134
+ : undefined;
1135
+ const author = body.author?.trim() || undefined;
1136
+
1045
1137
  // Only BM25 supported in web UI (vector/hybrid require LLM ports)
1046
1138
  const options: SearchOptions = {
1047
1139
  limit: Math.min(body.limit || 10, 50),
@@ -1049,6 +1141,10 @@ export async function handleSearch(
1049
1141
  collection: body.collection,
1050
1142
  tagsAll,
1051
1143
  tagsAny,
1144
+ since: body.since,
1145
+ until: body.until,
1146
+ categories,
1147
+ author,
1052
1148
  };
1053
1149
 
1054
1150
  const result = await searchBm25(store, query, options);
@@ -1062,7 +1158,7 @@ export async function handleSearch(
1062
1158
 
1063
1159
  /**
1064
1160
  * POST /api/query
1065
- * Body: { query, limit?, minScore?, collection?, lang?, noExpand?, noRerank? }
1161
+ * Body: { query, limit?, minScore?, collection?, lang?, queryModes?, noExpand?, noRerank? }
1066
1162
  * Returns hybrid search results (BM25 + vector + expansion + reranking).
1067
1163
  */
1068
1164
  export async function handleQuery(
@@ -1106,6 +1202,72 @@ export async function handleQuery(
1106
1202
  );
1107
1203
  }
1108
1204
 
1205
+ if (body.since !== undefined && typeof body.since !== "string") {
1206
+ return errorResponse("VALIDATION", "since must be a string");
1207
+ }
1208
+ if (body.until !== undefined && typeof body.until !== "string") {
1209
+ return errorResponse("VALIDATION", "until must be a string");
1210
+ }
1211
+ if (body.category !== undefined && typeof body.category !== "string") {
1212
+ return errorResponse(
1213
+ "VALIDATION",
1214
+ "category must be a comma-separated string"
1215
+ );
1216
+ }
1217
+ if (body.author !== undefined && typeof body.author !== "string") {
1218
+ return errorResponse("VALIDATION", "author must be a string");
1219
+ }
1220
+
1221
+ // Validate queryModes
1222
+ let queryModes: QueryModeInput[] | undefined;
1223
+ if (body.queryModes !== undefined) {
1224
+ if (!Array.isArray(body.queryModes)) {
1225
+ return errorResponse(
1226
+ "VALIDATION",
1227
+ "queryModes must be an array of { mode, text } objects"
1228
+ );
1229
+ }
1230
+
1231
+ queryModes = [];
1232
+ let hydeCount = 0;
1233
+
1234
+ for (const [index, entry] of body.queryModes.entries()) {
1235
+ if (!entry || typeof entry !== "object") {
1236
+ return errorResponse(
1237
+ "VALIDATION",
1238
+ `queryModes[${index}] must be an object`
1239
+ );
1240
+ }
1241
+
1242
+ const mode = (entry as { mode?: unknown }).mode;
1243
+ const text = (entry as { text?: unknown }).text;
1244
+ if (mode !== "term" && mode !== "intent" && mode !== "hyde") {
1245
+ return errorResponse(
1246
+ "VALIDATION",
1247
+ `queryModes[${index}].mode must be one of: term, intent, hyde`
1248
+ );
1249
+ }
1250
+ if (typeof text !== "string" || !text.trim()) {
1251
+ return errorResponse(
1252
+ "VALIDATION",
1253
+ `queryModes[${index}].text must be a non-empty string`
1254
+ );
1255
+ }
1256
+
1257
+ if (mode === "hyde") {
1258
+ hydeCount += 1;
1259
+ if (hydeCount > 1) {
1260
+ return errorResponse(
1261
+ "VALIDATION",
1262
+ "Only one hyde mode is allowed in queryModes"
1263
+ );
1264
+ }
1265
+ }
1266
+
1267
+ queryModes.push({ mode, text: text.trim() });
1268
+ }
1269
+ }
1270
+
1109
1271
  // Parse tag filters
1110
1272
  let tagsAll: string[] | undefined;
1111
1273
  let tagsAny: string[] | undefined;
@@ -1132,6 +1294,11 @@ export async function handleQuery(
1132
1294
  }
1133
1295
  }
1134
1296
 
1297
+ const categories = body.category
1298
+ ? parseCommaSeparatedValues(body.category)
1299
+ : undefined;
1300
+ const author = body.author?.trim() || undefined;
1301
+
1135
1302
  const result = await searchHybrid(
1136
1303
  {
1137
1304
  store: ctx.store,
@@ -1147,10 +1314,15 @@ export async function handleQuery(
1147
1314
  minScore: body.minScore,
1148
1315
  collection: body.collection,
1149
1316
  lang: body.lang,
1317
+ queryModes,
1150
1318
  noExpand: body.noExpand,
1151
1319
  noRerank: body.noRerank,
1152
1320
  tagsAll,
1153
1321
  tagsAny,
1322
+ since: body.since,
1323
+ until: body.until,
1324
+ categories,
1325
+ author,
1154
1326
  }
1155
1327
  );
1156
1328
 
@@ -1199,6 +1371,22 @@ export async function handleAsk(
1199
1371
  let tagsAll: string[] | undefined;
1200
1372
  let tagsAny: string[] | undefined;
1201
1373
 
1374
+ if (body.since !== undefined && typeof body.since !== "string") {
1375
+ return errorResponse("VALIDATION", "since must be a string");
1376
+ }
1377
+ if (body.until !== undefined && typeof body.until !== "string") {
1378
+ return errorResponse("VALIDATION", "until must be a string");
1379
+ }
1380
+ if (body.category !== undefined && typeof body.category !== "string") {
1381
+ return errorResponse(
1382
+ "VALIDATION",
1383
+ "category must be a comma-separated string"
1384
+ );
1385
+ }
1386
+ if (body.author !== undefined && typeof body.author !== "string") {
1387
+ return errorResponse("VALIDATION", "author must be a string");
1388
+ }
1389
+
1202
1390
  if (body.tagsAll) {
1203
1391
  try {
1204
1392
  tagsAll = parseAndValidateTagFilter(body.tagsAll);
@@ -1221,6 +1409,11 @@ export async function handleAsk(
1221
1409
  }
1222
1410
  }
1223
1411
 
1412
+ const categories = body.category
1413
+ ? parseCommaSeparatedValues(body.category)
1414
+ : undefined;
1415
+ const author = body.author?.trim() || undefined;
1416
+
1224
1417
  const limit = Math.min(body.limit ?? 5, 20);
1225
1418
 
1226
1419
  // Run hybrid search first
@@ -1242,6 +1435,10 @@ export async function handleAsk(
1242
1435
  noRerank: body.noRerank,
1243
1436
  tagsAll,
1244
1437
  tagsAny,
1438
+ since: body.since,
1439
+ until: body.until,
1440
+ categories,
1441
+ author,
1245
1442
  }
1246
1443
  );
1247
1444
 
@@ -1254,6 +1451,7 @@ export async function handleAsk(
1254
1451
  // Generate grounded answer (requires genPort)
1255
1452
  let answer: string | undefined;
1256
1453
  let citations: Citation[] | undefined;
1454
+ let answerContext: AskResult["meta"]["answerContext"] | undefined;
1257
1455
  let answerGenerated = false;
1258
1456
 
1259
1457
  if (ctx.genPort) {
@@ -1269,6 +1467,7 @@ export async function handleAsk(
1269
1467
  const processed = processAnswerResult(rawResult);
1270
1468
  answer = processed.answer;
1271
1469
  citations = processed.citations;
1470
+ answerContext = processed.answerContext;
1272
1471
  answerGenerated = true;
1273
1472
  }
1274
1473
  }
@@ -1286,6 +1485,7 @@ export async function handleAsk(
1286
1485
  vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
1287
1486
  answerGenerated,
1288
1487
  totalResults: results.length,
1488
+ answerContext,
1289
1489
  },
1290
1490
  };
1291
1491
 
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Migration: document metadata fields for temporal/category/author filtering.
3
+ *
4
+ * @module src/store/migrations/006-document-metadata
5
+ */
6
+
7
+ import type { Database } from "bun:sqlite";
8
+
9
+ import type { Migration } from "./runner";
10
+
11
+ const TABLE_INFO_DOCUMENTS = "PRAGMA table_info(documents)";
12
+
13
+ interface TableInfoRow {
14
+ name: string;
15
+ }
16
+
17
+ function getDocumentColumns(db: Database): Set<string> {
18
+ const rows = db.query<TableInfoRow, []>(TABLE_INFO_DOCUMENTS).all();
19
+ return new Set(rows.map((row) => row.name));
20
+ }
21
+
22
+ function addColumnIfMissing(
23
+ db: Database,
24
+ columns: Set<string>,
25
+ column: string,
26
+ ddl: string
27
+ ): void {
28
+ if (columns.has(column)) {
29
+ return;
30
+ }
31
+
32
+ db.exec(ddl);
33
+ columns.add(column);
34
+ }
35
+
36
+ export const migration: Migration = {
37
+ version: 6,
38
+ name: "document_metadata",
39
+
40
+ up(db): void {
41
+ const columns = getDocumentColumns(db);
42
+
43
+ addColumnIfMissing(
44
+ db,
45
+ columns,
46
+ "source_ctime",
47
+ "ALTER TABLE documents ADD COLUMN source_ctime TEXT"
48
+ );
49
+ // SQLite rejects non-constant defaults in ALTER TABLE on older engines.
50
+ addColumnIfMissing(
51
+ db,
52
+ columns,
53
+ "indexed_at",
54
+ "ALTER TABLE documents ADD COLUMN indexed_at TEXT"
55
+ );
56
+ addColumnIfMissing(
57
+ db,
58
+ columns,
59
+ "content_type",
60
+ "ALTER TABLE documents ADD COLUMN content_type TEXT"
61
+ );
62
+ addColumnIfMissing(
63
+ db,
64
+ columns,
65
+ "categories",
66
+ "ALTER TABLE documents ADD COLUMN categories TEXT"
67
+ );
68
+ addColumnIfMissing(
69
+ db,
70
+ columns,
71
+ "author",
72
+ "ALTER TABLE documents ADD COLUMN author TEXT"
73
+ );
74
+ addColumnIfMissing(
75
+ db,
76
+ columns,
77
+ "frontmatter_date",
78
+ "ALTER TABLE documents ADD COLUMN frontmatter_date TEXT"
79
+ );
80
+
81
+ db.exec(`
82
+ UPDATE documents
83
+ SET indexed_at = datetime('now')
84
+ WHERE indexed_at IS NULL
85
+ `);
86
+
87
+ db.exec(
88
+ "CREATE INDEX IF NOT EXISTS idx_documents_source_mtime ON documents(source_mtime)"
89
+ );
90
+ db.exec(
91
+ "CREATE INDEX IF NOT EXISTS idx_documents_content_type ON documents(content_type)"
92
+ );
93
+ db.exec(
94
+ "CREATE INDEX IF NOT EXISTS idx_documents_author ON documents(author)"
95
+ );
96
+ },
97
+
98
+ down(db): void {
99
+ db.exec("DROP INDEX IF EXISTS idx_documents_author");
100
+ db.exec("DROP INDEX IF EXISTS idx_documents_content_type");
101
+ db.exec("DROP INDEX IF EXISTS idx_documents_source_mtime");
102
+ // SQLite cannot drop columns without table rebuild; keep columns on rollback.
103
+ },
104
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Migration: document frontmatter date field map.
3
+ *
4
+ * @module src/store/migrations/007-document-date-fields
5
+ */
6
+
7
+ import type { Database } from "bun:sqlite";
8
+
9
+ import type { Migration } from "./runner";
10
+
11
+ export const migration: Migration = {
12
+ version: 7,
13
+ name: "document_date_fields",
14
+
15
+ up(db: Database): void {
16
+ db.exec(`
17
+ ALTER TABLE documents ADD COLUMN date_fields TEXT
18
+ `);
19
+ },
20
+
21
+ down(): void {
22
+ // SQLite cannot drop columns without table rebuild; keep column on rollback.
23
+ },
24
+ };
@@ -19,6 +19,8 @@ import { migration as m002 } from "./002-documents-fts";
19
19
  import { migration as m003 } from "./003-doc-tags";
20
20
  import { migration as m004 } from "./004-doc-links";
21
21
  import { migration as m005 } from "./005-graph-indexes";
22
+ import { migration as m006 } from "./006-document-metadata";
23
+ import { migration as m007 } from "./007-document-date-fields";
22
24
 
23
25
  /** All migrations in order */
24
- export const migrations = [m001, m002, m003, m004, m005];
26
+ export const migrations = [m001, m002, m003, m004, m005, m006, m007];