diff --git a/apps/readest-app/src/__tests__/reedy/consolidatorCursor.test.ts b/apps/readest-app/src/__tests__/reedy/consolidatorCursor.test.ts new file mode 100644 index 00000000..5d171272 --- /dev/null +++ b/apps/readest-app/src/__tests__/reedy/consolidatorCursor.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { sliceSinceLastId } from '@/services/reedy/memory/consolidatorCursor'; +import type { ReedyMessage } from '@/services/reedy/store/reedyStore'; + +function user(id: string, text: string, ts = 0): ReedyMessage { + return { id, role: 'user', text, createdAt: ts }; +} + +function assistant(id: string, parts: Array<{ type: 'text'; text: string }>, ts = 0): ReedyMessage { + return { + id, + role: 'assistant', + parts, + createdAt: ts, + }; +} + +describe('sliceSinceLastId', () => { + const log: ReedyMessage[] = [ + user('u1', 'hi'), + assistant('a1', [{ type: 'text', text: 'hello' }]), + user('u2', 'tell me more'), + assistant('a2', [ + { type: 'text', text: 'sure, ' }, + { type: 'text', text: 'here we go' }, + ]), + ]; + + it('returns the full log when afterId is null (no prior consolidation)', () => { + const out = sliceSinceLastId(log, null); + expect(out.map((m) => m.id)).toEqual(['u1', 'a1', 'u2', 'a2']); + }); + + it('returns only messages strictly after afterId', () => { + const out = sliceSinceLastId(log, 'a1'); + expect(out.map((m) => m.id)).toEqual(['u2', 'a2']); + }); + + it('returns [] when afterId is the last message', () => { + const out = sliceSinceLastId(log, 'a2'); + expect(out).toEqual([]); + }); + + it('falls back to the full log when afterId is not in the messages (lost cursor)', () => { + const out = sliceSinceLastId(log, 'cursor-from-deleted-session'); + expect(out.map((m) => m.id)).toEqual(['u1', 'a1', 'u2', 'a2']); + }); + + it('flattens assistant text parts into one content string per message', () => { + const out = sliceSinceLastId(log, 'u2'); + expect(out[0]).toMatchObject({ id: 'a2', role: 'assistant', content: 'sure, here we go' }); + }); + + it('passes user text through unchanged', () => { + const out = sliceSinceLastId(log, null); + const u = out.find((m) => m.id === 'u1')!; + expect(u).toMatchObject({ role: 'user', content: 'hi' }); + }); + + it('emits an empty string for assistant messages that only carry non-text parts', () => { + const onlyTool: ReedyMessage = { + id: 'a-tool', + role: 'assistant', + parts: [ + { + type: 'tool_call', + id: 'tc1', + name: 'find', + args: {}, + permission: 'read', + state: 'pending', + }, + ], + createdAt: 0, + }; + const out = sliceSinceLastId([onlyTool], null); + expect(out[0]).toMatchObject({ id: 'a-tool', role: 'assistant', content: '' }); + }); +}); diff --git a/apps/readest-app/src/services/reedy/memory/consolidatorCursor.ts b/apps/readest-app/src/services/reedy/memory/consolidatorCursor.ts new file mode 100644 index 00000000..a518eedb --- /dev/null +++ b/apps/readest-app/src/services/reedy/memory/consolidatorCursor.ts @@ -0,0 +1,40 @@ +import type { ConsolidatorMessage } from './MemoryConsolidator'; +import type { ReedyMessage } from '../store/reedyStore'; + +/** + * Slice the store's message log to the tail after a given id (exclusive), + * normalizing to the ConsolidatorMessage shape the MemoryConsolidator + * accepts. Used by ReedyAssistant's post-turn consolidate hook so we + * never re-summarize already-distilled turns. + * + * `afterId === null` (no prior consolidation) returns the whole log. + * `afterId` not found in the log (e.g. user cleared the conversation) + * also returns the whole log — the safer fallback, since the alternative + * is to skip consolidation entirely. + */ +export function sliceSinceLastId( + messages: readonly ReedyMessage[], + afterId: string | null, +): ConsolidatorMessage[] { + const startIdx = + afterId == null + ? 0 + : (() => { + const idx = messages.findIndex((m) => m.id === afterId); + return idx >= 0 ? idx + 1 : 0; + })(); + const tail = messages.slice(startIdx); + return tail.map((m) => ({ + id: m.id, + role: m.role, + content: m.role === 'user' ? m.text : flattenAssistantText(m), + createdAt: m.createdAt, + })); +} + +function flattenAssistantText(m: Extract): string { + return m.parts + .filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map((p) => p.text) + .join(''); +} diff --git a/apps/readest-app/src/services/reedy/ui/ReedyAssistant.tsx b/apps/readest-app/src/services/reedy/ui/ReedyAssistant.tsx index 84a68a52..7c773db0 100644 --- a/apps/readest-app/src/services/reedy/ui/ReedyAssistant.tsx +++ b/apps/readest-app/src/services/reedy/ui/ReedyAssistant.tsx @@ -33,6 +33,8 @@ import { type SkillInstructions, } from '../context'; import { MemoryService } from '../memory/MemoryService'; +import { MemoryConsolidator } from '../memory/MemoryConsolidator'; +import { sliceSinceLastId } from '../memory/consolidatorCursor'; import { SkillRegistry } from '../skills/SkillRegistry'; import type { Skill } from '../skills/types'; import { useReedyStore } from '../store/reedyStore'; @@ -142,6 +144,31 @@ export function ReedyAssistant({ : null; }, [activeSkill]); + // Live memory snapshots fed into BookMemoryLayer / UserMemoryLayer. + // Refs (not state) because the prompt builder reads them synchronously + // at runTurn time and we don't want every memory refresh to invalidate + // the runtime memo. refreshMemorySnapshots() is called on initial + // load, after each successful consolidate(), and whenever a memory + // write tool fires. + const bookMemorySnapshotRef = useRef(''); + const userMemorySnapshotRef = useRef(''); + const refreshMemorySnapshots = useCallback(async () => { + if (!reedy) return; + try { + const [book, user] = await Promise.all([ + reedy.memory.list('book', bookHash, 10), + reedy.memory.list('user', userId, 10), + ]); + bookMemorySnapshotRef.current = book.map((m) => `- [${m.key}] ${m.summary}`).join('\n'); + userMemorySnapshotRef.current = user.map((m) => `- [${m.key}] ${m.summary}`).join('\n'); + } catch (err) { + console.warn('[Reedy] memory snapshot refresh failed', err); + } + }, [reedy, bookHash, userId]); + useEffect(() => { + void refreshMemorySnapshots(); + }, [refreshMemorySnapshots]); + // Build the tool registry + runtime once per (reedy ready, model) // pair. The userId and bookHash flow through the memory tool factories // so each tool dispatches under the right scope_key. @@ -187,8 +214,8 @@ export function ReedyAssistant({ })(), createReadingLayer(readingRef.current), createToolCatalogLayer(reg.list()), - createBookMemoryLayer(() => ''), - createUserMemoryLayer(() => ''), + createBookMemoryLayer(() => bookMemorySnapshotRef.current), + createUserMemoryLayer(() => userMemorySnapshotRef.current), ]; return new AgentRuntime({ model: models.chat, tools: reg, layers }); @@ -199,6 +226,40 @@ export function ReedyAssistant({ const resetStore = useReedyStore((s) => s.reset); const { send, abort } = useReedyTurn(runtime); + // MemoryConsolidator (Phase 3.2) — distills the last ~3 turns into 1-3 + // durable memories on turn-finish. Fire-and-forget; failures are + // surfaced via console.warn so they don't block the chat. We track + // the last message id we've consolidated through so subsequent turns + // only feed the new tail to the model. + const consolidator = useMemo(() => { + if (!reedy) return null; + return new MemoryConsolidator({ + model: models.chat, + memory: reedy.memory, + bookHash, + userId, + onError: (err) => console.warn('[Reedy] consolidate', err), + }); + }, [reedy, models.chat, bookHash, userId]); + const lastConsolidatedIdRef = useRef(null); + const wasRunningRef = useRef(false); + useEffect(() => { + const justFinished = wasRunningRef.current && !isRunning; + wasRunningRef.current = isRunning; + if (!justFinished || !consolidator || messages.length === 0) return; + + // Pull the tail since the last consolidation. The consolidator's + // own threshold (default 6 messages) decides whether to actually + // call the model. + const tail = sliceSinceLastId(messages, lastConsolidatedIdRef.current); + if (tail.length === 0) return; + const lastId = messages[messages.length - 1]!.id; + void consolidator.consolidate(tail).then(async (written) => { + lastConsolidatedIdRef.current = lastId; + if (written.length > 0) await refreshMemorySnapshots(); + }); + }, [isRunning, messages, consolidator, refreshMemorySnapshots]); + // Indexing state — tracked locally to avoid layering yet another store. const [indexingPhase, setIndexingPhase] = useState('idle'); const [indexProgress, setIndexProgress] = useState<{