feat(memory): singleton ingestion + status RPC + UI pill (#1126)

This commit is contained in:
Steven Enamakel
2026-05-03 12:52:55 -07:00
committed by GitHub
parent 744344e4de
commit 52eb3a175f
12 changed files with 652 additions and 10 deletions
@@ -0,0 +1,80 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useMemoryIngestionStatus } from '../useMemoryIngestionStatus';
const mockCallCoreRpc = vi.fn();
vi.mock('../../services/coreRpcClient', () => ({
callCoreRpc: (args: unknown) => mockCallCoreRpc(args),
}));
describe('useMemoryIngestionStatus', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('maps the snake_case RPC envelope into camelCase status', async () => {
mockCallCoreRpc.mockResolvedValue({
running: true,
current_document_id: 'doc-1',
current_title: 'Notes',
current_namespace: 'global',
queue_depth: 2,
last_completed_at: 1700000000000,
last_document_id: 'doc-0',
last_success: true,
});
const { result } = renderHook(() => useMemoryIngestionStatus());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.status).toEqual({
running: true,
currentDocumentId: 'doc-1',
currentTitle: 'Notes',
currentNamespace: 'global',
queueDepth: 2,
lastCompletedAt: 1700000000000,
lastDocumentId: 'doc-0',
lastSuccess: true,
});
expect(result.current.error).toBeNull();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.memory_ingestion_status' });
});
it('reports an error when the RPC fails and keeps idle defaults', async () => {
mockCallCoreRpc.mockRejectedValue(new Error('boom'));
const { result } = renderHook(() => useMemoryIngestionStatus());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.status.running).toBe(false);
expect(result.current.status.queueDepth).toBe(0);
expect(result.current.error).toBe('boom');
});
it('refresh() re-issues the RPC and updates status', async () => {
mockCallCoreRpc
.mockResolvedValueOnce({ running: false, queue_depth: 0 })
.mockResolvedValueOnce({ running: true, queue_depth: 1, current_document_id: 'doc-2' });
const { result } = renderHook(() => useMemoryIngestionStatus());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.status.running).toBe(false);
await act(async () => {
await result.current.refresh();
});
expect(result.current.status.running).toBe(true);
expect(result.current.status.queueDepth).toBe(1);
expect(result.current.status.currentDocumentId).toBe('doc-2');
});
});
+97
View File
@@ -0,0 +1,97 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { callCoreRpc } from '../services/coreRpcClient';
export interface MemoryIngestionStatus {
running: boolean;
currentDocumentId?: string;
currentTitle?: string;
currentNamespace?: string;
queueDepth: number;
lastCompletedAt?: number;
lastDocumentId?: string;
lastSuccess?: boolean;
}
interface IngestionStatusEnvelope {
running: boolean;
current_document_id?: string;
current_title?: string;
current_namespace?: string;
queue_depth: number;
last_completed_at?: number;
last_document_id?: string;
last_success?: boolean;
}
const DEFAULT_POLL_MS = 4000;
const FAST_POLL_MS = 1500;
const EMPTY_STATUS: MemoryIngestionStatus = { running: false, queueDepth: 0 };
/**
* Polls `openhuman.memory_ingestion_status`. Polls faster while a job is
* running or queued so the UI reacts quickly when ingestion finishes;
* relaxes to a slower cadence at idle.
*/
export function useMemoryIngestionStatus(): {
status: MemoryIngestionStatus;
loading: boolean;
error: string | null;
refresh: () => void;
} {
const [status, setStatus] = useState<MemoryIngestionStatus>(EMPTY_STATUS);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const cancelledRef = useRef(false);
const fetchOnce = useCallback(async () => {
try {
const env = await callCoreRpc<IngestionStatusEnvelope>({
method: 'openhuman.memory_ingestion_status',
});
if (cancelledRef.current) return;
setStatus({
running: env.running,
currentDocumentId: env.current_document_id,
currentTitle: env.current_title,
currentNamespace: env.current_namespace,
queueDepth: env.queue_depth ?? 0,
lastCompletedAt: env.last_completed_at,
lastDocumentId: env.last_document_id,
lastSuccess: env.last_success,
});
setError(null);
} catch (err) {
if (cancelledRef.current) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (!cancelledRef.current) setLoading(false);
}
}, []);
const statusRef = useRef(status);
statusRef.current = status;
useEffect(() => {
cancelledRef.current = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = async () => {
await fetchOnce();
if (cancelledRef.current) return;
const live = statusRef.current;
const delay = live.running || live.queueDepth > 0 ? FAST_POLL_MS : DEFAULT_POLL_MS;
timer = setTimeout(tick, delay);
};
void tick();
return () => {
cancelledRef.current = true;
if (timer) clearTimeout(timer);
};
}, [fetchOnce]);
return { status, loading, error, refresh: fetchOnce };
}
+20
View File
@@ -17,6 +17,7 @@ import {
useIntelligenceSocketManager,
} from '../hooks/useIntelligenceSocket';
import { useIntelligenceStats } from '../hooks/useIntelligenceStats';
import { useMemoryIngestionStatus } from '../hooks/useMemoryIngestionStatus';
import { useScreenIntelligenceItems } from '../hooks/useScreenIntelligenceItems';
import { useSubconscious } from '../hooks/useSubconscious';
import type {
@@ -31,6 +32,7 @@ type IntelligenceTab = 'memory' | 'subconscious' | 'dreams';
export default function Intelligence() {
const { aiStatus } = useIntelligenceStats();
const { status: ingestionStatus } = useMemoryIngestionStatus();
const [activeTab, setActiveTab] = useState<IntelligenceTab>('memory');
const [sourceFilter, setSourceFilter] = useState<ActionableItemSource | 'all'>('all');
@@ -312,6 +314,24 @@ export default function Intelligence() {
<span className="text-xs text-stone-400">{systemStatusLabel}</span>
</div>
)}
{activeTab === 'memory' &&
(ingestionStatus.running || ingestionStatus.queueDepth > 0) && (
<div
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full border border-amber-200 bg-amber-50 text-amber-700"
title={
ingestionStatus.running
? ingestionStatus.currentTitle
? `Ingesting: ${ingestionStatus.currentTitle}`
: 'Memory ingestion running'
: 'Memory ingestion queued'
}>
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
<span className="text-[11px] font-medium">
{ingestionStatus.running ? 'Ingesting' : 'Queued'}
{ingestionStatus.queueDepth > 0 && ` · ${ingestionStatus.queueDepth}`}
</span>
</div>
)}
{activeTab === 'memory' && (
<button
onClick={usingMemoryData ? refreshConscious : handleAnalyzeNow}