@gmickel/gno 1.35.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.
- package/assets/skill/SKILL.md +26 -3
- package/assets/skill/cli-reference.md +9 -0
- package/assets/skill/mcp-reference.md +3 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v1.35.0.zip → gno-browser-clipper-v1.36.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.36.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +74 -1
- package/spec/mcp.md +60 -1
- package/spec/output-schemas/peek.schema.json +212 -0
- package/spec/output-schemas/search-results.schema.json +1 -1
- package/src/cli/commands/peek.ts +66 -0
- package/src/cli/options.ts +2 -0
- package/src/cli/program.ts +20 -0
- package/src/core/context-evidence.ts +8 -2
- package/src/core/peek.ts +202 -0
- package/src/mcp/http-egress.ts +1 -0
- package/src/mcp/tools/index.ts +14 -0
- package/src/mcp/tools/peek.ts +78 -0
- package/src/pipeline/hybrid.ts +15 -1
- package/src/pipeline/search.ts +15 -6
- package/src/pipeline/snippet.ts +203 -0
- package/src/pipeline/vsearch.ts +15 -6
- package/src/serve/public/globals.built.css +1 -1
- package/browser-extension/artifacts/gno-browser-clipper-v1.35.0.zip.sha256 +0 -1
package/src/core/peek.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared peek snapshot builder for CLI and later MCP.
|
|
3
|
+
* Metadata-only: never opens the model cache, resolves model URIs, or activates.
|
|
4
|
+
*
|
|
5
|
+
* @module src/core/peek
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// node:path — no Bun path utils
|
|
9
|
+
import { join as pathJoin } from "node:path";
|
|
10
|
+
|
|
11
|
+
import type { DocumentRow, IndexStatus } from "../store/types";
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_INDEX_NAME, VERSION, getIndexDbPath } from "../app/constants";
|
|
14
|
+
import {
|
|
15
|
+
isProcessAlive,
|
|
16
|
+
readPidFile,
|
|
17
|
+
resolveProcessPaths,
|
|
18
|
+
} from "../cli/detach";
|
|
19
|
+
import { CliError } from "../cli/errors";
|
|
20
|
+
import { isInitialized, loadConfig } from "../config";
|
|
21
|
+
import { SqliteAdapter } from "../store/sqlite/adapter";
|
|
22
|
+
|
|
23
|
+
export const PEEK_SCHEMA_VERSION = "peek@1.0" as const;
|
|
24
|
+
export const PEEK_RECENT_LIMIT = 10;
|
|
25
|
+
|
|
26
|
+
export interface PeekCounts {
|
|
27
|
+
documents: number;
|
|
28
|
+
collections: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PeekBacklog {
|
|
32
|
+
pending: number;
|
|
33
|
+
failed: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PeekRecentItem {
|
|
37
|
+
docid: string;
|
|
38
|
+
uri: string;
|
|
39
|
+
title: string | null;
|
|
40
|
+
collection: string;
|
|
41
|
+
absPath: string;
|
|
42
|
+
modifiedAt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PeekServe {
|
|
46
|
+
running: boolean;
|
|
47
|
+
url: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PeekSnapshot {
|
|
51
|
+
schemaVersion: typeof PEEK_SCHEMA_VERSION;
|
|
52
|
+
gnoVersion: string;
|
|
53
|
+
generatedAt: string;
|
|
54
|
+
initialized: boolean;
|
|
55
|
+
indexName: string;
|
|
56
|
+
counts: PeekCounts | null;
|
|
57
|
+
backlog: PeekBacklog | null;
|
|
58
|
+
lastIndexedAt: string | null;
|
|
59
|
+
recent: PeekRecentItem[];
|
|
60
|
+
serve: PeekServe;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface BuildPeekOptions {
|
|
64
|
+
configPath?: string;
|
|
65
|
+
indexName?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function asRuntimeError(error: unknown, fallback: string): CliError {
|
|
69
|
+
if (error instanceof CliError) {
|
|
70
|
+
return error;
|
|
71
|
+
}
|
|
72
|
+
return new CliError(
|
|
73
|
+
"RUNTIME",
|
|
74
|
+
error instanceof Error ? error.message : fallback
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readServeLiveness(): Promise<PeekServe> {
|
|
79
|
+
try {
|
|
80
|
+
const { pidFile } = resolveProcessPaths("serve");
|
|
81
|
+
const payload = await readPidFile(pidFile);
|
|
82
|
+
if (!payload || !isProcessAlive(payload.pid) || payload.port == null) {
|
|
83
|
+
return { running: false, url: null };
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
running: true,
|
|
87
|
+
url: `http://localhost:${payload.port}`,
|
|
88
|
+
};
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw asRuntimeError(error, "Failed to read serve process state");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mapRecent(
|
|
95
|
+
documents: DocumentRow[],
|
|
96
|
+
status: IndexStatus
|
|
97
|
+
): PeekRecentItem[] {
|
|
98
|
+
const collectionPaths = new Map(
|
|
99
|
+
status.collections.map((collection) => [collection.name, collection.path])
|
|
100
|
+
);
|
|
101
|
+
return documents.slice(0, PEEK_RECENT_LIMIT).map((doc) => {
|
|
102
|
+
const sourceRelPath = doc.recordSourcePath ?? doc.relPath;
|
|
103
|
+
const collectionPath = collectionPaths.get(doc.collection) ?? "";
|
|
104
|
+
return {
|
|
105
|
+
docid: doc.docid,
|
|
106
|
+
uri: doc.uri,
|
|
107
|
+
title: doc.title,
|
|
108
|
+
collection: doc.collection,
|
|
109
|
+
absPath: pathJoin(collectionPath, sourceRelPath),
|
|
110
|
+
modifiedAt: doc.sourceMtime,
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function emptySnapshot(
|
|
116
|
+
indexName: string,
|
|
117
|
+
generatedAt: string,
|
|
118
|
+
serve: PeekServe
|
|
119
|
+
): PeekSnapshot {
|
|
120
|
+
return {
|
|
121
|
+
schemaVersion: PEEK_SCHEMA_VERSION,
|
|
122
|
+
gnoVersion: VERSION,
|
|
123
|
+
generatedAt,
|
|
124
|
+
initialized: false,
|
|
125
|
+
indexName,
|
|
126
|
+
counts: null,
|
|
127
|
+
backlog: null,
|
|
128
|
+
lastIndexedAt: null,
|
|
129
|
+
recent: [],
|
|
130
|
+
serve,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a peek@1.0 snapshot. Throws CliError("RUNTIME") on any subquery
|
|
136
|
+
* failure so callers never emit a half-filled payload.
|
|
137
|
+
*/
|
|
138
|
+
export async function buildPeekSnapshot(
|
|
139
|
+
options: BuildPeekOptions = {}
|
|
140
|
+
): Promise<PeekSnapshot> {
|
|
141
|
+
const generatedAt = new Date().toISOString();
|
|
142
|
+
const requestedIndex = options.indexName ?? DEFAULT_INDEX_NAME;
|
|
143
|
+
const serve = await readServeLiveness();
|
|
144
|
+
|
|
145
|
+
const initialized = await isInitialized(options.configPath);
|
|
146
|
+
if (!initialized) {
|
|
147
|
+
return emptySnapshot(requestedIndex, generatedAt, serve);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const configResult = await loadConfig(options.configPath);
|
|
151
|
+
if (!configResult.ok) {
|
|
152
|
+
throw new CliError("RUNTIME", configResult.error.message);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const store = new SqliteAdapter();
|
|
156
|
+
const openResult = await store.open(
|
|
157
|
+
getIndexDbPath(options.indexName),
|
|
158
|
+
configResult.value.ftsTokenizer
|
|
159
|
+
);
|
|
160
|
+
if (!openResult.ok) {
|
|
161
|
+
throw new CliError("RUNTIME", openResult.error.message);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const statusResult = await store.getStatus();
|
|
166
|
+
if (!statusResult.ok) {
|
|
167
|
+
throw new CliError("RUNTIME", statusResult.error.message);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const recentResult = await store.listDocumentsPaginated({
|
|
171
|
+
limit: PEEK_RECENT_LIMIT,
|
|
172
|
+
offset: 0,
|
|
173
|
+
});
|
|
174
|
+
if (!recentResult.ok) {
|
|
175
|
+
throw new CliError("RUNTIME", recentResult.error.message);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const status = statusResult.value;
|
|
179
|
+
return {
|
|
180
|
+
schemaVersion: PEEK_SCHEMA_VERSION,
|
|
181
|
+
gnoVersion: VERSION,
|
|
182
|
+
generatedAt,
|
|
183
|
+
initialized: true,
|
|
184
|
+
indexName: status.indexName,
|
|
185
|
+
counts: {
|
|
186
|
+
documents: status.activeDocuments,
|
|
187
|
+
collections: status.collections.length,
|
|
188
|
+
},
|
|
189
|
+
backlog: {
|
|
190
|
+
pending: status.embeddingBacklog,
|
|
191
|
+
failed: status.recentErrors,
|
|
192
|
+
},
|
|
193
|
+
lastIndexedAt: status.lastUpdatedAt,
|
|
194
|
+
recent: mapRecent(recentResult.value.documents, status),
|
|
195
|
+
serve,
|
|
196
|
+
};
|
|
197
|
+
} catch (error) {
|
|
198
|
+
throw asRuntimeError(error, "Failed to build peek snapshot");
|
|
199
|
+
} finally {
|
|
200
|
+
await store.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/mcp/http-egress.ts
CHANGED
package/src/mcp/tools/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
import { handleListJobs } from "./list-jobs";
|
|
66
66
|
import { handleListTags } from "./list-tags";
|
|
67
67
|
import { handleMultiGet } from "./multi-get";
|
|
68
|
+
import { handlePeek, PEEK_MCP_ANNOTATIONS } from "./peek";
|
|
68
69
|
import { handleQuery, handleQueryDiagnose } from "./query";
|
|
69
70
|
import { handleRemoveCollection } from "./remove-collection";
|
|
70
71
|
import { handleSearch } from "./search";
|
|
@@ -129,6 +130,7 @@ export const MCP_TOOL_DESCRIPTIONS = {
|
|
|
129
130
|
"Retrieve multiple documents by refs array or glob pattern. Use after gno_search/gno_query to batch top result URIs/docids; set maxBytes and lineNumbers to control context size.",
|
|
130
131
|
section:
|
|
131
132
|
"Create or resolve a durable SectionTargetV1 against one indexed document. action=create needs ref plus exactly one of anchor|line; action=resolve needs ref plus target. Exact/recovered include citation (uri, anchor, title, inclusive lines, fingerprint); ambiguous/stale/missing omit citation and are not safe to navigate or cite. Read-only — does not write or persist targets. Follow navigable ranges with gno_get fromLine/lineCount.",
|
|
133
|
+
peek: "Cheap peek@1.0 snapshot: initialized flag, document/collection counts, embedding backlog, recent files, and serve liveness. Model-free — never initializes embeddings or models. Use for counts/backlog/recent/serve questions; use gno_status for full health and activation.",
|
|
132
134
|
status:
|
|
133
135
|
"Get index health: collection count, document count, chunk count, embedding backlog, and per-collection stats. Check first when vector/hybrid results look stale or unavailable.",
|
|
134
136
|
audit:
|
|
@@ -664,6 +666,8 @@ const multiGetInputSchema = z.object({
|
|
|
664
666
|
.describe("Include line numbers in output"),
|
|
665
667
|
});
|
|
666
668
|
|
|
669
|
+
const peekInputSchema = z.object({});
|
|
670
|
+
|
|
667
671
|
const statusInputSchema = z.object({});
|
|
668
672
|
|
|
669
673
|
const jobStatusInputSchema = z.object({
|
|
@@ -1086,6 +1090,16 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
|
|
|
1086
1090
|
(args) => handleMultiGet(args, ctx)
|
|
1087
1091
|
);
|
|
1088
1092
|
|
|
1093
|
+
server.registerTool(
|
|
1094
|
+
"gno_peek",
|
|
1095
|
+
{
|
|
1096
|
+
description: MCP_TOOL_DESCRIPTIONS.peek,
|
|
1097
|
+
inputSchema: peekInputSchema,
|
|
1098
|
+
annotations: PEEK_MCP_ANNOTATIONS,
|
|
1099
|
+
},
|
|
1100
|
+
(args) => handlePeek(args, ctx)
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1089
1103
|
server.tool(
|
|
1090
1104
|
"gno_status",
|
|
1091
1105
|
MCP_TOOL_DESCRIPTIONS.status,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP gno_peek tool — cheap peek@1.0 snapshot, model-free.
|
|
3
|
+
*
|
|
4
|
+
* @module src/mcp/tools/peek
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PeekSnapshot } from "../../core/peek";
|
|
8
|
+
import type { ToolContext } from "../server";
|
|
9
|
+
|
|
10
|
+
import { buildPeekSnapshot } from "../../core/peek";
|
|
11
|
+
import { runTool, type ToolResult } from "./index";
|
|
12
|
+
|
|
13
|
+
type PeekInput = Record<string, never>;
|
|
14
|
+
|
|
15
|
+
export const PEEK_MCP_ANNOTATIONS = {
|
|
16
|
+
readOnlyHint: true,
|
|
17
|
+
destructiveHint: false,
|
|
18
|
+
idempotentHint: true,
|
|
19
|
+
openWorldHint: false,
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
function formatPeek(snapshot: PeekSnapshot): string {
|
|
23
|
+
const lines = [
|
|
24
|
+
`schema: ${snapshot.schemaVersion}`,
|
|
25
|
+
`gno: ${snapshot.gnoVersion}`,
|
|
26
|
+
`index: ${snapshot.indexName}`,
|
|
27
|
+
`initialized: ${snapshot.initialized ? "yes" : "no"}`,
|
|
28
|
+
];
|
|
29
|
+
if (snapshot.counts) {
|
|
30
|
+
lines.push(
|
|
31
|
+
`documents: ${snapshot.counts.documents}`,
|
|
32
|
+
`collections: ${snapshot.counts.collections}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (snapshot.backlog) {
|
|
36
|
+
lines.push(
|
|
37
|
+
`backlog: ${snapshot.backlog.pending} pending, ${snapshot.backlog.failed} failed`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (snapshot.lastIndexedAt) {
|
|
41
|
+
lines.push(`lastIndexedAt: ${snapshot.lastIndexedAt}`);
|
|
42
|
+
}
|
|
43
|
+
lines.push(
|
|
44
|
+
snapshot.serve.running && snapshot.serve.url
|
|
45
|
+
? `serve: ${snapshot.serve.url}`
|
|
46
|
+
: "serve: down"
|
|
47
|
+
);
|
|
48
|
+
if (snapshot.recent.length > 0) {
|
|
49
|
+
lines.push("recent:");
|
|
50
|
+
for (const item of snapshot.recent) {
|
|
51
|
+
const label = item.title ?? item.uri;
|
|
52
|
+
lines.push(` ${item.docid} ${label}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Handle gno_peek tool call.
|
|
60
|
+
*
|
|
61
|
+
* Uninitialized (no config / no store) is a success payload, not an error.
|
|
62
|
+
* Never probes serve over HTTP; the shared builder uses pid-file liveness.
|
|
63
|
+
*/
|
|
64
|
+
export function handlePeek(
|
|
65
|
+
_args: PeekInput,
|
|
66
|
+
ctx: ToolContext
|
|
67
|
+
): Promise<ToolResult> {
|
|
68
|
+
return runTool(
|
|
69
|
+
ctx,
|
|
70
|
+
"gno_peek",
|
|
71
|
+
async () =>
|
|
72
|
+
buildPeekSnapshot({
|
|
73
|
+
configPath: ctx.actualConfigPath,
|
|
74
|
+
indexName: ctx.indexName,
|
|
75
|
+
}),
|
|
76
|
+
formatPeek
|
|
77
|
+
);
|
|
78
|
+
}
|
package/src/pipeline/hybrid.ts
CHANGED
|
@@ -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:
|
|
1067
|
+
line: snippetStartLine,
|
|
1054
1068
|
snippet,
|
|
1055
1069
|
snippetLanguage: chunk.language ?? undefined,
|
|
1056
1070
|
snippetRange,
|
package/src/pipeline/search.ts
CHANGED
|
@@ -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:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
+
}
|
package/src/pipeline/vsearch.ts
CHANGED
|
@@ -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:
|
|
248
|
-
snippet:
|
|
250
|
+
line: snippetStartLine,
|
|
251
|
+
snippet: cleanedSnippet.text,
|
|
249
252
|
snippetLanguage: chunk.language ?? undefined,
|
|
250
253
|
snippetRange: {
|
|
251
|
-
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:
|
|
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:
|
|
365
|
+
: { startLine: snippetStartLine, endLine: chunk.endLine },
|
|
357
366
|
source: {
|
|
358
367
|
relPath: sourceRelPath,
|
|
359
368
|
absPath: collectionPath
|