feat(reedy): Appendix A · Phase 2.4 — built-in tools (non-memory families) (#4299)
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ToolRegistry } from '@/services/reedy/tools/ToolRegistry';
|
||||
import type { ToolContext } from '@/services/reedy/tools/types';
|
||||
import {
|
||||
createAddCitationTool,
|
||||
createCreateHighlightTool,
|
||||
createCreateNoteTool,
|
||||
createGetReadingContextTool,
|
||||
createGetSelectionTool,
|
||||
createLookupPassageTool,
|
||||
createNavigateToCfiTool,
|
||||
type ReadingContextSnapshot,
|
||||
} from '@/services/reedy/tools/builtins';
|
||||
import type { BookRetriever, RetrieverResult } from '@/services/reedy/retrieval/BookRetriever';
|
||||
import type { EmbeddingModel } from '@/services/reedy/models/EmbeddingModel';
|
||||
|
||||
function ctxFor(overrides: Partial<ToolContext> = {}): ToolContext {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
bookHash: 'bk1',
|
||||
sessionId: 's1',
|
||||
assistantMessageId: 'm1',
|
||||
signal: controller.signal,
|
||||
requestPermission: vi.fn(async () => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const readingSnapshot: ReadingContextSnapshot = {
|
||||
cfi: 'epubcfi(/6/4!/4/2,/1:0)',
|
||||
sectionIndex: 1,
|
||||
chapterTitle: 'Down the Rabbit Hole',
|
||||
pageNumber: 3,
|
||||
selection: {
|
||||
text: 'curiouser and curiouser',
|
||||
startCfi: 'epubcfi(/6/4!/4/2,/1:10,/1:20)',
|
||||
endCfi: 'epubcfi(/6/4!/4/2,/1:20,/1:33)',
|
||||
},
|
||||
};
|
||||
|
||||
describe('getReadingContext tool', () => {
|
||||
it('round-trips through ToolRegistry and returns the provider snapshot', async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createGetReadingContextTool(() => readingSnapshot));
|
||||
const out = await reg.invoke('getReadingContext', {}, ctxFor());
|
||||
expect(out).toEqual(readingSnapshot);
|
||||
});
|
||||
|
||||
it('declares permission=read so the registry skips the permission prompt', async () => {
|
||||
const reqPerm = vi.fn(async () => true);
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createGetReadingContextTool(() => readingSnapshot));
|
||||
await reg.invoke('getReadingContext', {}, ctxFor({ requestPermission: reqPerm }));
|
||||
expect(reqPerm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSelection tool', () => {
|
||||
it('returns the active selection wrapped under { selection }', async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createGetSelectionTool(() => readingSnapshot.selection ?? null));
|
||||
const out = (await reg.invoke('getSelection', {}, ctxFor())) as { selection: unknown };
|
||||
expect(out.selection).toEqual(readingSnapshot.selection);
|
||||
});
|
||||
|
||||
it('returns { selection: null } when nothing is selected', async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createGetSelectionTool(() => null));
|
||||
const out = (await reg.invoke('getSelection', {}, ctxFor())) as { selection: unknown };
|
||||
expect(out.selection).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookupPassage (Phase 2.4 wrapper) tool', () => {
|
||||
function fakeRetriever(result: RetrieverResult): BookRetriever {
|
||||
return { search: vi.fn(async () => result) } as unknown as BookRetriever;
|
||||
}
|
||||
const model: EmbeddingModel = {
|
||||
id: 'fake',
|
||||
dim: 4,
|
||||
async embed(texts) {
|
||||
return texts.map(() => [1, 0, 0, 0]);
|
||||
},
|
||||
};
|
||||
|
||||
it('maps RetrievedChunk[] into the slimmer LookupPassageResult shape', async () => {
|
||||
const retriever = fakeRetriever({
|
||||
passages: [
|
||||
{
|
||||
id: 'c1',
|
||||
bookHash: 'bk1',
|
||||
cfi: 'epubcfi(/6/2!/4/2)',
|
||||
endCfi: 'epubcfi(/6/2!/4/4)',
|
||||
sectionIndex: 0,
|
||||
chapterTitle: 'Ch1',
|
||||
text: 'hello',
|
||||
positionIndex: 0,
|
||||
score: 0.5,
|
||||
},
|
||||
],
|
||||
status: 'ok',
|
||||
});
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(
|
||||
createLookupPassageTool({ bookHash: 'bk1', retriever, activeEmbeddingModel: model }),
|
||||
);
|
||||
const out = (await reg.invoke('lookupPassage', { query: 'hello', topK: 3 }, ctxFor())) as {
|
||||
passages: Array<{ cfi: string; chapter: string | null }>;
|
||||
status: string;
|
||||
};
|
||||
expect(out.status).toBe('ok');
|
||||
expect(out.passages).toHaveLength(1);
|
||||
expect(out.passages[0]!.cfi).toBe('epubcfi(/6/2!/4/2)');
|
||||
expect(out.passages[0]!.chapter).toBe('Ch1');
|
||||
});
|
||||
|
||||
it('forwards spoilerBoundPosition through to BookRetriever.search', async () => {
|
||||
const retriever = fakeRetriever({ passages: [], status: 'ok' });
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(
|
||||
createLookupPassageTool({ bookHash: 'bk1', retriever, activeEmbeddingModel: model }),
|
||||
);
|
||||
await reg.invoke('lookupPassage', { query: 'q', topK: 3, spoilerBoundPosition: 7 }, ctxFor());
|
||||
expect(retriever.search).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ spoilerBoundPosition: 7 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addCitation tool', () => {
|
||||
it('invokes onCite with the parsed citation and returns ok', async () => {
|
||||
const onCite = vi.fn();
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createAddCitationTool(onCite));
|
||||
const out = await reg.invoke(
|
||||
'addCitation',
|
||||
{
|
||||
cfi: 'epubcfi(/6/2!/4/2)',
|
||||
snippet: 'quoted text',
|
||||
chapterTitle: 'Ch1',
|
||||
sectionIndex: 0,
|
||||
},
|
||||
ctxFor(),
|
||||
);
|
||||
expect(out).toEqual({ ok: true });
|
||||
expect(onCite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfi: 'epubcfi(/6/2!/4/2)',
|
||||
snippet: 'quoted text',
|
||||
chapterTitle: 'Ch1',
|
||||
sectionIndex: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects oversized snippets via the Zod schema', async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createAddCitationTool(vi.fn()));
|
||||
await expect(
|
||||
reg.invoke('addCitation', { cfi: 'epubcfi(/6/2)', snippet: 'x'.repeat(2_001) }, ctxFor()),
|
||||
).rejects.toMatchObject({ kind: 'tool_invalid_args' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigateToCfi tool', () => {
|
||||
it('declares permission=navigate so the registry prompts before invoking', async () => {
|
||||
const reqPerm = vi.fn(async () => true);
|
||||
const navigate = vi.fn(async () => ({ navigated: true }));
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createNavigateToCfiTool(navigate));
|
||||
await reg.invoke(
|
||||
'navigateToCfi',
|
||||
{ cfi: 'epubcfi(/6/2)' },
|
||||
ctxFor({ requestPermission: reqPerm }),
|
||||
);
|
||||
expect(reqPerm).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith('epubcfi(/6/2)');
|
||||
});
|
||||
|
||||
it('throws tool_permission_denied when the user refuses', async () => {
|
||||
const navigate = vi.fn(async () => ({ navigated: true }));
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createNavigateToCfiTool(navigate));
|
||||
await expect(
|
||||
reg.invoke(
|
||||
'navigateToCfi',
|
||||
{ cfi: 'epubcfi(/6/2)' },
|
||||
ctxFor({ requestPermission: vi.fn(async () => false) }),
|
||||
),
|
||||
).rejects.toMatchObject({ kind: 'tool_permission_denied' });
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serializes concurrent navigations (parallelSafe=false)', async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const navigate = vi.fn(async () => {
|
||||
active++;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
active--;
|
||||
return { navigated: true };
|
||||
});
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createNavigateToCfiTool(navigate));
|
||||
const ctx = ctxFor();
|
||||
await Promise.all([
|
||||
reg.invoke('navigateToCfi', { cfi: 'a' }, ctx),
|
||||
reg.invoke('navigateToCfi', { cfi: 'b' }, ctx),
|
||||
reg.invoke('navigateToCfi', { cfi: 'c' }, ctx),
|
||||
]);
|
||||
expect(maxActive).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHighlight tool', () => {
|
||||
it('persists via the injected annotation service and returns the id', async () => {
|
||||
const services = { createHighlight: vi.fn(async () => ({ id: 'h-1' })) };
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createCreateHighlightTool(services));
|
||||
const out = await reg.invoke(
|
||||
'createHighlight',
|
||||
{ cfi: 'epubcfi(/6/2)', text: 'quoted', color: 'yellow' },
|
||||
ctxFor(),
|
||||
);
|
||||
expect(out).toEqual({ id: 'h-1' });
|
||||
expect(services.createHighlight).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cfi: 'epubcfi(/6/2)', text: 'quoted', color: 'yellow' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown colors via the Zod enum', async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createCreateHighlightTool({ createHighlight: vi.fn() }));
|
||||
await expect(
|
||||
reg.invoke(
|
||||
'createHighlight',
|
||||
{ cfi: 'epubcfi(/6/2)', text: 'x', color: 'magenta' },
|
||||
ctxFor(),
|
||||
),
|
||||
).rejects.toMatchObject({ kind: 'tool_invalid_args' });
|
||||
});
|
||||
|
||||
it('write tools always prompt for permission', async () => {
|
||||
const reqPerm = vi.fn(async () => true);
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createCreateHighlightTool({ createHighlight: vi.fn(async () => ({ id: 'x' })) }));
|
||||
await reg.invoke(
|
||||
'createHighlight',
|
||||
{ cfi: 'epubcfi(/6/2)', text: 'x' },
|
||||
ctxFor({ requestPermission: reqPerm }),
|
||||
);
|
||||
expect(reqPerm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNote tool', () => {
|
||||
it('persists the note via the injected annotation service', async () => {
|
||||
const services = { createNote: vi.fn(async () => ({ id: 'n-1' })) };
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(createCreateNoteTool(services));
|
||||
const out = await reg.invoke(
|
||||
'createNote',
|
||||
{
|
||||
cfi: 'epubcfi(/6/2)',
|
||||
quotedText: 'curiouser',
|
||||
note: 'reminds me of Alice in Wonderland',
|
||||
},
|
||||
ctxFor(),
|
||||
);
|
||||
expect(out).toEqual({ id: 'n-1' });
|
||||
expect(services.createNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ quotedText: 'curiouser', note: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
import type { ReedyTool } from '../types';
|
||||
import type { CitationData } from './types';
|
||||
|
||||
const inputSchema = z.object({
|
||||
cfi: z.string().min(1),
|
||||
endCfi: z.string().optional(),
|
||||
snippet: z.string().min(1).max(2_000),
|
||||
chapterTitle: z.string().optional(),
|
||||
sectionIndex: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export interface AddCitationResult {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the model attach an explicit citation to the current assistant
|
||||
* message without having to round-trip through `lookupPassage`. The
|
||||
* AgentRuntime (Phase 2.6) translates each successful invocation into a
|
||||
* `{ type: 'citation', ... }` ReedyEvent so the Sources UI can render it.
|
||||
*/
|
||||
export function createAddCitationTool(
|
||||
onCite: (citation: CitationData) => void | Promise<void>,
|
||||
): ReedyTool<z.input<typeof inputSchema>, AddCitationResult> {
|
||||
return {
|
||||
name: 'addCitation',
|
||||
description:
|
||||
'Attach an explicit citation (CFI anchor + snippet) to the current assistant reply. Call this when you want to point the user at a passage you remembered without re-searching.',
|
||||
permission: 'read',
|
||||
parallelSafe: true,
|
||||
inputSchema,
|
||||
async run(args) {
|
||||
await onCite({
|
||||
cfi: args.cfi,
|
||||
endCfi: args.endCfi,
|
||||
snippet: args.snippet,
|
||||
chapterTitle: args.chapterTitle,
|
||||
sectionIndex: args.sectionIndex,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
import type { ReedyTool } from '../types';
|
||||
import type { AnnotationServices } from './types';
|
||||
|
||||
const inputSchema = z.object({
|
||||
cfi: z.string().min(1),
|
||||
endCfi: z.string().optional(),
|
||||
text: z.string().min(1).max(10_000),
|
||||
color: z.enum(['yellow', 'green', 'blue', 'pink', 'red']).optional(),
|
||||
});
|
||||
|
||||
export interface CreateHighlightResult {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a highlight on the user's book through the existing annotation
|
||||
* service. `permission: 'write'` — the ToolRegistry prompts the user
|
||||
* before every call (no auto-approve, even after the first), since
|
||||
* highlights mutate the user's annotations and we'd rather over-prompt
|
||||
* than land a surprise edit.
|
||||
*
|
||||
* The annotation service callback owns persistence (Hardcover sync, local
|
||||
* IDB, etc.); the tool only translates the model's request into the call.
|
||||
*/
|
||||
export function createCreateHighlightTool(
|
||||
services: Pick<AnnotationServices, 'createHighlight'>,
|
||||
): ReedyTool<z.input<typeof inputSchema>, CreateHighlightResult> {
|
||||
return {
|
||||
name: 'createHighlight',
|
||||
description:
|
||||
"Highlight a passage in the user's book at the given CFI range. Optionally pick a color. Call this only when the user explicitly asks to highlight something — never unsolicited.",
|
||||
permission: 'write',
|
||||
parallelSafe: false,
|
||||
inputSchema,
|
||||
async run(args) {
|
||||
const created = await services.createHighlight({
|
||||
cfi: args.cfi,
|
||||
endCfi: args.endCfi,
|
||||
text: args.text,
|
||||
color: args.color,
|
||||
});
|
||||
return { id: created.id };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
import type { ReedyTool } from '../types';
|
||||
import type { AnnotationServices } from './types';
|
||||
|
||||
const inputSchema = z.object({
|
||||
cfi: z.string().min(1),
|
||||
endCfi: z.string().optional(),
|
||||
quotedText: z.string().min(1).max(10_000),
|
||||
note: z.string().min(1).max(10_000),
|
||||
});
|
||||
|
||||
export interface CreateNoteResult {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a note attached to a passage in the user's book. Like
|
||||
* `createHighlight`, marked `permission: 'write'` so the user approves
|
||||
* each call before any annotation lands. The note body is the model's
|
||||
* own commentary; quotedText is the passage being annotated.
|
||||
*/
|
||||
export function createCreateNoteTool(
|
||||
services: Pick<AnnotationServices, 'createNote'>,
|
||||
): ReedyTool<z.input<typeof inputSchema>, CreateNoteResult> {
|
||||
return {
|
||||
name: 'createNote',
|
||||
description:
|
||||
"Attach a note to a passage in the user's book at the given CFI range. The note body is your commentary; quotedText is the original passage. Call this only when the user explicitly asks to add a note.",
|
||||
permission: 'write',
|
||||
parallelSafe: false,
|
||||
inputSchema,
|
||||
async run(args) {
|
||||
const created = await services.createNote({
|
||||
cfi: args.cfi,
|
||||
endCfi: args.endCfi,
|
||||
quotedText: args.quotedText,
|
||||
note: args.note,
|
||||
});
|
||||
return { id: created.id };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
import type { ReedyTool } from '../types';
|
||||
import type { ReadingContextSnapshot } from './types';
|
||||
|
||||
const inputSchema = z.object({});
|
||||
|
||||
/**
|
||||
* Read-only tool that returns the user's current reading position +
|
||||
* chapter title + active selection. The agent uses this when it needs
|
||||
* to ground its answer in where the user actually is, especially when
|
||||
* the user asks vague questions ("what does this mean?", "summarize the
|
||||
* last paragraph").
|
||||
*/
|
||||
export function createGetReadingContextTool(
|
||||
provider: () => ReadingContextSnapshot,
|
||||
): ReedyTool<z.input<typeof inputSchema>, ReadingContextSnapshot> {
|
||||
return {
|
||||
name: 'getReadingContext',
|
||||
description:
|
||||
'Get the user\'s current reading position, chapter title, page number, and any active text selection. Call this when you need to answer questions about the user\'s immediate context ("this paragraph", "this chapter", etc).',
|
||||
permission: 'read',
|
||||
parallelSafe: true,
|
||||
inputSchema,
|
||||
async run() {
|
||||
return provider();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
import type { ReedyTool } from '../types';
|
||||
import type { ReadingContextSnapshot } from './types';
|
||||
|
||||
const inputSchema = z.object({});
|
||||
|
||||
export interface GetSelectionResult {
|
||||
selection: NonNullable<ReadingContextSnapshot['selection']> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user's active text selection if any, or null otherwise.
|
||||
* Narrower than getReadingContext — exists so the agent can pin a
|
||||
* quoted-passage interaction (right-click "Ask Reedy about this") without
|
||||
* having to fetch full reading state.
|
||||
*/
|
||||
export function createGetSelectionTool(
|
||||
provider: () => ReadingContextSnapshot['selection'] | null,
|
||||
): ReedyTool<z.input<typeof inputSchema>, GetSelectionResult> {
|
||||
return {
|
||||
name: 'getSelection',
|
||||
description:
|
||||
"Get the user's currently selected text in the book (with start and end CFI anchors). Returns null if nothing is selected. Use this when the user references something they highlighted.",
|
||||
permission: 'read',
|
||||
parallelSafe: true,
|
||||
inputSchema,
|
||||
async run() {
|
||||
return { selection: provider() ?? null };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Phase 2.4 built-in tools — each factory takes its dependencies as an
|
||||
* argument so callers (AgentRuntime, tests) wire whatever read/write/
|
||||
* navigate surfaces they have. Memory tools (families 3, 4, 5) ship after
|
||||
* Phase 3.
|
||||
*/
|
||||
export { createGetReadingContextTool } from './getReadingContext';
|
||||
export { createGetSelectionTool } from './getSelection';
|
||||
export type { GetSelectionResult } from './getSelection';
|
||||
export { createLookupPassageTool } from './lookupPassage';
|
||||
export type { LookupPassageDeps, LookupPassageResult } from './lookupPassage';
|
||||
export { createAddCitationTool } from './addCitation';
|
||||
export type { AddCitationResult } from './addCitation';
|
||||
export { createNavigateToCfiTool } from './navigateToCfi';
|
||||
export { createCreateHighlightTool } from './createHighlight';
|
||||
export type { CreateHighlightResult } from './createHighlight';
|
||||
export { createCreateNoteTool } from './createNote';
|
||||
export type { CreateNoteResult } from './createNote';
|
||||
export type {
|
||||
AnnotationServices,
|
||||
CitationData,
|
||||
CreateHighlightArgs,
|
||||
CreateNoteArgs,
|
||||
NavigateResult,
|
||||
ReadingContextSnapshot,
|
||||
} from './types';
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from 'zod';
|
||||
import type { ReedyTool } from '../types';
|
||||
import type { BookRetriever, RetrieverStatus } from '@/services/reedy/retrieval/BookRetriever';
|
||||
import type { EmbeddingModel } from '@/services/reedy/models/EmbeddingModel';
|
||||
|
||||
/**
|
||||
* Phase 2.4 ReedyTool wrapper around BookRetriever.search().
|
||||
*
|
||||
* Distinct from the MVP `src/services/reedy/tools/lookupPassage.ts` —
|
||||
* that one is a Vercel `ai`-SDK Tool factory with baked-in turn-state
|
||||
* (dedupe, budget, parallel serialization, size clamp, trust marker).
|
||||
* This one is a thinner ReedyTool that exposes a single search call to
|
||||
* the M2.6 AgentRuntime. The runtime adds turn-state policies through
|
||||
* the ToolRegistry + PromptContextBuilder layers instead of baking them
|
||||
* into the tool body. Both lookupPassage paths can coexist while we
|
||||
* compare them against the measurement-plan targets.
|
||||
*/
|
||||
|
||||
const inputSchema = z.object({
|
||||
query: z.string().min(1).max(500),
|
||||
topK: z.number().int().min(1).max(5).default(5),
|
||||
spoilerBoundPosition: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export interface LookupPassageDeps {
|
||||
bookHash: string;
|
||||
retriever: BookRetriever;
|
||||
activeEmbeddingModel: EmbeddingModel;
|
||||
}
|
||||
|
||||
export interface LookupPassageResult {
|
||||
passages: Array<{
|
||||
cfi: string;
|
||||
endCfi: string;
|
||||
chapter: string | null;
|
||||
text: string;
|
||||
score: number;
|
||||
}>;
|
||||
status: RetrieverStatus;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function createLookupPassageTool(
|
||||
deps: LookupPassageDeps,
|
||||
): ReedyTool<z.input<typeof inputSchema>, LookupPassageResult> {
|
||||
return {
|
||||
name: 'lookupPassage',
|
||||
description:
|
||||
"Search the user's currently open book for passages relevant to a query. Returns up to topK passages with CFI anchors the user can navigate to. Use this whenever the user asks about book content.",
|
||||
permission: 'read',
|
||||
parallelSafe: true,
|
||||
inputSchema,
|
||||
async run(args) {
|
||||
const parsed = inputSchema.parse(args);
|
||||
const res = await deps.retriever.search({
|
||||
bookHash: deps.bookHash,
|
||||
query: parsed.query,
|
||||
k: parsed.topK,
|
||||
spoilerBoundPosition: parsed.spoilerBoundPosition,
|
||||
activeEmbeddingModel: deps.activeEmbeddingModel,
|
||||
});
|
||||
return {
|
||||
passages: res.passages.map((p) => ({
|
||||
cfi: p.cfi,
|
||||
endCfi: p.endCfi,
|
||||
chapter: p.chapterTitle,
|
||||
text: p.text,
|
||||
score: p.score,
|
||||
})),
|
||||
status: res.status,
|
||||
reason: res.reason,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
import type { ReedyTool } from '../types';
|
||||
import type { NavigateResult } from './types';
|
||||
|
||||
const inputSchema = z.object({
|
||||
cfi: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Navigate the reader to a specific CFI. Marked `permission: 'navigate'`
|
||||
* so the ToolRegistry's permission gate prompts the user before the
|
||||
* first call per session (per §10's v1 UX).
|
||||
*
|
||||
* `parallelSafe: false` — only one navigate may be in flight at a time so
|
||||
* later calls in the same turn don't yank the view mid-scroll.
|
||||
*/
|
||||
export function createNavigateToCfiTool(
|
||||
navigate: (cfi: string) => Promise<NavigateResult>,
|
||||
): ReedyTool<z.input<typeof inputSchema>, NavigateResult> {
|
||||
return {
|
||||
name: 'navigateToCfi',
|
||||
description:
|
||||
"Navigate the reader to a specific CFI location in the user's currently open book. Useful when the user asks 'take me to chapter 3' or 'show me where Alice meets the rabbit'.",
|
||||
permission: 'navigate',
|
||||
parallelSafe: false,
|
||||
inputSchema,
|
||||
async run(args) {
|
||||
return navigate(args.cfi);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Shared types for the built-in Reedy tools (Phase 2.4).
|
||||
*
|
||||
* These are deliberately small interfaces — each tool factory takes a
|
||||
* narrowly-typed dependency rather than the whole reader/annotation/store
|
||||
* surface, so unit tests can mock cleanly and the runtime can swap impls
|
||||
* (e.g. Tauri reader view vs. a fake test view) without per-tool refactor.
|
||||
*/
|
||||
|
||||
export interface ReadingContextSnapshot {
|
||||
/** Current CFI the user is on, or null when the book hasn't been opened. */
|
||||
cfi: string | null;
|
||||
sectionIndex: number;
|
||||
chapterTitle: string | null;
|
||||
/** Page number in Readest's 1500-chars-per-page formula. */
|
||||
pageNumber: number;
|
||||
/** Active text selection, if any. */
|
||||
selection?: {
|
||||
text: string;
|
||||
startCfi: string;
|
||||
endCfi: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CitationData {
|
||||
cfi: string;
|
||||
endCfi?: string;
|
||||
snippet: string;
|
||||
chapterTitle?: string;
|
||||
sectionIndex?: number;
|
||||
}
|
||||
|
||||
export interface NavigateResult {
|
||||
navigated: boolean;
|
||||
/** Optional reason when navigated=false (e.g. "view-not-ready"). */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CreateHighlightArgs {
|
||||
cfi: string;
|
||||
endCfi?: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface CreateNoteArgs {
|
||||
cfi: string;
|
||||
endCfi?: string;
|
||||
quotedText: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface AnnotationServices {
|
||||
createHighlight(args: CreateHighlightArgs): Promise<{ id: string }>;
|
||||
createNote(args: CreateNoteArgs): Promise<{ id: string }>;
|
||||
}
|
||||
Reference in New Issue
Block a user