From f158e98caca37bd46a54887c8a6eb22ccaa40a60 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Wed, 19 Aug 2026 22:34:51 +0800 Subject: [PATCH] feat: copy page numbers onto grep matches Grep already had the source chunk in memory; omitting pageNumbers forced clients to re-read the chunk just to label citations. Co-authored-by: Cursor --- .changeset/grep-match-page-numbers.md | 6 ++ README.md | 1 + packages/mcp/src/__tests__/mcp.test.ts | 3 +- packages/mcp/src/tool-result-formatter.ts | 1 + src/knowledge/__tests__/knowledge.test.ts | 62 +++++++++++++++++++++ src/knowledge/knowledge.ts | 67 +++++++++-------------- src/knowledge/types.ts | 2 + 7 files changed, 101 insertions(+), 41 deletions(-) create mode 100644 .changeset/grep-match-page-numbers.md diff --git a/.changeset/grep-match-page-numbers.md b/.changeset/grep-match-page-numbers.md new file mode 100644 index 0000000..01aded8 --- /dev/null +++ b/.changeset/grep-match-page-numbers.md @@ -0,0 +1,6 @@ +--- +"@ontos-ai/knowhere-sdk": patch +"@ontos-ai/knowhere-mcp": patch +--- + +Copy source-chunk `pageNumbers` onto `knowledge.grepChunks` matches so clients can label citations without a follow-up `readChunks` call. diff --git a/README.md b/README.md index 3266cc8..b30a13a 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,7 @@ const grep = await client.knowledge.grepChunks({ pattern: 'warranty', maxResults: 10, }); +// grep.matches include pageNumbers when the source chunk has them. const serverSearch = await client.knowledge.search({ query: 'battery warranty', diff --git a/packages/mcp/src/__tests__/mcp.test.ts b/packages/mcp/src/__tests__/mcp.test.ts index 4e64b8a..f2eb519 100644 --- a/packages/mcp/src/__tests__/mcp.test.ts +++ b/packages/mcp/src/__tests__/mcp.test.ts @@ -648,6 +648,7 @@ describe('knowhere MCP wrapper', () => { startOffset: 5, endOffset: 12, snippet: '2026 revenue grew', + pageNumbers: [4], }, ], scannedChunks: 20, @@ -672,7 +673,7 @@ describe('knowhere MCP wrapper', () => { - + 2026 revenue grew diff --git a/packages/mcp/src/tool-result-formatter.ts b/packages/mcp/src/tool-result-formatter.ts index f05f542..32ed231 100644 --- a/packages/mcp/src/tool-result-formatter.ts +++ b/packages/mcp/src/tool-result-formatter.ts @@ -423,6 +423,7 @@ function appendGrepMatch( chunkPath: fields.chunkPath, filePath: fields.filePath, storageLocation: fields.storageLocation, + pageNumbers: fields.pageNumbers?.join(','), startOffset: readNumber(match, 'startOffset'), endOffset: readNumber(match, 'endOffset'), })}>`, diff --git a/src/knowledge/__tests__/knowledge.test.ts b/src/knowledge/__tests__/knowledge.test.ts index 7edf1f6..586f9db 100644 --- a/src/knowledge/__tests__/knowledge.test.ts +++ b/src/knowledge/__tests__/knowledge.test.ts @@ -780,6 +780,68 @@ describe('Knowledge', () => { }); }); + it('should copy page numbers onto grep matches from the source chunk', async () => { + const knowledge = await createKnowledgeWithCachedResult(createPageParseResult()); + + const grep = await knowledge.grepChunks({ + localDocumentId: 'local-report', + pattern: 'Page one', + maxResults: 1, + }); + + expect(grep.matches[0]).toMatchObject({ + chunkId: 'page-1', + chunkType: 'page', + pageNumbers: [1], + }); + }); + + it('should copy page numbers onto remote grep matches from listed chunks', async () => { + const cacheDirectory = await createTempDirectory(); + const { client } = createClient(createPageParseResult()); + const knowledge = new Knowledge(client, { cacheDirectory }); + + const grep = await knowledge.grepChunks({ + documentId: 'doc_remote', + pattern: 'Page one', + maxResults: 1, + }); + + expect(grep.matches[0]).toMatchObject({ + chunkId: 'page-1', + chunkType: 'page', + pageNumbers: [1], + }); + }); + + it('should copy page numbers onto grep matches from parsed storage', async () => { + const cacheDirectory = await createTempDirectory(); + const parseResult = createPageParseResult(); + const storage = createInMemoryParsedStorage(); + storage.seedResult({ + documentId: 'doc_remote', + revisionKey: 'jres_remote', + result: parseResult, + }); + const { client, documentsListChunks } = createClient(parseResult); + const knowledge = new Knowledge(client, { cacheDirectory }).withParsedStorage({ + storage, + }); + + const grep = await knowledge.grepChunks({ + documentId: 'doc_remote', + pattern: 'Page one', + maxResults: 1, + }); + + expect(grep.matches[0]).toMatchObject({ + chunkId: 'page-1', + chunkType: 'page', + pageNumbers: [1], + }); + expect(documentsListChunks).toHaveBeenCalled(); + }); + it('should omit useAgentic when unset so API map-nav default applies', async () => { const cacheDirectory = await createTempDirectory(); const { client, retrievalQuery } = createClient(createParseResult()); diff --git a/src/knowledge/knowledge.ts b/src/knowledge/knowledge.ts index a7c9003..198580c 100644 --- a/src/knowledge/knowledge.ts +++ b/src/knowledge/knowledge.ts @@ -456,17 +456,7 @@ export class Knowledge { scannedChunks += 1; const chunkMatches = matcher(chunk.content); for (const match of chunkMatches) { - matches.push({ - position: chunk.position, - chunkId: chunk.chunkId, - chunkType: chunk.chunkType, - sectionPath: chunk.sectionPath, - sourceChunkPath: chunk.sourceChunkPath, - filePath: chunk.filePath, - startOffset: match.startOffset, - endOffset: match.endOffset, - snippet: buildSnippet(chunk.content, match.startOffset, match.endOffset, contextChars), - }); + matches.push(toGrepMatch(chunk, match, contextChars)); if (matches.length >= maxResults) { return { document, matches, scannedChunks, truncated: true }; } @@ -753,11 +743,12 @@ export class Knowledge { ) { continue; } - if (!matchesGrepScope(toIndexedRemoteChunk(chunk), params)) { + const indexed = toIndexedRemoteChunk(chunk); + if (!matchesGrepScope(indexed, params)) { continue; } scannedChunks += 1; - const chunkMatches = matcher(chunk.content ?? ''); + const chunkMatches = matcher(indexed.content); for (const [matchIndex, match] of chunkMatches.entries()) { if ( response.pagination.page === startPage && @@ -767,22 +758,7 @@ export class Knowledge { ) { continue; } - matches.push({ - position: toChunkPosition(chunk), - chunkId: chunk.chunkId, - chunkType: chunk.chunkType, - sectionPath: chunk.sectionPath ?? '', - sourceChunkPath: chunk.sourceChunkPath ?? chunk.sectionPath ?? '', - filePath: chunk.filePath ?? undefined, - startOffset: match.startOffset, - endOffset: match.endOffset, - snippet: buildSnippet( - chunk.content ?? '', - match.startOffset, - match.endOffset, - contextChars, - ), - }); + matches.push(toGrepMatch(indexed, match, contextChars)); if (matches.length >= maxResults) { const continuationCursor = createGrepContinuationCursor({ documentId, @@ -985,17 +961,7 @@ export class Knowledge { for (const chunk of scopedChunks) { scannedChunks += 1; for (const match of matcher(chunk.content)) { - matches.push({ - position: chunk.position, - chunkId: chunk.chunkId, - chunkType: chunk.chunkType, - sectionPath: chunk.sectionPath, - sourceChunkPath: chunk.sourceChunkPath, - filePath: chunk.filePath, - startOffset: match.startOffset, - endOffset: match.endOffset, - snippet: buildSnippet(chunk.content, match.startOffset, match.endOffset, contextChars), - }); + matches.push(toGrepMatch(chunk, match, contextChars)); if (matches.length >= maxResults) { return { document: createStoredKnowledgeDocument({ @@ -2325,6 +2291,27 @@ function toReadChunk(chunk: IndexedKnowledgeChunk): KnowledgeReadChunk { }; } +function toGrepMatch( + chunk: IndexedKnowledgeChunk, + match: KnowledgeGrepMatchOffset, + contextChars: number, +): KnowledgeGrepMatch { + return { + position: chunk.position, + chunkId: chunk.chunkId, + chunkType: chunk.chunkType, + sectionPath: chunk.sectionPath, + sourceChunkPath: chunk.sourceChunkPath, + filePath: chunk.filePath, + startOffset: match.startOffset, + endOffset: match.endOffset, + snippet: buildSnippet(chunk.content, match.startOffset, match.endOffset, contextChars), + ...(chunk.pageNumbers && chunk.pageNumbers.length > 0 + ? { pageNumbers: [...chunk.pageNumbers] } + : {}), + }; +} + function matchesGrepScope(chunk: IndexedKnowledgeChunk, params: KnowledgeGrepParams): boolean { if (params.chunkType && chunk.chunkType !== params.chunkType) { return false; diff --git a/src/knowledge/types.ts b/src/knowledge/types.ts index 64e49ca..e889a81 100644 --- a/src/knowledge/types.ts +++ b/src/knowledge/types.ts @@ -206,6 +206,8 @@ export interface KnowledgeGrepMatch { startOffset: number; endOffset: number; snippet: string; + /** Copied from the source chunk when the indexed chunk has page numbers. */ + pageNumbers?: number[]; } export interface KnowledgeGrepResponse {