@gmickel/gno 1.36.0 → 1.36.1

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.
@@ -0,0 +1 @@
1
+ 8ece31111978af50978a899662a7bd14eddcc90f2c90f6b0f04b67b3b602d980 gno-browser-clipper-v1.36.1.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.36.1"
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.36.1",
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",
@@ -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
@@ -1 +0,0 @@
1
- 8d8b73a55081409e3dbba4e6927d864839329cbcdb98a8f78274c29688edeb9e gno-browser-clipper-v1.36.0.zip