feat(reedy): wire MemoryConsolidator + live memory providers into ReedyAssistant (#4310)
This commit is contained in:
@@ -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: '' });
|
||||
});
|
||||
});
|
||||
@@ -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<ReedyMessage, { role: 'assistant' }>): string {
|
||||
return m.parts
|
||||
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
||||
.map((p) => p.text)
|
||||
.join('');
|
||||
}
|
||||
@@ -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<string | null>(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<IndexingPhase>('idle');
|
||||
const [indexProgress, setIndexProgress] = useState<{
|
||||
|
||||
Reference in New Issue
Block a user