@gmickel/gno 0.35.0 → 0.37.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # GNO
2
2
 
3
- **Your Local Second Brain**: Index, search, and synthesize your entire digital life.
3
+ **Local search, retrieval, and synthesis for the files you actually work in.**
4
4
 
5
5
  [![npm](./assets/badges/npm.svg)](https://www.npmjs.com/package/@gmickel/gno)
6
6
  [![MIT License](./assets/badges/license.svg)](./LICENSE)
@@ -12,7 +12,57 @@
12
12
 
13
13
  ![GNO](./assets/og-image.png)
14
14
 
15
- GNO is a local knowledge engine that turns your documents into a searchable, connected knowledge graph. Index notes, code, PDFs, and Office docs. Get hybrid search, AI answers with citations, and wiki-style note linking—all 100% offline.
15
+ GNO is a local knowledge engine for notes, code, PDFs, Office docs, meeting transcripts, and reference material. It gives you fast keyword search, semantic retrieval, grounded answers with citations, wiki-style linking, and a real workspace UI, while keeping the whole stack local by default.
16
+
17
+ Use it when:
18
+
19
+ - your notes live in more than one folder
20
+ - your important knowledge is split across Markdown, code, PDFs, and Office files
21
+ - you want one retrieval layer that works from the CLI, browser, MCP, and a Bun/TypeScript SDK
22
+ - you want better local context for agents without shipping your docs to a cloud API
23
+
24
+ ### What GNO Gives You
25
+
26
+ - **Fast local search**: BM25 for exact hits, vectors for concepts, hybrid for best quality
27
+ - **Real retrieval surfaces**: CLI, Web UI, REST API, MCP, SDK
28
+ - **Local-first answers**: grounded synthesis with citations when you want answers, raw retrieval when you do not
29
+ - **Connected knowledge**: backlinks, related notes, graph view, cross-collection navigation
30
+ - **Operational fit**: daemon mode, model presets, remote GPU backends, safe config/state on disk
31
+
32
+ ### One-Minute Tour
33
+
34
+ ```bash
35
+ # Install
36
+ bun install -g @gmickel/gno
37
+
38
+ # Add a few collections
39
+ gno init ~/notes --name notes
40
+ gno collection add ~/work/docs --name work-docs --pattern "**/*.{md,pdf,docx}"
41
+ gno collection add ~/work/gno/src --name gno-code --pattern "**/*.{ts,tsx,js,jsx}"
42
+
43
+ # Add context so retrieval results come back with the right framing
44
+ gno context add "notes:" "Personal notes, journal entries, and long-form ideas"
45
+ gno context add "work-docs:" "Architecture docs, runbooks, RFCs, meeting notes"
46
+ gno context add "gno-code:" "Source code for the GNO application"
47
+
48
+ # Index + embed
49
+ gno update --yes
50
+ gno embed
51
+
52
+ # Search in the way that fits the question
53
+ gno search "DEC-0054" # exact keyword / identifier
54
+ gno vsearch "retry failed jobs with backoff" # natural-language semantic lookup
55
+ gno query "JWT refresh token rotation" --explain # hybrid retrieval with score traces
56
+
57
+ # Retrieve documents or export context for an agent
58
+ gno get "gno://work-docs/architecture/auth.md"
59
+ gno multi-get "gno-code/**/*.ts" --max-bytes 30000 --md
60
+ gno query "deployment process" --all --files --min-score 0.35
61
+
62
+ # Run the workspace
63
+ gno serve
64
+ gno daemon
65
+ ```
16
66
 
17
67
  ---
18
68
 
@@ -265,6 +315,14 @@ headless. In v0.30 it is foreground-only and does not expose built-in
265
315
 
266
316
  Embed GNO directly in another Bun or TypeScript app. No CLI subprocesses. No local server required.
267
317
 
318
+ Install:
319
+
320
+ ```bash
321
+ bun add @gmickel/gno
322
+ ```
323
+
324
+ Minimal client:
325
+
268
326
  ```ts
269
327
  import { createDefaultConfig, createGnoClient } from "@gmickel/gno";
270
328
 
@@ -295,6 +353,43 @@ console.log(results.results[0]?.uri);
295
353
  await client.close();
296
354
  ```
297
355
 
356
+ More SDK examples:
357
+
358
+ ```ts
359
+ import { createGnoClient } from "@gmickel/gno";
360
+
361
+ const client = await createGnoClient({
362
+ configPath: "/Users/me/.config/gno/index.yml",
363
+ });
364
+
365
+ // Fast exact search
366
+ const bm25 = await client.search("DEC-0054", {
367
+ collection: "work-docs",
368
+ });
369
+
370
+ // Semantic code lookup
371
+ const semantic = await client.vsearch("retry failed jobs with backoff", {
372
+ collection: "gno-code",
373
+ });
374
+
375
+ // Hybrid retrieval with explicit intent
376
+ const hybrid = await client.query("token refresh", {
377
+ collection: "work-docs",
378
+ intent: "JWT refresh token rotation in our auth stack",
379
+ candidateLimit: 12,
380
+ });
381
+
382
+ // Fetch content directly
383
+ const doc = await client.get("gno://work-docs/auth/refresh.md");
384
+ const bundle = await client.multiGet(["gno-code/**/*.ts"], { maxBytes: 25000 });
385
+
386
+ // Indexing / embedding
387
+ await client.update({ collection: "work-docs" });
388
+ await client.embed({ collection: "gno-code" });
389
+
390
+ await client.close();
391
+ ```
392
+
298
393
  Core SDK surface:
299
394
 
300
395
  - `createGnoClient({ config | configPath, dbPath? })`
@@ -303,12 +398,6 @@ Core SDK surface:
303
398
  - `update`, `embed`, `index`
304
399
  - `close`
305
400
 
306
- Install in an app:
307
-
308
- ```bash
309
- bun add @gmickel/gno
310
- ```
311
-
312
401
  Full guide: [SDK docs](https://gno.sh/docs/SDK/)
313
402
 
314
403
  ---
@@ -338,6 +427,31 @@ gno ask "what did we decide" --answer # AI synthesis
338
427
 
339
428
  Output formats: `--json`, `--files`, `--csv`, `--md`, `--xml`
340
429
 
430
+ ### Common CLI Recipes
431
+
432
+ ```bash
433
+ # Search one collection
434
+ gno search "PostgreSQL connection pool" --collection work-docs
435
+
436
+ # Export retrieval results for an agent
437
+ gno query "authentication flow" --json -n 10
438
+ gno query "deployment rollback" --all --files --min-score 0.4
439
+
440
+ # Retrieve a document by URI or docid
441
+ gno get "gno://work-docs/runbooks/deploy.md"
442
+ gno get "#abc123"
443
+
444
+ # Fetch many documents at once
445
+ gno multi-get "work-docs/**/*.md" --max-bytes 20000 --md
446
+
447
+ # Inspect how the hybrid rank was assembled
448
+ gno query "refresh token rotation" --explain
449
+
450
+ # Work with filters
451
+ gno query "meeting notes" --since "last month" --category "meeting,notes"
452
+ gno search "incident review" --tags-all "status/active,team/platform"
453
+ ```
454
+
341
455
  ### Retrieval V2 Controls
342
456
 
343
457
  Existing query calls still work. Retrieval v2 adds optional structured intent control and deeper explain output.
@@ -382,6 +496,20 @@ gno skill install --scope user
382
496
 
383
497
  Then ask your agent: _"Search my notes for the auth discussion"_
384
498
 
499
+ Agent-friendly CLI examples:
500
+
501
+ ```bash
502
+ # Structured retrieval output for an agent
503
+ gno query "authentication" --json -n 10
504
+
505
+ # File list for downstream retrieval
506
+ gno query "error handling" --all --files --min-score 0.35
507
+
508
+ # Full document content when the agent already knows the ref
509
+ gno get "gno://work-docs/api-reference.md" --full
510
+ gno multi-get "work-docs/**/*.md" --md --max-bytes 30000
511
+ ```
512
+
385
513
  [Skill setup guide →](https://gno.sh/docs/integrations/skills/)
386
514
 
387
515
  ### MCP Server
@@ -655,7 +783,7 @@ See:
655
783
  Offload inference to a GPU server on your network:
656
784
 
657
785
  ```yaml
658
- # ~/.config/gno/config.yaml
786
+ # ~/.config/gno/index.yml
659
787
  models:
660
788
  activePreset: remote-gpu
661
789
  presets:
@@ -715,6 +843,61 @@ bun run eval:hybrid:delta
715
843
  - Benchmark guide: [evals/README.md](./evals/README.md)
716
844
  - Latest baseline snapshot: [evals/fixtures/hybrid-baseline/latest.json](./evals/fixtures/hybrid-baseline/latest.json)
717
845
 
846
+ ### Code Embedding Benchmark Harness
847
+
848
+ GNO also has a dedicated harness for comparing alternate embedding models on code retrieval without touching product defaults:
849
+
850
+ ```bash
851
+ # Establish the current incumbent baseline
852
+ bun run bench:code-embeddings --candidate bge-m3-incumbent --write
853
+
854
+ # Add candidate model URIs to the search space, then inspect them
855
+ bun run research:embeddings:autonomous:list-search-candidates
856
+
857
+ # Benchmark one candidate explicitly
858
+ bun run research:embeddings:autonomous:run-candidate bge-m3-incumbent
859
+
860
+ # Or let the bounded search harness walk the remaining candidates later
861
+ bun run research:embeddings:autonomous:search --dry-run
862
+ ```
863
+
864
+ See [research/embeddings/README.md](./research/embeddings/README.md).
865
+
866
+ If a model turns out to be better specifically for code, the intended user story is:
867
+
868
+ - keep the default global preset for mixed prose/docs collections
869
+ - use per-collection `models.embed` overrides for code collections
870
+
871
+ That lets GNO stay sane by default while still giving power users a clean path to code-specialist retrieval.
872
+
873
+ Current code-focused recommendation:
874
+
875
+ ```yaml
876
+ collections:
877
+ - name: gno-code
878
+ path: /Users/you/work/gno/src
879
+ pattern: "**/*.{ts,tsx,js,jsx,go,rs,py,swift,c}"
880
+ models:
881
+ embed: "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf"
882
+ ```
883
+
884
+ GNO treats that override like any other model URI:
885
+
886
+ - auto-downloads on first use by default
887
+ - manual-only if `GNO_NO_AUTO_DOWNLOAD=1`
888
+ - offline-safe if the model is already cached
889
+
890
+ Why this is the current recommendation:
891
+
892
+ - matches `bge-m3` on the tiny canonical benchmark
893
+ - significantly beats `bge-m3` on the real GNO `src/serve` code slice
894
+ - also beats `bge-m3` on a pinned public-OSS code slice
895
+
896
+ Trade-off:
897
+
898
+ - Qwen is slower to embed than `bge-m3`
899
+ - use it where code retrieval quality matters, not necessarily as the global default for every collection
900
+
718
901
  ---
719
902
 
720
903
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -69,6 +69,8 @@
69
69
  "eval:hybrid": "bun --bun evalite evals/hybrid.eval.ts",
70
70
  "eval:hybrid:baseline": "bun scripts/hybrid-benchmark.ts --write",
71
71
  "eval:hybrid:delta": "bun scripts/hybrid-benchmark.ts --delta",
72
+ "bench:code-embeddings": "bun scripts/code-embedding-benchmark.ts",
73
+ "bench:code-embeddings:write": "bun scripts/code-embedding-benchmark.ts --write",
72
74
  "eval:retrieval-candidates": "bun scripts/retrieval-candidate-benchmark.ts",
73
75
  "eval:retrieval-candidates:write": "bun scripts/retrieval-candidate-benchmark.ts --write",
74
76
  "eval:watch": "bun --bun evalite watch",
@@ -83,6 +85,11 @@
83
85
  "research:finetune:autonomous:confirm-winner": "bun research/finetune/autonomous/scripts/confirm-winner.ts",
84
86
  "research:finetune:autonomous:check-promotion-targets": "bun research/finetune/autonomous/scripts/check-promotion-targets.ts",
85
87
  "research:finetune:validate": "bun research/finetune/scripts/validate-sandbox.ts",
88
+ "research:embeddings:autonomous:list-search-candidates": "bun research/embeddings/autonomous/scripts/list-search-candidates.ts",
89
+ "research:embeddings:autonomous:run-candidate": "bun research/embeddings/autonomous/scripts/run-candidate.ts",
90
+ "research:embeddings:autonomous:leaderboard": "bun research/embeddings/autonomous/scripts/leaderboard.ts",
91
+ "research:embeddings:autonomous:confirm-winner": "bun research/embeddings/autonomous/scripts/confirm-winner.ts",
92
+ "research:embeddings:autonomous:search": "bun research/embeddings/autonomous/scripts/search.ts",
86
93
  "research:finetune:qmd-import:legacy": "bun research/finetune/scripts/import-qmd-training.ts",
87
94
  "research:finetune:mlx:build-dataset": "bun research/finetune/scripts/build-mlx-dataset.ts",
88
95
  "research:finetune:build-variant-dataset": "bun research/finetune/scripts/build-variant-dataset.ts",
@@ -14,7 +14,7 @@ import type { AskOptions, AskResult, Citation } from "../../pipeline/types";
14
14
 
15
15
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
16
16
  import { resolveDownloadPolicy } from "../../llm/policy";
17
- import { getActivePreset } from "../../llm/registry";
17
+ import { resolveModelUri } from "../../llm/registry";
18
18
  import {
19
19
  generateGroundedAnswer,
20
20
  processAnswerResult,
@@ -90,7 +90,6 @@ export async function ask(
90
90
  let rerankPort: RerankPort | null = null;
91
91
 
92
92
  try {
93
- const preset = getActivePreset(config);
94
93
  const llm = new LlmAdapter(config);
95
94
 
96
95
  // Resolve download policy from env/flags
@@ -106,7 +105,12 @@ export async function ask(
106
105
  : undefined;
107
106
 
108
107
  // Create embedding port
109
- const embedUri = options.embedModel ?? preset.embed;
108
+ const embedUri = resolveModelUri(
109
+ config,
110
+ "embed",
111
+ options.embedModel,
112
+ options.collection
113
+ );
110
114
  const embedResult = await llm.createEmbeddingPort(embedUri, {
111
115
  policy,
112
116
  onProgress: downloadProgress
@@ -119,8 +123,12 @@ export async function ask(
119
123
 
120
124
  // Create expansion port when expansion is enabled.
121
125
  if (!options.noExpand && !options.queryModes?.length) {
122
- const expandUri =
123
- options.expandModel ?? options.genModel ?? preset.expand;
126
+ const expandUri = resolveModelUri(
127
+ config,
128
+ "expand",
129
+ options.expandModel ?? options.genModel,
130
+ options.collection
131
+ );
124
132
  const genResult = await llm.createExpansionPort(expandUri, {
125
133
  policy,
126
134
  onProgress: downloadProgress
@@ -134,7 +142,12 @@ export async function ask(
134
142
 
135
143
  // Create answer generation port when answers are requested.
136
144
  if (options.answer) {
137
- const genUri = options.genModel ?? preset.gen;
145
+ const genUri = resolveModelUri(
146
+ config,
147
+ "gen",
148
+ options.genModel,
149
+ options.collection
150
+ );
138
151
  const genResult = await llm.createGenerationPort(genUri, {
139
152
  policy,
140
153
  onProgress: downloadProgress
@@ -148,7 +161,12 @@ export async function ask(
148
161
 
149
162
  // Create rerank port (unless --fast or --no-rerank)
150
163
  if (!options.noRerank) {
151
- const rerankUri = options.rerankModel ?? preset.rerank;
164
+ const rerankUri = resolveModelUri(
165
+ config,
166
+ "rerank",
167
+ options.rerankModel,
168
+ options.collection
169
+ );
152
170
  const rerankResult = await llm.createRerankPort(rerankUri, {
153
171
  policy,
154
172
  onProgress: downloadProgress
@@ -14,6 +14,7 @@ import type { Config } from "../../config/types";
14
14
 
15
15
  import { getIndexDbPath, getModelsCachePath } from "../../app/constants";
16
16
  import { getConfigPaths, isInitialized, loadConfig } from "../../config";
17
+ import { getCodeChunkingStatus } from "../../ingestion/chunker";
17
18
  import { ModelCache } from "../../llm/cache";
18
19
  import { getActivePreset } from "../../llm/registry";
19
20
  import { loadFts5Snowball } from "../../store/sqlite/fts5-snowball";
@@ -122,6 +123,19 @@ async function checkModels(config: Config): Promise<DoctorCheck[]> {
122
123
  return checks;
123
124
  }
124
125
 
126
+ function checkCodeChunking(): DoctorCheck {
127
+ const status = getCodeChunkingStatus();
128
+ return {
129
+ name: "code-chunking",
130
+ status: "ok",
131
+ message: `${status.mode} structural chunking for ${status.supportedExtensions.join(", ")}`,
132
+ details: [
133
+ "Unsupported extensions fall back to the default markdown chunker.",
134
+ "Chunking mode is automatic-only in the first pass.",
135
+ ],
136
+ };
137
+ }
138
+
125
139
  async function checkNodeLlamaCpp(): Promise<DoctorCheck> {
126
140
  try {
127
141
  const { getLlama } = await import("node-llama-cpp");
@@ -319,6 +333,9 @@ export async function doctor(
319
333
  const sqliteChecks = await checkSqliteExtensions();
320
334
  checks.push(...sqliteChecks);
321
335
 
336
+ // Code chunking capability
337
+ checks.push(checkCodeChunking());
338
+
322
339
  // Determine overall health
323
340
  const hasErrors = checks.some((c) => c.status === "error");
324
341
 
@@ -19,7 +19,7 @@ import {
19
19
  } from "../../config";
20
20
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
21
21
  import { resolveDownloadPolicy } from "../../llm/policy";
22
- import { getActivePreset } from "../../llm/registry";
22
+ import { resolveModelUri } from "../../llm/registry";
23
23
  import { formatDocForEmbedding } from "../../pipeline/contextual";
24
24
  import { SqliteAdapter } from "../../store/sqlite/adapter";
25
25
  import { err, ok } from "../../store/types";
@@ -271,8 +271,7 @@ async function initEmbedContext(
271
271
  return { ok: false, error: `Collection not found: ${collection}` };
272
272
  }
273
273
 
274
- const preset = getActivePreset(config);
275
- const modelUri = model ?? preset.embed;
274
+ const modelUri = resolveModelUri(config, "embed", model, collection);
276
275
 
277
276
  const store = new SqliteAdapter();
278
277
  const dbPath = getIndexDbPath();
@@ -14,7 +14,7 @@ import type { HybridSearchOptions, SearchResults } from "../../pipeline/types";
14
14
 
15
15
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
16
16
  import { resolveDownloadPolicy } from "../../llm/policy";
17
- import { getActivePreset } from "../../llm/registry";
17
+ import { resolveModelUri } from "../../llm/registry";
18
18
  import { type HybridSearchDeps, searchHybrid } from "../../pipeline/hybrid";
19
19
  import {
20
20
  createVectorIndexPort,
@@ -58,6 +58,7 @@ export interface QueryFormatOptions {
58
58
  format: "terminal" | "json" | "files" | "csv" | "md" | "xml";
59
59
  full?: boolean;
60
60
  lineNumbers?: boolean;
61
+ terminalLinks?: import("../format/search-results").FormatOptions["terminalLinks"];
61
62
  }
62
63
 
63
64
  export type QueryResult =
@@ -97,7 +98,6 @@ export async function query(
97
98
  let rerankPort: RerankPort | null = null;
98
99
 
99
100
  try {
100
- const preset = getActivePreset(config);
101
101
  const llm = new LlmAdapter(config);
102
102
 
103
103
  // Resolve download policy from env/flags
@@ -113,7 +113,12 @@ export async function query(
113
113
  : undefined;
114
114
 
115
115
  // Create embedding port (for vector search)
116
- const embedUri = options.embedModel ?? preset.embed;
116
+ const embedUri = resolveModelUri(
117
+ config,
118
+ "embed",
119
+ options.embedModel,
120
+ options.collection
121
+ );
117
122
  const embedResult = await llm.createEmbeddingPort(embedUri, {
118
123
  policy,
119
124
  onProgress: downloadProgress
@@ -127,8 +132,12 @@ export async function query(
127
132
  // Create expansion port - optional.
128
133
  // Skip when structured query modes are provided.
129
134
  if (!options.noExpand && !options.queryModes?.length) {
130
- const expandUri =
131
- options.expandModel ?? options.genModel ?? preset.expand;
135
+ const expandUri = resolveModelUri(
136
+ config,
137
+ "expand",
138
+ options.expandModel ?? options.genModel,
139
+ options.collection
140
+ );
132
141
  const genResult = await llm.createExpansionPort(expandUri, {
133
142
  policy,
134
143
  onProgress: downloadProgress
@@ -142,7 +151,12 @@ export async function query(
142
151
 
143
152
  // Create rerank port - optional
144
153
  if (!options.noRerank) {
145
- const rerankUri = options.rerankModel ?? preset.rerank;
154
+ const rerankUri = resolveModelUri(
155
+ config,
156
+ "rerank",
157
+ options.rerankModel,
158
+ options.collection
159
+ );
146
160
  const rerankResult = await llm.createRerankPort(rerankUri, {
147
161
  policy,
148
162
  onProgress: downloadProgress
@@ -260,5 +274,6 @@ export function formatQuery(
260
274
  format: options.format,
261
275
  full: options.full,
262
276
  lineNumbers: options.lineNumbers,
277
+ terminalLinks: options.terminalLinks,
263
278
  });
264
279
  }
@@ -31,6 +31,8 @@ export type SearchCommandOptions = SearchOptions & {
31
31
  xml?: boolean;
32
32
  /** Output files only */
33
33
  files?: boolean;
34
+ /** Terminal hyperlink policy */
35
+ terminalLinks?: FormatOptions["terminalLinks"];
34
36
  };
35
37
 
36
38
  export type SearchResult =
@@ -132,6 +134,7 @@ export function formatSearch(
132
134
  format: getFormatType(options),
133
135
  full: options.full,
134
136
  lineNumbers: options.lineNumbers,
137
+ terminalLinks: options.terminalLinks,
135
138
  };
136
139
 
137
140
  return formatSearchResults(result.data, formatOpts);
@@ -8,7 +8,7 @@
8
8
  import type { SearchOptions, SearchResults } from "../../pipeline/types";
9
9
 
10
10
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
11
- import { getActivePreset } from "../../llm/registry";
11
+ import { resolveModelUri } from "../../llm/registry";
12
12
  import { formatQueryForEmbedding } from "../../pipeline/contextual";
13
13
  import {
14
14
  searchVectorWithEmbedding,
@@ -40,6 +40,8 @@ export type VsearchCommandOptions = SearchOptions & {
40
40
  xml?: boolean;
41
41
  /** Output files only */
42
42
  files?: boolean;
43
+ /** Terminal hyperlink policy */
44
+ terminalLinks?: FormatOptions["terminalLinks"];
43
45
  };
44
46
 
45
47
  export type VsearchResult =
@@ -76,8 +78,12 @@ export async function vsearch(
76
78
 
77
79
  try {
78
80
  // Get model URI from preset
79
- const preset = getActivePreset(config);
80
- const modelUri = options.model ?? preset.embed;
81
+ const modelUri = resolveModelUri(
82
+ config,
83
+ "embed",
84
+ options.model,
85
+ options.collection
86
+ );
81
87
 
82
88
  // Create LLM adapter for embeddings
83
89
  const llm = new LlmAdapter(config);
@@ -187,6 +193,7 @@ export function formatVsearch(
187
193
  format: getFormatType(options),
188
194
  full: options.full,
189
195
  lineNumbers: options.lineNumbers,
196
+ terminalLinks: options.terminalLinks,
190
197
  };
191
198
 
192
199
  return formatSearchResults(result.data, formatOpts);
@@ -5,6 +5,8 @@
5
5
  * @module src/cli/format/searchResults
6
6
  */
7
7
 
8
+ import { pathToFileURL } from "node:url";
9
+
8
10
  import type { SearchResults } from "../../pipeline/types";
9
11
 
10
12
  // ─────────────────────────────────────────────────────────────────────────────
@@ -15,6 +17,10 @@ export interface FormatOptions {
15
17
  full?: boolean;
16
18
  lineNumbers?: boolean;
17
19
  format: "terminal" | "json" | "files" | "csv" | "md" | "xml";
20
+ terminalLinks?: {
21
+ isTTY: boolean;
22
+ editorUriTemplate?: string | null;
23
+ };
18
24
  }
19
25
 
20
26
  // ─────────────────────────────────────────────────────────────────────────────
@@ -76,7 +82,9 @@ function formatTerminal(data: SearchResults, options: FormatOptions): string {
76
82
 
77
83
  const lines: string[] = [];
78
84
  for (const r of data.results) {
79
- lines.push(`[${r.docid}] ${r.uri} (score: ${r.score.toFixed(2)})`);
85
+ lines.push(
86
+ `[${r.docid}] ${formatTerminalUri(r, options)} (score: ${r.score.toFixed(2)})`
87
+ );
80
88
  if (r.title) {
81
89
  lines.push(` ${r.title}`);
82
90
  }
@@ -100,6 +108,55 @@ function formatTerminal(data: SearchResults, options: FormatOptions): string {
100
108
  return lines.join("\n");
101
109
  }
102
110
 
111
+ function formatTerminalUri(
112
+ result: SearchResults["results"][number],
113
+ options: FormatOptions
114
+ ): string {
115
+ const links = options.terminalLinks;
116
+ if (!links?.isTTY || !result.source.absPath) {
117
+ return result.uri;
118
+ }
119
+
120
+ const target = buildTerminalLinkTarget(
121
+ result.source.absPath,
122
+ result.snippetRange?.startLine,
123
+ links.editorUriTemplate ?? undefined
124
+ );
125
+
126
+ if (!target) {
127
+ return result.uri;
128
+ }
129
+
130
+ return wrapOsc8(target, result.uri);
131
+ }
132
+
133
+ function buildTerminalLinkTarget(
134
+ absPath: string,
135
+ line: number | undefined,
136
+ template?: string
137
+ ): string | null {
138
+ if (template) {
139
+ if (line === undefined && template.includes("{line}")) {
140
+ return null;
141
+ }
142
+
143
+ const withPath = template.replaceAll("{path}", absPath);
144
+ const withLine = withPath.replaceAll(
145
+ "{line}",
146
+ line !== undefined ? String(line) : ""
147
+ );
148
+ return withLine.replaceAll("{col}", line !== undefined ? "1" : "");
149
+ }
150
+
151
+ return pathToFileURL(absPath).toString();
152
+ }
153
+
154
+ function wrapOsc8(target: string, label: string): string {
155
+ const OSC = "\u001B]8;;";
156
+ const BEL = "\u0007";
157
+ return `${OSC}${target}${BEL}${label}${OSC}${BEL}`;
158
+ }
159
+
103
160
  function formatMarkdown(data: SearchResults, options: FormatOptions): string {
104
161
  const modeLabel = data.meta.mode === "vector" ? "Vector " : "";
105
162
  if (data.results.length === 0) {