@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
@@ -372,16 +372,18 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
372
372
  `
373
373
  INSERT INTO documents (
374
374
  collection, rel_path, source_hash, source_mime, source_ext,
375
- source_size, source_mtime, docid, uri, title, mirror_hash,
376
- converter_id, converter_version, language_hint, active,
377
- last_error_code, last_error_message, last_error_at, ingest_version, updated_at
378
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, datetime('now'))
375
+ source_size, source_mtime, source_ctime, docid, uri, title, mirror_hash,
376
+ converter_id, converter_version, language_hint, content_type, categories,
377
+ author, frontmatter_date, date_fields, active, indexed_at, last_error_code,
378
+ last_error_message, last_error_at, ingest_version, updated_at
379
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'), ?, ?, ?, ?, datetime('now'))
379
380
  ON CONFLICT(collection, rel_path) DO UPDATE SET
380
381
  source_hash = excluded.source_hash,
381
382
  source_mime = excluded.source_mime,
382
383
  source_ext = excluded.source_ext,
383
384
  source_size = excluded.source_size,
384
385
  source_mtime = excluded.source_mtime,
386
+ source_ctime = excluded.source_ctime,
385
387
  docid = excluded.docid,
386
388
  uri = excluded.uri,
387
389
  title = excluded.title,
@@ -389,7 +391,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
389
391
  converter_id = excluded.converter_id,
390
392
  converter_version = excluded.converter_version,
391
393
  language_hint = excluded.language_hint,
394
+ content_type = excluded.content_type,
395
+ categories = excluded.categories,
396
+ author = excluded.author,
397
+ frontmatter_date = excluded.frontmatter_date,
398
+ date_fields = excluded.date_fields,
392
399
  active = 1,
400
+ indexed_at = datetime('now'),
393
401
  last_error_code = excluded.last_error_code,
394
402
  last_error_message = excluded.last_error_message,
395
403
  last_error_at = excluded.last_error_at,
@@ -404,6 +412,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
404
412
  doc.sourceExt,
405
413
  doc.sourceSize,
406
414
  doc.sourceMtime,
415
+ doc.sourceCtime ?? doc.sourceMtime,
407
416
  docid,
408
417
  uri,
409
418
  doc.title ?? null,
@@ -411,6 +420,11 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
411
420
  doc.converterId ?? null,
412
421
  doc.converterVersion ?? null,
413
422
  doc.languageHint ?? null,
423
+ doc.contentType ?? null,
424
+ doc.categories ? JSON.stringify(doc.categories) : null,
425
+ doc.author ?? null,
426
+ doc.frontmatterDate ?? null,
427
+ doc.dateFields ? JSON.stringify(doc.dateFields) : null,
414
428
  doc.lastErrorCode ?? null,
415
429
  doc.lastErrorMessage ?? null,
416
430
  doc.lastErrorCode ? new Date().toISOString() : null,
@@ -529,12 +543,74 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
529
543
  }
530
544
  }
531
545
 
546
+ async getDocumentsByMirrorHashes(
547
+ mirrorHashes: string[],
548
+ options: {
549
+ collection?: string;
550
+ activeOnly?: boolean;
551
+ } = {}
552
+ ): Promise<StoreResult<DocumentRow[]>> {
553
+ try {
554
+ if (mirrorHashes.length === 0) {
555
+ return ok([]);
556
+ }
557
+
558
+ const uniqueHashes = [
559
+ ...new Set(mirrorHashes.filter((hash) => hash.trim().length > 0)),
560
+ ];
561
+ if (uniqueHashes.length === 0) {
562
+ return ok([]);
563
+ }
564
+
565
+ const db = this.ensureOpen();
566
+ const rows: DbDocumentRow[] = [];
567
+
568
+ // SQLite SQLITE_LIMIT_VARIABLE_NUMBER defaults to 999.
569
+ // Reserve headroom for optional non-IN parameters.
570
+ const SQL_PARAM_LIMIT = options.collection ? 899 : 900;
571
+
572
+ for (let i = 0; i < uniqueHashes.length; i += SQL_PARAM_LIMIT) {
573
+ const batch = uniqueHashes.slice(i, i + SQL_PARAM_LIMIT);
574
+ const placeholders = batch.map(() => "?").join(",");
575
+ const clauses = [`mirror_hash IN (${placeholders})`];
576
+ const params: string[] = [...batch];
577
+
578
+ if (options.activeOnly ?? true) {
579
+ clauses.push("active = 1");
580
+ }
581
+ if (options.collection) {
582
+ clauses.push("collection = ?");
583
+ params.push(options.collection);
584
+ }
585
+
586
+ const sql = `SELECT * FROM documents WHERE ${clauses.join(" AND ")} ORDER BY id`;
587
+ rows.push(...db.query<DbDocumentRow, string[]>(sql).all(...params));
588
+ }
589
+
590
+ return ok(rows.map(mapDocumentRow));
591
+ } catch (cause) {
592
+ return err(
593
+ "QUERY_FAILED",
594
+ cause instanceof Error
595
+ ? cause.message
596
+ : "Failed to get documents by mirror hashes",
597
+ cause
598
+ );
599
+ }
600
+ }
601
+
532
602
  async listDocumentsPaginated(options: {
533
603
  collection?: string;
534
604
  limit: number;
535
605
  offset: number;
536
606
  tagsAll?: string[];
537
607
  tagsAny?: string[];
608
+ since?: string;
609
+ until?: string;
610
+ categories?: string[];
611
+ author?: string;
612
+ sortField?: string;
613
+ sortOrder?: "asc" | "desc";
538
614
  }): Promise<StoreResult<{ documents: DocumentRow[]; total: number }>> {
539
615
  try {
540
616
  const db = this.ensureOpen();
@@ -549,6 +625,28 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
549
625
  params.push(collection);
550
626
  }
551
627
 
628
+ if (options.since) {
629
+ conditions.push("d.source_mtime >= ?");
630
+ params.push(options.since);
631
+ }
632
+ if (options.until) {
633
+ conditions.push("d.source_mtime <= ?");
634
+ params.push(options.until);
635
+ }
636
+
637
+ if (options.categories && options.categories.length > 0) {
638
+ const placeholders = options.categories.map(() => "?").join(",");
639
+ conditions.push(
640
+ `(d.content_type IN (${placeholders}) OR EXISTS (SELECT 1 FROM json_each(COALESCE(d.categories, '[]')) jc WHERE jc.value IN (${placeholders})))`
641
+ );
642
+ params.push(...options.categories, ...options.categories);
643
+ }
644
+
645
+ if (options.author) {
646
+ conditions.push("LOWER(COALESCE(d.author, '')) LIKE ?");
647
+ params.push(`%${options.author.toLowerCase()}%`);
648
+ }
649
+
552
650
  // tagsAny: document has at least one of these tags (OR)
553
651
  if (tagsAny && tagsAny.length > 0) {
554
652
  const placeholders = tagsAny.map(() => "?").join(",");
@@ -570,6 +668,15 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
570
668
 
571
669
  const whereClause = conditions.join(" AND ");
572
670
 
671
+ // Sort options
672
+ const sortOrder = options.sortOrder === "asc" ? "ASC" : "DESC";
673
+ const sortField = options.sortField ?? "modified";
674
+ const isSafeDateField = /^[a-z0-9_]+$/.test(sortField);
675
+ let orderClause = `d.source_mtime ${sortOrder}`;
676
+ if (sortField !== "modified" && isSafeDateField) {
677
+ orderClause = `COALESCE(json_extract(d.date_fields, '$."${sortField}"'), d.source_mtime) ${sortOrder}`;
678
+ }
679
+
573
680
  // Get total count
574
681
  // Use COUNT(DISTINCT d.id) to prevent duplicate counting when tag filters match multiple tags
575
682
  const countSql = `SELECT COUNT(DISTINCT d.id) as count FROM documents d WHERE ${whereClause}`;
@@ -580,7 +687,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
580
687
 
581
688
  // Get paginated documents
582
689
  // Use DISTINCT to prevent duplicate rows when tag filters match multiple tags
583
- const selectSql = `SELECT DISTINCT d.* FROM documents d WHERE ${whereClause} ORDER BY d.updated_at DESC LIMIT ? OFFSET ?`;
690
+ const selectSql = `SELECT DISTINCT d.* FROM documents d WHERE ${whereClause} ORDER BY ${orderClause}, d.id ASC LIMIT ? OFFSET ?`;
584
691
  const rows = db
585
692
  .query<DbDocumentRow, (string | number)[]>(selectSql)
586
693
  .all(...params, limit, offset);
@@ -595,6 +702,43 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
595
702
  }
596
703
  }
597
704
 
705
+ async getCollectionDateFields(
706
+ collection?: string
707
+ ): Promise<StoreResult<string[]>> {
708
+ try {
709
+ const db = this.ensureOpen();
710
+ const conditions: string[] = [
711
+ "d.active = 1",
712
+ "d.date_fields IS NOT NULL",
713
+ ];
714
+ const params: string[] = [];
715
+
716
+ if (collection) {
717
+ conditions.push("d.collection = ?");
718
+ params.push(collection);
719
+ }
720
+
721
+ const sql = `
722
+ SELECT DISTINCT jf.key as field
723
+ FROM documents d
724
+ JOIN json_each(COALESCE(d.date_fields, '{}')) jf
725
+ WHERE ${conditions.join(" AND ")}
726
+ ORDER BY jf.key ASC
727
+ `;
728
+
729
+ const rows = db.query<{ field: string }, string[]>(sql).all(...params);
730
+ return ok(rows.map((r) => r.field));
731
+ } catch (cause) {
732
+ return err(
733
+ "QUERY_FAILED",
734
+ cause instanceof Error
735
+ ? cause.message
736
+ : "Failed to list collection date fields",
737
+ cause
738
+ );
739
+ }
740
+ }
741
+
598
742
  async markInactive(
599
743
  collection: string,
600
744
  relPaths: string[]
@@ -828,6 +972,26 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
828
972
  }
829
973
  }
830
974
 
975
+ if (options.since) {
976
+ tagConditions.push("d.source_mtime >= ?");
977
+ params.push(options.since);
978
+ }
979
+ if (options.until) {
980
+ tagConditions.push("d.source_mtime <= ?");
981
+ params.push(options.until);
982
+ }
983
+ if (options.categories && options.categories.length > 0) {
984
+ const placeholders = options.categories.map(() => "?").join(",");
985
+ tagConditions.push(
986
+ `(d.content_type IN (${placeholders}) OR EXISTS (SELECT 1 FROM json_each(COALESCE(d.categories, '[]')) jc WHERE jc.value IN (${placeholders})))`
987
+ );
988
+ params.push(...options.categories, ...options.categories);
989
+ }
990
+ if (options.author) {
991
+ tagConditions.push("LOWER(COALESCE(d.author, '')) LIKE ?");
992
+ params.push(`%${options.author.toLowerCase()}%`);
993
+ }
994
+
831
995
  if (options.collection) {
832
996
  params.push(options.collection);
833
997
  }
@@ -850,6 +1014,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
850
1014
  d.source_mime,
851
1015
  d.source_ext,
852
1016
  d.source_mtime,
1017
+ d.frontmatter_date,
853
1018
  d.source_size,
854
1019
  d.source_hash
855
1020
  FROM documents_fts fts
@@ -874,6 +1039,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
874
1039
  source_mime: string | null;
875
1040
  source_ext: string | null;
876
1041
  source_mtime: string | null;
1042
+ frontmatter_date: string | null;
877
1043
  source_size: number | null;
878
1044
  source_hash: string | null;
879
1045
  }
@@ -894,6 +1060,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
894
1060
  sourceMime: r.source_mime ?? undefined,
895
1061
  sourceExt: r.source_ext ?? undefined,
896
1062
  sourceMtime: r.source_mtime ?? undefined,
1063
+ frontmatterDate: r.frontmatter_date ?? undefined,
897
1064
  sourceSize: r.source_size ?? undefined,
898
1065
  sourceHash: r.source_hash ?? undefined,
899
1066
  }))
@@ -2632,6 +2799,7 @@ interface DbDocumentRow {
2632
2799
  source_ext: string;
2633
2800
  source_size: number;
2634
2801
  source_mtime: string;
2802
+ source_ctime: string | null;
2635
2803
  docid: string;
2636
2804
  uri: string;
2637
2805
  title: string | null;
@@ -2639,6 +2807,12 @@ interface DbDocumentRow {
2639
2807
  converter_id: string | null;
2640
2808
  converter_version: string | null;
2641
2809
  language_hint: string | null;
2810
+ content_type: string | null;
2811
+ categories: string | null;
2812
+ author: string | null;
2813
+ frontmatter_date: string | null;
2814
+ date_fields: string | null;
2815
+ indexed_at: string | null;
2642
2816
  active: number;
2643
2817
  ingest_version: number | null;
2644
2818
  last_error_code: string | null;
@@ -2697,6 +2871,38 @@ function mapContextRow(row: DbContextRow): ContextRow {
2697
2871
  }
2698
2872
 
2699
2873
  function mapDocumentRow(row: DbDocumentRow): DocumentRow {
2874
+ let categories: string[] | null = null;
2875
+ if (row.categories) {
2876
+ try {
2877
+ const parsed = JSON.parse(row.categories);
2878
+ if (Array.isArray(parsed)) {
2879
+ categories = parsed.filter((v): v is string => typeof v === "string");
2880
+ }
2881
+ } catch {
2882
+ categories = null;
2883
+ }
2884
+ }
2885
+
2886
+ let dateFields: Record<string, string> | null = null;
2887
+ if (row.date_fields) {
2888
+ try {
2889
+ const parsed = JSON.parse(row.date_fields);
2890
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2891
+ const normalized: Record<string, string> = {};
2892
+ for (const [key, value] of Object.entries(parsed)) {
2893
+ if (typeof value === "string") {
2894
+ normalized[key] = value;
2895
+ }
2896
+ }
2897
+ if (Object.keys(normalized).length > 0) {
2898
+ dateFields = normalized;
2899
+ }
2900
+ }
2901
+ } catch {
2902
+ dateFields = null;
2903
+ }
2904
+ }
2905
+
2700
2906
  return {
2701
2907
  id: row.id,
2702
2908
  collection: row.collection,
@@ -2706,6 +2912,7 @@ function mapDocumentRow(row: DbDocumentRow): DocumentRow {
2706
2912
  sourceExt: row.source_ext,
2707
2913
  sourceSize: row.source_size,
2708
2914
  sourceMtime: row.source_mtime,
2915
+ sourceCtime: row.source_ctime,
2709
2916
  docid: row.docid,
2710
2917
  uri: row.uri,
2711
2918
  title: row.title,
@@ -2713,6 +2920,12 @@ function mapDocumentRow(row: DbDocumentRow): DocumentRow {
2713
2920
  converterId: row.converter_id,
2714
2921
  converterVersion: row.converter_version,
2715
2922
  languageHint: row.language_hint,
2923
+ contentType: row.content_type,
2924
+ categories,
2925
+ author: row.author,
2926
+ frontmatterDate: row.frontmatter_date,
2927
+ dateFields,
2928
+ indexedAt: row.indexed_at,
2716
2929
  active: row.active === 1,
2717
2930
  ingestVersion: row.ingest_version,
2718
2931
  lastErrorCode: row.last_error_code,
@@ -95,6 +95,7 @@ export interface DocumentRow {
95
95
  sourceExt: string;
96
96
  sourceSize: number;
97
97
  sourceMtime: string;
98
+ sourceCtime?: string | null;
98
99
 
99
100
  // Derived identifiers
100
101
  docid: string;
@@ -106,6 +107,12 @@ export interface DocumentRow {
106
107
  converterId: string | null;
107
108
  converterVersion: string | null;
108
109
  languageHint: string | null;
110
+ contentType?: string | null;
111
+ categories?: string[] | null;
112
+ author?: string | null;
113
+ frontmatterDate?: string | null;
114
+ dateFields?: Record<string, string> | null;
115
+ indexedAt?: string | null;
109
116
 
110
117
  // Status
111
118
  active: boolean;
@@ -242,11 +249,17 @@ export interface DocumentInput {
242
249
  sourceExt: string;
243
250
  sourceSize: number;
244
251
  sourceMtime: string;
252
+ sourceCtime?: string;
245
253
  title?: string;
246
254
  mirrorHash?: string;
247
255
  converterId?: string;
248
256
  converterVersion?: string;
249
257
  languageHint?: string;
258
+ contentType?: string;
259
+ categories?: string[];
260
+ author?: string;
261
+ frontmatterDate?: string;
262
+ dateFields?: Record<string, string>;
250
263
  lastErrorCode?: string;
251
264
  lastErrorMessage?: string;
252
265
  /** Ingest schema version for backfill detection */
@@ -303,6 +316,14 @@ export interface FtsSearchOptions {
303
316
  tagsAny?: string[];
304
317
  /** Filter to docs with ALL of these tags */
305
318
  tagsAll?: string[];
319
+ /** Filter by modified time lower bound (ISO 8601) */
320
+ since?: string;
321
+ /** Filter by modified time upper bound (ISO 8601) */
322
+ until?: string;
323
+ /** Filter to docs matching ANY category */
324
+ categories?: string[];
325
+ /** Filter by author field (case-insensitive contains) */
326
+ author?: string;
306
327
  }
307
328
 
308
329
  /** Single FTS search result */
@@ -321,6 +342,7 @@ export interface FtsResult {
321
342
  sourceMime?: string;
322
343
  sourceExt?: string;
323
344
  sourceMtime?: string;
345
+ frontmatterDate?: string;
324
346
  sourceSize?: number;
325
347
  sourceHash?: string;
326
348
  }
@@ -599,6 +621,18 @@ export interface StorePort {
599
621
  */
600
622
  listDocuments(collection?: string): Promise<StoreResult<DocumentRow[]>>;
601
623
 
624
+ /**
625
+ * Fetch documents by mirror hashes in batch.
626
+ * Useful for retrieval pipelines to avoid full document scans.
627
+ */
628
+ getDocumentsByMirrorHashes(
629
+ mirrorHashes: string[],
630
+ options?: {
631
+ collection?: string;
632
+ activeOnly?: boolean;
633
+ }
634
+ ): Promise<StoreResult<DocumentRow[]>>;
635
+
602
636
  /**
603
637
  * List documents with pagination support.
604
638
  * Returns documents and total count for efficient browsing.
@@ -611,6 +645,18 @@ export interface StorePort {
611
645
  tagsAll?: string[];
612
646
  /** Filter to docs having ANY of these tags (OR) */
613
647
  tagsAny?: string[];
648
+ /** Filter by modified time lower bound (ISO 8601) */
649
+ since?: string;
650
+ /** Filter by modified time upper bound (ISO 8601) */
651
+ until?: string;
652
+ /** Filter to docs matching ANY category */
653
+ categories?: string[];
654
+ /** Filter by author field (case-insensitive contains) */
655
+ author?: string;
656
+ /** Sort field: "modified" or frontmatter date key */
657
+ sortField?: string;
658
+ /** Sort direction */
659
+ sortOrder?: "asc" | "desc";
614
660
  }): Promise<StoreResult<{ documents: DocumentRow[]; total: number }>>;
615
661
 
616
662
  /**