@gmickel/gno 1.36.0 → 1.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
@@ -105,7 +105,7 @@ gno daemon --detach # headless indexing + resident MCP gateway
105
105
 
106
106
  **Start here** · [Quick Start](#quick-start) · [Installation](#installation) · [Agent Integration](#agent-integration) · [Search Modes](#search-modes)
107
107
 
108
- **Surfaces** · [Web UI](#web-ui) · [REST API](#rest-api) · [SDK](#sdk) · [Daemon Mode](#daemon-mode) · [Publish to gno.sh](#publish-to-gnosh)
108
+ **Surfaces** · [Web UI](#web-ui) · [Omarchy Plugin](#omarchy-plugin) · [REST API](#rest-api) · [SDK](#sdk) · [Daemon Mode](#daemon-mode) · [Publish to gno.sh](#publish-to-gnosh)
109
109
 
110
110
  **Under the hood** · [How It Works](#how-it-works) · [Features](#features) · [Local Models](#local-models) · [Fine-Tuned Models](#fine-tuned-models) · [Architecture](#architecture) · [Development](#development)
111
111
 
@@ -117,12 +117,25 @@ gno daemon --detach # headless indexing + resident MCP gateway
117
117
 
118
118
  <!-- public-truth:current-version -->
119
119
 
120
- > Current release: **v1.35.0** see [CHANGELOG.md](./CHANGELOG.md)
120
+ > Current release: **v1.36.1**. See [CHANGELOG.md](./CHANGELOG.md).
121
121
 
122
122
  <!-- /public-truth -->
123
123
 
124
124
  > Full release history: [CHANGELOG.md](./CHANGELOG.md)
125
125
 
126
+ - **Cheap peek snapshot**: `gno peek --json` and MCP `gno_peek` return a
127
+ model-free `peek@1.0` snapshot (document/collection counts, embedding backlog,
128
+ 10 recent docs with `docid` and `absPath`, pid-file serve detection).
129
+ Uninitialized reports `initialized:false` with pinned nulls and exit 0. Any
130
+ subquery failure is an atomic `RUNTIME` envelope (exit 2). `serve.running` is
131
+ true only for `gno serve --detach`. JSON search hits pin
132
+ `results[].source.absPath`. Open a document in the Web UI with
133
+ `{serveUrl}/doc?uri=<encodeURIComponent(uri)>`.
134
+ - **Prose-first snippets**: `gno search`, `gno vsearch`, and `gno query` skip
135
+ leading YAML frontmatter and prefer document prose. A frontmatter-dominated
136
+ FTS window falls back to stripped chunk prose. `--full` and `--line-numbers`
137
+ still emit raw source. `line` and `snippetRange.startLine` follow the trimmed
138
+ display range.
126
139
  - **Trustworthy local context compiler**: deterministic Context Capsules replace
127
140
  repeated agent `query → get → multi-get` orchestration with one bounded,
128
141
  citation-complete evidence handoff. The promoted benchmark retained 100%
@@ -544,7 +557,10 @@ Output formats: `--json`, `--files`, `--csv`, `--md`, `--xml`
544
557
  # Search one collection
545
558
  gno search "PostgreSQL connection pool" --collection work-docs
546
559
 
547
- # Export retrieval results for an agent
560
+ # Cheap index snapshot for a status bar or desktop widget
561
+ gno peek --json
562
+
563
+ # Export retrieval results for an agent (`source.absPath` on file-backed hits)
548
564
  gno query "authentication flow" --json -n 10
549
565
  gno query "deployment rollback" --all --files --min-score 0.4
550
566
 
@@ -645,30 +661,31 @@ Connect GNO to Claude Desktop, Cursor, Raycast, and more:
645
661
 
646
662
  ![GNO MCP](./assets/screenshots/mcp.jpg)
647
663
 
648
- GNO exposes 25 tools by default via [Model Context Protocol](https://modelcontextprotocol.io),
664
+ GNO exposes 33 tools by default via [Model Context Protocol](https://modelcontextprotocol.io),
649
665
  including the core retrieval tools below. Starting MCP with `--enable-write`
650
- adds 15 opt-in mutation tools, for 40 total.
651
-
652
- | Tool | Description |
653
- | :------------------- | :------------------------------------ |
654
- | `gno_search` | BM25 keyword search |
655
- | `gno_vsearch` | Vector semantic search |
656
- | `gno_query` | Hybrid search (recommended) |
657
- | `gno_context` | Budgeted exact evidence Capsule |
658
- | `gno_context_verify` | Verify saved Capsule provenance |
659
- | `gno_ask` | Opt-in closed-Capsule verified answer |
660
- | `gno_get` | Retrieve document by ID |
661
- | `gno_multi_get` | Batch document retrieval |
662
- | `gno_links` | Get outgoing links from document |
663
- | `gno_backlinks` | Get documents linking TO document |
664
- | `gno_similar` | Find semantically similar documents |
665
- | `gno_graph` | Get knowledge graph (nodes and edges) |
666
- | `gno_status` | Index health check |
667
- | `gno_trace_list` | List private local retrieval receipts |
668
- | `gno_trace_show` | Inspect one bounded trace receipt |
669
- | `gno_changes` | Read retained metadata-only changes |
670
- | `gno_diff` | Read one structural document delta |
671
- | `gno_impact` | Trace bounded dependency impact |
666
+ adds 18 opt-in mutation tools, for 51 total.
667
+
668
+ | Tool | Description |
669
+ | :------------------- | :---------------------------------------------- |
670
+ | `gno_search` | BM25 keyword search |
671
+ | `gno_vsearch` | Vector semantic search |
672
+ | `gno_query` | Hybrid search (recommended) |
673
+ | `gno_context` | Budgeted exact evidence Capsule |
674
+ | `gno_context_verify` | Verify saved Capsule provenance |
675
+ | `gno_ask` | Opt-in closed-Capsule verified answer |
676
+ | `gno_get` | Retrieve document by ID |
677
+ | `gno_multi_get` | Batch document retrieval |
678
+ | `gno_links` | Get outgoing links from document |
679
+ | `gno_backlinks` | Get documents linking TO document |
680
+ | `gno_similar` | Find semantically similar documents |
681
+ | `gno_graph` | Get knowledge graph (nodes and edges) |
682
+ | `gno_peek` | Cheap `peek@1.0` counts, backlog, recent, serve |
683
+ | `gno_status` | Index health check |
684
+ | `gno_trace_list` | List private local retrieval receipts |
685
+ | `gno_trace_show` | Inspect one bounded trace receipt |
686
+ | `gno_changes` | Read retained metadata-only changes |
687
+ | `gno_diff` | Read one structural document delta |
688
+ | `gno_impact` | Trace bounded dependency impact |
672
689
 
673
690
  **Design**: Default MCP mode is read-only: retrieval, opt-in verified synthesis,
674
691
  graph, status, and job inspection. Raw retrieval tools leave synthesis to your
@@ -792,6 +809,18 @@ gno.sh.
792
809
 
793
810
  ---
794
811
 
812
+ ## Omarchy Plugin
813
+
814
+ On [Omarchy](https://omarchy.org/), [**GNO Recall**](https://github.com/gmickel/omarchy-gno-recall) puts the index in your bar: a quiet health widget, an anchored panel with counts and recent documents, and a keyboard-first overlay (Super+R) for search, deep search, collection browsing, and opening documents.
815
+
816
+ ```bash
817
+ omarchy plugin add https://github.com/gmickel/omarchy-gno-recall --enable
818
+ ```
819
+
820
+ Requires gno >= 1.36.0 on `PATH`.
821
+
822
+ ---
823
+
795
824
  ## Publish to gno.sh
796
825
 
797
826
  GNO is local-first, but sometimes you want a URL to send someone. [**gno.sh**](https://gno.sh/publish) is the hosted reader on top of GNO — a polished, reading-first page for a single note or a whole collection, without mounting your vault or syncing anything.
Binary file
@@ -0,0 +1 @@
1
+ 497d8e90a34b7472eac55da861c3049b7453a33d9fdbd0a27e49f2ce36ccde9b gno-browser-clipper-v1.37.0.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "1.36.0"
24
+ "version": "1.37.0"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.36.0",
3
+ "version": "1.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",
@@ -65,6 +65,7 @@
65
65
  "test": "bun test",
66
66
  "test:web": "bun test test/serve/public --timeout 30000",
67
67
  "test:e2e": "bun scripts/web-ui-smoke.ts",
68
+ "bench:webui-first-page": "bun scripts/webui-first-page-load.ts",
68
69
  "test:e2e:clipper": "bun test test/clipper/e2e.test.ts --timeout 240000",
69
70
  "test:e2e:pdf": "bun scripts/pdf-viewer-smoke.ts",
70
71
  "smoke:pdf-viewer": "bun scripts/pdf-viewer-smoke.ts",
@@ -144,8 +145,9 @@
144
145
  "website:sync-assets": "bun scripts/sync-assets.ts",
145
146
  "sync:agents": "scripts/sync-agents.sh",
146
147
  "build:css": "tailwindcss -i src/serve/public/globals.css -o src/serve/public/globals.built.css --minify",
148
+ "build:spa": "bun scripts/build-spa-production.ts",
147
149
  "serve": "bun src/index.ts serve",
148
- "serve:dev": "NODE_ENV=development bun --hot src/index.ts serve",
150
+ "serve:dev": "bun --hot src/index.ts serve --dev",
149
151
  "version:patch": "npm version patch --no-git-tag-version",
150
152
  "version:minor": "npm version minor --no-git-tag-version",
151
153
  "version:major": "npm version major --no-git-tag-version",
package/spec/cli.md CHANGED
@@ -3335,16 +3335,26 @@ API share the listener; use `gno daemon` for authenticated non-loopback MCP.
3335
3335
  **Synopsis:**
3336
3336
 
3337
3337
  ```bash
3338
- gno serve [--port <num>] [gateway-options] [--detach] [--pid-file <path>] [--log-file <path>]
3338
+ gno serve [--port <num>] [--dev] [gateway-options] [--detach] [--pid-file <path>] [--log-file <path>]
3339
3339
  gno serve --status [--json]
3340
3340
  gno serve --stop
3341
3341
  ```
3342
3342
 
3343
+ Default `gno serve` (no `--dev`) is the production WebUI bundle, even when
3344
+ `NODE_ENV` is unset. Production serve loads the committed SPA snapshot
3345
+ (`assets/spa-production.json.gz`) so first listen does not wait on `Bun.build`.
3346
+ Refresh the snapshot with `bun scripts/build-spa-production.ts`. `--dev` is the
3347
+ operator switch for the development bundle and HMR (`serve:dev` / `bun --hot`
3348
+ remain the hot-reload path). `--dev` does not apply to `--status` or `--stop`
3349
+ (those paths do not boot the UI). A detached child inherits the same
3350
+ production default unless the parent was started with `--dev`.
3351
+
3343
3352
  **Options:**
3344
3353
 
3345
3354
  | Option | Type | Default | Description |
3346
3355
  | ---------------------- | ------- | ------------------------ | ---------------------------------------------------------------- |
3347
3356
  | `-p, --port` | number | 3000 | Port to listen on |
3357
+ | `--dev` | boolean | false | Serve the development bundle with HMR (default is production) |
3348
3358
  | `--detach` | boolean | false | Self-spawn a detached child; parent prints `{pid,url}` and exits |
3349
3359
  | `--pid-file <path>` | string | `{data}/serve.pid` | Override pid-file location (JSON metadata, absolute path) |
3350
3360
  | `--log-file <path>` | string | `{data}/serve.log` | Override log-file location (append mode) |
@@ -3423,6 +3433,7 @@ is blocked.
3423
3433
  ```bash
3424
3434
  gno serve
3425
3435
  gno serve --port 8080
3436
+ gno serve --dev
3426
3437
 
3427
3438
  # Backgrounding
3428
3439
  gno serve --detach
@@ -4164,12 +4164,29 @@ function wireServeCommand(program: Command): void {
4164
4164
  "internal detached-child marker"
4165
4165
  ).hideHelp()
4166
4166
  );
4167
+ serveCmd.addOption(
4168
+ new Option(
4169
+ "--dev",
4170
+ "serve the development bundle with HMR (default: production)"
4171
+ )
4172
+ );
4167
4173
 
4168
4174
  serveCmd.action(async (cmdOpts: Record<string, unknown>, cmd: Command) => {
4169
4175
  await handleServeAction(cmdOpts, cmd);
4170
4176
  });
4171
4177
  }
4172
4178
 
4179
+ /** CLI serve bundle mode. Default `gno serve` is production unless `--dev`. */
4180
+ export function resolveServeNodeEnv(
4181
+ dev: boolean
4182
+ ): "development" | "production" {
4183
+ return dev ? "development" : "production";
4184
+ }
4185
+
4186
+ function applyServeBundleEnv(dev: boolean): void {
4187
+ process.env.NODE_ENV = resolveServeNodeEnv(dev);
4188
+ }
4189
+
4173
4190
  // ─────────────────────────────────────────────────────────────────────────────
4174
4191
  // Serve lifecycle branching (detach / status / stop / detached-child / fg)
4175
4192
  // ─────────────────────────────────────────────────────────────────────────────
@@ -4238,6 +4255,9 @@ async function handleServeAction(
4238
4255
  return;
4239
4256
  }
4240
4257
 
4258
+ const dev = cmdOpts.dev === true;
4259
+ applyServeBundleEnv(dev);
4260
+
4241
4261
  if (cmdOpts.detach) {
4242
4262
  const port = parsePositiveInt("port", cmdOpts.port);
4243
4263
  await runServeDetach({
@@ -4245,6 +4265,7 @@ async function handleServeAction(
4245
4265
  paths,
4246
4266
  spawnDetached,
4247
4267
  argv: resolveCliArgv(cmd),
4268
+ nodeEnv: resolveServeNodeEnv(dev),
4248
4269
  });
4249
4270
  return;
4250
4271
  }
@@ -4423,6 +4444,7 @@ interface ServeDetachDeps {
4423
4444
  * can't taint each other's child argv.
4424
4445
  */
4425
4446
  argv: string[];
4447
+ nodeEnv: "development" | "production";
4426
4448
  }
4427
4449
 
4428
4450
  async function runServeDetach(deps: ServeDetachDeps): Promise<void> {
@@ -4432,6 +4454,7 @@ async function runServeDetach(deps: ServeDetachDeps): Promise<void> {
4432
4454
  const result = await deps.spawnDetached({
4433
4455
  kind: "serve",
4434
4456
  argv: childArgv,
4457
+ env: { NODE_ENV: deps.nodeEnv },
4435
4458
  pidFile: deps.paths.pidFile,
4436
4459
  logFile: deps.paths.logFile,
4437
4460
  port: deps.port,
@@ -413,13 +413,19 @@ export const materializeContextEvidenceCandidates = async (
413
413
  const content = contentByHash.get(document.mirrorHash);
414
414
  const chunk = getChunk(document.mirrorHash, metadata.seq);
415
415
  const chunkKey = `${document.mirrorHash}:${metadata.seq}`;
416
+ // Display snippets may omit leading YAML frontmatter, so startLine can sit
417
+ // after the stored chunk start. endLine must still identify the same chunk.
418
+ const displayRangeWithinChunk =
419
+ chunk !== undefined &&
420
+ snippetRange.endLine === chunk.endLine &&
421
+ snippetRange.startLine >= chunk.startLine &&
422
+ snippetRange.startLine <= chunk.endLine;
416
423
  if (
417
424
  !content ||
418
425
  metadata.mirrorHash !== document.mirrorHash ||
419
426
  !chunk ||
420
427
  chunk.mirrorHash !== document.mirrorHash ||
421
- snippetRange.startLine !== chunk.startLine ||
422
- snippetRange.endLine !== chunk.endLine
428
+ !displayRangeWithinChunk
423
429
  ) {
424
430
  throw new ContextEvidenceError(
425
431
  "chunk_coordinate_mismatch",
@@ -58,6 +58,7 @@ import {
58
58
  } from "./query-modes";
59
59
  import { rerankCandidates } from "./rerank";
60
60
  import { attachSearchResultContexts } from "./result-context";
61
+ import { cleanDisplaySnippet } from "./snippet";
61
62
  import {
62
63
  isWithinTemporalRange,
63
64
  resolveRecencyTimestamp,
@@ -1003,6 +1004,7 @@ export async function searchHybrid(
1003
1004
  ) ?? chunk);
1004
1005
 
1005
1006
  let snippet = snippetChunk.text;
1007
+ let snippetStartLine = snippetChunk.startLine;
1006
1008
  let snippetRange: { startLine: number; endLine: number } | undefined = {
1007
1009
  startLine: snippetChunk.startLine,
1008
1010
  endLine: snippetChunk.endLine,
@@ -1021,6 +1023,18 @@ export async function searchHybrid(
1021
1023
  snippetRange = undefined; // Full content has no range
1022
1024
  }
1023
1025
  // Fallback to chunk text if content unavailable
1026
+ } else {
1027
+ const cleanedSnippet = cleanDisplaySnippet(
1028
+ snippetChunk.text,
1029
+ snippetChunk.text
1030
+ );
1031
+ snippet = cleanedSnippet.text;
1032
+ snippetStartLine =
1033
+ snippetChunk.startLine + cleanedSnippet.startLineOffset;
1034
+ snippetRange = {
1035
+ startLine: snippetStartLine,
1036
+ endLine: snippetChunk.endLine,
1037
+ };
1024
1038
  }
1025
1039
 
1026
1040
  for (const doc of candidateDocs) {
@@ -1050,7 +1064,7 @@ export async function searchHybrid(
1050
1064
  title: doc.title ?? undefined,
1051
1065
  contentType: doc.contentType ?? undefined,
1052
1066
  categories: doc.categories ?? undefined,
1053
- line: snippetChunk.startLine,
1067
+ line: snippetStartLine,
1054
1068
  snippet,
1055
1069
  snippetLanguage: chunk.language ?? undefined,
1056
1070
  snippetRange,
@@ -30,6 +30,7 @@ import { selectBestChunkForSteering } from "./intent";
30
30
  import { hasProjectAffinity } from "./project-affinity";
31
31
  import { detectQueryLanguage } from "./query-language";
32
32
  import { attachSearchResultContexts } from "./result-context";
33
+ import { cleanDisplaySnippet } from "./snippet";
33
34
  import {
34
35
  resolveRecencyTimestamp,
35
36
  resolveTemporalRange,
@@ -107,6 +108,7 @@ function buildSearchResult(ctx: BuildResultContext): SearchResult {
107
108
  // Determine snippet content and range
108
109
  let snippet: string;
109
110
  let snippetRange: { startLine: number; endLine: number } | undefined;
111
+ let line = chunk?.startLine;
110
112
 
111
113
  if (options?.full && fullContent) {
112
114
  // --full: use full content, no range (full doc)
@@ -117,11 +119,18 @@ function buildSearchResult(ctx: BuildResultContext): SearchResult {
117
119
  snippet = chunk.text;
118
120
  snippetRange = { startLine: chunk.startLine, endLine: chunk.endLine };
119
121
  } else {
120
- // Default: use FTS snippet or chunk text
121
- snippet = fts.snippet ?? chunk?.text ?? "";
122
- snippetRange = chunk
123
- ? { startLine: chunk.startLine, endLine: chunk.endLine }
124
- : undefined;
122
+ // Default: FTS snippet or chunk text, with leading frontmatter stripped
123
+ const cleaned = cleanDisplaySnippet(
124
+ fts.snippet ?? chunk?.text ?? "",
125
+ chunk?.text
126
+ );
127
+ snippet = cleaned.text;
128
+ if (chunk) {
129
+ line = chunk.startLine + cleaned.startLineOffset;
130
+ snippetRange = { startLine: line, endLine: chunk.endLine };
131
+ } else {
132
+ snippetRange = undefined;
133
+ }
125
134
  }
126
135
 
127
136
  const result: SearchResult = {
@@ -131,7 +140,7 @@ function buildSearchResult(ctx: BuildResultContext): SearchResult {
131
140
  title: fts.title,
132
141
  contentType: fts.contentType,
133
142
  categories: fts.categories,
134
- line: chunk?.startLine,
143
+ line,
135
144
  snippet,
136
145
  snippetLanguage: chunk?.language ?? undefined,
137
146
  snippetRange,
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Display-layer snippet cleaning for search/query results.
3
+ * Strips leading YAML frontmatter so snippets prefer prose. Does not change
4
+ * indexed text, `--full` content, or `--line-numbers` raw chunks.
5
+ *
6
+ * @module src/pipeline/snippet
7
+ */
8
+
9
+ import { stripFrontmatter } from "../ingestion/frontmatter";
10
+
11
+ /** FTS5 highlight markers from snippet(documents_fts, ..., '<mark>', '</mark>', '...', 32). */
12
+ const MARK_TAG_REGEX = /<\/?mark>/g;
13
+
14
+ /** Leading blank lines after a closed frontmatter fence. */
15
+ const LEADING_BLANK_LINES_REGEX = /^(?:[ \t]*\r?\n)+/;
16
+
17
+ /** YAML mapping line (`key: value` or `key:`). */
18
+ const YAML_MAPPING_LINE_REGEX = /^[\w./-]+\s*:/;
19
+
20
+ /** YAML sequence item. */
21
+ const YAML_SEQUENCE_LINE_REGEX = /^- /;
22
+
23
+ export interface DisplaySnippet {
24
+ text: string;
25
+ /**
26
+ * Lines to add to the chunk's startLine when the emitted text is derived
27
+ * from stripped chunk prose (not a kept FTS window).
28
+ */
29
+ startLineOffset: number;
30
+ /** True when a frontmatter-dominated FTS snippet was replaced by chunk prose. */
31
+ usedChunkFallback: boolean;
32
+ }
33
+
34
+ /**
35
+ * Clean a default-path snippet: strip a leading closed YAML fence, or replace
36
+ * an FTS window that is only frontmatter with stripped chunk prose.
37
+ * Never returns an empty string when the original text had content.
38
+ */
39
+ export function cleanDisplaySnippet(
40
+ snippet: string,
41
+ chunkText?: string
42
+ ): DisplaySnippet {
43
+ const strippedSnippet = stripLeadingFrontmatterBlock(snippet);
44
+ if (strippedSnippet.didStrip) {
45
+ return {
46
+ text: strippedSnippet.text,
47
+ startLineOffset: strippedSnippet.lineCount,
48
+ usedChunkFallback: false,
49
+ };
50
+ }
51
+
52
+ const afterEmbeddedFence = proseAfterEmbeddedFrontmatterFence(snippet);
53
+ if (afterEmbeddedFence !== undefined) {
54
+ const cleanedChunk =
55
+ chunkText === undefined
56
+ ? undefined
57
+ : stripLeadingFrontmatterBlock(chunkText);
58
+ return {
59
+ text: afterEmbeddedFence,
60
+ startLineOffset: cleanedChunk?.didStrip ? cleanedChunk.lineCount : 0,
61
+ usedChunkFallback: false,
62
+ };
63
+ }
64
+
65
+ const canFallback =
66
+ chunkText !== undefined &&
67
+ chunkText !== snippet &&
68
+ isFrontmatterDominatedSnippet(snippet);
69
+ if (canFallback) {
70
+ const cleanedChunk = stripLeadingFrontmatterBlock(chunkText);
71
+ if (cleanedChunk.text.length > 0) {
72
+ return {
73
+ text: cleanedChunk.text,
74
+ startLineOffset: cleanedChunk.didStrip ? cleanedChunk.lineCount : 0,
75
+ usedChunkFallback: true,
76
+ };
77
+ }
78
+ }
79
+
80
+ return {
81
+ text: snippet,
82
+ startLineOffset: 0,
83
+ usedChunkFallback: false,
84
+ };
85
+ }
86
+
87
+ /** True for FTS-style snippets that are only (or start as) YAML frontmatter. */
88
+ export function isFrontmatterDominatedSnippet(text: string): boolean {
89
+ const unmarked = text.replace(MARK_TAG_REGEX, "");
90
+ const trimmed = unmarked.trimStart();
91
+ const withoutLeadingEllipsis = trimmed.startsWith("...")
92
+ ? trimmed.slice(3).trimStart()
93
+ : trimmed;
94
+ if (withoutLeadingEllipsis.startsWith("---")) {
95
+ return true;
96
+ }
97
+
98
+ const contentLines = unmarked.split(/\r?\n/).filter((line) => {
99
+ const trimmedLine = line.trim();
100
+ return trimmedLine.length > 0 && trimmedLine !== "...";
101
+ });
102
+ if (contentLines.length === 0) {
103
+ return false;
104
+ }
105
+ if (contentLines.every(isYamlFrontmatterLine)) {
106
+ return true;
107
+ }
108
+ return proseAfterEmbeddedFrontmatterFence(text) !== undefined;
109
+ }
110
+
111
+ /**
112
+ * FTS windows often straddle the closing fence (`...yaml\n---\n# Heading`).
113
+ * Keep the prose after that fence when the prefix looks like YAML.
114
+ */
115
+ function proseAfterEmbeddedFrontmatterFence(text: string): string | undefined {
116
+ const lines = text.split(/\r?\n/);
117
+ for (let i = 1; i < lines.length; i++) {
118
+ const line = lines[i];
119
+ if (line === undefined) {
120
+ continue;
121
+ }
122
+ if (line.replace(MARK_TAG_REGEX, "").trim() !== "---") {
123
+ continue;
124
+ }
125
+ const prefixLines = lines.slice(0, i);
126
+ if (!prefixLooksLikeFrontmatter(prefixLines)) {
127
+ continue;
128
+ }
129
+ const after = lines
130
+ .slice(i + 1)
131
+ .join("\n")
132
+ .replace(LEADING_BLANK_LINES_REGEX, "");
133
+ if (after.trim().length === 0) {
134
+ continue;
135
+ }
136
+ return after;
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ function prefixLooksLikeFrontmatter(lines: string[]): boolean {
142
+ const content = lines.filter((line) => {
143
+ const trimmed = line.replace(MARK_TAG_REGEX, "").trim();
144
+ return trimmed.length > 0 && trimmed !== "...";
145
+ });
146
+ if (content.length === 0) {
147
+ return true;
148
+ }
149
+ return content.every((line, index) => {
150
+ const trimmed = line.replace(MARK_TAG_REGEX, "").trim();
151
+ if (index === 0 && trimmed.startsWith("...")) {
152
+ return true;
153
+ }
154
+ return isYamlFrontmatterLine(trimmed);
155
+ });
156
+ }
157
+
158
+ function stripLeadingFrontmatterBlock(text: string): {
159
+ text: string;
160
+ didStrip: boolean;
161
+ lineCount: number;
162
+ } {
163
+ const afterFence = stripFrontmatter(text);
164
+ if (afterFence === text) {
165
+ return { text, didStrip: false, lineCount: 0 };
166
+ }
167
+
168
+ const withoutBlanks = afterFence.replace(LEADING_BLANK_LINES_REGEX, "");
169
+ if (withoutBlanks.trim().length === 0) {
170
+ return { text, didStrip: false, lineCount: 0 };
171
+ }
172
+
173
+ const prefix = text.slice(0, text.length - withoutBlanks.length);
174
+ return {
175
+ text: withoutBlanks,
176
+ didStrip: true,
177
+ lineCount: countConsumedLines(prefix),
178
+ };
179
+ }
180
+
181
+ function isYamlFrontmatterLine(line: string): boolean {
182
+ const trimmed = line.trim();
183
+ if (trimmed === "---") {
184
+ return true;
185
+ }
186
+ if (YAML_SEQUENCE_LINE_REGEX.test(trimmed)) {
187
+ return true;
188
+ }
189
+ return YAML_MAPPING_LINE_REGEX.test(trimmed);
190
+ }
191
+
192
+ function countConsumedLines(prefix: string): number {
193
+ if (prefix.length === 0) {
194
+ return 0;
195
+ }
196
+ let newlineCount = 0;
197
+ for (const char of prefix) {
198
+ if (char === "\n") {
199
+ newlineCount += 1;
200
+ }
201
+ }
202
+ return prefix.endsWith("\n") ? newlineCount : newlineCount + 1;
203
+ }
@@ -28,6 +28,7 @@ import { selectBestChunkForSteering } from "./intent";
28
28
  import { hasProjectAffinity } from "./project-affinity";
29
29
  import { detectQueryLanguage } from "./query-language";
30
30
  import { attachSearchResultContexts } from "./result-context";
31
+ import { cleanDisplaySnippet } from "./snippet";
31
32
  import {
32
33
  resolveRecencyTimestamp,
33
34
  isWithinTemporalRange,
@@ -236,6 +237,8 @@ export async function searchVectorWithEmbedding(
236
237
  continue;
237
238
  }
238
239
 
240
+ const cleanedSnippet = cleanDisplaySnippet(chunk.text, chunk.text);
241
+ const snippetStartLine = chunk.startLine + cleanedSnippet.startLineOffset;
239
242
  const scoredResult = applyContentTypeBoost(
240
243
  {
241
244
  docid: doc.docid,
@@ -244,11 +247,11 @@ export async function searchVectorWithEmbedding(
244
247
  title: doc.title ?? undefined,
245
248
  contentType: doc.contentType ?? undefined,
246
249
  categories: doc.categories ?? undefined,
247
- line: chunk.startLine,
248
- snippet: chunk.text,
250
+ line: snippetStartLine,
251
+ snippet: cleanedSnippet.text,
249
252
  snippetLanguage: chunk.language ?? undefined,
250
253
  snippetRange: {
251
- startLine: chunk.startLine,
254
+ startLine: snippetStartLine,
252
255
  endLine: chunk.endLine,
253
256
  },
254
257
  source: {
@@ -338,6 +341,12 @@ export async function searchVectorWithEmbedding(
338
341
 
339
342
  const collectionPath = collectionPaths.get(doc.collection);
340
343
  const sourceRelPath = doc.recordSourcePath ?? doc.relPath;
344
+ const cleanedChunk = fullContent
345
+ ? undefined
346
+ : cleanDisplaySnippet(chunk.text, chunk.text);
347
+ const snippetStartLine = cleanedChunk
348
+ ? chunk.startLine + cleanedChunk.startLineOffset
349
+ : chunk.startLine;
341
350
 
342
351
  const result = applyContentTypeBoost(
343
352
  {
@@ -347,13 +356,13 @@ export async function searchVectorWithEmbedding(
347
356
  title: doc.title ?? undefined,
348
357
  contentType: doc.contentType ?? undefined,
349
358
  categories: doc.categories ?? undefined,
350
- line: chunk.startLine,
351
- snippet: fullContent ?? chunk.text,
359
+ line: snippetStartLine,
360
+ snippet: fullContent ?? cleanedChunk?.text ?? chunk.text,
352
361
  snippetLanguage: chunk.language ?? undefined,
353
362
  // --full: no snippetRange (full doc content)
354
363
  snippetRange: fullContent
355
364
  ? undefined
356
- : { startLine: chunk.startLine, endLine: chunk.endLine },
365
+ : { startLine: snippetStartLine, endLine: chunk.endLine },
357
366
  source: {
358
367
  relPath: sourceRelPath,
359
368
  absPath: collectionPath