Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Steven Enamakel
2026-03-31 11:14:17 -07:00
6 changed files with 534 additions and 6 deletions
@@ -6,8 +6,10 @@ import {
aiListMemoryFiles,
aiReadMemoryFile,
aiWriteMemoryFile,
type GraphRelation,
isTauri,
memoryDeleteDocument,
memoryGraphQuery,
memoryListDocuments,
memoryListNamespaces,
memoryQueryNamespace,
@@ -158,6 +160,9 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
const [memoryNote, setMemoryNote] = useState('');
const [memoryNoteSaving, setMemoryNoteSaving] = useState(false);
const [graphRelations, setGraphRelations] = useState<GraphRelation[]>([]);
const [graphRelationsLoading, setGraphRelationsLoading] = useState(false);
const loadWorkspace = useCallback(async () => {
if (!isTauri()) return;
@@ -171,6 +176,17 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
aiListMemoryFiles('memory'),
]);
// Load graph relations from local store
setGraphRelationsLoading(true);
try {
const relations = await memoryGraphQuery();
setGraphRelations(relations);
} catch {
setGraphRelations([]);
} finally {
setGraphRelationsLoading(false);
}
const docs = normalizeMemoryDocuments(documentsPayload);
const combinedFiles = ['memory.md', ...memoryDirFiles.map(file => `memory/${file}`)];
@@ -342,13 +358,33 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
value: `${item.count} docs`,
}));
const entityNodes = Object.entries(entities || {})
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([type, count]) => ({ label: type, value: `${count} entities` }));
// Build entity nodes from local graph store relations
const entityCounts = new Map<string, number>();
for (const rel of graphRelations) {
const subjectType =
(rel.attrs?.entity_types as Record<string, string> | undefined)?.subject ?? 'entity';
const objectType =
(rel.attrs?.entity_types as Record<string, string> | undefined)?.object ?? 'entity';
entityCounts.set(subjectType, (entityCounts.get(subjectType) ?? 0) + 1);
entityCounts.set(objectType, (entityCounts.get(objectType) ?? 0) + 1);
}
let entityNodes: MemoryNode[];
if (entityCounts.size > 0) {
entityNodes = Array.from(entityCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([type, count]) => ({ label: type, value: `${count} entities` }));
} else {
// Fallback to backend API entity counts
entityNodes = Object.entries(entities || {})
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([type, count]) => ({ label: type, value: `${count} entities` }));
}
return [...namespaceNodes, ...entityNodes].slice(0, 6);
}, [entities, topNamespaces]);
}, [entities, graphRelations, topNamespaces]);
return (
<section className="glass rounded-2xl p-5 border border-white/10 animate-fade-up">
@@ -464,6 +500,52 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
<div className="mt-3 text-xs text-stone-400">
Nodes show top namespaces and entity buckets connected to your core profile.
</div>
<div className="mt-3">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-stone-400">
Graph relations{' '}
{graphRelations.length > 0 && (
<span className="text-stone-500">({graphRelations.length})</span>
)}
</div>
{graphRelationsLoading && (
<div className="text-[10px] text-stone-500">Loading...</div>
)}
</div>
<div className="space-y-1 max-h-36 overflow-y-auto pr-1">
{graphRelations.length === 0 ? (
<div className="text-[11px] text-stone-500">
{graphRelationsLoading
? 'Loading graph...'
: 'No relations yet. Ingest documents to populate the graph.'}
</div>
) : (
graphRelations.slice(0, 20).map((rel, idx) => (
<div
key={`${rel.subject}-${rel.predicate}-${rel.object}-${idx}`}
className="flex items-center gap-1.5 rounded border border-white/5 bg-stone-950/50 px-2 py-1 text-[11px]">
<span className="text-primary-300 truncate max-w-20" title={rel.subject}>
{rel.subject}
</span>
<span className="text-stone-500 shrink-0">&rarr;</span>
<span className="text-amber-300/80 truncate max-w-20" title={rel.predicate}>
{rel.predicate}
</span>
<span className="text-stone-500 shrink-0">&rarr;</span>
<span className="text-emerald-300/80 truncate max-w-20" title={rel.object}>
{rel.object}
</span>
{rel.evidenceCount > 1 && (
<span className="ml-auto text-[10px] text-stone-500 shrink-0">
x{rel.evidenceCount}
</span>
)}
</div>
))
)}
</div>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-4 xl:col-span-2">
@@ -635,6 +717,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
<div className="mt-3 text-[11px] text-stone-500">
Sessions: {sessions ? formatNumber(sessions.total) : '--'} · Token volume:{' '}
{sessions ? formatNumber(sessions.totalTokens) : '--'}
{graphRelations.length > 0 && <> · Relations: {formatNumber(graphRelations.length)}</>}
{entities && <> · Entity buckets: {formatNumber(Object.keys(entities).length)}</>}
</div>
</section>
@@ -0,0 +1,160 @@
import { screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import { MemoryWorkspace } from '../MemoryWorkspace';
// Mock useIntelligenceStats — the hook used by MemoryWorkspace
vi.mock('../../../hooks/useIntelligenceStats', () => ({
useIntelligenceStats: () => ({
sessions: { total: 5, totalTokens: 1200 },
memoryFiles: 3,
entities: { contact: 2, message: 10 },
isLoading: false,
refetch: vi.fn(),
}),
}));
// Override the global tauriCommands mock from setup.ts with memory-specific stubs
vi.mock('../../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
memoryListDocuments: vi.fn().mockResolvedValue({
documents: [
{ documentId: 'doc-1', namespace: 'research', title: 'Paper A' },
{ documentId: 'doc-2', namespace: 'research', title: 'Paper B' },
],
}),
memoryListNamespaces: vi.fn().mockResolvedValue(['research', 'conversations']),
aiListMemoryFiles: vi.fn().mockResolvedValue(['2026-03-31.md']),
aiReadMemoryFile: vi.fn().mockResolvedValue('# Memory\nSome content'),
aiWriteMemoryFile: vi.fn().mockResolvedValue(undefined),
memoryDeleteDocument: vi.fn().mockResolvedValue(undefined),
memoryQueryNamespace: vi.fn().mockResolvedValue('query result'),
memoryRecallNamespace: vi.fn().mockResolvedValue('recall result'),
memoryGraphQuery: vi.fn().mockResolvedValue([
{
namespace: 'research',
subject: 'Alice',
predicate: 'AUTHORED',
object: 'Paper A',
attrs: { entity_types: { subject: 'person', object: 'document' } },
updatedAt: 1700000000,
evidenceCount: 3,
orderIndex: null,
documentIds: ['doc-1'],
chunkIds: ['doc-1#chunk-1'],
},
{
namespace: 'research',
subject: 'Bob',
predicate: 'REVIEWED',
object: 'Paper A',
attrs: { entity_types: { subject: 'person', object: 'document' } },
updatedAt: 1700000001,
evidenceCount: 1,
orderIndex: null,
documentIds: ['doc-1'],
chunkIds: [],
},
]),
}));
describe('MemoryWorkspace', () => {
const onToast = vi.fn();
it('renders the Memory Workspace heading', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
expect(screen.getByText('Memory Workspace')).toBeInTheDocument();
});
it('displays graph relations after loading', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(screen.getByText('AUTHORED')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getByText('REVIEWED')).toBeInTheDocument();
// "Paper A" appears in both graph relations and documents list,
// so just verify at least one instance is present
expect(screen.getAllByText('Paper A').length).toBeGreaterThanOrEqual(1);
});
it('shows evidence count badge when > 1', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(screen.getByText('x3')).toBeInTheDocument();
});
// Bob's relation has evidenceCount 1 — should NOT show a badge
expect(screen.queryByText('x1')).not.toBeInTheDocument();
});
it('shows relations count in footer stats', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(screen.getByText(/Relations: 2/)).toBeInTheDocument();
});
});
it('displays Graph relations header with count', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(screen.getByText('(2)')).toBeInTheDocument();
});
});
it('builds entity nodes from graph relation attrs', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
// Entity nodes are built from entity_types in attrs
// person appears 2x (Alice and Bob as subject), document appears 2x (Paper A as object)
await waitFor(() => {
// Entity nodes should include counts from relation entity_types
expect(screen.getByText('2 entities')).toBeInTheDocument();
});
});
});
describe('MemoryWorkspace no graph relations', () => {
const onToast = vi.fn();
it('shows empty-state message when no relations exist', async () => {
// Override only memoryGraphQuery to return empty
const tauriMod = await import('../../../utils/tauriCommands');
vi.mocked(tauriMod.memoryGraphQuery).mockResolvedValueOnce([]);
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(
screen.getByText('No relations yet. Ingest documents to populate the graph.')
).toBeInTheDocument();
});
});
});
describe('MemoryWorkspace non-Tauri environment', () => {
const onToast = vi.fn();
it('shows Tauri-required warning when not running in Tauri', async () => {
const tauriMod = await import('../../../utils/tauriCommands');
vi.mocked(tauriMod.isTauri).mockReturnValue(false);
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(
screen.getByText('Memory workspace requires the desktop Tauri runtime to load real data.')
).toBeInTheDocument();
});
// Restore for other tests
vi.mocked(tauriMod.isTauri).mockReturnValue(true);
});
});
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from 'vitest';
import { memoryDocIngest, memoryGraphQuery } from '../tauriCommands';
// The global setup mocks isTauri to return false by default.
// We need to selectively override it for these tests.
// Re-mock tauriCommands so we can test the actual implementations
// rather than the blanket mock from setup.ts.
vi.mock('../tauriCommands', async () => {
const actual = await vi.importActual<Record<string, unknown>>('../tauriCommands');
return actual;
});
// Mock @tauri-apps/api/core — isTauri controls the guard in each function
const mockIsTauri = vi.fn(() => false);
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: () => mockIsTauri() }));
// Mock callCoreRpc — the underlying transport for all memory commands
const mockCallCoreRpc = vi.fn();
vi.mock('../../services/coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
describe('memoryGraphQuery', () => {
it('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(memoryGraphQuery()).rejects.toThrow('Not running in Tauri');
});
it('calls core RPC with memory.graph.query method and optional params', async () => {
mockIsTauri.mockReturnValue(true);
const mockRelations = [
{
namespace: 'team',
subject: 'Alice',
predicate: 'OWNS',
object: 'Atlas',
attrs: {},
updatedAt: 1700000000,
evidenceCount: 2,
orderIndex: null,
documentIds: ['doc-1'],
chunkIds: ['doc-1#chunk-1'],
},
];
mockCallCoreRpc.mockResolvedValue(mockRelations);
const result = await memoryGraphQuery('team', 'Alice', 'OWNS');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'memory.graph.query',
params: { namespace: 'team', subject: 'Alice', predicate: 'OWNS' },
});
expect(result).toEqual(mockRelations);
});
it('passes undefined params when called with no arguments', async () => {
mockIsTauri.mockReturnValue(true);
mockCallCoreRpc.mockResolvedValue([]);
await memoryGraphQuery();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'memory.graph.query',
params: { namespace: undefined, subject: undefined, predicate: undefined },
});
});
});
describe('memoryDocIngest', () => {
it('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(
memoryDocIngest({ namespace: 'ns', key: 'k', title: 't', content: 'c' })
).rejects.toThrow('Not running in Tauri');
});
it('calls core RPC with memory.doc.ingest and forwards all params', async () => {
mockIsTauri.mockReturnValue(true);
const ingestResult = {
document_id: 'doc-123',
entity_count: 3,
relation_count: 2,
chunk_count: 5,
};
mockCallCoreRpc.mockResolvedValue(ingestResult);
const params = {
namespace: 'research',
key: 'paper-1',
title: 'Memory Paper',
content: 'Some content about memory systems',
source_type: 'paper',
priority: 'high',
tags: ['memory', 'ai'],
metadata: { author: 'Alice' },
category: 'research',
};
const result = await memoryDocIngest(params);
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'memory.doc.ingest', params });
expect(result).toEqual(ingestResult);
});
it('sends only required fields when optional fields are omitted', async () => {
mockIsTauri.mockReturnValue(true);
mockCallCoreRpc.mockResolvedValue({});
const params = { namespace: 'ns', key: 'k', title: 't', content: 'c' };
await memoryDocIngest(params);
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'memory.doc.ingest', params });
});
});
+46
View File
@@ -267,6 +267,52 @@ export async function memoryRecallNamespace(
});
}
export interface GraphRelation {
namespace: string | null;
subject: string;
predicate: string;
object: string;
attrs: Record<string, unknown>;
updatedAt: number;
evidenceCount: number;
orderIndex: number | null;
documentIds: string[];
chunkIds: string[];
}
export async function memoryGraphQuery(
namespace?: string,
subject?: string,
predicate?: string
): Promise<GraphRelation[]> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<GraphRelation[]>({
method: 'memory.graph.query',
params: { namespace, subject, predicate },
});
}
export async function memoryDocIngest(params: {
namespace: string;
key: string;
title: string;
content: string;
source_type?: string;
priority?: string;
tags?: string[];
metadata?: Record<string, unknown>;
category?: string;
session_id?: string;
document_id?: string;
}): Promise<unknown> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<unknown>({ method: 'memory.doc.ingest', params });
}
export async function aiListMemoryFiles(relativeDir = 'memory'): Promise<string[]> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
+55 -1
View File
@@ -8,7 +8,8 @@ use crate::openhuman::memory::{
ApiEnvelope, ApiError, ApiMeta, DeleteDocumentRequest, DeleteDocumentResponse, EmptyRequest,
ListDocumentsRequest, ListDocumentsResponse, ListMemoryFilesRequest, ListMemoryFilesResponse,
ListNamespacesResponse, MemoryClient, MemoryClientRef, MemoryDocumentSummary,
MemoryInitRequest, MemoryInitResponse, MemoryItemKind, MemoryRecallItem, MemoryRetrievalChunk,
MemoryIngestionConfig, MemoryIngestionRequest, MemoryIngestionResult, MemoryInitRequest,
MemoryInitResponse, MemoryItemKind, MemoryRecallItem, MemoryRetrievalChunk,
MemoryRetrievalContext, MemoryRetrievalEntity, MemoryRetrievalRelation, NamespaceDocumentInput,
NamespaceMemoryHit, NamespaceRetrievalContext, PaginationMeta, QueryNamespaceRequest,
QueryNamespaceResponse, ReadMemoryFileRequest, ReadMemoryFileResponse, RecallContextRequest,
@@ -449,6 +450,30 @@ pub struct PutDocParams {
pub document_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct IngestDocParams {
pub namespace: String,
pub key: String,
pub title: String,
pub content: String,
#[serde(default = "default_source_type")]
pub source_type: String,
#[serde(default = "default_priority")]
pub priority: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: serde_json::Value,
#[serde(default = "default_category")]
pub category: String,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub document_id: Option<String>,
#[serde(default)]
pub config: Option<MemoryIngestionConfig>,
}
#[derive(Debug, Deserialize)]
pub struct NamespaceOnlyParams {
pub namespace: String,
@@ -560,6 +585,35 @@ pub async fn doc_put(params: PutDocParams) -> Result<RpcOutcome<PutDocResult>, S
))
}
pub async fn doc_ingest(
params: IngestDocParams,
) -> Result<RpcOutcome<MemoryIngestionResult>, String> {
let client = active_memory_client().await?;
let result = client
.ingest_doc(MemoryIngestionRequest {
document: NamespaceDocumentInput {
namespace: params.namespace,
key: params.key,
title: params.title,
content: params.content,
source_type: params.source_type,
priority: params.priority,
tags: params.tags,
metadata: params.metadata,
category: params.category,
session_id: params.session_id,
document_id: params.document_id,
},
config: params.config.unwrap_or_default(),
})
.await?;
let msg = format!(
"ingested document {}{} entities, {} relations, {} chunks",
result.document_id, result.entity_count, result.relation_count, result.chunk_count,
);
Ok(RpcOutcome::single_log(result, &msg))
}
pub async fn doc_list(
params: Option<NamespaceOnlyParams>,
) -> Result<RpcOutcome<serde_json::Value>, String> {
+69
View File
@@ -119,6 +119,14 @@ pub async fn try_dispatch(
.await,
),
"memory.doc.ingest" => Some(
async move {
let payload: crate::openhuman::memory::rpc::IngestDocParams = parse_params(params)?;
rpc_json(crate::openhuman::memory::rpc::doc_ingest(payload).await?)
}
.await,
),
"memory.doc.list" => Some(
async move {
let payload: MemoryDocListParams = parse_params(params)?;
@@ -216,3 +224,64 @@ pub async fn try_dispatch(
_ => None,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::try_dispatch;
/// Verify that the dispatcher recognises `memory.doc.ingest` (returns `Some`).
/// The inner handler will fail because no memory client is initialised,
/// but the route being present (not `None`) is what we need to assert.
#[tokio::test]
async fn dispatch_routes_memory_doc_ingest() {
let params = json!({
"namespace": "test",
"key": "k1",
"title": "Title",
"content": "body"
});
let result = try_dispatch("memory.doc.ingest", params).await;
assert!(
result.is_some(),
"memory.doc.ingest should be routed by dispatch"
);
}
/// Verify that `memory.graph.query` is routed.
#[tokio::test]
async fn dispatch_routes_memory_graph_query() {
let params = json!({});
let result = try_dispatch("memory.graph.query", params).await;
assert!(
result.is_some(),
"memory.graph.query should be routed by dispatch"
);
}
/// Unknown methods must return `None` so callers can fall through.
#[tokio::test]
async fn dispatch_returns_none_for_unknown_method() {
let result = try_dispatch("nonexistent.method", json!({})).await;
assert!(result.is_none(), "unknown methods should return None");
}
/// Verify that params deserialization errors surface as `Some(Err(...))`.
#[tokio::test]
async fn dispatch_memory_doc_ingest_rejects_invalid_params() {
// Missing required fields → should be Some(Err)
let result = try_dispatch("memory.doc.ingest", json!({})).await;
assert!(result.is_some());
let inner = result.unwrap();
assert!(
inner.is_err(),
"missing required fields should produce a deserialization error"
);
let err_msg = inner.unwrap_err();
assert!(
err_msg.contains("invalid params"),
"error should mention invalid params, got: {err_msg}"
);
}
}