Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/grep-match-page-numbers.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/src/__tests__/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ describe('knowhere MCP wrapper', () => {
startOffset: 5,
endOffset: 12,
snippet: '2026 revenue grew',
pageNumbers: [4],
},
],
scannedChunks: 20,
Expand All @@ -672,7 +673,7 @@ describe('knowhere MCP wrapper', () => {
<chunkCounts total="2" text="0" image="0" table="0" page="2" />
</document>
<grep scannedChunks="20" truncated="true" continuationCursor="cursor-next" count="1">
<match position="2" chunkId="chunk-table-1" chunkType="table" sectionPath="Tables" chunkPath="chunks/table-1.md" filePath="tables/revenue.html" storageLocation="parsed-storage:doc_remote/tables/revenue.html" startOffset="5" endOffset="12">
<match position="2" chunkId="chunk-table-1" chunkType="table" sectionPath="Tables" chunkPath="chunks/table-1.md" filePath="tables/revenue.html" storageLocation="parsed-storage:doc_remote/tables/revenue.html" pageNumbers="4" startOffset="5" endOffset="12">
<snippet>2026 revenue grew</snippet>
</match>
</grep>
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/tool-result-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
})}>`,
Expand Down
62 changes: 62 additions & 0 deletions src/knowledge/__tests__/knowledge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
67 changes: 27 additions & 40 deletions src/knowledge/knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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 &&
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/knowledge/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading