feat(reedy): wire MemoryConsolidator + live memory providers into ReedyAssistant (#4310)

This commit is contained in:
Huang Xin
2026-05-26 18:02:03 +08:00
committed by GitHub
parent 6be7606da4
commit 64492d6551
3 changed files with 182 additions and 2 deletions
@@ -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<{