feat(memory_tree): cloud-default LLM, queue priority, entity filter, Memory tab UI (#1198)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-05-04 20:52:56 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent f9f2360341
commit 62516ba418
46 changed files with 7761 additions and 2151 deletions
@@ -0,0 +1,133 @@
import { useState } from 'react';
import type { Backend } from '../../lib/intelligence/settingsApi';
interface BackendChooserProps {
/** Currently selected backend. */
value: Backend;
/** Called when the user clicks a different card. */
onChange: (next: Backend) => void;
/** Optional cloud-cost estimate. Mock value until cost-tracker hook lands. */
costEstimate?: string;
/** Disabled while a backend switch is in flight. */
busy?: boolean;
}
/**
* Two large cards — Cloud (default, recommended) vs Local (advanced).
*
* Visual style intentionally matches the rest of the Intelligence page:
* `bg-white` + `border-stone-200` + `rounded-2xl`, primary blue for the
* selected accent. The inline tokens from the brief
* (paper, hairline, ocean) map onto the existing stone/primary scale —
* we keep the existing scale to avoid forking the design system.
*/
export default function BackendChooser({
value,
onChange,
costEstimate = '$0.42 / mo est.',
busy = false,
}: BackendChooserProps) {
const [hoveredCloud, setHoveredCloud] = useState(false);
const cardBase =
'flex-1 min-h-[160px] px-6 py-5 rounded-2xl text-left transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed';
return (
<div className="flex gap-4 flex-col sm:flex-row" role="radiogroup" aria-label="AI backend">
{/* Cloud */}
<button
type="button"
role="radio"
aria-checked={value === 'cloud'}
disabled={busy}
onClick={() => onChange('cloud')}
onMouseEnter={() => setHoveredCloud(true)}
onMouseLeave={() => setHoveredCloud(false)}
onFocus={() => setHoveredCloud(true)}
onBlur={() => setHoveredCloud(false)}
className={`${cardBase} border-2 ${
value === 'cloud'
? 'border-primary-500 bg-white shadow-soft'
: 'border-stone-200 bg-stone-50 hover:bg-white hover:border-stone-300'
}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<RadioDot active={value === 'cloud'} />
<span className="text-sm font-semibold text-stone-900">Cloud</span>
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded-full bg-primary-50 text-primary-700 border border-primary-100">
Recommended
</span>
</div>
</div>
<p className="text-xs text-stone-600 leading-relaxed mb-3">
Runs on OpenHuman servers. Costs credits. No local CPU.
</p>
<div className="font-mono text-[11px] text-stone-500">{costEstimate}</div>
{/* Privacy reassurance — appears on hover/focus of the Cloud card. */}
<div
className={`mt-3 text-[11px] text-stone-500 leading-snug transition-opacity ${
hoveredCloud ? 'opacity-100' : 'opacity-0'
}`}
aria-live="polite">
Your data still stays local. bge-m3 embedder runs on your machine regardless.
</div>
</button>
{/* Local */}
<button
type="button"
role="radio"
aria-checked={value === 'local'}
disabled={busy}
onClick={() => onChange('local')}
className={`${cardBase} border-2 ${
value === 'local'
? 'border-primary-500 bg-white shadow-soft'
: 'border-stone-200 bg-stone-50 hover:bg-white hover:border-stone-300'
}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<RadioDot active={value === 'local'} />
<span className="text-sm font-semibold text-stone-900">Local</span>
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded-full bg-stone-100 text-stone-600 border border-stone-200">
Advanced
</span>
</div>
</div>
<p className="text-xs text-stone-600 leading-relaxed mb-3">
Runs on your machine. Free. Uses your CPU and battery.
</p>
<div className="flex items-center gap-1.5 text-[11px] text-amber-700">
<svg
className="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m0 3.75h.008v.008H12v-.008zM21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>8 GB RAM recommended</span>
</div>
</button>
</div>
);
}
function RadioDot({ active }: { active: boolean }) {
return (
<span
aria-hidden
className={`w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center ${
active ? 'border-primary-500' : 'border-stone-300'
}`}>
<span
className={`w-1.5 h-1.5 rounded-full ${active ? 'bg-primary-500' : 'bg-transparent'}`}
/>
</span>
);
}
@@ -0,0 +1,181 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
type Backend,
capabilityForModel,
DEFAULT_EXTRACT_MODEL,
downloadAsset,
fetchInstalledModels,
getMemoryTreeLlm,
type ModelDescriptor,
REQUIRED_EMBEDDER_MODEL,
setMemoryTreeLlm,
} from '../../lib/intelligence/settingsApi';
import BackendChooser from './BackendChooser';
import ModelAssignment from './ModelAssignment';
import ModelCatalog from './ModelCatalog';
/**
* Settings tab for the Intelligence page.
*
* Layout (top → bottom):
* 1. AI Backend — Cloud / Local toggle
* 2. Model Assignment — per-role dropdowns (visible only in Local mode)
* 3. Model Catalog — full curated list with download / use / delete
* 4. Currently Loaded — live `/api/ps`-style readout
*
* The orchestrator owns the cross-section state (backend, role assignments,
* cached installed-models / status). Sections themselves stay presentational.
*/
export default function IntelligenceSettingsTab() {
const [backend, setBackend] = useState<Backend>('cloud');
const [backendBusy, setBackendBusy] = useState(false);
// Single Memory LLM that drives both extractor and summariser. Most
// users want one model for both; the rare case of mixing them is not
// worth the second dropdown's cognitive cost.
const [memoryModel, setMemoryModel] = useState<string>(DEFAULT_EXTRACT_MODEL);
const [installedModels, setInstalledModels] = useState<string[]>([]);
// One-shot bootstrap — pull current backend and the installed-model list.
useEffect(() => {
let cancelled = false;
void (async () => {
try {
console.debug('[intelligence-settings] bootstrap');
const [bk, models] = await Promise.all([getMemoryTreeLlm(), fetchInstalledModels()]);
if (cancelled) return;
setBackend(bk);
setInstalledModels(models.map(m => m.name));
} catch (err) {
if (!cancelled) {
// Bootstrap failure leaves the tab on its useState defaults
// (cloud backend, empty installed list) rather than throwing
// an unhandled rejection. The user can still flip the backend
// chooser; subsequent reads will retry the RPCs.
console.error('[intelligence-settings] bootstrap failed', err);
}
}
})();
return () => {
cancelled = true;
};
}, []);
const handleBackendChange = useCallback(async (next: Backend) => {
setBackendBusy(true);
try {
const { effective } = await setMemoryTreeLlm(next);
setBackend(effective);
} catch (err) {
console.error('[intelligence-settings] backend switch failed', err);
} finally {
setBackendBusy(false);
}
}, []);
// Persist Memory LLM changes to config.toml. Fans out to both
// extractor and summariser keys in a single atomic write — the unified
// UI is one dropdown, but the underlying schema retains both keys so
// power users can still split them via the RPC directly if needed.
const handleMemoryModelChange = useCallback(
async (id: string) => {
console.debug('[intelligence-settings] memory model -> %s', id);
const previous = memoryModel;
setMemoryModel(id);
try {
await setMemoryTreeLlm('local', { extractModel: id, summariserModel: id });
} catch (err) {
// Persistence failed → roll back the optimistic UI update so the
// dropdown reflects the value that's actually saved on disk
// rather than the one the user just attempted.
setMemoryModel(previous);
console.error('[intelligence-settings] persist memory model failed', err);
}
},
[memoryModel]
);
const handleDownload = useCallback(async (model: ModelDescriptor) => {
const cap = capabilityForModel(model);
if (!cap) {
console.debug('[intelligence-settings] no capability for model', { id: model.id });
return;
}
try {
await downloadAsset(cap);
} catch (err) {
console.error('[intelligence-settings] model download failed', err);
} finally {
// Refresh installed list after any download attempt — even on
// failure, Ollama may have partially landed assets we should
// surface; if it hasn't, the next bootstrap tick will catch up.
const refreshed = await fetchInstalledModels();
setInstalledModels(refreshed.map(m => m.name));
}
}, []);
const handleUse = useCallback(
(model: ModelDescriptor) => {
if (model.roles.includes('extract') || model.roles.includes('summariser')) {
void handleMemoryModelChange(model.id);
}
},
[handleMemoryModelChange]
);
const activeModelIds = useMemo<string[]>(() => {
const ids = new Set<string>();
ids.add(memoryModel);
ids.add(REQUIRED_EMBEDDER_MODEL);
return [...ids];
}, [memoryModel]);
return (
<div className="space-y-10" data-testid="intelligence-settings-tab">
<Section title="AI backend">
<BackendChooser value={backend} onChange={handleBackendChange} busy={backendBusy} />
</Section>
{/* All local-model sections (assignment, catalog, currently-loaded)
are gated on local backend. Cloud users get just the backend
chooser + the explanatory copy that lives inside it — they don't
need to see Ollama-related UI at all. */}
{backend === 'local' && (
<>
<Section title="Model assignment">
<ModelAssignment
installedModelIds={installedModels}
memoryModel={memoryModel}
onChangeMemory={handleMemoryModelChange}
/>
</Section>
<Section title="Model catalog">
<ModelCatalog
installedModelIds={installedModels}
activeModelIds={activeModelIds}
onDownload={handleDownload}
onUse={handleUse}
/>
</Section>
</>
)}
</div>
);
}
interface SectionProps {
title: string;
children: React.ReactNode;
}
function Section({ title, children }: SectionProps) {
return (
<section>
<h2 className="font-display text-[11px] uppercase tracking-[0.18em] text-stone-400 mb-3">
{title}
</h2>
{children}
</section>
);
}
@@ -0,0 +1,134 @@
/**
* Right pane — single-chunk detail rendered as correspondence (a letter):
*
* 1. Letterhead (from / to / date)
* 2. Subject + body (markdown-ish prose; entities highlighted)
* 3. Mentioned (entity index for the chunk)
* 4. Why kept (signal breakdown + threshold)
* 5. Footer (source_ref, chunk id, embedder info)
*/
import { useEffect, useState } from 'react';
import {
type Chunk,
type EntityRef,
memoryTreeChunkScore,
memoryTreeEntityIndexFor,
type ScoreBreakdown,
} from '../../utils/tauriCommands';
import { MemoryChunkLetterhead } from './MemoryChunkLetterhead';
import { MemoryChunkMentioned } from './MemoryChunkMentioned';
import { MemoryChunkScoreBars } from './MemoryChunkScoreBars';
import { MemoryTextWithEntities } from './MemoryTextWithEntities';
interface MemoryChunkDetailProps {
chunk: Chunk;
onSelectEntity: (entity: EntityRef) => void;
}
function deriveSubject(chunk: Chunk): string {
const preview = (chunk.content_preview ?? '').trim();
if (!preview) return chunk.id;
const firstLine = preview.split(/[.!?\n]/)[0]?.trim() ?? '';
if (firstLine.length === 0) return chunk.id;
return firstLine.length > 200 ? `${firstLine.slice(0, 197)}` : firstLine;
}
function deriveBody(chunk: Chunk): string {
const preview = (chunk.content_preview ?? '').trim();
if (!preview) return '';
// Drop the subject (first sentence/line) when there's more content after it.
const firstBreak = preview.search(/[.!?\n](?:\s|$)/);
if (firstBreak > 0 && preview.length > firstBreak + 2) {
return preview.slice(firstBreak + 1).trim();
}
return preview;
}
function shortChunkId(id: string): string {
// Trim "chunk-" prefix if present, then take 8 chars.
const stripped = id.startsWith('chunk-') ? id.slice('chunk-'.length) : id;
return stripped.slice(0, 8);
}
export function MemoryChunkDetail({ chunk, onSelectEntity }: MemoryChunkDetailProps) {
const [entities, setEntities] = useState<EntityRef[]>([]);
const [breakdown, setBreakdown] = useState<ScoreBreakdown | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
let cancelled = false;
console.debug('[ui-flow][memory-workspace] loading detail for chunk', chunk.id);
void Promise.all([memoryTreeEntityIndexFor(chunk.id), memoryTreeChunkScore(chunk.id)]).then(
([ents, score]) => {
if (cancelled) return;
setEntities(ents);
setBreakdown(score);
console.debug(
'[ui-flow][memory-workspace] detail loaded',
chunk.id,
'entities=',
ents.length,
'score_total=',
score?.total ?? null
);
}
);
return () => {
cancelled = true;
};
}, [chunk.id]);
const handleCopyId = async () => {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(chunk.id);
}
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch (err) {
console.warn('[ui-flow][memory-workspace] copy chunk id failed', err);
}
};
const subject = deriveSubject(chunk);
const body = deriveBody(chunk);
return (
<article className="mw-pane-detail" data-testid="memory-chunk-detail">
<div className="mw-pane-scroll mw-detail-scroll">
<div className="mw-letter">
<MemoryChunkLetterhead chunk={chunk} />
<hr className="mw-rule" />
<h1 className="mw-letter-subject">{subject}</h1>
{body && (
<div className="mw-letter-body">
<MemoryTextWithEntities text={body} />
</div>
)}
{entities.length > 0 && <hr className="mw-rule" />}
<MemoryChunkMentioned entities={entities} onSelectEntity={onSelectEntity} />
{breakdown && <hr className="mw-rule" />}
{breakdown && <MemoryChunkScoreBars breakdown={breakdown} />}
<footer className="mw-letter-footer">
{chunk.source_ref && <span>{chunk.source_ref}</span>}
<span>·</span>
<button type="button" onClick={() => void handleCopyId()} title="Copy chunk id">
chunk {shortChunkId(chunk.id)}
{copied && <span style={{ marginLeft: 6, color: 'var(--sage)' }}>copied</span>}
</button>
<span>·</span>
<span>{chunk.has_embedding ? 'bge-m3 1024dim' : 'no embedding'}</span>
</footer>
</div>
</div>
</article>
);
}
@@ -0,0 +1,72 @@
/**
* Letterhead: the from / to / date frontmatter of a chunk, rendered
* as correspondence (dl-style with monospace labels in a fixed column).
*/
import type { Chunk } from '../../utils/tauriCommands';
interface LetterheadParts {
fromName: string;
fromAddress?: string;
toAddress: string;
}
function parseSourceParts(chunk: Chunk): LetterheadParts {
const left = chunk.source_id.split('|');
const senderRaw = left[0];
const recipient = left[1] ?? chunk.owner;
const afterColon = senderRaw.includes(':') ? senderRaw.split(':').slice(1).join(':') : senderRaw;
// Heuristic for known prefixes: prefer the human-readable display when we have one,
// else fall back to the raw email/handle.
const isEmailish = /@/.test(afterColon);
// Try to recover a personalized name from the chunk's tags (first person/* tag)
const personTag = chunk.tags.find(t => t.startsWith('person/'));
const personName = personTag ? personTag.slice('person/'.length).replace(/-/g, ' ') : null;
if (isEmailish && personName) {
return { fromName: personName, fromAddress: afterColon, toAddress: recipient };
}
if (isEmailish) {
return { fromName: afterColon, toAddress: recipient };
}
if (personName) {
return { fromName: personName, fromAddress: afterColon, toAddress: recipient };
}
return { fromName: afterColon || chunk.source_kind, toAddress: recipient };
}
function formatLetterDate(ms: number): string {
const d = new Date(ms);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
const hh = String(d.getUTCHours()).padStart(2, '0');
const mi = String(d.getUTCMinutes()).padStart(2, '0');
return `${yyyy}·${mm}·${dd} · ${hh}:${mi} utc`;
}
export function MemoryChunkLetterhead({ chunk }: { chunk: Chunk }) {
const parts = parseSourceParts(chunk);
return (
<header className="mw-letterhead" data-testid="memory-chunk-letterhead">
<dl style={{ margin: 0 }}>
<div className="mw-letterhead-row">
<dt className="mw-letterhead-label">from</dt>
<dd className="mw-letterhead-value" style={{ margin: 0 }}>
{parts.fromName}
{parts.fromAddress && parts.fromAddress !== parts.fromName && (
<span className="mw-letterhead-value-secondary">{parts.fromAddress}</span>
)}
</dd>
</div>
<div className="mw-letterhead-row">
<dt className="mw-letterhead-label">to</dt>
<dd className="mw-letterhead-value" style={{ margin: 0 }}>
{parts.toAddress}
</dd>
</div>
</dl>
<div className="mw-letterhead-date">{formatLetterDate(chunk.timestamp_ms)}</div>
</header>
);
}
@@ -0,0 +1,38 @@
/**
* "Mentioned" entity list — the marginalia of a chunk's letter view.
*
* Each row is `[kind label mono] [surface] [chunk count]`. Clicking a row
* activates the corresponding entity in the Navigator, filtering the
* result list to chunks tagged with that entity.
*/
import type { EntityRef } from '../../utils/tauriCommands';
interface MemoryChunkMentionedProps {
entities: EntityRef[];
onSelectEntity: (entity: EntityRef) => void;
}
export function MemoryChunkMentioned({ entities, onSelectEntity }: MemoryChunkMentionedProps) {
if (entities.length === 0) return null;
return (
<section data-testid="memory-chunk-mentioned">
<h3 className="mw-mentioned-heading">m e n t i o n e d</h3>
<div className="mw-mentioned-table">
{entities.map(ent => (
<button
type="button"
key={ent.entity_id}
className="mw-mentioned-row"
onClick={() => onSelectEntity(ent)}>
<span className="mw-mentioned-kind">{ent.kind}</span>
<span className="mw-mentioned-surface">{ent.surface}</span>
<span className="mw-mentioned-count">
{ent.count} chunk{ent.count === 1 ? '' : 's'}
</span>
</button>
))}
</div>
</section>
);
}
@@ -0,0 +1,62 @@
/**
* "Why kept" score bars — SVG-rendered (not CSS divs) for crisp pixel
* alignment regardless of zoom or DPR.
*/
import type { ScoreBreakdown } from '../../utils/tauriCommands';
interface MemoryChunkScoreBarsProps {
breakdown: ScoreBreakdown;
}
const TRACK_WIDTH = 200;
const TRACK_HEIGHT = 8;
function clamp01(v: number): number {
if (Number.isNaN(v)) return 0;
return Math.max(0, Math.min(1, v));
}
export function MemoryChunkScoreBars({ breakdown }: MemoryChunkScoreBarsProps) {
return (
<section data-testid="memory-chunk-scorebars">
<h3 className="mw-whykept-heading">w h y &nbsp; k e p t</h3>
<div>
{breakdown.signals.map(sig => {
const pct = clamp01(sig.value);
return (
<div key={sig.name} className="mw-scorebar-row">
<span className="mw-scorebar-label">{sig.name}</span>
<svg
width="100%"
viewBox={`0 0 ${TRACK_WIDTH} ${TRACK_HEIGHT}`}
preserveAspectRatio="none"
role="img"
aria-label={`${sig.name} score ${(pct * 100).toFixed(0)} percent`}>
<rect
x={0}
y={0}
width={TRACK_WIDTH}
height={TRACK_HEIGHT}
rx={2}
fill="var(--paper-recessed)"
/>
<rect
x={0}
y={0}
width={pct * TRACK_WIDTH}
height={TRACK_HEIGHT}
rx={2}
fill="var(--sage)"
/>
</svg>
<span className="mw-scorebar-value">{pct.toFixed(2)}</span>
</div>
);
})}
</div>
<div className="mw-scorebar-threshold">
{breakdown.kept ? 'kept' : 'dropped'} at {breakdown.threshold.toFixed(2)}
</div>
</section>
);
}
@@ -0,0 +1,19 @@
/**
* Right-pane placeholder shown to brand-new users (zero chunks).
*
* Centered, generous whitespace, no call-to-action buttons — the only path
* forward is connecting an integration in Settings, so we point there in
* prose without an explicit link to keep the surface meditative.
*/
export function MemoryEmptyPlaceholder() {
return (
<div className="mw-detail-empty" data-testid="memory-empty-placeholder">
<h2 className="mw-empty-title">Nothing yet.</h2>
<p className="mw-empty-body">
Connect an integration in Settings to start
<br />
building your memory tree.
</p>
</div>
);
}
@@ -1,402 +0,0 @@
import debug from 'debug';
import { useCallback, useMemo, useState } from 'react';
import type { GraphRelation } from '../../utils/tauriCommands';
const log = debug('openhuman:memory-graph');
interface MemoryGraphMapProps {
relations: GraphRelation[];
loading?: boolean;
}
interface GraphNode {
id: string;
label: string;
namespace: string | null;
entityType: string | null;
connectionCount: number;
x: number;
y: number;
vx: number;
vy: number;
}
interface GraphEdge {
source: string;
target: string;
predicate: string;
}
const NAMESPACE_COLORS = [
'#4A83DD', // ocean blue
'#4DC46F', // sage green
'#E8A838', // amber
'#9B8AFB', // lavender
'#F56565', // coral
'#7DD3FC', // sky
'#FDA4AF', // rose
'#6EE7B7', // mint
];
const WIDTH = 800;
const HEIGHT = 500;
const MAX_NODES = 100;
const MAX_EDGES = 200;
function truncate(s: string, max = 15): string {
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
function buildGraph(relations: GraphRelation[]): { nodes: GraphNode[]; edges: GraphEdge[] } {
// Cap edges by evidence count descending
const sorted = [...relations].sort((a, b) => b.evidenceCount - a.evidenceCount);
const cappedRelations = sorted.slice(0, MAX_EDGES);
// Collect unique entity ids
const entitySet = new Map<
string,
{ namespace: string | null; count: number; entityType: string | null }
>();
for (const r of cappedRelations) {
const subKey = r.subject.toLowerCase();
const objKey = r.object.toLowerCase();
const entityTypes = (r.attrs?.entity_types ?? {}) as Record<string, string>;
const existing = entitySet.get(subKey);
entitySet.set(subKey, {
namespace: r.namespace,
count: (existing?.count ?? 0) + 1,
entityType: existing?.entityType ?? entityTypes.subject ?? null,
});
const existingObj = entitySet.get(objKey);
entitySet.set(objKey, {
namespace: existingObj?.namespace ?? r.namespace,
count: (existingObj?.count ?? 0) + 1,
entityType: existingObj?.entityType ?? entityTypes.object ?? null,
});
}
// Sort by connection count, cap at MAX_NODES
const sortedEntities = [...entitySet.entries()].sort((a, b) => b[1].count - a[1].count);
const cappedEntities = sortedEntities.slice(0, MAX_NODES);
const allowedIds = new Set(cappedEntities.map(([id]) => id));
const nodes: GraphNode[] = cappedEntities.map(([id, info]) => ({
id,
label: id,
namespace: info.namespace,
entityType: info.entityType,
connectionCount: info.count,
x: 80 + Math.random() * (WIDTH - 160),
y: 80 + Math.random() * (HEIGHT - 160),
vx: 0,
vy: 0,
}));
const edges: GraphEdge[] = cappedRelations
.filter(r => allowedIds.has(r.subject.toLowerCase()) && allowedIds.has(r.object.toLowerCase()))
.map(r => ({
source: r.subject.toLowerCase(),
target: r.object.toLowerCase(),
predicate: r.predicate,
}));
return { nodes, edges };
}
function runSimulation(nodes: GraphNode[], edges: GraphEdge[], iterations = 150): GraphNode[] {
const nodeMap = new Map(nodes.map(n => [n.id, { ...n }]));
const nodeList = [...nodeMap.values()];
const REPULSION = 3500;
const ATTRACTION = 0.04;
const CENTER_FORCE = 0.012;
const DAMPING = 0.75;
const cx = WIDTH / 2;
const cy = HEIGHT / 2;
for (let iter = 0; iter < iterations; iter++) {
for (let i = 0; i < nodeList.length; i++) {
for (let j = i + 1; j < nodeList.length; j++) {
const a = nodeList[i];
const b = nodeList[j];
const dx = b.x - a.x || 0.01;
const dy = b.y - a.y || 0.01;
const dist2 = dx * dx + dy * dy;
const force = REPULSION / (dist2 + 1);
const fx = (dx / Math.sqrt(dist2 + 1)) * force;
const fy = (dy / Math.sqrt(dist2 + 1)) * force;
a.vx -= fx;
a.vy -= fy;
b.vx += fx;
b.vy += fy;
}
}
for (const edge of edges) {
const a = nodeMap.get(edge.source);
const b = nodeMap.get(edge.target);
if (!a || !b) continue;
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const delta = dist - 120;
const fx = (dx / dist) * delta * ATTRACTION;
const fy = (dy / dist) * delta * ATTRACTION;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
for (const n of nodeList) {
n.vx += (cx - n.x) * CENTER_FORCE;
n.vy += (cy - n.y) * CENTER_FORCE;
n.vx *= DAMPING;
n.vy *= DAMPING;
n.x = Math.max(40, Math.min(WIDTH - 40, n.x + n.vx));
n.y = Math.max(40, Math.min(HEIGHT - 40, n.y + n.vy));
}
}
return nodeList;
}
export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) {
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
// Build graph data from relations (synchronous, deterministic)
const { nodes, edges, namespacePalette } = useMemo(() => {
log('[memory-graph] recomputing graph relations=%d', relations.length);
if (relations.length === 0) {
return {
nodes: [] as GraphNode[],
edges: [] as GraphEdge[],
namespacePalette: new Map<string, string>(),
};
}
const { nodes: rawNodes, edges: rawEdges } = buildGraph(relations);
const namespaces = [...new Set(rawNodes.map(n => n.namespace ?? '__none__'))];
const p = new Map<string, string>();
namespaces.forEach((ns, i) => {
p.set(ns, NAMESPACE_COLORS[i % NAMESPACE_COLORS.length]);
});
const simulated = runSimulation(rawNodes, rawEdges);
log('[memory-graph] graph built nodes=%d edges=%d', simulated.length, rawEdges.length);
return { nodes: simulated, edges: rawEdges, namespacePalette: p };
}, [relations]);
const getNodeColor = useCallback(
(node: GraphNode): string => {
const ns = node.namespace ?? '__none__';
return namespacePalette.get(ns) ?? NAMESPACE_COLORS[0];
},
[namespacePalette]
);
const nodeMap = new Map(nodes.map(n => [n.id, n]));
// Guard against stale selectedNode — if the node no longer exists in the current
// graph (e.g. after a relations refresh), treat it as deselected so no dimming occurs.
const activeSelectedNode =
selectedNode !== null && nodeMap.has(selectedNode) ? selectedNode : null;
const centerNodeId =
nodes.find(n => n.id === 'user' || n.id === 'self' || n.id === 'you')?.id ??
(nodes.length > 0 ? nodes[0].id : null);
// Connected node ids for selected highlight
const connectedIds = activeSelectedNode
? new Set(
edges
.filter(e => e.source === activeSelectedNode || e.target === activeSelectedNode)
.flatMap(e => [e.source, e.target])
)
: null;
const namespaceEntries = [...namespacePalette.entries()].filter(([ns]) => ns !== '__none__');
if (loading) {
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<p className="text-sm font-semibold text-stone-900 mb-3">Memory Graph</p>
<div className="flex items-center justify-center" style={{ minHeight: 320 }}>
<div className="flex gap-2 items-center text-stone-600 text-sm">
<div className="w-4 h-4 rounded-full border-2 border-primary-500 border-t-transparent animate-spin" />
Loading graph
</div>
</div>
</div>
);
}
if (nodes.length === 0) {
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<p className="text-sm font-semibold text-stone-900 mb-3">Memory Graph</p>
<div className="flex items-center justify-center" style={{ minHeight: 320 }}>
<p className="text-stone-600 text-sm">No memory graph data yet</p>
</div>
</div>
);
}
const maxConn = Math.max(...nodes.map(n => n.connectionCount), 1);
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<p className="text-sm font-semibold text-stone-900 mb-3">Memory Graph</p>
<div
className="w-full overflow-hidden rounded-lg border border-stone-200"
style={{ minHeight: 320 }}>
<svg
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
width="100%"
style={{ display: 'block', background: 'rgba(248,248,247,1)' }}
onClick={() => setSelectedNode(null)}>
<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="rgba(0,0,0,0.25)" />
</marker>
</defs>
{/* Edges */}
{edges.map((edge, i) => {
const src = nodeMap.get(edge.source);
const tgt = nodeMap.get(edge.target);
if (!src || !tgt) return null;
const isHighlighted =
activeSelectedNode === null ||
edge.source === activeSelectedNode ||
edge.target === activeSelectedNode;
const midX = (src.x + tgt.x) / 2;
const midY = (src.y + tgt.y) / 2;
return (
<g key={i}>
<line
x1={src.x}
y1={src.y}
x2={tgt.x}
y2={tgt.y}
stroke={isHighlighted ? 'rgba(0,0,0,0.22)' : 'rgba(0,0,0,0.08)'}
strokeWidth={isHighlighted ? 1.5 : 1}
markerEnd="url(#arrowhead)"
style={{ cursor: 'pointer', transition: 'stroke 0.15s' }}
onMouseEnter={() => setHoveredEdge(i)}
onMouseLeave={() => setHoveredEdge(null)}
/>
{/* Edge label */}
<text
x={midX}
y={midY - 4}
textAnchor="middle"
fontSize={9}
fill={
hoveredEdge === i
? 'rgba(0,0,0,0.7)'
: isHighlighted
? 'rgba(0,0,0,0.35)'
: 'rgba(0,0,0,0.1)'
}
style={{ pointerEvents: 'none', userSelect: 'none', transition: 'fill 0.15s' }}>
{truncate(edge.predicate, 18)}
</text>
</g>
);
})}
{/* Nodes */}
{nodes.map(node => {
const r = 8 + (node.connectionCount / maxConn) * 18;
const color = getNodeColor(node);
const isCenter = node.id === centerNodeId;
const isSelected = activeSelectedNode === node.id;
const isDimmed = activeSelectedNode !== null && !connectedIds?.has(node.id);
return (
<g
key={node.id}
transform={`translate(${node.x},${node.y})`}
style={{ cursor: 'pointer' }}
onClick={e => {
e.stopPropagation();
setSelectedNode(activeSelectedNode === node.id ? null : node.id);
}}>
{(isCenter || isSelected) && (
<circle r={r + 5} fill="none" stroke={color} strokeWidth={2} opacity={0.4} />
)}
<circle
r={r}
fill={color}
opacity={isDimmed ? 0.15 : isSelected ? 1 : 0.82}
stroke={isSelected ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.5)'}
strokeWidth={isSelected ? 2 : 1}
style={{ transition: 'opacity 0.15s' }}
/>
<text
y={r + 11}
textAnchor="middle"
fontSize={isCenter ? 11 : 9}
fontWeight={isCenter ? 600 : 400}
fill={isDimmed ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.75)'}
style={{ pointerEvents: 'none', userSelect: 'none', transition: 'fill 0.15s' }}>
{isCenter && node.id !== 'you' ? 'You' : truncate(node.label)}
</text>
{node.entityType && (
<text
y={r + 22}
textAnchor="middle"
fontSize={7}
fontWeight={500}
letterSpacing="0.04em"
fill={isDimmed ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)'}
style={{
pointerEvents: 'none',
userSelect: 'none',
textTransform: 'uppercase',
transition: 'fill 0.15s',
}}>
{node.entityType}
</text>
)}
</g>
);
})}
</svg>
</div>
{/* Legend */}
{namespaceEntries.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
{namespaceEntries.map(([ns, color]) => (
<div key={ns} className="flex items-center gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-xs text-stone-400 truncate max-w-[120px]">{ns}</span>
</div>
))}
{namespacePalette.has('__none__') && (
<div className="flex items-center gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: namespacePalette.get('__none__') }}
/>
<span className="text-xs text-stone-400">uncategorized</span>
</div>
)}
</div>
)}
<p className="mt-2 text-xs text-stone-500">
{nodes.length} entities · {edges.length} relations · click a node to highlight connections
</p>
</div>
);
}
@@ -0,0 +1,270 @@
/**
* Left pane of MemoryWorkspace — search box, slim heatmap header, and four
* collapsible lens sections (recent / sources / people / topics).
*
* Selections are NOT mutually exclusive: multiple selected items intersect
* the result list filter. Sections may be collapsed independently.
*/
import { useEffect, useMemo, useState } from 'react';
import type { Chunk, EntityRef, Source } from '../../utils/tauriCommands';
import { MemoryHeatmap } from './MemoryHeatmap';
export interface NavigatorSelection {
sourceIds: string[];
entityIds: string[];
}
interface MemoryNavigatorProps {
chunks: Chunk[];
sources: Source[];
topPeople: EntityRef[];
topTopics: EntityRef[];
selection: NavigatorSelection;
onSelectionChange: (next: NavigatorSelection) => void;
searchQuery: string;
onSearchChange: (next: string) => void;
}
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
function dotClassFor(status: string | undefined): string {
switch (status) {
case 'admitted':
return 'mw-dot dot-admitted';
case 'pending_extraction':
return 'mw-dot dot-pending';
case 'buffered':
return 'mw-dot dot-buffered';
case 'dropped':
return 'mw-dot dot-dropped';
default:
return 'mw-dot';
}
}
interface SectionProps {
label: string;
defaultOpen?: boolean;
countSummary?: string;
children: React.ReactNode;
}
function NavSection({ label, defaultOpen = true, countSummary, children }: SectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="mw-section">
<button
type="button"
className="mw-section-heading"
onClick={() => setOpen(o => !o)}
aria-expanded={open}>
<span>{label}</span>
{countSummary && (
<span
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 10,
letterSpacing: 0,
color: 'var(--ink-whisper)',
marginLeft: 8,
textTransform: 'none',
}}>
{countSummary}
</span>
)}
<span className={`mw-section-chev${open ? ' open' : ''}`} aria-hidden>
</span>
</button>
{open && children}
</div>
);
}
export function MemoryNavigator({
chunks,
sources,
topPeople,
topTopics,
selection,
onSelectionChange,
searchQuery,
onSearchChange,
}: MemoryNavigatorProps) {
const heatmapTimestamps = useMemo(
() => chunks.map(c => Math.floor(c.timestamp_ms / 1000)),
[chunks]
);
// Wall-clock-derived counts. Computed in an effect to keep render pure
// (the `react-hooks/components-and-hooks-must-be-pure` rule rejects a
// raw `Date.now()` call inside a `useMemo` body, since two equivalent
// renders could produce different values).
const [recentCounts, setRecentCounts] = useState<{ today: number; week: number }>({
today: 0,
week: 0,
});
useEffect(() => {
const now = Date.now();
const startOfDay = new Date(now);
startOfDay.setHours(0, 0, 0, 0);
const startOfDayMs = startOfDay.getTime();
const startOfWeekMs = now - 7 * DAY_MS;
let today = 0;
let week = 0;
for (const c of chunks) {
if (c.timestamp_ms >= startOfDayMs) today++;
if (c.timestamp_ms >= startOfWeekMs) week++;
}
setRecentCounts({ today, week });
}, [chunks]);
const { today: todayCount, week: weekCount } = recentCounts;
const toggleSource = (id: string) => {
const has = selection.sourceIds.includes(id);
onSelectionChange({
...selection,
sourceIds: has ? selection.sourceIds.filter(s => s !== id) : [...selection.sourceIds, id],
});
};
const toggleEntity = (id: string) => {
const has = selection.entityIds.includes(id);
const next = has ? selection.entityIds.filter(s => s !== id) : [...selection.entityIds, id];
console.debug(
'[ui-flow][memory-navigator] toggleEntity id=%s wasActive=%o next=%o',
id,
has,
next
);
onSelectionChange({ ...selection, entityIds: next });
};
const renderEntityList = (refs: EntityRef[]) => (
<ul className="mw-list">
{refs.map(ref => {
const tag = `${ref.kind}/${ref.surface.replace(/\s+/g, '-')}`;
// For tag-based selection we use the raw tag string; for entity_id we
// compare against tags on chunks. Match either form to be lenient.
const isActive =
selection.entityIds.includes(tag) || selection.entityIds.includes(ref.entity_id);
return (
<li key={ref.entity_id}>
<button
type="button"
className={`mw-list-item${isActive ? ' is-active' : ''}`}
onClick={() => toggleEntity(ref.entity_id)}
aria-pressed={isActive}>
<span className="mw-dot" aria-hidden />
<span className="mw-list-name" title={ref.surface}>
{ref.surface}
</span>
<span className="mw-list-count">{ref.count}</span>
</button>
</li>
);
})}
{refs.length === 0 && (
<li style={{ padding: '6px 16px', fontSize: 12, color: 'var(--ink-whisper)' }}></li>
)}
</ul>
);
return (
<aside className="mw-pane-navigator" data-testid="memory-navigator">
<div className="mw-search-row">
<input
type="text"
className="mw-search-input"
placeholder="search memory…"
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
aria-label="Search memory"
/>
</div>
<div className="mw-heatmap-host" data-testid="memory-navigator-heatmap">
<MemoryHeatmap timestamps={heatmapTimestamps} />
</div>
<div className="mw-pane-scroll">
<NavSection label="recent" defaultOpen>
<div className="mw-recent-summary">
<span>today {todayCount}</span>
<span>this week {weekCount}</span>
</div>
</NavSection>
<NavSection label="sources" defaultOpen countSummary={String(sources.length)}>
{sources.length === 0 ? (
<ul className="mw-list">
<li style={{ padding: '6px 16px', fontSize: 12, color: 'var(--ink-whisper)' }}></li>
</ul>
) : (
(() => {
// Group sources by their source_kind ('email', 'slack', 'chat', …)
// and render each kind as its own nested collapsible. Lets the
// user filter at the kind level (drill into Email vs Slack) and
// then by individual sender within each.
const byKind = new Map<string, Source[]>();
for (const s of sources) {
const arr = byKind.get(s.source_kind) ?? [];
arr.push(s);
byKind.set(s.source_kind, arr);
}
const kindLabel: Record<string, string> = {
email: 'Email',
slack: 'Slack',
chat: 'Chat',
document: 'Documents',
};
const kinds = Array.from(byKind.entries()).sort((a, b) => b[1].length - a[1].length);
return (
<div>
{kinds.map(([kind, kindSources]) => (
<NavSection
key={kind}
label={kindLabel[kind] ?? kind}
defaultOpen={false}
countSummary={String(kindSources.length)}>
<ul className="mw-list">
{kindSources.map(src => {
const isActive = selection.sourceIds.includes(src.source_id);
return (
<li key={src.source_id}>
<button
type="button"
className={`mw-list-item${isActive ? ' is-active' : ''}`}
onClick={() => toggleSource(src.source_id)}
aria-pressed={isActive}>
<span className={dotClassFor(src.lifecycle_status)} aria-hidden />
<span className="mw-list-name" title={src.display_name}>
{src.display_name}
</span>
<span className="mw-list-count">{src.chunk_count}</span>
</button>
</li>
);
})}
</ul>
</NavSection>
))}
</div>
);
})()
)}
</NavSection>
<NavSection label="people" defaultOpen countSummary={String(topPeople.length)}>
{renderEntityList(topPeople)}
</NavSection>
<NavSection label="topics" defaultOpen countSummary={String(topTopics.length)}>
{renderEntityList(topTopics)}
</NavSection>
</div>
</aside>
);
}
@@ -0,0 +1,175 @@
/**
* Middle pane of MemoryWorkspace — time-grouped chunk rows.
*
* Sections: TODAY / YESTERDAY / THIS WEEK / OLDER, headers are sticky
* so the user always knows which time bucket is on screen.
*
* Auto-scrolls to the active row on mount and on selection change.
*
* The list is intentionally non-virtualized for now — mock fixtures
* top out at ~30 rows. Once real data lands we can swap in react-window
* (or similar) without changing the public API.
*/
import { useEffect, useMemo, useRef } from 'react';
import type { Chunk } from '../../utils/tauriCommands';
interface MemoryResultListProps {
chunks: Chunk[];
selectedChunkId: string | null;
onSelectChunk: (id: string) => void;
}
type GroupKey = 'TODAY' | 'YESTERDAY' | 'THIS WEEK' | 'OLDER';
interface Group {
key: GroupKey;
chunks: Chunk[];
}
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
function startOfLocalDay(d: Date): Date {
const out = new Date(d);
out.setHours(0, 0, 0, 0);
return out;
}
function bucketFor(
ts: number,
todayMs: number,
yesterdayMs: number,
weekStartMs: number
): GroupKey {
if (ts >= todayMs) return 'TODAY';
if (ts >= yesterdayMs) return 'YESTERDAY';
if (ts >= weekStartMs) return 'THIS WEEK';
return 'OLDER';
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
const WEEKDAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTH_SHORT = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
function formatTime(ts: number, group: GroupKey): string {
const d = new Date(ts);
if (group === 'TODAY' || group === 'YESTERDAY') {
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
if (group === 'THIS WEEK') {
return `${WEEKDAY_SHORT[d.getDay()]} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
return `${MONTH_SHORT[d.getMonth()]} ${d.getDate()}`;
}
function chunkSubject(chunk: Chunk): string {
const preview = (chunk.content_preview ?? '').trim();
if (!preview) return chunk.id;
// Use the first sentence/line as the subject
const firstLine = preview.split('\n')[0];
const sentenceEnd = firstLine.search(/[.!?](?:\s|$)/);
if (sentenceEnd > 16 && sentenceEnd < 120) {
return firstLine.slice(0, sentenceEnd + 1).trim();
}
return firstLine.length > 140 ? `${firstLine.slice(0, 137)}` : firstLine;
}
function chunkSenderLabel(chunk: Chunk): string {
// Try to derive a sender from source_id; fall back to source kind.
const left = chunk.source_id.split('|')[0];
const after = left.includes(':') ? left.split(':').slice(1).join(':') : left;
return after || chunk.source_kind;
}
export function MemoryResultList({
chunks,
selectedChunkId,
onSelectChunk,
}: MemoryResultListProps) {
const groups = useMemo<Group[]>(() => {
const today = startOfLocalDay(new Date()).getTime();
const yesterday = today - DAY_MS;
const weekStart = today - 7 * DAY_MS;
const buckets: Record<GroupKey, Chunk[]> = {
TODAY: [],
YESTERDAY: [],
'THIS WEEK': [],
OLDER: [],
};
for (const c of chunks) {
buckets[bucketFor(c.timestamp_ms, today, yesterday, weekStart)].push(c);
}
const order: GroupKey[] = ['TODAY', 'YESTERDAY', 'THIS WEEK', 'OLDER'];
return order.map(key => ({ key, chunks: buckets[key] })).filter(g => g.chunks.length > 0);
}, [chunks]);
const activeRowRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (activeRowRef.current) {
activeRowRef.current.scrollIntoView({ block: 'nearest' });
}
}, [selectedChunkId]);
if (chunks.length === 0) {
return (
<section className="mw-pane-results" data-testid="memory-result-list">
<div className="mw-results-empty">No matching chunks.</div>
</section>
);
}
return (
<section className="mw-pane-results" data-testid="memory-result-list">
<div className="mw-pane-scroll">
{groups.map(group => (
<div key={group.key} className="mw-results-section">
<div className="mw-results-section-header">{group.key}</div>
{group.chunks.map(chunk => {
const isActive = chunk.id === selectedChunkId;
return (
<button
type="button"
key={chunk.id}
ref={isActive ? activeRowRef : undefined}
className={`mw-result-row${isActive ? ' is-active' : ''}`}
onClick={() => onSelectChunk(chunk.id)}
data-chunk-id={chunk.id}>
<span className="mw-result-time">
{formatTime(chunk.timestamp_ms, group.key)}
</span>
<span className="mw-result-content">
<span className="mw-result-subject">{chunkSubject(chunk)}</span>
<span className="mw-result-meta">
<span className="mw-result-kind">{chunk.source_kind}</span>
{' · '}
{chunkSenderLabel(chunk)} · {chunk.token_count} tok
</span>
</span>
</button>
);
})}
</div>
))}
</div>
</section>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
import {
DEFAULT_EXTRACT_MODEL,
DEFAULT_SUMMARISER_MODEL,
type ModelDescriptor,
RECOMMENDED_MODEL_CATALOG,
REQUIRED_EMBEDDER_MODEL,
} from '../../lib/intelligence/settingsApi';
interface ModelAssignmentProps {
/** Names of models that are already installed on the user's machine. */
installedModelIds: ReadonlyArray<string>;
/** Currently chosen memory LLM (used for both extract + summarise). */
memoryModel: string;
/** Called when the user picks a different memory LLM. The setting fans
* out to both `llm_extractor_model` and `llm_summariser_model` in
* config.toml — most users want one model for both roles, and the
* cognitive load of two dropdowns isn't worth the rare power-user
* case of mixing them. */
onChangeMemory: (id: string) => void;
}
/**
* Per-role assignment table — two rows: Memory LLM (covers both extract
* and summarise), and Embedder.
*
* The embedder row is locked to `bge-m3` for v1 (the spec says we never
* round-trip embeddings through the cloud). The Memory LLM dropdown is
* populated from the recommended catalog filtered to models that can
* serve both extract AND summarise roles, plus any locally-installed
* models the user has pulled outside the curated catalog.
*/
export default function ModelAssignment({
installedModelIds,
memoryModel,
onChangeMemory,
}: ModelAssignmentProps) {
// Ollama returns tags as `<name>:latest` for default-tag models. The
// catalog stores bare names (e.g. `bge-m3`). Strip the `:latest` suffix
// on the installed side so the bare-name comparison matches.
const normalizedInstalled = installedModelIds.map(id =>
id.endsWith(':latest') ? id.slice(0, -':latest'.length) : id
);
const memoryOptions = memoryLlmOptions(normalizedInstalled);
const embedderDescriptor = RECOMMENDED_MODEL_CATALOG.find(m => m.id === REQUIRED_EMBEDDER_MODEL);
const embedderInstalled = normalizedInstalled.includes(REQUIRED_EMBEDDER_MODEL);
return (
<div className="border border-stone-200 rounded-2xl overflow-hidden">
<Row
label="Memory LLM"
sublabel={describeMemory(memoryOptions.find(opt => opt.id === memoryModel))}>
<select
value={memoryModel}
onChange={e => onChangeMemory(e.target.value)}
className="w-full sm:w-64 px-3 py-1.5 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:border-primary-500/50 transition-colors"
aria-label="Memory LLM (extract + summarise)">
{memoryOptions.map(opt => (
<option key={opt.id} value={opt.id}>
{opt.label ?? opt.id}
</option>
))}
</select>
</Row>
<Row
label="Embedder"
sublabel={
embedderDescriptor
? `${embedderDescriptor.size} · required · 1024-dim`
: 'required · 1024-dim'
}
last>
<div className="flex items-center gap-2 text-sm font-mono text-stone-700">
<span>{REQUIRED_EMBEDDER_MODEL}</span>
{embedderInstalled ? (
<span className="inline-flex items-center gap-1 text-sage-600 text-xs">
<svg
className="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
loaded
</span>
) : (
<span className="text-amber-700 text-xs">not downloaded</span>
)}
</div>
</Row>
</div>
);
}
interface RowProps {
label: string;
sublabel: string;
last?: boolean;
children: React.ReactNode;
}
function Row({ label, sublabel, last, children }: RowProps) {
return (
<div
className={`grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2 sm:gap-6 px-5 py-4 ${
last ? '' : 'border-b border-stone-100'
}`}>
<div>
<div className="text-sm font-semibold text-stone-900">{label}</div>
<div className="font-mono text-[11px] text-stone-500 mt-0.5">{sublabel}</div>
</div>
<div className="flex items-center sm:justify-end">{children}</div>
</div>
);
}
function describeMemory(model?: ModelDescriptor): string {
if (!model) return 'used for extract + summarise';
return `${model.size} · ${model.ramHint} · ${model.category}`;
}
/**
* Build the Memory LLM dropdown options. A model qualifies if it can serve
* BOTH extract and summarise roles. Catalog entries come first; locally
* installed extras (pulled outside the curated catalog) are appended so
* they remain selectable.
*/
function memoryLlmOptions(installedModelIds: ReadonlyArray<string>): ModelDescriptor[] {
const catalog = RECOMMENDED_MODEL_CATALOG.filter(
m => m.roles.includes('extract') && m.roles.includes('summariser')
);
const known = new Set(catalog.map(m => m.id));
const extras = installedModelIds
.filter(id => !known.has(id) && id !== REQUIRED_EMBEDDER_MODEL)
.map<ModelDescriptor>(id => ({
id,
size: '—',
approxBytes: 0,
ramHint: '—',
category: 'balanced',
note: 'locally installed',
roles: ['extract', 'summariser'],
}));
return [...catalog, ...extras];
}
// Re-export defaults so callers can still seed initial state via these
// constants without chasing them through the API module.
export { DEFAULT_EXTRACT_MODEL, DEFAULT_SUMMARISER_MODEL, REQUIRED_EMBEDDER_MODEL };
@@ -0,0 +1,252 @@
import { useState } from 'react';
import {
capabilityForModel,
type ModelDescriptor,
RECOMMENDED_MODEL_CATALOG,
} from '../../lib/intelligence/settingsApi';
interface ModelCatalogProps {
/** Names of models that are already installed on the user's machine. */
installedModelIds: ReadonlyArray<string>;
/** Models in active use right now (assigned to a role). */
activeModelIds: ReadonlyArray<string>;
/** Called when the user kicks off a download for a catalog entry. */
onDownload: (model: ModelDescriptor) => Promise<void>;
/** Called when the user wants to assign an installed model to its role. */
onUse: (model: ModelDescriptor) => void;
/** Called when the user removes an installed model. */
onDelete?: (model: ModelDescriptor) => Promise<void>;
}
type RowState = 'idle' | 'downloading' | 'error';
/**
* Single-column list of curated models. Each row is one card showing
* <id> <size> <status> [action]
* The action button changes by state:
* - not installed → "Download" (clicks fire the per-capability RPC)
* - installed but unused → "Use"
* - installed and active → "Active"
* - downloading → inline progress bar (mocked client-side animation
* since the per-asset RPC is fire-and-forget; the real progress
* stream is wired in `local_ai_downloads_progress` polling — out of
* scope for v1)
*/
// Ollama reports tags as `<name>:<tag>` (e.g. `bge-m3:latest`,
// `gemma3:1b-it-qat`). The recommended catalog uses bare names for the
// default-`:latest` case (e.g. `bge-m3`) and full `<name>:<tag>` for
// non-default tags. Normalize both sides by stripping the `:latest`
// suffix before comparing — that way `bge-m3` matches `bge-m3:latest`,
// while `gemma3:1b-it-qat` still requires the explicit tag.
function normalizeModelId(id: string): string {
return id.endsWith(':latest') ? id.slice(0, -':latest'.length) : id;
}
export default function ModelCatalog({
installedModelIds,
activeModelIds,
onDownload,
onUse,
onDelete,
}: ModelCatalogProps) {
const installedSet = new Set(installedModelIds.map(normalizeModelId));
const activeSet = new Set(activeModelIds.map(normalizeModelId));
return (
<div className="space-y-2">
{RECOMMENDED_MODEL_CATALOG.map(model => (
<CatalogRow
key={model.id}
model={model}
installed={installedSet.has(normalizeModelId(model.id))}
active={activeSet.has(normalizeModelId(model.id))}
onDownload={onDownload}
onUse={onUse}
onDelete={onDelete}
/>
))}
</div>
);
}
interface CatalogRowProps {
model: ModelDescriptor;
installed: boolean;
active: boolean;
onDownload: ModelCatalogProps['onDownload'];
onUse: ModelCatalogProps['onUse'];
onDelete: ModelCatalogProps['onDelete'];
}
function CatalogRow({ model, installed, active, onDownload, onUse, onDelete }: CatalogRowProps) {
const [state, setState] = useState<RowState>('idle');
const [progress, setProgress] = useState(0);
const status: 'active' | 'installed' | 'available' = active
? 'active'
: installed
? 'installed'
: 'available';
const handleDownload = async () => {
setState('downloading');
setProgress(8);
// Animated mock progress while the real per-capability RPC is in flight.
// The real download progress stream comes from
// `openhumanLocalAiDownloadsProgress` polling — wiring that in is
// tracked separately and out of scope for v1.
const tick = setInterval(() => {
setProgress(prev => {
if (prev >= 90) return prev;
return prev + Math.max(2, Math.round((100 - prev) * 0.06));
});
}, 220);
let didFail = false;
try {
await onDownload(model);
setProgress(100);
} catch (err) {
console.debug('[intelligence-settings] catalog download failed', { id: model.id, err });
setState('error');
didFail = true;
} finally {
clearInterval(tick);
// Hold the terminal state long enough for the user to actually read
// it. Success collapses fast (~600 ms) so the row settles back to
// its post-install state without a long pause; error lingers ~3s
// so an unsuccessful pull doesn't snap back before the user has
// a chance to notice. Tracked via a local flag because `state` is
// React state and won't reflect the just-issued `setState('error')`
// until the next render.
const settleMs = didFail ? 3000 : 600;
window.setTimeout(() => {
setState('idle');
setProgress(0);
}, settleMs);
}
};
return (
<div className="border border-stone-200 rounded-xl bg-white px-4 py-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="text-sm font-medium text-stone-900 truncate">{model.id}</div>
<div className="font-mono text-[11px] text-stone-500 whitespace-nowrap">{model.size}</div>
<StatusChip status={status} />
</div>
<div className="flex items-center gap-2">
{state === 'downloading' ? (
<ProgressBar progress={progress} />
) : (
<ActionButton
status={status}
hasDelete={!!onDelete}
onDownload={handleDownload}
onUse={() => onUse(model)}
onDelete={onDelete ? () => onDelete(model) : undefined}
/>
)}
</div>
</div>
<div className="mt-1 flex items-center gap-3 font-mono text-[11px] text-stone-500">
<span>{model.ramHint}</span>
<span>·</span>
<span>{model.category}</span>
<span>·</span>
<span className="text-stone-400">{model.note}</span>
{capabilityForModel(model) === null && (
<span className="text-amber-600 ml-auto">no capability binding</span>
)}
</div>
{state === 'error' && (
<div className="mt-2 text-[11px] text-coral-700">
Download failed check Ollama is running and try again.
</div>
)}
</div>
);
}
function StatusChip({ status }: { status: 'active' | 'installed' | 'available' }) {
if (status === 'active') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] uppercase tracking-wider rounded-full bg-sage-50 text-sage-700 border border-sage-100">
active
</span>
);
}
if (status === 'installed') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] uppercase tracking-wider rounded-full bg-stone-100 text-stone-600 border border-stone-200">
installed
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] uppercase tracking-wider rounded-full bg-white text-stone-500 border border-stone-200">
not downloaded
</span>
);
}
interface ActionButtonProps {
status: 'active' | 'installed' | 'available';
hasDelete: boolean;
onDownload: () => void;
onUse: () => void;
onDelete?: () => void;
}
function ActionButton({ status, hasDelete, onDownload, onUse, onDelete }: ActionButtonProps) {
if (status === 'active') {
return (
<span className="px-3 py-1.5 text-xs text-stone-500 border border-transparent">in use</span>
);
}
if (status === 'installed') {
return (
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={onUse}
className="px-3 py-1.5 text-xs font-medium bg-primary-50 hover:bg-primary-100 text-primary-700 border border-primary-100 rounded-lg transition-colors">
Use
</button>
{hasDelete && onDelete && (
<button
type="button"
onClick={onDelete}
className="px-2 py-1.5 text-xs text-stone-500 hover:text-coral-700 border border-stone-200 rounded-lg transition-colors"
aria-label="Delete model">
Delete
</button>
)}
</div>
);
}
return (
<button
type="button"
onClick={onDownload}
className="px-3 py-1.5 text-xs font-medium bg-white hover:bg-stone-50 text-stone-700 border border-stone-200 rounded-lg transition-colors">
Download
</button>
);
}
function ProgressBar({ progress }: { progress: number }) {
return (
<div
className="w-32 h-2 rounded-full bg-stone-100 overflow-hidden"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress)}>
<div
className="h-full bg-primary-500 transition-all duration-200"
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
/>
</div>
);
}
@@ -0,0 +1,257 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import IntelligenceSettingsTab from '../IntelligenceSettingsTab';
// The orchestrator hits these RPCs on mount; the global tauriCommands mock
// in setup.ts only stubs auth/service helpers, so we extend it here with
// the local-AI surface the Settings tab uses, plus the new memory_tree
// LLM-selector RPCs that replaced the dev-time mock backend.
vi.mock('../../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
// memory_tree LLM selector — the BackendChooser polls these on mount and
// again on every backend toggle. We track the value in a closure so the
// set→get round-trip behaves like the real persistent core.
memoryTreeGetLlm: vi.fn(),
memoryTreeSetLlm: vi.fn(),
openhumanLocalAiAssetsStatus: vi
.fn()
.mockResolvedValue({
result: {
chat: { state: 'NotInstalled', id: '', provider: 'ollama' },
vision: { state: 'NotInstalled', id: '', provider: 'ollama' },
embedding: { state: 'NotInstalled', id: '', provider: 'ollama' },
stt: { state: 'NotInstalled', id: '', provider: 'ollama' },
tts: { state: 'NotInstalled', id: '', provider: 'ollama' },
quantization: 'q4_k_m',
},
}),
openhumanLocalAiDiagnostics: vi.fn().mockResolvedValue({
ollama_running: true,
ollama_binary_path: '/usr/local/bin/ollama',
installed_models: [
{ name: 'gemma3:1b-it-qat', size: 1_700_000_000, modified_at: null },
{ name: 'bge-m3', size: 1_300_000_000, modified_at: null },
],
expected: {
chat_model: 'gemma3:1b-it-qat',
chat_found: true,
embedding_model: 'bge-m3',
embedding_found: true,
vision_model: '',
vision_found: false,
},
issues: [],
ok: true,
}),
openhumanLocalAiStatus: vi
.fn()
.mockResolvedValue({
result: {
state: 'Ready',
model_id: 'gemma3:1b-it-qat',
chat_model_id: 'gemma3:1b-it-qat',
vision_model_id: '',
embedding_model_id: 'bge-m3',
stt_model_id: '',
tts_voice_id: '',
quantization: 'q4_k_m',
vision_state: 'idle',
vision_mode: 'off',
embedding_state: 'Ready',
stt_state: 'idle',
tts_state: 'idle',
provider: 'ollama',
active_backend: 'cpu',
last_latency_ms: 142,
},
}),
openhumanLocalAiPresets: vi
.fn()
.mockResolvedValue({
presets: [],
recommended_tier: 'minimal',
current_tier: 'minimal',
device: {
total_ram_bytes: 16_000_000_000,
cpu_count: 8,
cpu_brand: 'Test CPU',
os_name: 'macos',
os_version: '14',
has_gpu: false,
gpu_description: null,
},
local_ai_enabled: false,
}),
openhumanLocalAiDownloadAsset: vi
.fn()
.mockResolvedValue({
result: {
chat: { state: 'Ready', id: 'gemma3:1b-it-qat', provider: 'ollama' },
vision: { state: 'NotInstalled', id: '', provider: 'ollama' },
embedding: { state: 'Ready', id: 'bge-m3', provider: 'ollama' },
stt: { state: 'NotInstalled', id: '', provider: 'ollama' },
tts: { state: 'NotInstalled', id: '', provider: 'ollama' },
quantization: 'q4_k_m',
},
}),
}));
// Pull mocked references after vi.mock() has hoisted. Cast through unknown
// because the import here is the typed wrapper module shape.
const { memoryTreeGetLlm, memoryTreeSetLlm } =
(await import('../../../utils/tauriCommands')) as unknown as {
memoryTreeGetLlm: Mock;
memoryTreeSetLlm: Mock;
};
describe('IntelligenceSettingsTab', () => {
beforeEach(() => {
let backend: 'cloud' | 'local' = 'cloud';
memoryTreeGetLlm.mockReset();
memoryTreeSetLlm.mockReset();
memoryTreeGetLlm.mockImplementation(async () => ({ current: backend }));
// Accept both legacy (bare string) and the new request-object shape so
// tests can assert on either call form.
memoryTreeSetLlm.mockImplementation(
async (req: 'cloud' | 'local' | { backend: 'cloud' | 'local' }) => {
backend = typeof req === 'string' ? req : req.backend;
return { current: backend };
}
);
});
// Helper: bootstrap into Local mode so the model assignment + catalog
// render. Cloud is the default; clicking the Advanced radio flips to
// local and renders the Ollama-related sections.
async function flipToLocal() {
await waitFor(() => {
expect(screen.getByText('AI backend')).toBeInTheDocument();
});
const radios = screen.getAllByRole('radio');
const localCard = radios.find(el => /Advanced/.test(el.textContent ?? ''));
expect(localCard).toBeDefined();
fireEvent.click(localCard!);
await waitFor(() => {
expect(screen.getByText('Model assignment')).toBeInTheDocument();
});
}
it('renders the AI backend section in cloud mode (no local sections)', async () => {
renderWithProviders(<IntelligenceSettingsTab />);
await waitFor(() => {
expect(screen.getByText('AI backend')).toBeInTheDocument();
});
// Cloud is default — local-only sections are hidden so cloud users
// never see Ollama-related UI.
expect(screen.queryByText('Model assignment')).not.toBeInTheDocument();
expect(screen.queryByText('Model catalog')).not.toBeInTheDocument();
// Currently-loaded panel was removed entirely (was dev-debug noise).
expect(screen.queryByText('Currently loaded')).not.toBeInTheDocument();
});
it('hides Model assignment in Cloud mode and reveals it in Local mode', async () => {
renderWithProviders(<IntelligenceSettingsTab />);
await flipToLocal();
// The new UI consolidates Extract + Summariser LLM into a single
// Memory LLM picker (the underlying RPC still fans out to both
// extract_model and summariser_model in config.toml).
expect(screen.getByText('Memory LLM')).toBeInTheDocument();
expect(screen.getByText('Embedder')).toBeInTheDocument();
// Old separate dropdowns must be absent.
expect(screen.queryByText('Extract LLM')).not.toBeInTheDocument();
expect(screen.queryByText('Summariser LLM')).not.toBeInTheDocument();
});
it('shows model catalog rows with sizes (in local mode)', async () => {
renderWithProviders(<IntelligenceSettingsTab />);
await flipToLocal();
await waitFor(() => {
expect(screen.getAllByText('qwen2.5:0.5b').length).toBeGreaterThanOrEqual(1);
});
// Each model can appear in the Memory LLM dropdown AND the catalog,
// so use getAllByText. Just confirm the catalog has at least one of
// each curated entry rendered somewhere on the screen.
expect(screen.getAllByText('gemma3:1b-it-qat').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('llama3.1:8b').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('bge-m3').length).toBeGreaterThanOrEqual(1);
// 4.9 GB is unique to llama3.1:8b in the catalog row meta.
expect(screen.getByText('4.9 GB')).toBeInTheDocument();
});
it('renders a Download action for models that are not installed', async () => {
renderWithProviders(<IntelligenceSettingsTab />);
await flipToLocal();
// qwen2.5:0.5b is NOT in the diagnostics installed list, so it shows
// a Download button.
await waitFor(() => {
expect(screen.getByText('qwen2.5:0.5b')).toBeInTheDocument();
});
const downloadButtons = screen.getAllByRole('button', { name: 'Download' });
expect(downloadButtons.length).toBeGreaterThanOrEqual(1);
});
it('reads the backend via memoryTreeGetLlm on mount and persists toggles via memoryTreeSetLlm', async () => {
renderWithProviders(<IntelligenceSettingsTab />);
// Bootstrap: getMemoryTreeLlm must run once on mount.
await waitFor(() => {
expect(memoryTreeGetLlm).toHaveBeenCalled();
});
// Click Local — setMemoryTreeLlm must be called with the request
// object form `{ backend: 'local' }`. settingsApi.ts always normalizes
// to the request-object shape because the wrapper now accepts both
// forms but the API layer translates camelCase options through the
// object shape. Model fields are absent so the corresponding
// config keys stay untouched.
const radios = screen.getAllByRole('radio');
const localCard = radios.find(el => /Advanced/.test(el.textContent ?? ''));
fireEvent.click(localCard!);
await waitFor(() => {
expect(memoryTreeSetLlm).toHaveBeenCalledWith({ backend: 'local' });
});
// The mocked setter persists state in the closure, so the bootstrap
// value of any subsequent get_llm call would now be 'local' — sanity
// check that the closure flipped.
const after = await memoryTreeGetLlm();
expect(after.current).toBe('local');
});
it('persists Memory LLM dropdown changes via memoryTreeSetLlm with both extract_model and summariser_model', async () => {
// The single Memory LLM picker fans out to BOTH extract_model and
// summariser_model in one atomic write — the underlying schema keeps
// the two keys separate so power users can split via the RPC, but the
// UI consolidates them into one cognitive unit.
renderWithProviders(<IntelligenceSettingsTab />);
await flipToLocal();
// Reset call history so the assertion below is scoped to the
// dropdown change, not the earlier backend toggle.
memoryTreeSetLlm.mockClear();
// Pick a different memory LLM. `llama3.1:8b` is in the curated
// catalog with both `extract` and `summariser` roles.
const memorySelect = screen.getByLabelText(
'Memory LLM (extract + summarise)'
) as HTMLSelectElement;
fireEvent.change(memorySelect, { target: { value: 'llama3.1:8b' } });
await waitFor(() => {
expect(memoryTreeSetLlm).toHaveBeenCalledWith({
backend: 'local',
extract_model: 'llama3.1:8b',
summariser_model: 'llama3.1:8b',
});
});
});
});
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { Chunk } from '../../../utils/tauriCommands';
import { MemoryChunkLetterhead } from '../MemoryChunkLetterhead';
const BASE_CHUNK: Chunk = {
id: 'chunk-letterhead-01',
source_kind: 'email',
source_id: 'gmail:steve@example.com|sanil@vezures.xyz',
source_ref: 'gmail://msg/abc',
owner: 'sanil@vezures.xyz',
timestamp_ms: Date.UTC(2026, 4, 4, 9, 14, 0),
token_count: 100,
lifecycle_status: 'admitted',
content_preview: 'hello',
has_embedding: true,
tags: [],
};
describe('MemoryChunkLetterhead', () => {
it('renders the from/to/date frontmatter from a personalized email source', () => {
const chunk: Chunk = { ...BASE_CHUNK, tags: ['person/Steven-Enamakel'] };
render(<MemoryChunkLetterhead chunk={chunk} />);
expect(screen.getByText('from')).toBeInTheDocument();
expect(screen.getByText('to')).toBeInTheDocument();
// Person tag wins over the raw email handle as the display name.
expect(screen.getByText('Steven Enamakel')).toBeInTheDocument();
// The raw address is rendered as secondary text.
expect(screen.getByText('steve@example.com')).toBeInTheDocument();
expect(screen.getByText('sanil@vezures.xyz')).toBeInTheDocument();
// Date formatted as YYYY·MM·DD · HH:MM utc (UTC components).
expect(screen.getByText('2026·05·04 · 09:14 utc')).toBeInTheDocument();
});
it('falls back to the raw email when no person/* tag is present', () => {
render(<MemoryChunkLetterhead chunk={BASE_CHUNK} />);
// Without a person tag, fromName === the raw email.
expect(screen.getByText('steve@example.com')).toBeInTheDocument();
});
it('falls back to the chunk owner when the source_id has no recipient half', () => {
const chunk: Chunk = {
...BASE_CHUNK,
source_id: 'notion:launch-plan',
owner: 'sanil@vezures.xyz',
};
render(<MemoryChunkLetterhead chunk={chunk} />);
// No `|` → recipient defaults to owner.
expect(screen.getByText('sanil@vezures.xyz')).toBeInTheDocument();
});
it('uses source_kind as the display when source_id is bare', () => {
const chunk: Chunk = { ...BASE_CHUNK, source_kind: 'doc', source_id: '', tags: [] };
render(<MemoryChunkLetterhead chunk={chunk} />);
// Empty source_id → fromName falls back to the source_kind label.
expect(screen.getByText('doc')).toBeInTheDocument();
});
});
@@ -0,0 +1,39 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { EntityRef } from '../../../utils/tauriCommands';
import { MemoryChunkMentioned } from '../MemoryChunkMentioned';
const ENTITIES: EntityRef[] = [
{ entity_id: 'person:steve', kind: 'person', surface: 'Steven Enamakel', count: 4 },
{ entity_id: 'org:tinyhumans', kind: 'organization', surface: 'TinyHumans', count: 1 },
{ entity_id: 'event:launch', kind: 'event', surface: 'Phoenix launch', count: 7 },
];
describe('MemoryChunkMentioned', () => {
it('renders nothing when the entity list is empty', () => {
const { container } = render(<MemoryChunkMentioned entities={[]} onSelectEntity={vi.fn()} />);
expect(container.firstChild).toBeNull();
});
it('renders one row per entity with kind, surface, and a pluralised count', () => {
render(<MemoryChunkMentioned entities={ENTITIES} onSelectEntity={vi.fn()} />);
expect(screen.getByText('Steven Enamakel')).toBeInTheDocument();
expect(screen.getByText('TinyHumans')).toBeInTheDocument();
expect(screen.getByText('Phoenix launch')).toBeInTheDocument();
// Singular vs plural — the surface display has to switch on count.
expect(screen.getByText('1 chunk')).toBeInTheDocument();
expect(screen.getByText('4 chunks')).toBeInTheDocument();
expect(screen.getByText('7 chunks')).toBeInTheDocument();
});
it('fires onSelectEntity with the clicked entity', () => {
const onSelectEntity = vi.fn();
render(<MemoryChunkMentioned entities={ENTITIES} onSelectEntity={onSelectEntity} />);
fireEvent.click(screen.getByText('TinyHumans').closest('button')!);
expect(onSelectEntity).toHaveBeenCalledTimes(1);
expect(onSelectEntity).toHaveBeenCalledWith(ENTITIES[1]);
});
});
@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { ScoreBreakdown } from '../../../utils/tauriCommands';
import { MemoryChunkScoreBars } from '../MemoryChunkScoreBars';
describe('MemoryChunkScoreBars', () => {
it('renders one row per signal with a clamped, formatted value', () => {
const breakdown: ScoreBreakdown = {
total: 0.65,
threshold: 0.5,
kept: true,
llm_consulted: false,
signals: [
{ name: 'recency', weight: 0.5, value: 0.83 },
{ name: 'salience', weight: 0.3, value: 0.4 },
// Out-of-range and NaN both clamp to 0..1 — the bar must not crash
// or render past the track.
{ name: 'pinned', weight: 0.1, value: 1.7 },
{ name: 'broken', weight: 0.1, value: Number.NaN },
],
};
render(<MemoryChunkScoreBars breakdown={breakdown} />);
expect(screen.getByText('recency')).toBeInTheDocument();
expect(screen.getByText('0.83')).toBeInTheDocument();
expect(screen.getByText('0.40')).toBeInTheDocument();
// Clamped to 1.00 (over-range) and 0.00 (NaN).
expect(screen.getByText('1.00')).toBeInTheDocument();
expect(screen.getByText('0.00')).toBeInTheDocument();
// ARIA labels on the bars are how a screen reader would surface the
// percentage; check the over-range one collapsed to "100 percent".
expect(screen.getByLabelText('pinned score 100 percent')).toBeInTheDocument();
expect(screen.getByLabelText('broken score 0 percent')).toBeInTheDocument();
});
it('shows the threshold footer with kept/dropped state', () => {
const breakdown: ScoreBreakdown = {
total: 0.2,
threshold: 0.5,
kept: false,
llm_consulted: false,
signals: [{ name: 'recency', weight: 1, value: 0.2 }],
};
render(<MemoryChunkScoreBars breakdown={breakdown} />);
expect(screen.getByText(/dropped at 0\.50/)).toBeInTheDocument();
});
});
@@ -1,257 +1,279 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import type { Chunk, EntityRef, ScoreBreakdown, Source } from '../../../utils/tauriCommands';
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(),
}),
}));
// Mock channelConnectionsApi so listStatus doesn't hit the network
vi.mock('../../../services/api/channelConnectionsApi', () => ({
channelConnectionsApi: {
listStatus: vi.fn().mockResolvedValue([
{
channel_id: 'telegram-main',
auth_mode: 'managed_dm',
connected: true,
has_credentials: true,
},
{ channel_id: 'discord-bot', auth_mode: 'bot_token', connected: true, has_credentials: true },
]),
},
}));
// Override the global tauriCommands mock from setup.ts with memory-specific stubs
// The MemoryWorkspace orchestrator + its detail-pane child both fan out
// to the `memory_tree_*` JSON-RPC wrappers. The setup.ts global mock
// stubs auth helpers; we extend it here with the read-side surface so
// the workspace can render against a deterministic fixture set.
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({ text: 'query result', entities: [] }),
memoryRecallNamespace: vi.fn().mockResolvedValue({ text: 'recall result', entities: [] }),
memorySyncAll: vi.fn().mockResolvedValue({ requested: true }),
memorySyncChannel: vi.fn().mockResolvedValue({ requested: true, channel_id: 'telegram-main' }),
memoryLearnAll: vi.fn().mockResolvedValue({
namespaces_processed: 2,
results: [
{ namespace: 'research', status: 'ok' },
{ namespace: 'conversations', status: 'ok' },
],
}),
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: [],
},
]),
memoryTreeListChunks: vi.fn(),
memoryTreeListSources: vi.fn(),
memoryTreeTopEntities: vi.fn(),
memoryTreeEntityIndexFor: vi.fn(),
memoryTreeChunkScore: vi.fn(),
}));
describe('MemoryWorkspace', () => {
const onToast = vi.fn();
const {
memoryTreeListChunks,
memoryTreeListSources,
memoryTreeTopEntities,
memoryTreeEntityIndexFor,
memoryTreeChunkScore,
} = (await import('../../../utils/tauriCommands')) as unknown as {
memoryTreeListChunks: Mock;
memoryTreeListSources: Mock;
memoryTreeTopEntities: Mock;
memoryTreeEntityIndexFor: Mock;
memoryTreeChunkScore: Mock;
};
it('renders the Memory heading', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
expect(screen.getByText('Memory')).toBeInTheDocument();
// ── Fixtures — small but realistic ───────────────────────────────────────
const NOW_MS = Date.UTC(2026, 4, 4, 9, 14, 0);
const HOUR = 60 * 60 * 1000;
const FIXTURE_CHUNKS: Chunk[] = [
{
id: 'chunk-today-01',
source_kind: 'email',
source_id: 'gmail:enamakel@mail.tinyhumans.ai|sanil@vezures.xyz',
source_ref: 'gmail://msg/aaa',
owner: 'sanil@vezures.xyz',
timestamp_ms: NOW_MS,
token_count: 312,
lifecycle_status: 'admitted',
content_preview:
'welcome to the future of ai assistants — openhuman. hey hey Sanil Jain! steve here.',
has_embedding: true,
tags: ['person/Steven-Enamakel', 'organization/TinyHumans', 'product/openhuman'],
},
{
id: 'chunk-today-02',
source_kind: 'email',
source_id: 'gmail:notifications@github.com|sanil@vezures.xyz',
source_ref: 'gmail://msg/bbb',
owner: 'sanil@vezures.xyz',
timestamp_ms: NOW_MS - 90 * 60 * 1000,
token_count: 94,
lifecycle_status: 'admitted',
content_preview: '[tinyhumansai/openhuman] PR #1175 merged.',
has_embedding: true,
tags: ['organization/GitHub', 'product/openhuman', 'event/pr-merged'],
},
{
id: 'chunk-today-03',
source_kind: 'chat',
source_id: 'slack:T0123|C-engineering',
source_ref: 'slack://channel/eng/p1',
owner: 'sanil@vezures.xyz',
timestamp_ms: NOW_MS - 3 * HOUR,
token_count: 47,
lifecycle_status: 'admitted',
content_preview: 'maya patel: pushed the staging chart fix',
has_embedding: true,
tags: ['person/Maya-Patel', 'organization/TinyHumans'],
},
];
const FIXTURE_SOURCES: Source[] = [
{
source_id: 'gmail:enamakel@mail.tinyhumans.ai|sanil@vezures.xyz',
display_name: 'Steven Enamakel',
source_kind: 'email',
chunk_count: 1,
most_recent_ms: NOW_MS,
lifecycle_status: 'admitted',
},
{
source_id: 'gmail:notifications@github.com|sanil@vezures.xyz',
display_name: 'GitHub notifications',
source_kind: 'email',
chunk_count: 1,
most_recent_ms: NOW_MS - 90 * 60 * 1000,
lifecycle_status: 'admitted',
},
{
source_id: 'slack:T0123|C-engineering',
display_name: 'Slack: #engineering',
source_kind: 'chat',
chunk_count: 1,
most_recent_ms: NOW_MS - 3 * HOUR,
lifecycle_status: 'admitted',
},
];
const FIXTURE_PEOPLE: EntityRef[] = [
{ entity_id: 'person:Steven Enamakel', kind: 'person', surface: 'Steven Enamakel', count: 2 },
{ entity_id: 'person:Maya Patel', kind: 'person', surface: 'Maya Patel', count: 1 },
];
const FIXTURE_TOPICS: EntityRef[] = [
{ entity_id: 'product:openhuman', kind: 'product', surface: 'openhuman', count: 3 },
{ entity_id: 'event:pr-merged', kind: 'event', surface: 'pr-merged', count: 1 },
];
const FIXTURE_SCORE: ScoreBreakdown = {
signals: [
{ name: 'source', weight: 0.3, value: 0.8 },
{ name: 'entities', weight: 0.4, value: 0.7 },
{ name: 'recency', weight: 0.3, value: 0.9 },
],
total: 0.79,
threshold: 0.85,
kept: true,
llm_consulted: false,
};
beforeEach(() => {
memoryTreeListChunks.mockReset();
memoryTreeListSources.mockReset();
memoryTreeTopEntities.mockReset();
memoryTreeEntityIndexFor.mockReset();
memoryTreeChunkScore.mockReset();
memoryTreeListChunks.mockResolvedValue({ chunks: FIXTURE_CHUNKS, total: FIXTURE_CHUNKS.length });
memoryTreeListSources.mockResolvedValue(FIXTURE_SOURCES);
// The workspace calls topEntities twice: ('person', 12) and (undefined, 40).
memoryTreeTopEntities.mockImplementation((kind?: string) => {
if (kind === 'person') return Promise.resolve(FIXTURE_PEOPLE);
return Promise.resolve([...FIXTURE_PEOPLE, ...FIXTURE_TOPICS]);
});
memoryTreeEntityIndexFor.mockResolvedValue([
{ entity_id: 'person:Steven Enamakel', kind: 'person', surface: 'Steven Enamakel', count: 1 },
{ entity_id: 'organization:TinyHumans', kind: 'organization', surface: 'TinyHumans', count: 1 },
]);
memoryTreeChunkScore.mockResolvedValue(FIXTURE_SCORE);
});
it('displays graph relations after loading', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
describe('MemoryWorkspace — 2-pane + overlay browser', () => {
it('renders the navigator + result list scaffold and the search box', async () => {
renderWithProviders(<MemoryWorkspace />);
// Workspace renders the empty placeholder until the first fixture
// round-trip lands — the full 2-pane shell only mounts once allChunks
// is populated. Wait for the post-load state, then assert all four
// anchors exist together.
await waitFor(() => {
expect(screen.getByText('Alice', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByTestId('memory-workspace')).toBeInTheDocument();
});
expect(screen.getByText('AUTHORED', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByText('Bob', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByText('REVIEWED', { selector: 'span' })).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);
expect(screen.getByTestId('memory-navigator')).toBeInTheDocument();
expect(screen.getByTestId('memory-result-list')).toBeInTheDocument();
expect(screen.getByLabelText('Search memory')).toBeInTheDocument();
});
it('shows evidence count badge when > 1', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
it('calls the canonical memory_tree_* RPCs on mount', async () => {
renderWithProviders(<MemoryWorkspace />);
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 stat in the stats bar', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
// The stats bar has a "Relations" label
await waitFor(() => {
expect(screen.getByText('Relations')).toBeInTheDocument();
expect(memoryTreeListChunks).toHaveBeenCalledWith({ limit: 500 });
expect(memoryTreeListSources).toHaveBeenCalled();
expect(memoryTreeTopEntities).toHaveBeenCalledWith('person', 12);
expect(memoryTreeTopEntities).toHaveBeenCalledWith(undefined, 40);
});
});
it('renders the Memory Graph section', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
it('renders navigator section headings (recent, sources, people, topics)', async () => {
renderWithProviders(<MemoryWorkspace />);
await waitFor(() => {
expect(screen.getByText('recent')).toBeInTheDocument();
});
expect(screen.getByText('sources')).toBeInTheDocument();
expect(screen.getByText('people')).toBeInTheDocument();
expect(screen.getByText('topics')).toBeInTheDocument();
});
it('does NOT auto-open the detail overlay on mount (2-pane is the default rest state)', async () => {
renderWithProviders(<MemoryWorkspace />);
// Wait for fixtures to land so we know the workspace is fully rendered.
await waitFor(() => screen.getByTestId('memory-result-list'));
// The new layout opens detail only on row click; no overlay until then.
expect(screen.queryByTestId('memory-chunk-detail')).toBeNull();
});
it('renders the Sources section count + per-kind nesting after the load resolves', async () => {
renderWithProviders(<MemoryWorkspace />);
// Inside the Sources NavSection, fixtures group into Email (2) + Chat (1).
// Each per-kind sub-section is rendered as its own NavSection — closed by
// default, but the labels are visible.
await waitFor(() => {
expect(screen.getByText('Email')).toBeInTheDocument();
expect(screen.getByText('Chat')).toBeInTheDocument();
});
// Person entities (from FIXTURE_PEOPLE) ARE visible by default — the
// people NavSection is `defaultOpen`.
expect(screen.getAllByText('Steven Enamakel').length).toBeGreaterThan(0);
expect(screen.getByText('Maya Patel')).toBeInTheDocument();
});
it('typing in the search box narrows the result-list rows', async () => {
renderWithProviders(<MemoryWorkspace />);
await waitFor(() => {
const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
expect(rows.length).toBe(FIXTURE_CHUNKS.length);
});
const search = screen.getByLabelText('Search memory') as HTMLInputElement;
fireEvent.change(search, { target: { value: 'PR #1175' } });
await waitFor(() => {
expect(screen.getByText('Memory Graph')).toBeInTheDocument();
const visible = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
expect(visible.length).toBe(1);
expect(visible[0]?.textContent ?? '').toMatch(/PR #1175|github/i);
});
});
it('opens the detail overlay when a result row is clicked', async () => {
renderWithProviders(<MemoryWorkspace />);
await waitFor(() => {
const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
expect(rows.length).toBeGreaterThan(0);
});
const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
fireEvent.click(rows[0]!);
await waitFor(() => {
// Detail overlay mounts the ChunkDetail (data-testid="memory-chunk-detail")
// along with the letterhead — both show only after a row click in the
// 2-pane + overlay layout.
expect(screen.getByTestId('memory-chunk-detail')).toBeInTheDocument();
expect(screen.getByTestId('memory-chunk-letterhead')).toBeInTheDocument();
});
});
it('closes the detail overlay on Escape key', async () => {
renderWithProviders(<MemoryWorkspace />);
await waitFor(() => {
const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
expect(rows.length).toBeGreaterThan(0);
});
const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
fireEvent.click(rows[0]!);
await waitFor(() => screen.getByTestId('memory-chunk-detail'));
fireEvent.keyDown(window, { key: 'Escape' });
await waitFor(() => {
expect(screen.queryByTestId('memory-chunk-detail')).toBeNull();
});
});
});
describe('MemoryWorkspace no graph relations', () => {
const onToast = vi.fn();
describe('MemoryWorkspace — empty state', () => {
it('renders the empty placeholder when the core returns zero chunks', async () => {
memoryTreeListChunks.mockResolvedValueOnce({ chunks: [], total: 0 });
memoryTreeListSources.mockResolvedValueOnce([]);
memoryTreeTopEntities.mockResolvedValue([]);
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} />);
renderWithProviders(<MemoryWorkspace />);
await waitFor(() => {
expect(screen.getByText('No memory graph data yet')).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);
});
});
describe('MemoryWorkspace Sync section', () => {
const onToast = vi.fn();
it('renders the Sync collapsible button', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(screen.getByText('Sync')).toBeInTheDocument();
});
});
it('expands and shows Sync all button when toggled', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(screen.getByText('Sync')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Sync').closest('button')!);
await waitFor(() => {
expect(screen.getByText('Sync all')).toBeInTheDocument();
});
});
it('calls memorySyncAll when Sync all button clicked', async () => {
const tauriMod = await import('../../../utils/tauriCommands');
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => screen.getByText('Sync'));
fireEvent.click(screen.getByText('Sync').closest('button')!);
await waitFor(() => screen.getByText('Sync all'));
fireEvent.click(screen.getByText('Sync all'));
await waitFor(() => {
expect(vi.mocked(tauriMod.memorySyncAll)).toHaveBeenCalled();
});
});
it('calls memorySyncChannel when per-channel Sync button clicked', async () => {
const tauriMod = await import('../../../utils/tauriCommands');
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => screen.getByText('Sync'));
fireEvent.click(screen.getByText('Sync').closest('button')!);
await waitFor(() => screen.getAllByText('Sync').length > 1);
// The per-channel sync buttons appear after channels load
await waitFor(() => screen.getByText('telegram-main'));
const syncBtns = screen.getAllByText('Sync');
// Last Sync buttons are per-channel (first is the header)
fireEvent.click(syncBtns[syncBtns.length - 1]);
await waitFor(() => {
expect(vi.mocked(tauriMod.memorySyncChannel)).toHaveBeenCalledWith('discord-bot');
});
});
});
describe('MemoryWorkspace Learn section', () => {
const onToast = vi.fn();
it('renders the Learn collapsible button', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(screen.getByText('Learn')).toBeInTheDocument();
});
});
it('expands and shows Learn all button when toggled', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => screen.getByText('Learn'));
fireEvent.click(screen.getByText('Learn').closest('button')!);
await waitFor(() => {
expect(screen.getByText('Learn all')).toBeInTheDocument();
});
});
it('calls memoryLearnAll and shows result summary', async () => {
const tauriMod = await import('../../../utils/tauriCommands');
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => screen.getByText('Learn'));
fireEvent.click(screen.getByText('Learn').closest('button')!);
await waitFor(() => screen.getByText('Learn all'));
fireEvent.click(screen.getByText('Learn all'));
await waitFor(() => {
expect(vi.mocked(tauriMod.memoryLearnAll)).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText(/2 processed/)).toBeInTheDocument();
expect(screen.getByTestId('memory-empty-placeholder')).toBeInTheDocument();
});
expect(screen.getByText('Nothing yet.')).toBeInTheDocument();
});
});
@@ -0,0 +1,141 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import ModelCatalog from '../ModelCatalog';
describe('ModelCatalog', () => {
it('renders one row per recommended model', () => {
render(
<ModelCatalog
installedModelIds={[]}
activeModelIds={[]}
onDownload={vi.fn()}
onUse={vi.fn()}
/>
);
// Each id from RECOMMENDED_MODEL_CATALOG appears as a row title.
expect(screen.getByText('qwen2.5:0.5b')).toBeInTheDocument();
expect(screen.getByText('gemma3:1b-it-qat')).toBeInTheDocument();
expect(screen.getByText('llama3.1:8b')).toBeInTheDocument();
expect(screen.getByText('bge-m3')).toBeInTheDocument();
});
it('shows "Download" for models that are not installed', () => {
render(
<ModelCatalog
installedModelIds={[]}
activeModelIds={[]}
onDownload={vi.fn()}
onUse={vi.fn()}
/>
);
// Four models, all available → four Download buttons.
expect(screen.getAllByRole('button', { name: /download/i })).toHaveLength(4);
expect(screen.getAllByText('not downloaded').length).toBeGreaterThan(0);
});
it('shows "Use" for installed-but-not-active models, "in use" for active', () => {
render(
<ModelCatalog
installedModelIds={['gemma3:1b-it-qat', 'bge-m3:latest']}
activeModelIds={['bge-m3']}
onDownload={vi.fn()}
onUse={vi.fn()}
/>
);
// bge-m3 is installed AND active → "in use" pill, no Use button for it.
expect(screen.getAllByText('in use').length).toBeGreaterThan(0);
// gemma3 is installed but not active → Use button visible.
expect(screen.getByRole('button', { name: 'Use' })).toBeInTheDocument();
});
it('matches `bge-m3` against `bge-m3:latest` via the :latest normalization', () => {
// Ollama tags everything as `:latest` by default; the catalog uses bare
// names. The component must treat them as the same id.
render(
<ModelCatalog
installedModelIds={['bge-m3:latest']}
activeModelIds={[]}
onDownload={vi.fn()}
onUse={vi.fn()}
/>
);
// bge-m3 row is now in the "installed" state — at least one Use button
// appears (for bge-m3 specifically).
expect(screen.getByRole('button', { name: 'Use' })).toBeInTheDocument();
});
it('fires onUse with the matching model when the Use button is clicked', () => {
const onUse = vi.fn();
render(
<ModelCatalog
installedModelIds={['llama3.1:8b']}
activeModelIds={[]}
onDownload={vi.fn()}
onUse={onUse}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Use' }));
expect(onUse).toHaveBeenCalledTimes(1);
expect(onUse.mock.calls[0][0]).toMatchObject({ id: 'llama3.1:8b' });
});
it('renders Delete buttons only when onDelete is provided', () => {
const { rerender } = render(
<ModelCatalog
installedModelIds={['llama3.1:8b']}
activeModelIds={[]}
onDownload={vi.fn()}
onUse={vi.fn()}
/>
);
expect(screen.queryByLabelText('Delete model')).toBeNull();
rerender(
<ModelCatalog
installedModelIds={['llama3.1:8b']}
activeModelIds={[]}
onDownload={vi.fn()}
onUse={vi.fn()}
onDelete={vi.fn()}
/>
);
expect(screen.getByLabelText('Delete model')).toBeInTheDocument();
});
it('shows a progress bar while a download is in flight, then clears it', async () => {
let resolveDownload!: () => void;
const onDownload = vi.fn(
() =>
new Promise<void>(resolve => {
resolveDownload = resolve;
})
);
render(
<ModelCatalog
installedModelIds={[]}
activeModelIds={[]}
onDownload={onDownload}
onUse={vi.fn()}
/>
);
fireEvent.click(screen.getAllByRole('button', { name: /download/i })[0]);
// Mid-flight: a progressbar is rendered for that row.
await waitFor(() => {
expect(screen.queryAllByRole('progressbar').length).toBeGreaterThan(0);
});
resolveDownload();
// After settle (~600 ms on success), the bar disappears and the row
// returns to its post-install state. We just confirm the state
// eventually clears — not the exact timing.
await waitFor(
() => {
expect(screen.queryByRole('progressbar')).toBeNull();
},
{ timeout: 2000 }
);
});
});
@@ -0,0 +1,617 @@
/**
* Locked design tokens for the three-pane MemoryWorkspace browser.
*
* Scoped under `.memory-workspace-root` so the rest of the app's surfaces
* (which use the global stone/primary palette) are unaffected.
*/
.memory-workspace-root {
/* Match the rest of the app's stone palette — dropped the cream tones
(#faf7f2 / #f4f0e9) that read yellowish next to the bottom nav bar. */
--paper: #fafaf9; /* stone-50 — base surface */
--paper-elevated: #ffffff; /* white — active/selected/list */
--paper-recessed: #f5f5f4; /* stone-100 — hover */
--paper-recessed-darker: #e7e5e4; /* stone-200 — pressed */
--hairline: #e7e5e4; /* stone-200 — borders */
--ink: #1c1917;
--ink-soft: #44403c;
--ink-whisper: #78716c;
--ocean: #4a83dd;
--ocean-deep: #2e5baa;
--ocean-mist: rgba(74, 131, 221, 0.08);
--sage: #7a9b7e;
--amber: #c8954d;
--coral: #c26b5c;
--shadow-rest: 0 1px 2px rgba(26, 31, 46, 0.04);
--r-tight: 4px;
--r-card: 8px;
--r-room: 12px;
}
.memory-workspace-root,
.memory-workspace-root * {
box-sizing: border-box;
}
.memory-workspace-root {
background: var(--paper);
color: var(--ink);
border-radius: var(--r-room);
border: 1px solid var(--hairline);
box-shadow: var(--shadow-rest);
overflow: hidden;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
system-ui,
sans-serif;
}
.memory-workspace-grid {
display: grid;
grid-template-columns: 280px 380px 1fr;
min-height: 640px;
height: calc(100vh - 240px);
max-height: 900px;
}
.memory-workspace-grid > * + * {
border-left: 1px solid var(--hairline);
}
@media (max-width: 1100px) {
.memory-workspace-grid {
grid-template-columns: 1fr 1fr;
}
.memory-workspace-grid .mw-pane-detail {
display: none;
}
.memory-workspace-grid.mw-show-detail {
grid-template-columns: 1fr;
}
.memory-workspace-grid.mw-show-detail .mw-pane-navigator,
.memory-workspace-grid.mw-show-detail .mw-pane-results {
display: none;
}
.memory-workspace-grid.mw-show-detail .mw-pane-detail {
display: flex;
}
}
.mw-pane-navigator {
background: var(--paper-recessed);
display: flex;
flex-direction: column;
overflow: hidden;
}
.mw-pane-results {
background: var(--paper);
display: flex;
flex-direction: column;
overflow: hidden;
}
.mw-pane-detail {
background: var(--paper-elevated);
display: flex;
flex-direction: column;
overflow: hidden;
}
.mw-pane-scroll {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
}
/* Section headings — Inter uppercase, modern app-rail style. Replaces
the Cabinet Grotesk lowercase tracked treatment which read like a
magazine masthead in a left-rail context. */
.mw-section-heading {
font-family: Inter, system-ui, sans-serif;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-whisper);
padding: 10px 16px 6px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
background: transparent;
border: none;
width: 100%;
text-align: left;
transition: color 120ms ease;
}
.mw-section-heading:hover {
color: var(--ink-soft);
}
.mw-section-chev {
margin-left: auto;
font-size: 11px;
opacity: 0.5;
transition: transform 120ms ease;
}
.mw-section-chev.open {
transform: rotate(90deg);
}
.mw-section {
border-bottom: 1px solid var(--hairline);
}
.mw-section:last-child {
border-bottom: none;
}
/* Nested sections — sources expanded shows Email/Slack/etc. as
sub-collapsibles. Indent + tone-shift the nested level so the
hierarchy reads visually. */
.mw-section .mw-section {
border-bottom: 1px solid var(--hairline);
border-left: 2px solid var(--hairline);
margin: 0 0 0 12px;
background: var(--paper-recessed);
}
.mw-section .mw-section:last-child {
border-bottom: none;
}
.mw-section .mw-section .mw-section-heading {
font-size: 10px;
letter-spacing: 0.04em;
padding: 6px 12px 4px;
color: var(--ink-soft);
}
.mw-section .mw-section .mw-section-heading:hover {
color: var(--ink);
}
.mw-section .mw-section .mw-list-item {
/* Source rows under a kind heading need a stronger indent so the
hierarchy is unambiguous: kind heading at one level, individual
senders deeper. Bump left padding so each row sits visibly under
the kind's chevron, with a sub-rail line to continue the nesting. */
padding: 5px 12px 5px 26px;
position: relative;
}
.mw-section .mw-section .mw-list-item::before {
content: '';
position: absolute;
left: 14px;
top: 0;
bottom: 0;
width: 1px;
background: var(--hairline);
opacity: 0.7;
}
.mw-section .mw-section .mw-list-item.is-active::before {
background: var(--ocean);
opacity: 1;
}
.mw-search-row {
padding: 12px 16px;
border-bottom: 1px solid var(--hairline);
}
.mw-search-input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--hairline);
padding: 6px 0;
font-size: 14px;
color: var(--ink);
outline: none;
font-style: italic;
}
.mw-search-input:focus {
border-bottom-color: var(--ocean);
}
.mw-search-input::placeholder {
color: var(--ink-whisper);
font-style: italic;
}
.mw-heatmap-host {
padding: 8px 12px 12px;
border-bottom: 1px solid var(--hairline);
}
.mw-heatmap-host > div {
background: transparent !important;
border: none !important;
padding: 0 !important;
}
.mw-heatmap-host h3 {
display: none;
}
.mw-heatmap-host p {
font-size: 10px !important;
color: var(--ink-whisper) !important;
margin: 0 !important;
}
.mw-recent-summary {
padding: 8px 16px 14px;
font-size: 12px;
color: var(--ink-soft);
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
}
.mw-recent-summary span + span {
margin-left: 8px;
color: var(--ink-whisper);
}
.mw-list {
list-style: none;
margin: 0;
padding: 0 0 8px;
}
.mw-list-item {
display: grid;
grid-template-columns: 8px 1fr auto;
gap: 10px;
align-items: center;
padding: 6px 16px;
cursor: pointer;
border-left: 2px solid transparent;
transition:
background 80ms,
border-color 80ms;
background: transparent;
border-top: none;
border-right: none;
border-bottom: none;
width: 100%;
text-align: left;
}
.mw-list-item:hover {
background: var(--paper-recessed-darker);
}
.mw-list-item.is-active {
background: var(--paper-elevated); /* white — matches bottom nav active state */
border-left-color: var(--ocean);
box-shadow: 0 1px 2px rgba(28, 25, 23, 0.04);
}
.mw-list-item.is-active .mw-list-name {
font-weight: 600;
color: var(--ink);
}
.mw-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--ink-whisper);
}
.mw-dot.dot-admitted {
background: var(--sage);
}
.mw-dot.dot-pending {
background: var(--amber);
}
.mw-dot.dot-buffered {
background: var(--ocean);
}
.mw-dot.dot-dropped {
background: var(--coral);
}
.mw-list-name {
font-size: 14px;
color: var(--ink-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.mw-list-count {
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
font-size: 11px;
color: var(--ink-whisper);
}
/* Result list */
.mw-results-empty,
.mw-detail-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 32px;
color: var(--ink-soft);
}
.mw-results-empty {
font-size: 13px;
color: var(--ink-whisper);
}
.mw-detail-empty {
font-family: 'Cabinet Grotesk', Inter, system-ui, sans-serif;
}
.mw-detail-empty .mw-empty-title {
font-size: 22px;
margin: 0 0 12px;
color: var(--ink-soft);
}
.mw-detail-empty .mw-empty-body {
font-size: 14px;
color: var(--ink-whisper);
max-width: 320px;
line-height: 1.6;
}
.mw-results-section {
border-bottom: 1px solid var(--hairline);
}
.mw-results-section-header {
position: sticky;
top: 0;
z-index: 1;
background: var(--paper);
font-family: 'Cabinet Grotesk', Inter, sans-serif;
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-whisper);
padding: 10px 16px 8px;
border-bottom: 1px solid var(--hairline);
}
.mw-result-row {
display: grid;
grid-template-columns: 56px 1fr;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
border-left: 2px solid transparent;
border-bottom: 1px solid var(--hairline);
transition: background 80ms;
background: transparent;
border-right: none;
border-top: none;
width: 100%;
text-align: left;
}
.mw-result-row:last-child {
border-bottom: none;
}
.mw-result-row:hover {
background: var(--paper-recessed);
}
.mw-result-row.is-active {
background: var(--ocean-mist);
border-left-color: var(--ocean);
}
.mw-result-row.is-active .mw-result-subject {
font-weight: 600;
color: var(--ink);
}
.mw-result-time {
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
font-size: 12px;
color: var(--ink-whisper);
padding-top: 1px;
}
.mw-result-content {
min-width: 0;
}
.mw-result-subject {
font-size: 14px;
font-weight: 500;
color: var(--ink);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.mw-result-meta {
font-size: 12px;
color: var(--ink-soft);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mw-result-kind {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-whisper);
padding: 1px 6px;
border: 1px solid var(--hairline);
border-radius: 999px;
background: var(--paper);
margin-right: 2px;
}
/* Chunk detail letter */
.mw-detail-scroll {
padding: 40px 48px;
}
.mw-letter {
max-width: 720px;
margin: 0 auto;
}
.mw-letterhead {
margin-bottom: 24px;
}
.mw-letterhead-row {
display: grid;
grid-template-columns: 80px 1fr;
gap: 8px;
margin-bottom: 8px;
align-items: baseline;
}
.mw-letterhead-label {
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
font-size: 11px;
text-transform: lowercase;
color: var(--ink-whisper);
letter-spacing: 0.05em;
}
.mw-letterhead-label::after {
content: '';
display: block;
border-bottom: 1px solid var(--hairline);
width: 40px;
margin-top: 2px;
}
.mw-letterhead-value {
font-size: 14px;
color: var(--ink);
line-height: 1.5;
}
.mw-letterhead-value-secondary {
font-size: 12px;
color: var(--ink-soft);
display: block;
margin-top: 2px;
}
.mw-letterhead-date {
margin-top: 12px;
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
font-size: 12px;
color: var(--ink-soft);
letter-spacing: 0.05em;
}
.mw-rule {
border: none;
border-top: 1px solid var(--hairline);
margin: 24px 0;
}
.mw-letter-subject {
font-family: 'Cabinet Grotesk', Inter, system-ui, sans-serif;
font-size: 22px;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--ink);
margin: 0 0 16px;
line-height: 1.35;
}
.mw-letter-body {
font-size: 15px;
line-height: 1.7;
color: var(--ink);
white-space: pre-wrap;
/* `word-wrap` is the legacy alias of `overflow-wrap`; modern engines
respect `overflow-wrap` directly without the legacy fallback. */
overflow-wrap: break-word;
}
.mw-letter-body p {
margin: 0 0 12px;
}
/* Mentioned section */
.mw-mentioned-heading,
.mw-whykept-heading {
font-family: 'Cabinet Grotesk', Inter, sans-serif;
font-size: 12px;
letter-spacing: 0.3em;
color: var(--ink-whisper);
text-transform: lowercase;
margin: 16px 0 12px;
font-weight: 500;
}
.mw-mentioned-table {
width: 100%;
border-collapse: collapse;
}
.mw-mentioned-row {
cursor: pointer;
background: transparent;
border: none;
/* Single padding declaration — was duplicated (`padding: 0` then
`padding: 6px 0` later in the block). Combined into the right
value below. */
padding: 6px 0;
width: 100%;
display: grid;
grid-template-columns: 110px 1fr auto;
gap: 12px;
font-size: 13px;
color: var(--ink);
align-items: baseline;
text-align: left;
border-bottom: 1px dotted var(--hairline);
}
.mw-mentioned-row:last-child {
border-bottom: none;
}
.mw-mentioned-row:hover {
background: var(--paper-recessed);
}
.mw-mentioned-kind {
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
font-size: 11px;
color: var(--ink-whisper);
text-transform: lowercase;
}
.mw-mentioned-surface {
color: var(--ink);
font-size: 13px;
}
.mw-mentioned-count {
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
font-size: 11px;
color: var(--ink-whisper);
}
/* Score bars */
.mw-scorebar-row {
display: grid;
grid-template-columns: 110px 1fr 60px;
gap: 12px;
align-items: center;
padding: 6px 0;
font-size: 13px;
}
.mw-scorebar-label {
font-size: 13px;
color: var(--ink-soft);
}
.mw-scorebar-value {
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
font-size: 12px;
color: var(--ink);
text-align: right;
}
.mw-scorebar-threshold {
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
font-size: 11px;
color: var(--ink-whisper);
text-align: center;
margin-top: 12px;
letter-spacing: 0.05em;
}
/* Footer */
.mw-letter-footer {
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid var(--hairline);
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
font-size: 11px;
color: var(--ink-whisper);
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
}
.mw-letter-footer button {
background: transparent;
border: none;
padding: 0;
font-family: inherit;
font-size: inherit;
color: inherit;
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 2px;
}
.mw-letter-footer button:hover {
color: var(--ocean);
}
@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
capabilityForModel,
downloadAsset,
fetchInstalledAssets,
fetchInstalledModels,
fetchLocalAiStatus,
fetchPresets,
formatBytes,
getMemoryTreeLlm,
type ModelDescriptor,
setMemoryTreeLlm,
} from '../settingsApi';
// Stub the underlying tauri-command wrappers; we're testing the
// camelCase→snake_case translation + simple try/catch shells, not the
// RPC plumbing.
vi.mock('../../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
memoryTreeGetLlm: vi.fn(),
memoryTreeSetLlm: vi.fn(),
openhumanLocalAiAssetsStatus: vi.fn(),
openhumanLocalAiStatus: vi.fn(),
openhumanLocalAiDiagnostics: vi.fn(),
openhumanLocalAiPresets: vi.fn(),
openhumanLocalAiDownloadAsset: vi.fn(),
}));
const tauri = (await import('../../../utils/tauriCommands')) as unknown as {
memoryTreeGetLlm: ReturnType<typeof vi.fn>;
memoryTreeSetLlm: ReturnType<typeof vi.fn>;
openhumanLocalAiAssetsStatus: ReturnType<typeof vi.fn>;
openhumanLocalAiStatus: ReturnType<typeof vi.fn>;
openhumanLocalAiDiagnostics: ReturnType<typeof vi.fn>;
openhumanLocalAiPresets: ReturnType<typeof vi.fn>;
openhumanLocalAiDownloadAsset: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
Object.values(tauri).forEach(fn => fn.mockReset());
});
describe('getMemoryTreeLlm', () => {
it('returns the current backend value from the RPC', async () => {
tauri.memoryTreeGetLlm.mockResolvedValueOnce({ current: 'cloud' });
await expect(getMemoryTreeLlm()).resolves.toBe('cloud');
expect(tauri.memoryTreeGetLlm).toHaveBeenCalledTimes(1);
});
});
describe('setMemoryTreeLlm', () => {
it('passes only the backend when no options are supplied', async () => {
tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'cloud' });
await setMemoryTreeLlm('cloud');
expect(tauri.memoryTreeSetLlm).toHaveBeenCalledWith({ backend: 'cloud' });
});
it('translates camelCase options to snake_case wire fields and passes only those that are set', async () => {
tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'local' });
await setMemoryTreeLlm('local', { extractModel: 'a:b', summariserModel: 'c:d' });
expect(tauri.memoryTreeSetLlm).toHaveBeenCalledWith({
backend: 'local',
extract_model: 'a:b',
summariser_model: 'c:d',
});
// cloudModel was unset → cloud_model must NOT be on the wire payload.
expect(tauri.memoryTreeSetLlm.mock.calls[0][0]).not.toHaveProperty('cloud_model');
});
it('returns the effective backend value the core decided on', async () => {
tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'cloud' });
const out = await setMemoryTreeLlm('local');
expect(out).toEqual({ effective: 'cloud' });
});
});
describe('fetchInstalledAssets', () => {
it('unwraps the `result` field on success', async () => {
tauri.openhumanLocalAiAssetsStatus.mockResolvedValueOnce({ result: { foo: 1 } });
await expect(fetchInstalledAssets()).resolves.toEqual({ foo: 1 });
});
it('swallows RPC errors and returns null', async () => {
tauri.openhumanLocalAiAssetsStatus.mockRejectedValueOnce(new Error('boom'));
await expect(fetchInstalledAssets()).resolves.toBeNull();
});
});
describe('fetchLocalAiStatus', () => {
it('returns null on RPC failure', async () => {
tauri.openhumanLocalAiStatus.mockRejectedValueOnce(new Error('nope'));
await expect(fetchLocalAiStatus()).resolves.toBeNull();
});
});
describe('fetchInstalledModels', () => {
it('returns the installed_models array', async () => {
tauri.openhumanLocalAiDiagnostics.mockResolvedValueOnce({
installed_models: [{ name: 'bge-m3', size_bytes: 1 }],
});
const got = await fetchInstalledModels();
expect(got).toHaveLength(1);
expect(got[0]?.name).toBe('bge-m3');
});
it('returns [] when the RPC rejects', async () => {
tauri.openhumanLocalAiDiagnostics.mockRejectedValueOnce(new Error('rpc down'));
await expect(fetchInstalledModels()).resolves.toEqual([]);
});
it('returns [] when installed_models is missing', async () => {
tauri.openhumanLocalAiDiagnostics.mockResolvedValueOnce({});
await expect(fetchInstalledModels()).resolves.toEqual([]);
});
});
describe('fetchPresets', () => {
it('forwards the response on success', async () => {
tauri.openhumanLocalAiPresets.mockResolvedValueOnce({ presets: [] });
await expect(fetchPresets()).resolves.toEqual({ presets: [] });
});
it('returns null on failure', async () => {
tauri.openhumanLocalAiPresets.mockRejectedValueOnce(new Error('x'));
await expect(fetchPresets()).resolves.toBeNull();
});
});
describe('downloadAsset', () => {
it('returns the result envelope on success', async () => {
tauri.openhumanLocalAiDownloadAsset.mockResolvedValueOnce({ result: { ok: true } });
await expect(downloadAsset('chat')).resolves.toEqual({ ok: true });
expect(tauri.openhumanLocalAiDownloadAsset).toHaveBeenCalledWith('chat');
});
it('returns null on failure (and does not throw)', async () => {
tauri.openhumanLocalAiDownloadAsset.mockRejectedValueOnce(new Error('disconnected'));
await expect(downloadAsset('embedding')).resolves.toBeNull();
});
});
describe('capabilityForModel', () => {
const make = (roles: ModelDescriptor['roles']): ModelDescriptor => ({
id: 'x',
size: '0',
approxBytes: 0,
ramHint: '0',
category: 'fast',
note: '',
roles,
});
it('maps embedder → embedding', () => {
expect(capabilityForModel(make(['embedder']))).toBe('embedding');
});
it('maps extract / summariser → chat', () => {
expect(capabilityForModel(make(['extract']))).toBe('chat');
expect(capabilityForModel(make(['summariser']))).toBe('chat');
expect(capabilityForModel(make(['extract', 'summariser']))).toBe('chat');
});
it('returns null when no role binds to a known capability', () => {
expect(capabilityForModel(make([]))).toBeNull();
});
});
describe('formatBytes', () => {
it('falls back to em-dash for non-finite or zero', () => {
expect(formatBytes(Number.NaN)).toBe('—');
expect(formatBytes(0)).toBe('—');
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('—');
});
it('formats GB for >= 1 GiB', () => {
expect(formatBytes(2.5 * 1024 ** 3)).toBe('2.5 GB');
});
it('formats MB (rounded) for sub-GB inputs', () => {
expect(formatBytes(150 * 1024 ** 2)).toBe('150 MB');
expect(formatBytes(1024 ** 2 + 1)).toBe('1 MB');
});
});
+260
View File
@@ -0,0 +1,260 @@
/**
* Settings tab API layer for the Intelligence page.
*
* Wraps the existing `local_ai_*` core RPCs (re-exported with cleaner names)
* and the canonical `openhuman.memory_tree_get_llm` / `set_llm` JSON-RPC
* methods that drive the AI-backend selector. Both come from the shared
* `utils/tauriCommands` barrel.
*
* Logging convention: `[intelligence-settings-api]` prefix for grep-friendly
* tracing of the new flow per the project debug-logging rule.
*/
import {
type LlmBackend,
type LocalAiAssetsStatus,
type LocalAiDiagnostics,
type LocalAiStatus,
memoryTreeGetLlm,
memoryTreeSetLlm,
openhumanLocalAiAssetsStatus,
openhumanLocalAiDiagnostics,
openhumanLocalAiDownloadAsset,
openhumanLocalAiPresets,
openhumanLocalAiStatus,
type PresetsResponse,
} from '../../utils/tauriCommands';
/**
* AI backend the assistant is currently using for chat. Re-exports the
* canonical `LlmBackend` from the wrapper so both names remain valid as
* call-sites migrate.
*/
export type Backend = LlmBackend;
/** Static descriptor used by ModelAssignment + ModelCatalog. */
export interface ModelDescriptor {
/** Ollama-style identifier (e.g. `qwen2.5:0.5b`). */
id: string;
/** Pretty label shown in the UI (defaults to `id` when omitted). */
label?: string;
/** Human-readable disk size, e.g. `400 MB`. */
size: string;
/** Bytes — approximate; surfaced for sort / filter. */
approxBytes: number;
/** Approx RAM hint, e.g. `≤4 GB RAM`. */
ramHint: string;
/** Speed / quality tier — used for the inline annotation under each row. */
category: 'fast' | 'balanced' | 'high quality' | 'embedder';
/** One-sentence note about when to pick this model. */
note: string;
/** Role(s) this model is suitable for. */
roles: ReadonlyArray<'extract' | 'summariser' | 'embedder'>;
}
export type ModelRole = 'extract' | 'summariser' | 'embedder';
/**
* Hard-coded recommended catalog. In a future wave this should come from
* a `local_ai.recommended_catalog` RPC; for v1 we ship a curated list so
* the UI is fully populated without a server roundtrip.
*/
export const RECOMMENDED_MODEL_CATALOG: ReadonlyArray<ModelDescriptor> = [
{
id: 'qwen2.5:0.5b',
size: '400 MB',
approxBytes: 400 * 1024 * 1024,
ramHint: '≤4 GB RAM',
category: 'fast',
note: 'compact, lower quality',
roles: ['extract'],
},
{
id: 'gemma3:1b-it-qat',
size: '1.7 GB',
approxBytes: Math.round(1.7 * 1024 * 1024 * 1024),
ramHint: '≤8 GB RAM',
category: 'balanced',
note: 'default summariser',
roles: ['extract', 'summariser'],
},
{
id: 'llama3.1:8b',
size: '4.9 GB',
approxBytes: Math.round(4.9 * 1024 * 1024 * 1024),
ramHint: '≥8 GB RAM',
category: 'high quality',
note: 'for capable machines',
roles: ['extract', 'summariser'],
},
{
id: 'bge-m3',
size: '1.3 GB',
approxBytes: Math.round(1.3 * 1024 * 1024 * 1024),
ramHint: '≥4 GB RAM',
category: 'embedder',
note: 'required for embeddings',
roles: ['embedder'],
},
];
export const DEFAULT_EXTRACT_MODEL = 'qwen2.5:0.5b';
export const DEFAULT_SUMMARISER_MODEL = 'gemma3:1b-it-qat';
export const REQUIRED_EMBEDDER_MODEL = 'bge-m3';
/**
* Reads the currently configured chat backend from the core.
*
* Backed by `openhuman.memory_tree_get_llm` — the value persists across
* sidecar restarts via `config.toml`.
*/
export async function getMemoryTreeLlm(): Promise<Backend> {
console.debug('[intelligence-settings-api] getMemoryTreeLlm: entry');
const resp = await memoryTreeGetLlm();
console.debug('[intelligence-settings-api] getMemoryTreeLlm: exit current=%s', resp.current);
return resp.current;
}
/**
* Optional per-role model picks for {@link setMemoryTreeLlm}. Field names
* are camelCase here to match TS conventions; the wrapper translates them
* to the snake_case wire shape the Rust `SetLlmRequest` expects:
*
* | TS option | Rust / wire field | Targets `memory_tree.*` |
* | ----------------- | ------------------- | ----------------------- |
* | `cloudModel` | `cloud_model` | `cloud_llm_model` |
* | `extractModel` | `extract_model` | `llm_extractor_model` |
* | `summariserModel` | `summariser_model` | `llm_summariser_model` |
*
* Each field follows "absent → unchanged, present → overwritten" so a
* caller flipping just the backend doesn't have to re-supply every model
* id, and a caller persisting just one role doesn't have to re-supply
* the others.
*/
export interface SetMemoryTreeLlmOptions {
cloudModel?: string;
extractModel?: string;
summariserModel?: string;
}
/**
* Switches the chat backend and (optionally) persists per-role model
* choices in the same atomic `config.toml` write. Returns the effective
* value the core agreed on — today the handler accepts the input
* verbatim, but a future revision may downgrade `local` → `cloud` when
* the host can't satisfy the local minimums.
*
* Backed by `openhuman.memory_tree_set_llm`.
*
* Existing one-arg callers — `setMemoryTreeLlm('cloud')` — keep working
* unchanged because `options` is optional.
*/
export async function setMemoryTreeLlm(
next: Backend,
options?: SetMemoryTreeLlmOptions
): Promise<{ effective: Backend }> {
console.debug(
'[intelligence-settings-api] setMemoryTreeLlm: entry next=%s cloudModel=%s extractModel=%s summariserModel=%s',
next,
options?.cloudModel ?? '<none>',
options?.extractModel ?? '<none>',
options?.summariserModel ?? '<none>'
);
// camelCase → snake_case translation lives here, in one place. The
// wrapper layer just forwards the snake_case shape to the wire.
const resp = await memoryTreeSetLlm({
backend: next,
...(options?.cloudModel !== undefined && { cloud_model: options.cloudModel }),
...(options?.extractModel !== undefined && { extract_model: options.extractModel }),
...(options?.summariserModel !== undefined && { summariser_model: options.summariserModel }),
});
console.debug('[intelligence-settings-api] setMemoryTreeLlm: exit effective=%s', resp.current);
return { effective: resp.current };
}
/** Re-export the existing assets status fetch with a friendlier name. */
export async function fetchInstalledAssets(): Promise<LocalAiAssetsStatus | null> {
try {
const response = await openhumanLocalAiAssetsStatus();
return response.result;
} catch (err) {
console.debug('[intelligence-settings-api] fetchInstalledAssets failed', err);
return null;
}
}
/**
* Fetch local AI status (includes per-capability state + last latency).
* Used by `CurrentlyLoaded` to render Ollama-side telemetry.
*/
export async function fetchLocalAiStatus(): Promise<LocalAiStatus | null> {
try {
const response = await openhumanLocalAiStatus();
return response.result;
} catch (err) {
console.debug('[intelligence-settings-api] fetchLocalAiStatus failed', err);
return null;
}
}
/**
* Reach into the existing diagnostics RPC for the list of installed Ollama
* models. The diagnostics endpoint already enumerates them and is the
* cleanest single source of truth — we do not duplicate the model table.
*/
export async function fetchInstalledModels(): Promise<LocalAiDiagnostics['installed_models']> {
try {
const response = await openhumanLocalAiDiagnostics();
return response.installed_models ?? [];
} catch (err) {
console.debug('[intelligence-settings-api] fetchInstalledModels failed', err);
return [];
}
}
export async function fetchPresets(): Promise<PresetsResponse | null> {
try {
return await openhumanLocalAiPresets();
} catch (err) {
console.debug('[intelligence-settings-api] fetchPresets failed', err);
return null;
}
}
/**
* Trigger a download for a capability (chat / vision / embedding / stt / tts).
* Used by ModelCatalog when the user clicks "Download".
*
* NOTE: the real RPC is per-capability, not per-model-id, so the catalog
* picks the closest matching capability. This is acceptable for v1; future
* iterations can swap in a per-model RPC.
*/
export async function downloadAsset(
capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
): Promise<LocalAiAssetsStatus | null> {
try {
const response = await openhumanLocalAiDownloadAsset(capability);
return response.result;
} catch (err) {
console.debug('[intelligence-settings-api] downloadAsset failed', { capability, err });
return null;
}
}
/** Map a model descriptor to the closest capability bucket the core exposes. */
export function capabilityForModel(model: ModelDescriptor): 'chat' | 'embedding' | null {
if (model.roles.includes('embedder')) return 'embedding';
if (model.roles.includes('extract') || model.roles.includes('summariser')) return 'chat';
return null;
}
/**
* Cheap pretty-printer for a byte count. Mirrors the `JetBrains Mono`-style
* compact format we want in the technical-readout sections.
*/
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) return `${gb.toFixed(1)} GB`;
const mb = bytes / (1024 * 1024);
return `${Math.round(mb)} MB`;
}
+41 -203
View File
@@ -1,55 +1,45 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import IntelligenceDreamsTab from '../components/intelligence/IntelligenceDreamsTab';
import IntelligenceMemoryTab from '../components/intelligence/IntelligenceMemoryTab';
import IntelligenceSettingsTab from '../components/intelligence/IntelligenceSettingsTab';
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace';
import { ToastContainer } from '../components/intelligence/Toast';
import { filterItems, getItemStats, groupItemsByTime } from '../components/intelligence/utils';
import PillTabBar from '../components/PillTabBar';
import { useConsciousItems } from '../hooks/useConsciousItems';
import {
useSnoozeActionableItem,
useUpdateActionableItem,
} from '../hooks/useIntelligenceApiFallback';
import {
useIntelligenceSocket,
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 {
ActionableItem,
ActionableItemSource,
ActionableItemStatus,
ConfirmationModal as ConfirmationModalType,
ToastNotification,
} from '../types/intelligence';
type IntelligenceTab = 'memory' | 'subconscious' | 'dreams';
type IntelligenceTab = 'memory' | 'subconscious' | 'dreams' | 'settings';
export default function Intelligence() {
const { aiStatus } = useIntelligenceStats();
const { status: ingestionStatus } = useMemoryIngestionStatus();
const [activeTab, setActiveTab] = useState<IntelligenceTab>('memory');
const [sourceFilter, setSourceFilter] = useState<ActionableItemSource | 'all'>('all');
const [priorityFilter] = useState<'critical' | 'important' | 'normal' | 'all'>('all');
const [searchFilter, setSearchFilter] = useState('');
// Conscious memory items (real data from the background analysis loop)
const {
items: consciousItems,
loading: consciousLoading,
isRunning,
refresh: refreshConscious,
triggerAnalysis,
} = useConsciousItems();
// `useConsciousItems` is kept solely for the `isRunning` signal that
// drives the system-status pill in the Memory-tab header. The items
// themselves used to feed the actionable-cards count badge (now hidden,
// and the rendering surface — IntelligenceMemoryTab — is gone). When
// the status pill is rewired to a memory_tree-native source, drop this
// hook entirely.
const { isRunning } = useConsciousItems();
const { mutateAsync: updateItemStatus } = useUpdateActionableItem();
const { mutateAsync: snoozeItem } = useSnoozeActionableItem();
// useUpdateActionableItem / useSnoozeActionableItem hooks were the
// mutations behind handleComplete / Dismiss / Snooze. Removed along
// with those handlers since the Memory tab no longer renders the
// actionable-card surface.
// Subconscious engine data
const {
@@ -92,17 +82,6 @@ export default function Intelligence() {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const { items: screenIntelligenceItems, loading: screenIntelligenceLoading } =
useScreenIntelligenceItems();
const usingMemoryData = consciousItems.length > 0 || screenIntelligenceItems.length > 0;
const items: ActionableItem[] = useMemo(
() => [...consciousItems, ...screenIntelligenceItems],
[consciousItems, screenIntelligenceItems]
);
const itemsLoading = consciousLoading || screenIntelligenceLoading;
// Initialize socket connection
useEffect(() => {
if (!socketConnected) {
@@ -110,131 +89,18 @@ export default function Intelligence() {
}
}, [socketConnected, socketManager]);
// Filter and group items
const filteredItems = useMemo(() => {
const activeItems = items.filter(item => item.status === 'active');
return filterItems(activeItems, {
source: sourceFilter,
priority: priorityFilter,
searchTerm: searchFilter,
});
}, [items, priorityFilter, searchFilter, sourceFilter]);
const timeGroups = useMemo(() => groupItemsByTime(filteredItems), [filteredItems]);
const stats = useMemo(() => getItemStats(filteredItems), [filteredItems]);
// Item action handlers
const handleUpdateItemStatus = useCallback(
async (itemId: string, status: ActionableItemStatus) => {
try {
await updateItemStatus({ itemId, status });
let message = '';
switch (status) {
case 'completed':
message = 'Task marked as completed';
break;
case 'dismissed':
message = 'Task dismissed';
break;
case 'active':
message = 'Task reactivated';
break;
default:
message = 'Status updated';
}
addToast({ type: 'success', title: 'Status Updated', message });
} catch (error) {
console.error('Failed to update item status:', error);
addToast({
type: 'error',
title: 'Update Failed',
message: error instanceof Error ? error.message : 'Failed to update item status',
});
}
},
[updateItemStatus, addToast]
);
const handleComplete = useCallback(
async (item: ActionableItem) => {
await handleUpdateItemStatus(item.id, 'completed');
},
[handleUpdateItemStatus]
);
const handleDismiss = useCallback(
(item: ActionableItem) => {
setConfirmationModal({
isOpen: true,
title: 'Dismiss item?',
message: `Are you sure you want to dismiss "${item.title}"?`,
confirmText: 'Dismiss',
cancelText: 'Cancel',
destructive: item.priority === 'critical',
showDontShowAgain: !item.requiresConfirmation,
onConfirm: async () => {
try {
await handleUpdateItemStatus(item.id, 'dismissed');
addToast({
type: 'info',
title: 'Dismissed',
message: item.title.length > 40 ? `${item.title.substring(0, 40)}...` : item.title,
action: { label: 'Undo', handler: () => handleUpdateItemStatus(item.id, 'active') },
});
} catch (error) {
console.error('Failed to dismiss item:', error);
}
},
onCancel: () => {},
});
},
[handleUpdateItemStatus, addToast]
);
const handleSnooze = useCallback(
async (item: ActionableItem, duration: number) => {
try {
const snoozeUntil = new Date(Date.now() + duration);
await snoozeItem({ itemId: item.id, snoozeUntil });
const hours = Math.round(duration / (1000 * 60 * 60));
addToast({
type: 'info',
title: 'Snoozed',
message: `Reminded in ${hours === 1 ? '1 hour' : `${hours} hours`}`,
});
} catch (error) {
console.error('Failed to snooze item:', error);
addToast({
type: 'error',
title: 'Snooze Failed',
message: 'Failed to snooze item. Please try again.',
});
}
},
[snoozeItem, addToast]
);
const handleAnalyzeNow = useCallback(async () => {
await triggerAnalysis();
addToast({
type: 'info',
title: 'Analysis Started',
message: 'Analyzing your connected skills for actionable items…',
});
}, [triggerAnalysis, addToast]);
// System status
// System status — `itemsLoading` (the actionable-items + screen-items
// loading flag) used to feed the "loading" branch here, but both feeds
// are gone now. `isRunning` from useConsciousItems still surfaces the
// background analysis loop signal until that pill is rewired to
// memory_tree.
const systemStatus = isRunning
? 'loading'
: socketConnected && aiStatus === 'ready'
? 'ready'
: itemsLoading
? 'loading'
: !socketConnected
? 'disconnected'
: aiStatus;
: !socketConnected
? 'disconnected'
: aiStatus;
const systemStatusLabel = isRunning
? 'Analyzing…'
@@ -265,6 +131,7 @@ export default function Intelligence() {
{ id: 'memory', label: 'Memory' },
{ id: 'subconscious', label: 'Subconscious' },
{ id: 'dreams', label: 'Dreams', comingSoon: true },
{ id: 'settings', label: 'Settings' },
];
return (
@@ -301,11 +168,14 @@ export default function Intelligence() {
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-stone-900">Intelligence</h1>
{activeTab === 'memory' && stats.total > 0 && (
<div className="text-xs bg-stone-100 text-stone-900 px-2 py-1 rounded-full">
{stats.total}
</div>
)}
{/* Header count badge was sourced from `stats.total` which
in turn came from the legacy actionable-items pipeline
(`filterItems(items, ...)`). The Memory tab now mounts
`MemoryWorkspace`, which renders chunks from
`memory_tree` and has nothing to do with that pipeline,
so the badge would have shown a count that no longer
matches anything visible. Hidden until a memory_tree
-native count signal is exposed. */}
</div>
<div className="flex items-center gap-3">
{activeTab === 'memory' && (
@@ -332,51 +202,17 @@ export default function Intelligence() {
</span>
</div>
)}
{activeTab === 'memory' && (
<button
onClick={usingMemoryData ? refreshConscious : handleAnalyzeNow}
disabled={isRunning || itemsLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 hover:bg-stone-100 disabled:opacity-40 disabled:cursor-not-allowed border border-stone-200 rounded-lg text-stone-600 transition-colors">
{isRunning || itemsLoading ? (
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
) : (
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
)}
{usingMemoryData ? 'Refresh' : 'Analyze Now'}
</button>
)}
{/* Analyze Now / Refresh button removed — the new
MemoryWorkspace fetches via memory_tree RPCs that
don't need a manual trigger. The actionable-cards
flow (handleAnalyzeNow) is no longer reachable from
the Memory tab; left in scope only for the legacy
subconscious/dreams tabs that still use it. */}
</div>
</div>
{/* Tab content */}
{activeTab === 'memory' && (
<IntelligenceMemoryTab
handleAnalyzeNow={handleAnalyzeNow}
handleComplete={handleComplete}
handleDismiss={handleDismiss}
handleSnooze={handleSnooze}
isRunning={isRunning}
items={items}
itemsLoading={itemsLoading}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
setSourceFilter={setSourceFilter}
sourceFilter={sourceFilter}
timeGroups={timeGroups}
usingMemoryData={usingMemoryData}
/>
)}
{activeTab === 'memory' && <MemoryWorkspace onToast={addToast} />}
{activeTab === 'subconscious' && (
<IntelligenceSubconsciousTab
@@ -400,6 +236,8 @@ export default function Intelligence() {
)}
{activeTab === 'dreams' && <IntelligenceDreamsTab />}
{activeTab === 'settings' && <IntelligenceSettingsTab />}
</div>
</div>
</div>
+1
View File
@@ -6,6 +6,7 @@ export * from './auth';
export * from './window';
export * from './core';
export * from './memory';
export * from './memoryTree';
export * from './webhooks';
export * from './composio';
export * from './conscious';
@@ -0,0 +1,260 @@
/**
* Unit tests for memory_tree RPC wrappers. Mirror the pattern used by
* `memory.test.ts` — mock the underlying `callCoreRpc` and assert that
* each helper dispatches the right method name + params and unwraps
* `RpcOutcome`'s `{ result, logs }` envelope correctly.
*/
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import { callCoreRpc } from '../../services/coreRpcClient';
import {
memoryTreeChunkScore,
memoryTreeDeleteChunk,
memoryTreeEntityIndexFor,
memoryTreeGetLlm,
memoryTreeListChunks,
memoryTreeListSources,
memoryTreeRecall,
memoryTreeSearch,
memoryTreeSetLlm,
memoryTreeTopEntities,
} from './memoryTree';
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
const mockCallCoreRpc = callCoreRpc as Mock;
beforeEach(() => {
vi.clearAllMocks();
});
describe('memoryTreeListChunks', () => {
test('dispatches openhuman.memory_tree_list_chunks with the filter as params', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
result: { chunks: [], total: 0 },
logs: ['memory_tree::read: list_chunks n=0 total=0'],
});
const out = await memoryTreeListChunks({ limit: 50 });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_list_chunks',
params: { limit: 50 },
});
expect(out).toEqual({ chunks: [], total: 0 });
});
test('handles bare-value responses (no logs envelope)', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ chunks: [{ id: 'c1' }], total: 1 });
const out = await memoryTreeListChunks({});
expect(out.total).toBe(1);
expect(out.chunks[0]?.id).toBe('c1');
});
});
describe('memoryTreeListSources', () => {
test('omits user_email_hint when not provided', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
await memoryTreeListSources();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_list_sources',
params: {},
});
});
test('forwards user_email_hint when provided', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
await memoryTreeListSources('alice@example.com');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_list_sources',
params: { user_email_hint: 'alice@example.com' },
});
});
test('returns the unwrapped Source array', async () => {
const sources = [
{
source_id: 'gmail:x|y',
display_name: 'X',
source_kind: 'email',
chunk_count: 2,
most_recent_ms: 1,
},
];
mockCallCoreRpc.mockResolvedValueOnce({ result: sources, logs: ['stub'] });
const out = await memoryTreeListSources();
expect(out).toEqual(sources);
});
});
describe('memoryTreeSearch', () => {
test('dispatches with query + k', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
await memoryTreeSearch('phoenix', 25);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_search',
params: { query: 'phoenix', k: 25 },
});
});
});
describe('memoryTreeRecall', () => {
test('dispatches with query + k and unwraps the recall envelope', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
result: { chunks: [{ id: 'c1' }], scores: [0.9] },
logs: ['stub'],
});
const out = await memoryTreeRecall('design sync', 10);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_recall',
params: { query: 'design sync', k: 10 },
});
expect(out.chunks).toHaveLength(1);
expect(out.scores[0]).toBe(0.9);
});
});
describe('memoryTreeEntityIndexFor', () => {
test('dispatches with chunk_id', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
await memoryTreeEntityIndexFor('chunk-abc');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_entity_index_for',
params: { chunk_id: 'chunk-abc' },
});
});
});
describe('memoryTreeTopEntities', () => {
test('omits kind when not provided and defaults limit to 50', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
await memoryTreeTopEntities();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_top_entities',
params: { limit: 50 },
});
});
test('forwards kind + custom limit when provided', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
await memoryTreeTopEntities('person', 12);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_top_entities',
params: { limit: 12, kind: 'person' },
});
});
});
describe('memoryTreeChunkScore', () => {
test('returns null when the core reports no score row', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: null, logs: ['stub'] });
const out = await memoryTreeChunkScore('chunk-missing');
expect(out).toBeNull();
});
test('unwraps the breakdown when present', async () => {
const breakdown = {
signals: [{ name: 'token_count', weight: 1, value: 0.5 }],
total: 0.5,
threshold: 0.85,
kept: false,
llm_consulted: false,
};
mockCallCoreRpc.mockResolvedValueOnce({ result: breakdown, logs: ['stub'] });
const out = await memoryTreeChunkScore('chunk-real');
expect(out).toEqual(breakdown);
});
});
describe('memoryTreeDeleteChunk', () => {
test('dispatches with chunk_id and surfaces the full DeleteChunkResponse', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
result: { deleted: true, score_rows_removed: 1, entity_index_rows_removed: 3 },
logs: ['stub'],
});
const out = await memoryTreeDeleteChunk('chunk-xyz');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_delete_chunk',
params: { chunk_id: 'chunk-xyz' },
});
expect(out).toEqual({ deleted: true, score_rows_removed: 1, entity_index_rows_removed: 3 });
});
});
describe('memoryTreeGetLlm / memoryTreeSetLlm', () => {
test('get_llm dispatches without params', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: { current: 'cloud' }, logs: ['stub'] });
const out = await memoryTreeGetLlm();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.memory_tree_get_llm' });
expect(out.current).toBe('cloud');
});
test('set_llm dispatches with backend param and returns the effective value', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: { current: 'local' }, logs: ['stub'] });
const out = await memoryTreeSetLlm('local');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_set_llm',
params: { backend: 'local' },
});
expect(out.current).toBe('local');
});
test('set_llm forwards optional per-role model fields verbatim as snake_case', async () => {
// The wrapper takes either a bare backend string (legacy) or the full
// request object. When the caller passes a request, the snake_case
// field names must reach the wire untouched — no camelCase
// translation lives in this layer.
mockCallCoreRpc.mockResolvedValueOnce({ result: { current: 'local' }, logs: ['stub'] });
const out = await memoryTreeSetLlm({
backend: 'local',
extract_model: 'qwen2.5:0.5b',
summariser_model: 'gemma3:1b-it-qat',
});
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_set_llm',
params: {
backend: 'local',
extract_model: 'qwen2.5:0.5b',
summariser_model: 'gemma3:1b-it-qat',
},
});
expect(out.current).toBe('local');
});
test('set_llm with cloud_model only flips backend + cloud model', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: { current: 'cloud' }, logs: ['stub'] });
await memoryTreeSetLlm({ backend: 'cloud', cloud_model: 'summarizer-v2' });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_tree_set_llm',
params: { backend: 'cloud', cloud_model: 'summarizer-v2' },
});
});
});
+418
View File
@@ -0,0 +1,418 @@
/**
* memory_tree subsystem commands.
*
* Thin wrappers over the `openhuman.memory_tree_*` JSON-RPC surface that
* powers the Memory tab and the Settings → AI backend chooser. Method
* shapes mirror the Rust handlers in `src/openhuman/memory/tree/read_rpc.rs`
* and `schemas.rs`.
*
* Responses come back wrapped by `RpcOutcome::single_log` as
* `{ result: <T>, logs: string[] }` (single-log envelope). Each helper
* unwraps `result` so callers see the bare value the Rust handler
* returned, falling back gracefully if a future handler stops emitting
* logs and the bare value flows through.
*
* Logging convention: `[memory-tree-rpc]` prefix for grep-friendly tracing
* per the project debug-logging rule.
*/
import { callCoreRpc } from '../../services/coreRpcClient';
// ── Public types — match the memory_tree RPC contract ────────────────────
/**
* Source kind values the Rust core uses for canonical chunk metadata.
* The list is closed for the surfaces the Memory tab cares about, but
* the wire type is `string` so any future kind round-trips through the
* UI without a recompile.
*/
export type SourceKind = 'email' | 'chat' | 'screen' | 'voice' | 'doc';
/** Chunk lifecycle phase as emitted by the admission gate. */
export type LifecycleStatus = 'admitted' | 'buffered' | 'pending_extraction' | 'dropped';
/**
* Canonical entity-kind strings emitted by the entity index. Kept
* permissive (`string`) on the Rust side; the TS union is the curated
* subset the UI knows how to render.
*/
export type EntityKind =
| 'person'
| 'organization'
| 'location'
| 'event'
| 'product'
| 'datetime'
| 'technology'
| 'artifact'
| 'quantity'
| 'misc';
/**
* A single chunk in the memory tree — one user-visible message-sized unit
* (an email, a chat turn, a doc page, a transcribed voice clip).
*
* Wire shape mirrors Rust's [`ChunkRow`](src/openhuman/memory/tree/read_rpc.rs)
* — body is replaced with a `≤500-char preview` plus a flag indicating
* whether the row has an embedding.
*/
export interface Chunk {
id: string;
source_kind: SourceKind;
source_id: string;
source_ref?: string;
owner: string;
timestamp_ms: number;
token_count: number;
lifecycle_status: LifecycleStatus;
content_path?: string;
/** Up to 500 chars; used as the result-list subject preview. */
content_preview?: string;
has_embedding: boolean;
/** Hierarchical: ["person/Steve-Enamakel", "organization/TinyHumans"]. */
tags: string[];
}
export interface ChunkFilter {
source_kinds?: string[];
source_ids?: string[];
entity_ids?: string[];
since_ms?: number;
until_ms?: number;
query?: string;
limit?: number;
offset?: number;
}
export interface ListChunksResponse {
chunks: Chunk[];
total: number;
}
/**
* Distinct ingest source as returned by `memory_tree_list_sources`.
*
* `lifecycle_status` is **optional** — the Rust handler does not emit it
* (it's a UI-derived aggregate), but the navigator pane wants a per-source
* dot color. Consumers compute it from chunk-level state and pass it in,
* or omit it and the UI falls back to a neutral dot.
*/
export interface Source {
source_id: string;
/** Un-slugged readable; user-email stripped when `user_email_hint` matched. */
display_name: string;
source_kind: string;
chunk_count: number;
most_recent_ms: number;
lifecycle_status?: LifecycleStatus;
}
export interface EntityRef {
/** Canonical id (e.g. `person:Steven Enamakel`, `email:alice@example.com`). */
entity_id: string;
kind: string;
surface: string;
count: number;
}
export interface ScoreSignal {
name: string;
weight: number;
value: number;
}
export interface ScoreBreakdown {
signals: ScoreSignal[];
total: number;
threshold: number;
kept: boolean;
llm_consulted: boolean;
}
export interface RecallResponse {
chunks: Chunk[];
scores: number[];
}
/**
* Response shape for `memory_tree_delete_chunk`. The Rust handler also
* surfaces the number of dependent rows removed so UIs can render a
* detailed "purged X / Y / Z" toast.
*/
export interface DeleteChunkResponse {
deleted: boolean;
score_rows_removed: number;
entity_index_rows_removed: number;
}
/** Backend selector value. */
export type LlmBackend = 'cloud' | 'local';
export interface LlmResponse {
current: LlmBackend;
}
/**
* Wire shape for `openhuman.memory_tree_set_llm`.
*
* `backend` is required and always overwrites `memory_tree.llm_backend`.
*
* The three model fields are optional; absent means "leave the
* corresponding `memory_tree.*_model` config key untouched", present
* means "overwrite it". This lets the UI flip the backend without
* touching models, or persist a per-role model selection without having
* to re-supply every other model id. Field names are snake_case to match
* the Rust `SetLlmRequest` struct verbatim — the wrapper does not
* translate.
*/
export interface SetLlmRequest {
backend: LlmBackend;
cloud_model?: string;
extract_model?: string;
summariser_model?: string;
}
// ── Envelope unwrap helper ────────────────────────────────────────────────
/**
* Internal envelope shape produced by `RpcOutcome::single_log` on the
* Rust side. Every read_rpc handler emits at least one log line, so the
* shape will be `{ result, logs }` in practice — but we keep the
* fallback path for defensive parsing.
*/
interface ResultEnvelope<T> {
result?: T;
logs?: string[];
}
function unwrapResult<T>(resp: T | ResultEnvelope<T>): T {
if (resp && typeof resp === 'object' && 'result' in resp) {
return (resp as ResultEnvelope<T>).result as T;
}
return resp as T;
}
// ── memory_tree_list_chunks ──────────────────────────────────────────────
/**
* Paginated chunk listing with optional filters. Backed by
* `openhuman.memory_tree_list_chunks`.
*/
export async function memoryTreeListChunks(filter: ChunkFilter): Promise<ListChunksResponse> {
console.debug('[memory-tree-rpc] memoryTreeListChunks: entry filter=%o', filter);
const resp = await callCoreRpc<ListChunksResponse | ResultEnvelope<ListChunksResponse>>({
method: 'openhuman.memory_tree_list_chunks',
params: filter,
});
const out = unwrapResult(resp);
console.debug(
'[memory-tree-rpc] memoryTreeListChunks: exit n=%d total=%d',
out.chunks?.length ?? 0,
out.total ?? 0
);
return out;
}
// ── memory_tree_list_sources ─────────────────────────────────────────────
/**
* Distinct (source_kind, source_id) pairs with chunk counts and most-recent
* timestamps. `user_email_hint` (when supplied) tells the Rust handler to
* strip that address from email-thread display names.
*/
export async function memoryTreeListSources(userEmailHint?: string): Promise<Source[]> {
console.debug(
'[memory-tree-rpc] memoryTreeListSources: entry hint=%s',
userEmailHint ?? '<none>'
);
const params = userEmailHint ? { user_email_hint: userEmailHint } : {};
const resp = await callCoreRpc<Source[] | ResultEnvelope<Source[]>>({
method: 'openhuman.memory_tree_list_sources',
params,
});
const out = unwrapResult(resp);
console.debug('[memory-tree-rpc] memoryTreeListSources: exit n=%d', out?.length ?? 0);
return out ?? [];
}
// ── memory_tree_search ───────────────────────────────────────────────────
/**
* Keyword `LIKE`-search over chunk bodies. Cheap, deterministic; useful
* as a fallback when semantic recall is unavailable.
*/
export async function memoryTreeSearch(query: string, k: number): Promise<Chunk[]> {
console.debug('[memory-tree-rpc] memoryTreeSearch: entry query_len=%d k=%d', query.length, k);
const resp = await callCoreRpc<Chunk[] | ResultEnvelope<Chunk[]>>({
method: 'openhuman.memory_tree_search',
params: { query, k },
});
const out = unwrapResult(resp);
console.debug('[memory-tree-rpc] memoryTreeSearch: exit n=%d', out?.length ?? 0);
return out ?? [];
}
// ── memory_tree_recall ───────────────────────────────────────────────────
/**
* Semantic recall via the Phase 4 cosine rerank path. Returns leaf chunks
* and a parallel `scores` array.
*/
export async function memoryTreeRecall(query: string, k: number): Promise<RecallResponse> {
console.debug('[memory-tree-rpc] memoryTreeRecall: entry query_len=%d k=%d', query.length, k);
const resp = await callCoreRpc<RecallResponse | ResultEnvelope<RecallResponse>>({
method: 'openhuman.memory_tree_recall',
params: { query, k },
});
const out = unwrapResult(resp);
console.debug('[memory-tree-rpc] memoryTreeRecall: exit n=%d', out?.chunks?.length ?? 0);
return out ?? { chunks: [], scores: [] };
}
// ── memory_tree_entity_index_for ─────────────────────────────────────────
/**
* All canonical entities indexed against a single chunk (or summary node) id.
*/
export async function memoryTreeEntityIndexFor(chunkId: string): Promise<EntityRef[]> {
console.debug('[memory-tree-rpc] memoryTreeEntityIndexFor: entry chunk_id=%s', chunkId);
const resp = await callCoreRpc<EntityRef[] | ResultEnvelope<EntityRef[]>>({
method: 'openhuman.memory_tree_entity_index_for',
params: { chunk_id: chunkId },
});
const out = unwrapResult(resp);
console.debug('[memory-tree-rpc] memoryTreeEntityIndexFor: exit n=%d', out?.length ?? 0);
return out ?? [];
}
// ── memory_tree_chunks_for_entity ────────────────────────────────────────
/**
* Inverse of `memoryTreeEntityIndexFor` — return chunk IDs that reference
* the given entity. Used by the Memory tab's People/Topics lenses to
* filter the chunk list to those mentioning a selected entity.
*/
export async function memoryTreeChunksForEntity(entityId: string): Promise<string[]> {
console.debug('[memory-tree-rpc] memoryTreeChunksForEntity: entry entity_id=%s', entityId);
const resp = await callCoreRpc<string[] | ResultEnvelope<string[]>>({
method: 'openhuman.memory_tree_chunks_for_entity',
params: { entity_id: entityId },
});
const out = unwrapResult(resp);
console.debug('[memory-tree-rpc] memoryTreeChunksForEntity: exit n=%d', out?.length ?? 0);
return out ?? [];
}
// ── memory_tree_top_entities ─────────────────────────────────────────────
/**
* Most-frequent canonical entities across the workspace, optionally narrowed
* by `kind`. The Rust handler treats `limit` as required; we default to 50
* to match the navigator's lens cardinality.
*/
export async function memoryTreeTopEntities(kind?: string, limit = 50): Promise<EntityRef[]> {
console.debug(
'[memory-tree-rpc] memoryTreeTopEntities: entry kind=%s limit=%d',
kind ?? '<all>',
limit
);
const params: Record<string, unknown> = { limit };
if (kind) params.kind = kind;
const resp = await callCoreRpc<EntityRef[] | ResultEnvelope<EntityRef[]>>({
method: 'openhuman.memory_tree_top_entities',
params,
});
const out = unwrapResult(resp);
console.debug('[memory-tree-rpc] memoryTreeTopEntities: exit n=%d', out?.length ?? 0);
return out ?? [];
}
// ── memory_tree_chunk_score ──────────────────────────────────────────────
/**
* Score breakdown stored in `mem_tree_score` for one chunk. Returns
* `null` when the chunk has no score row (e.g. it was admitted before
* scoring was enabled, or it is a synthesized fixture in tests).
*/
export async function memoryTreeChunkScore(chunkId: string): Promise<ScoreBreakdown | null> {
console.debug('[memory-tree-rpc] memoryTreeChunkScore: entry chunk_id=%s', chunkId);
const resp = await callCoreRpc<ScoreBreakdown | null | ResultEnvelope<ScoreBreakdown | null>>({
method: 'openhuman.memory_tree_chunk_score',
params: { chunk_id: chunkId },
});
const out = unwrapResult(resp);
console.debug('[memory-tree-rpc] memoryTreeChunkScore: exit kept=%o', out?.kept);
return out ?? null;
}
// ── memory_tree_delete_chunk ─────────────────────────────────────────────
/**
* Purge one chunk plus its score row, entity-index rows, and on-disk .md
* file. Idempotent — missing chunk returns `deleted=false`.
*/
export async function memoryTreeDeleteChunk(chunkId: string): Promise<DeleteChunkResponse> {
console.debug('[memory-tree-rpc] memoryTreeDeleteChunk: entry chunk_id=%s', chunkId);
const resp = await callCoreRpc<DeleteChunkResponse | ResultEnvelope<DeleteChunkResponse>>({
method: 'openhuman.memory_tree_delete_chunk',
params: { chunk_id: chunkId },
});
const out = unwrapResult(resp);
console.debug(
'[memory-tree-rpc] memoryTreeDeleteChunk: exit deleted=%o score_rows=%d entity_rows=%d',
out?.deleted,
out?.score_rows_removed,
out?.entity_index_rows_removed
);
return out;
}
// ── memory_tree_get_llm / memory_tree_set_llm ────────────────────────────
/**
* Read the currently configured LLM backend (`cloud` or `local`).
*/
export async function memoryTreeGetLlm(): Promise<LlmResponse> {
console.debug('[memory-tree-rpc] memoryTreeGetLlm: entry');
const resp = await callCoreRpc<LlmResponse | ResultEnvelope<LlmResponse>>({
method: 'openhuman.memory_tree_get_llm',
});
const out = unwrapResult(resp);
console.debug('[memory-tree-rpc] memoryTreeGetLlm: exit current=%s', out?.current);
return out;
}
/**
* Update the LLM backend selector — and, optionally, per-role model
* choices (`cloud_model`, `extract_model`, `summariser_model`) — and
* persist the result to `config.toml` in a single atomic write. Survives
* sidecar restart.
*
* Returns the effective backend after the call (the core may downgrade
* `local` → `cloud` if the host can't satisfy the local minimums; today
* the handler accepts the value verbatim).
*
* Accepts either a bare backend string (legacy callers) or the full
* {@link SetLlmRequest} object, so call-sites that only flip the mode
* stay terse while sites that want to persist model picks pass the
* extended shape.
*/
export async function memoryTreeSetLlm(
reqOrBackend: LlmBackend | SetLlmRequest
): Promise<LlmResponse> {
const params: SetLlmRequest =
typeof reqOrBackend === 'string' ? { backend: reqOrBackend } : reqOrBackend;
console.debug(
'[memory-tree-rpc] memoryTreeSetLlm: entry backend=%s cloud_model=%s extract_model=%s summariser_model=%s',
params.backend,
params.cloud_model ?? '<none>',
params.extract_model ?? '<none>',
params.summariser_model ?? '<none>'
);
const resp = await callCoreRpc<LlmResponse | ResultEnvelope<LlmResponse>>({
method: 'openhuman.memory_tree_set_llm',
params,
});
const out = unwrapResult(resp);
console.debug('[memory-tree-rpc] memoryTreeSetLlm: exit current=%s', out?.current);
return out;
}
+34
View File
@@ -624,6 +624,40 @@ async fn run_server_inner(
});
crate::core::auth::init_rpc_token(&token_dir)?;
// Initialize the global MemoryClient so composio providers
// (gmail/slack/notion) can persist their sync_state via kv_get/kv_set,
// and so any subsystem that calls `memory::global::client_if_ready()`
// gets a live handle. Without this, every periodic sync bails with
// "[composio:gmail] memory client not ready".
{
// Surface a config-load failure explicitly. Falling silently to
// `Config::default()` would hide a serious operator-visible
// problem (corrupt toml, permissions, missing OPENHUMAN_WORKSPACE
// workspace dir) and the memory client would init against the
// wrong workspace — leading to chunk loss / cross-workspace
// bleed-over. We log loud, then proceed with default so the
// server still comes up; the operator sees the error in stderr
// and can fix their config.
let cfg = match crate::openhuman::config::Config::load_or_init().await {
Ok(c) => c,
Err(e) => {
log::error!(
"[boot] memory::global init: Config::load_or_init failed ({e:#}); \
falling back to default workspace dir — fix your config.toml \
or OPENHUMAN_WORKSPACE before relying on memory persistence"
);
Default::default()
}
};
match crate::openhuman::memory::global::init(cfg.workspace_dir.clone()) {
Ok(_) => log::info!(
"[boot] memory::global initialized (workspace={})",
cfg.workspace_dir.display()
),
Err(e) => log::warn!("[boot] memory::global init failed: {e}"),
}
}
let (resolved_port, port_source) = match port {
Some(p) => (p, "CLI --port"),
None => (
+9 -9
View File
@@ -28,15 +28,15 @@ pub use schema::{
BrowserConfig, ChannelsConfig, ComposioConfig, Config, ContextConfig, CostConfig, CronConfig,
CurlConfig, DelegateAgentConfig, DictationActivationMode, DictationConfig, DiscordConfig,
DockerRuntimeConfig, EmbeddingRouteConfig, GitbooksConfig, HeartbeatConfig, HttpRequestConfig,
IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig,
LocalAiConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, MultimodalConfig,
ObservabilityConfig, ProxyConfig, ProxyScope, ReflectionSource, ReliabilityConfig,
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig, SecretsConfig,
SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection,
StreamMode, TelegramConfig, UpdateConfig, VoiceActivationMode, VoiceServerConfig,
WebSearchConfig, WebhookConfig, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CODING_V1,
MODEL_REASONING_V1,
IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LlmBackend,
LocalAiConfig, MatrixConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig,
MultimodalConfig, ObservabilityConfig, ProxyConfig, ProxyScope, ReflectionSource,
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
SchedulerConfig, SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig,
SecretsConfig, SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig,
StorageProviderSection, StreamMode, TelegramConfig, UpdateConfig, VoiceActivationMode,
VoiceServerConfig, WebSearchConfig, WebhookConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL,
MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_V1,
};
pub use schema::{
clear_active_user, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id,
+41 -1
View File
@@ -998,7 +998,9 @@ impl Config {
// Phase MD-content: chunk body directory override. Empty string means
// "fall back to default", consistent with other memory_tree env vars.
if let Ok(dir) = std::env::var("OPENHUMAN_MEMORY_TREE_CONTENT_DIR") {
// Routed through `env.get` so `HashMapEnv`-style test callers see the
// override too — same seam as every other branch in this function.
if let Some(dir) = env.get("OPENHUMAN_MEMORY_TREE_CONTENT_DIR") {
let trimmed = dir.trim();
self.memory_tree.content_dir = if trimmed.is_empty() {
None
@@ -1007,6 +1009,44 @@ impl Config {
};
}
// Memory-tree LLM backend selector: "cloud" (default) routes through
// the OpenHuman backend's summarizer model; "local" keeps the legacy
// Ollama-direct path. Empty / unset / unknown leaves the existing
// value untouched (and we warn on unknown). The embedder is unaffected.
if let Some(raw) = env.get("OPENHUMAN_MEMORY_TREE_LLM_BACKEND") {
let trimmed = raw.trim();
if !trimmed.is_empty() {
match crate::openhuman::config::LlmBackend::parse(trimmed) {
Ok(b) => {
log::debug!(
"[memory_tree] OPENHUMAN_MEMORY_TREE_LLM_BACKEND override applied: {}",
b.as_str()
);
self.memory_tree.llm_backend = b;
}
Err(e) => {
tracing::warn!(
value = trimmed,
error = %e,
"ignoring invalid OPENHUMAN_MEMORY_TREE_LLM_BACKEND (valid: cloud, local)"
);
}
}
}
}
// Cloud LLM model override (only meaningful when llm_backend = cloud).
// Empty string explicitly clears the default — useful for tests that
// want to assert the absence of a configured cloud model. Non-empty
// strings are stored verbatim.
if let Some(raw) = env.get("OPENHUMAN_MEMORY_TREE_CLOUD_LLM_MODEL") {
let trimmed = raw.trim();
self.memory_tree.cloud_llm_model = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
}
// Auto-update overrides
if let Some(flag) = env.get("OPENHUMAN_AUTO_UPDATE_ENABLED") {
let normalized = flag.trim().to_ascii_lowercase();
+2 -1
View File
@@ -56,7 +56,8 @@ pub use routes::{EmbeddingRouteConfig, ModelRouteConfig};
pub use runtime::{DockerRuntimeConfig, ReliabilityConfig, RuntimeConfig, SchedulerConfig};
pub use scheduler_gate::{SchedulerGateConfig, SchedulerGateMode};
pub use storage_memory::{
MemoryConfig, MemoryTreeConfig, StorageConfig, StorageProviderConfig, StorageProviderSection,
LlmBackend, MemoryConfig, MemoryTreeConfig, StorageConfig, StorageProviderConfig,
StorageProviderSection, DEFAULT_CLOUD_LLM_MODEL,
};
pub use tools::{
BrowserComputerUseConfig, BrowserConfig, ComposioConfig, ComputerControlConfig, CurlConfig,
@@ -74,6 +74,66 @@ impl Default for MemoryConfig {
}
}
/// Which inference backend the memory_tree's LLM calls (extractor +
/// summariser) should use.
///
/// - `Cloud` (default): route through `providers::router` against the
/// OpenHuman backend with the `summarization-v1` model. No local Ollama
/// required.
/// - `Local`: keep using the legacy Ollama-direct path (the
/// `llm_extractor_endpoint` / `llm_summariser_endpoint` config). Useful
/// for offline development and CI smoke tests.
///
/// Embedder selection is unchanged — `OllamaEmbedder` (bge-m3) stays
/// local-only and isn't governed by this enum.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum LlmBackend {
/// Route through the OpenHuman backend (default).
Cloud,
/// Use the local Ollama path configured via `llm_extractor_*` /
/// `llm_summariser_*`.
Local,
}
impl LlmBackend {
/// Stable wire string for env vars / RPCs / logs.
pub fn as_str(self) -> &'static str {
match self {
Self::Cloud => "cloud",
Self::Local => "local",
}
}
/// Inverse of [`Self::as_str`]; case-insensitive parse.
pub fn parse(s: &str) -> Result<Self, String> {
match s.trim().to_ascii_lowercase().as_str() {
"cloud" => Ok(Self::Cloud),
"local" => Ok(Self::Local),
other => Err(format!("unknown llm (expected cloud|local): {other}")),
}
}
}
impl Default for LlmBackend {
fn default() -> Self {
Self::Cloud
}
}
fn default_llm_backend() -> LlmBackend {
LlmBackend::default()
}
/// Default model identifier to use when `llm_backend = "cloud"`. Routed
/// through the OpenHuman backend; keep in sync with the backend's
/// summariser model registry.
pub const DEFAULT_CLOUD_LLM_MODEL: &str = "summarization-v1";
fn default_cloud_llm_model() -> Option<String> {
Some(DEFAULT_CLOUD_LLM_MODEL.to_string())
}
/// Phase 4 memory-tree configuration — embedding provider wiring for the
/// hierarchical memory (#710).
///
@@ -95,6 +155,8 @@ impl Default for MemoryConfig {
/// - `OPENHUMAN_MEMORY_SUMMARISE_MODEL`
/// - `OPENHUMAN_MEMORY_SUMMARISE_TIMEOUT_MS`
/// - `OPENHUMAN_MEMORY_TREE_CONTENT_DIR` (Phase MD-content)
/// - `OPENHUMAN_MEMORY_TREE_LLM_BACKEND` (cloud|local)
/// - `OPENHUMAN_MEMORY_TREE_CLOUD_LLM_MODEL`
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MemoryTreeConfig {
/// Ollama endpoint for the embedder (e.g. `http://localhost:11434`).
@@ -164,6 +226,25 @@ pub struct MemoryTreeConfig {
/// back to default, consistent with other memory_tree env vars).
#[serde(default = "default_memory_tree_content_dir")]
pub content_dir: Option<PathBuf>,
/// Backend selector for the memory_tree's LLM calls (extractor +
/// summariser). Defaults to [`LlmBackend::Cloud`] so a fresh install
/// works without requiring a local Ollama daemon. Set to
/// [`LlmBackend::Local`] (or `OPENHUMAN_MEMORY_TREE_LLM_BACKEND=local`) to
/// keep the legacy Ollama-direct path.
///
/// The embedder is unaffected by this setting — `OllamaEmbedder` (bge-m3)
/// stays local-only.
#[serde(default = "default_llm_backend")]
pub llm_backend: LlmBackend,
/// Model identifier used when `llm_backend = "cloud"`. Routed through the
/// OpenHuman backend's chat-completions surface.
///
/// Defaults to [`DEFAULT_CLOUD_LLM_MODEL`] (`summarization-v1`).
/// Env override: `OPENHUMAN_MEMORY_TREE_CLOUD_LLM_MODEL`.
#[serde(default = "default_cloud_llm_model")]
pub cloud_llm_model: Option<String>,
}
/// Returns `None` so that existing installs that never opted into Phase 4
@@ -229,6 +310,8 @@ impl Default for MemoryTreeConfig {
llm_summariser_model: default_memory_tree_llm_endpoint(),
llm_summariser_timeout_ms: default_memory_tree_llm_summariser_timeout_ms(),
content_dir: default_memory_tree_content_dir(),
llm_backend: default_llm_backend(),
cloud_llm_model: default_cloud_llm_model(),
}
}
}
@@ -237,6 +320,41 @@ impl Default for MemoryTreeConfig {
mod tests {
use super::*;
#[test]
fn llm_default_is_cloud() {
assert_eq!(LlmBackend::default(), LlmBackend::Cloud);
assert_eq!(MemoryTreeConfig::default().llm_backend, LlmBackend::Cloud);
}
#[test]
fn llm_round_trip() {
for v in [LlmBackend::Cloud, LlmBackend::Local] {
assert_eq!(LlmBackend::parse(v.as_str()).unwrap(), v);
}
}
#[test]
fn llm_parse_is_case_insensitive() {
assert_eq!(LlmBackend::parse("CLOUD").unwrap(), LlmBackend::Cloud);
assert_eq!(LlmBackend::parse(" Local ").unwrap(), LlmBackend::Local);
}
#[test]
fn llm_parse_rejects_unknown() {
assert!(LlmBackend::parse("hybrid").is_err());
assert!(LlmBackend::parse("").is_err());
}
#[test]
fn cloud_llm_model_default_is_summarizer_v1() {
let cfg = MemoryTreeConfig::default();
assert_eq!(
cfg.cloud_llm_model.as_deref(),
Some(DEFAULT_CLOUD_LLM_MODEL)
);
assert_eq!(DEFAULT_CLOUD_LLM_MODEL, "summarization-v1");
}
#[test]
fn memory_tree_config_default_content_dir_is_none() {
let cfg = MemoryTreeConfig::default();
+119
View File
@@ -0,0 +1,119 @@
//! Cloud chat provider — routes through the OpenHuman backend's
//! `/openai/v1/chat/completions` surface using the existing
//! [`crate::openhuman::providers::openhuman_backend::OpenHumanBackendProvider`].
//!
//! Used when `memory_tree.llm_backend = "cloud"` (the default). The
//! request shape is the standard OpenAI-compatible chat-completions
//! protocol, with `temperature: 0.0` and a `summarization-v1` (or
//! caller-configured) model.
use std::path::PathBuf;
use anyhow::{Context, Result};
use async_trait::async_trait;
use crate::openhuman::providers::openhuman_backend::OpenHumanBackendProvider;
use crate::openhuman::providers::traits::{ChatMessage, Provider};
use crate::openhuman::providers::ProviderRuntimeOptions;
use super::{ChatPrompt, ChatProvider};
/// Cloud-routed chat provider. Holds an [`OpenHumanBackendProvider`] and
/// forwards each [`ChatProvider::chat_for_json`] call through its
/// `chat_with_history` method.
pub struct CloudChatProvider {
inner: OpenHumanBackendProvider,
model: String,
/// Cached display name `"cloud:<model>"` for logs.
display: String,
}
impl CloudChatProvider {
/// Build a new cloud provider against `api_url` (or the default
/// `effective_api_url` when `None`) for `model`. The provider does NOT
/// resolve the bearer token at construction — it does so per request,
/// matching the existing `OpenHumanBackendProvider` contract. That way
/// a session refresh between memory-tree calls is picked up
/// transparently.
///
/// `openhuman_dir` is the directory containing `auth-profiles.json` (i.e.
/// the parent of `config.config_path`). Without it the inner provider
/// would fall back to `~/.openhuman` and fail with "No backend session"
/// on workspaces not located at the home default.
pub fn new(
api_url: Option<String>,
model: String,
openhuman_dir: Option<PathBuf>,
secrets_encrypt: bool,
) -> Self {
let opts = ProviderRuntimeOptions {
openhuman_dir,
secrets_encrypt,
..ProviderRuntimeOptions::default()
};
let inner = OpenHumanBackendProvider::new(api_url.as_deref(), &opts);
let display = format!("cloud:{model}");
Self {
inner,
model,
display,
}
}
}
#[async_trait]
impl ChatProvider for CloudChatProvider {
fn name(&self) -> &str {
&self.display
}
async fn chat_for_json(&self, prompt: &ChatPrompt) -> Result<String> {
log::debug!(
"[memory_tree::chat::cloud] kind={} model={} sys_chars={} user_chars={}",
prompt.kind,
self.model,
prompt.system.len(),
prompt.user.len()
);
let messages = vec![
ChatMessage::system(prompt.system.clone()),
ChatMessage::user(prompt.user.clone()),
];
let text = self
.inner
.chat_with_history(&messages, &self.model, prompt.temperature)
.await
.with_context(|| {
format!(
"cloud chat request kind={} model={} failed",
prompt.kind, self.model
)
})?;
log::debug!(
"[memory_tree::chat::cloud] response chars={} kind={}",
text.len(),
prompt.kind
);
Ok(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_includes_model() {
let p = CloudChatProvider::new(None, "summarization-v1".into(), None, true);
assert_eq!(p.name(), "cloud:summarization-v1");
}
#[test]
fn name_changes_with_model() {
let p = CloudChatProvider::new(None, "claude-haiku-4.5".into(), None, true);
assert!(p.name().contains("claude-haiku-4.5"));
}
}
+263
View File
@@ -0,0 +1,263 @@
//! Local Ollama chat provider — the legacy `llm_backend = "local"` path.
//!
//! Speaks Ollama's `/api/chat` with `format: "json"` and
//! `temperature: 0.0`. Mirrors what the per-extractor/summariser HTTP client
//! used to do, but behind the [`super::ChatProvider`] trait so the same
//! call site can be cloud-routed instead.
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use super::{ChatPrompt, ChatProvider};
/// Ollama-direct chat provider.
pub struct OllamaChatProvider {
endpoint: String,
model: String,
http: Client,
/// Cached display name `"local:ollama:<model>"` for logs.
display: String,
}
impl OllamaChatProvider {
/// Build the provider. `endpoint` and `model` may be `None` — when
/// either is unset, [`ChatProvider::chat_for_json`] returns a clear
/// error so the caller's soft-fallback path engages and the seal/admit
/// pipeline keeps running.
pub fn new(endpoint: Option<String>, model: Option<String>, timeout: Duration) -> Result<Self> {
// No body-read timeout. Ollama is a local process — slow responses
// mean the model is genuinely processing under CPU load (e.g.
// gemma3:1b on CPU-only inference can take minutes per call), not
// that the network broke. A body-read timeout here would cancel
// mid-flight generation and force pointless retries against the
// same slow model. `timeout` becomes the TCP connect timeout —
// short enough to fail fast when Ollama is actually unreachable.
let http = Client::builder()
.connect_timeout(timeout)
.build()
.context("build ollama http client")?;
let endpoint = endpoint.unwrap_or_default();
let model = model.unwrap_or_default();
let display = format!(
"local:ollama:{}",
if model.is_empty() { "<unset>" } else { &model }
);
Ok(Self {
endpoint,
model,
http,
display,
})
}
}
#[async_trait]
impl ChatProvider for OllamaChatProvider {
fn name(&self) -> &str {
&self.display
}
async fn chat_for_json(&self, prompt: &ChatPrompt) -> Result<String> {
if self.endpoint.is_empty() || self.model.is_empty() {
return Err(anyhow!(
"[memory_tree::chat::local] Ollama endpoint or model not configured \
(endpoint_set={}, model_set={}); set memory_tree.llm_*_endpoint / \
_model in config or switch memory_tree.llm_backend to cloud",
!self.endpoint.is_empty(),
!self.model.is_empty()
));
}
let url = format!("{}/api/chat", self.endpoint.trim_end_matches('/'));
let body = OllamaChatRequest {
model: self.model.clone(),
messages: vec![
OllamaMessage {
role: "system".to_string(),
content: prompt.system.clone(),
},
OllamaMessage {
role: "user".to_string(),
content: prompt.user.clone(),
},
],
format: "json".to_string(),
stream: false,
options: OllamaOptions {
temperature: prompt.temperature as f32,
},
};
log::debug!(
"[memory_tree::chat::local] POST {url} kind={} model={} sys_chars={} user_chars={}",
prompt.kind,
self.model,
prompt.system.len(),
prompt.user.len()
);
let resp = self
.http
.post(&url)
.json(&body)
.send()
.await
.with_context(|| format!("ollama POST {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let snippet = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"ollama non-success status {status}: {}",
truncate_for_log(&snippet, 200)
));
}
let envelope: OllamaChatResponse = resp
.json()
.await
.context("decode ollama chat response envelope")?;
log::debug!(
"[memory_tree::chat::local] ollama response chars={} kind={}",
envelope.message.content.len(),
prompt.kind
);
Ok(envelope.message.content)
}
}
fn truncate_for_log(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let truncated: String = s.chars().take(max_chars).collect();
format!("{truncated}")
}
#[derive(Debug, Serialize)]
struct OllamaChatRequest {
model: String,
messages: Vec<OllamaMessage>,
format: String,
stream: bool,
options: OllamaOptions,
}
#[derive(Debug, Serialize)]
struct OllamaMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct OllamaOptions {
temperature: f32,
}
#[derive(Debug, Deserialize)]
struct OllamaChatResponse {
message: OllamaResponseMessage,
}
#[derive(Debug, Deserialize)]
struct OllamaResponseMessage {
content: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn errors_clearly_when_endpoint_missing() {
let p = OllamaChatProvider::new(None, Some("m".into()), Duration::from_millis(50)).unwrap();
let err = p
.chat_for_json(&ChatPrompt {
system: "s".into(),
user: "u".into(),
temperature: 0.0,
kind: "test",
})
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("not configured"),
"expected config error, got: {msg}"
);
}
#[tokio::test]
async fn errors_when_model_missing() {
let p = OllamaChatProvider::new(
Some("http://localhost:11434".into()),
None,
Duration::from_millis(50),
)
.unwrap();
let err = p
.chat_for_json(&ChatPrompt {
system: "s".into(),
user: "u".into(),
temperature: 0.0,
kind: "test",
})
.await
.unwrap_err();
assert!(format!("{err}").contains("not configured"));
}
#[tokio::test]
async fn transport_failure_returns_err() {
// Endpoint pointing at an unreachable port. The provider returns
// Err — the consumer is responsible for soft-fallback.
let p = OllamaChatProvider::new(
Some("http://127.0.0.1:1".into()),
Some("m".into()),
Duration::from_millis(50),
)
.unwrap();
let err = p
.chat_for_json(&ChatPrompt {
system: "s".into(),
user: "u".into(),
temperature: 0.0,
kind: "test",
})
.await
.unwrap_err();
// Connection error chain — message contains "ollama POST" prefix.
assert!(format!("{err}").contains("ollama POST"));
}
#[test]
fn name_includes_model() {
let p =
OllamaChatProvider::new(None, Some("qwen2.5:0.5b".into()), Duration::from_millis(50))
.unwrap();
assert!(p.name().contains("qwen2.5:0.5b"));
assert!(p.name().starts_with("local:ollama:"));
}
#[test]
fn name_handles_unset_model() {
let p = OllamaChatProvider::new(None, None, Duration::from_millis(50)).unwrap();
assert!(p.name().contains("<unset>"));
}
#[test]
fn truncate_for_log_short_unchanged() {
assert_eq!(truncate_for_log("hi", 10), "hi");
}
#[test]
fn truncate_for_log_long_appends_ellipsis() {
let long = "x".repeat(500);
let out = truncate_for_log(&long, 10);
assert_eq!(out.chars().count(), 11);
assert!(out.ends_with('…'));
}
}
+258
View File
@@ -0,0 +1,258 @@
//! Memory-tree chat backend abstraction.
//!
//! The memory_tree's two LLM consumers (the entity extractor and the
//! summariser) both want a small, structured "give me JSON for this prompt"
//! call. Historically each built its own `reqwest::Client` and talked to a
//! local Ollama daemon directly. This module replaces that with a single
//! [`ChatProvider`] trait so the same call site can be served by either:
//!
//! - **Cloud** — `providers::router` against the OpenHuman backend with
//! the `summarization-v1` model. No local daemon required. Default for new
//! installs.
//! - **Local** — the legacy Ollama-direct path. Opt-in via
//! `memory_tree.llm_backend = "local"` in config or
//! `OPENHUMAN_MEMORY_TREE_LLM_BACKEND=local`.
//!
//! ## Why a memory-tree-local trait
//!
//! The existing top-level [`crate::openhuman::providers::Provider`] trait
//! is rich (streaming, native tool calling, vision, …) and depends on the
//! agent's full conversation surface. The extractor and summariser only
//! need:
//!
//! 1. Send a (system, user) prompt pair.
//! 2. Get a JSON-shaped string back.
//!
//! Defining [`ChatProvider`] here keeps the memory_tree decoupled from
//! the agent's prompt/tool-calling stack, makes the extractor/summariser
//! trivial to mock in unit tests, and lets us route either the cloud or
//! the local backend through the same trait object.
//!
//! ## Soft-fallback contract
//!
//! Implementations of `chat_for_json` MUST NOT return `Err` for transient
//! upstream issues. Both memory_tree consumers fall back to a deterministic
//! no-op when the LLM is unavailable; bubbling the error up would abort
//! ingest cascades. Real bugs (e.g. malformed config) are still acceptable
//! `Err` cases — they should be rare and surfaced loudly.
//!
//! See [`local::OllamaChatProvider`] and [`cloud::CloudChatProvider`] for
//! the two production implementations.
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use crate::openhuman::config::{Config, LlmBackend, DEFAULT_CLOUD_LLM_MODEL};
pub mod cloud;
pub mod local;
/// One pair of prompt messages handed to the chat backend.
///
/// Keeps the surface deliberately tiny — the memory_tree's two consumers
/// both build a system prompt + a single user message. Multi-turn,
/// streaming, and tool calling are out of scope.
#[derive(Debug, Clone)]
pub struct ChatPrompt {
/// System prompt anchoring the model's role and expected output schema.
pub system: String,
/// User prompt carrying the dynamic input (the chunk text, the inputs
/// to summarise, etc.).
pub user: String,
/// Sampling temperature. Both consumers use 0.0 today (max determinism).
pub temperature: f64,
/// Diagnostic tag included in tracing logs so seal-time and admit-time
/// calls are easy to disambiguate. Stable, lowercase, no PII.
pub kind: &'static str,
}
/// Pluggable chat surface used by the memory_tree's extractor + summariser.
///
/// Returns the model's raw output as a string. Callers parse it themselves
/// (typically as JSON conforming to a schema embedded in the system prompt)
/// because the parsing logic is consumer-specific.
#[async_trait]
pub trait ChatProvider: Send + Sync {
/// Stable, grep-friendly name for logs. e.g. `"cloud:summarization-v1"`.
fn name(&self) -> &str;
/// Run one chat completion and return the assistant's content.
///
/// Implementations should log entry / exit at debug level under the
/// `[memory_tree::chat]` prefix.
async fn chat_for_json(&self, prompt: &ChatPrompt) -> Result<String>;
}
/// Build the [`ChatProvider`] dictated by `config.memory_tree.llm_backend`.
///
/// - `Cloud` (default): wires [`cloud::CloudChatProvider`] against the
/// OpenHuman backend with `cloud_llm_model` (defaulting to
/// `summarization-v1`).
/// - `Local`: wires [`local::OllamaChatProvider`] against the legacy
/// `llm_extractor_endpoint` / `llm_extractor_model` config — the same
/// knobs that drove the Ollama-direct path before this refactor.
///
/// `consumer` is one of `"extract"` / `"summarise"` and selects the local
/// endpoint+model pair (extract uses `llm_extractor_*`, summarise uses
/// `llm_summariser_*`). For cloud both consumers share the same model.
pub fn build_chat_provider(
config: &Config,
consumer: ChatConsumer,
) -> Result<Arc<dyn ChatProvider>> {
match config.memory_tree.llm_backend {
LlmBackend::Cloud => {
let model = config
.memory_tree
.cloud_llm_model
.clone()
.unwrap_or_else(|| DEFAULT_CLOUD_LLM_MODEL.to_string());
// The `auth-profiles.json` lives next to `config.toml`, so the
// openhuman_dir is the parent of config_path. Without this the
// inner OpenHumanBackendProvider falls back to `~/.openhuman`
// and fails with "No backend session" on any workspace not
// located at the home default — the bug observed when running
// with `OPENHUMAN_WORKSPACE` pointed elsewhere.
let openhuman_dir = config.config_path.parent().map(std::path::PathBuf::from);
log::debug!(
"[memory_tree::chat] building Cloud provider consumer={} model={} \
openhuman_dir={:?}",
consumer.as_str(),
model,
openhuman_dir
);
Ok(Arc::new(cloud::CloudChatProvider::new(
config.api_url.clone(),
model,
openhuman_dir,
config.secrets.encrypt,
)))
}
LlmBackend::Local => {
let (endpoint, model, timeout_ms) = match consumer {
ChatConsumer::Extract => (
config.memory_tree.llm_extractor_endpoint.clone(),
config.memory_tree.llm_extractor_model.clone(),
config
.memory_tree
.llm_extractor_timeout_ms
.unwrap_or(15_000),
),
ChatConsumer::Summarise => (
config.memory_tree.llm_summariser_endpoint.clone(),
config.memory_tree.llm_summariser_model.clone(),
config
.memory_tree
.llm_summariser_timeout_ms
.unwrap_or(120_000),
),
};
log::debug!(
"[memory_tree::chat] building Local (Ollama) provider consumer={} \
endpoint_set={} model_set={} timeout_ms={}",
consumer.as_str(),
endpoint.is_some(),
model.is_some(),
timeout_ms
);
Ok(Arc::new(local::OllamaChatProvider::new(
endpoint,
model,
std::time::Duration::from_millis(timeout_ms),
)?))
}
}
}
/// Which memory-tree consumer is requesting a chat provider. Determines
/// which `llm_*_endpoint` / `llm_*_model` config fields are read in the
/// `Local` branch. Both consumers share the same cloud model.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ChatConsumer {
/// `LlmEntityExtractor` (per-chunk NER + importance rating).
Extract,
/// `LlmSummariser` (bucket-seal summary of N children).
Summarise,
}
impl ChatConsumer {
/// Stable wire string used in logs.
pub fn as_str(self) -> &'static str {
match self {
Self::Extract => "extract",
Self::Summarise => "summarise",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// In-memory chat provider for unit tests. Returns a canned response
/// regardless of the prompt and counts invocations so tests can assert
/// they were exercised.
pub struct StaticChatProvider {
pub response: String,
pub calls: std::sync::atomic::AtomicUsize,
}
impl StaticChatProvider {
pub fn new(response: impl Into<String>) -> Self {
Self {
response: response.into(),
calls: std::sync::atomic::AtomicUsize::new(0),
}
}
}
#[async_trait]
impl ChatProvider for StaticChatProvider {
fn name(&self) -> &str {
"test:static"
}
async fn chat_for_json(&self, _prompt: &ChatPrompt) -> Result<String> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(self.response.clone())
}
}
#[test]
fn build_provider_returns_cloud_when_default() {
let cfg = Config::default();
// Default is LlmBackend::Cloud — provider construction must succeed
// without a configured local Ollama endpoint.
let provider = build_chat_provider(&cfg, ChatConsumer::Extract).unwrap();
assert!(provider.name().contains("cloud"));
}
#[test]
fn build_provider_returns_local_when_configured() {
let mut cfg = Config::default();
cfg.memory_tree.llm_backend = LlmBackend::Local;
cfg.memory_tree.llm_extractor_endpoint = Some("http://localhost:11434".into());
cfg.memory_tree.llm_extractor_model = Some("qwen2.5:0.5b".into());
let provider = build_chat_provider(&cfg, ChatConsumer::Extract).unwrap();
assert!(provider.name().contains("ollama") || provider.name().contains("local"));
}
#[test]
fn chat_consumer_str_round_trip() {
assert_eq!(ChatConsumer::Extract.as_str(), "extract");
assert_eq!(ChatConsumer::Summarise.as_str(), "summarise");
}
#[tokio::test]
async fn static_chat_provider_returns_response_and_counts() {
let p = StaticChatProvider::new("hello");
let prompt = ChatPrompt {
system: "sys".into(),
user: "u".into(),
temperature: 0.0,
kind: "test",
};
assert_eq!(p.chat_for_json(&prompt).await.unwrap(), "hello");
assert_eq!(p.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
}
+13 -1
View File
@@ -103,6 +103,9 @@ pub fn claim_next(config: &Config, lock_duration_ms: i64) -> Result<Option<Job>>
let row = conn
.query_row(
// Drain forward, don't widen. Most-downstream kinds run
// first so a slow LLM-bound `extract_chunk` can't starve
// the routing/seal/digest pipeline behind it.
"UPDATE mem_tree_jobs
SET status = 'running',
attempts = attempts + 1,
@@ -113,7 +116,16 @@ pub fn claim_next(config: &Config, lock_duration_ms: i64) -> Result<Option<Job>>
SELECT id FROM mem_tree_jobs
WHERE status = 'ready'
AND available_at_ms <= ?1
ORDER BY available_at_ms ASC
ORDER BY
CASE kind
WHEN 'digest_daily' THEN 0
WHEN 'seal' THEN 1
WHEN 'flush_stale' THEN 2
WHEN 'topic_route' THEN 3
WHEN 'append_buffer' THEN 4
ELSE 5
END ASC,
available_at_ms ASC
LIMIT 1
)
RETURNING id, kind, payload_json, dedupe_key, status, attempts,
+8 -2
View File
@@ -14,7 +14,13 @@ use crate::openhuman::memory::tree::jobs::store::{
claim_next, mark_done, mark_failed, recover_stale_locks, DEFAULT_LOCK_DURATION_MS,
};
const WORKER_COUNT: usize = 3;
// Held at 1 to keep concurrent bge-m3 embed calls (8K context, ~1.3 GB
// resident each) from saturating local RAM. The cloud-chat path itself
// would be fine at higher concurrency, but every worker also runs a
// bge-m3 embed step, and 3 concurrent embeds at 8K context have
// crashed the laptop in practice. See feedback memory
// `feedback_local_llm_load.md`.
const WORKER_COUNT: usize = 1;
const POLL_INTERVAL: Duration = Duration::from_secs(5);
static WORKER_NOTIFY: OnceLock<Arc<Notify>> = OnceLock::new();
@@ -41,7 +47,7 @@ pub fn start(config: Config) {
let notify = WORKER_NOTIFY
.get_or_init(|| Arc::new(Notify::new()))
.clone();
let llm_slots = Arc::new(Semaphore::new(3));
let llm_slots = Arc::new(Semaphore::new(1));
if let Err(err) = recover_stale_locks(&config) {
log::warn!("[memory_tree::jobs] recover_stale_locks failed at startup: {err:#}");
}
+2
View File
@@ -23,10 +23,12 @@
//! top of these chunks without modifying the Phase 1 surface.
pub mod canonicalize;
pub mod chat;
pub mod chunker;
pub mod content_store;
pub mod ingest;
pub mod jobs;
pub mod read_rpc;
pub mod retrieval;
pub mod rpc;
pub mod schemas;
File diff suppressed because it is too large Load Diff
+487 -33
View File
@@ -1,11 +1,14 @@
//! Controller schemas for the memory tree (Phase 1 / #707).
//! Controller schemas for the memory tree.
//!
//! Registered JSON-RPC methods:
//! - `openhuman.memory_tree_ingest` — unified ingest (source_kind + JSON payload)
//! - `openhuman.memory_tree_list_chunks`
//! - `openhuman.memory_tree_get_chunk`
//! Registered JSON-RPC methods include the original Phase 1 surface
//! (`ingest`, `list_chunks`, `get_chunk`, `trigger_digest`) plus the new
//! Memory-tab read RPCs added by the cloud-default backend refactor:
//! `list_sources`, `search`, `recall`, `entity_index_for`,
//! `top_entities`, `chunk_score`, `delete_chunk`, plus
//! `get_llm` / `set_llm` for the backend-selector UI.
//!
//! Handlers delegate to [`super::rpc`].
//! Handlers delegate to [`super::rpc`] (write side) or
//! [`super::read_rpc`] (UI read side).
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
@@ -13,6 +16,7 @@ use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::openhuman::memory::tree::read_rpc;
use crate::openhuman::memory::tree::rpc as tree_rpc;
use crate::rpc::RpcOutcome;
@@ -26,6 +30,16 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("list_chunks"),
schemas("get_chunk"),
schemas("trigger_digest"),
schemas("list_sources"),
schemas("search"),
schemas("recall"),
schemas("entity_index_for"),
schemas("chunks_for_entity"),
schemas("top_entities"),
schemas("chunk_score"),
schemas("delete_chunk"),
schemas("get_llm"),
schemas("set_llm"),
]
}
@@ -49,6 +63,46 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("trigger_digest"),
handler: handle_trigger_digest,
},
RegisteredController {
schema: schemas("list_sources"),
handler: handle_list_sources,
},
RegisteredController {
schema: schemas("search"),
handler: handle_search,
},
RegisteredController {
schema: schemas("recall"),
handler: handle_recall,
},
RegisteredController {
schema: schemas("entity_index_for"),
handler: handle_entity_index_for,
},
RegisteredController {
schema: schemas("chunks_for_entity"),
handler: handle_chunks_for_entity,
},
RegisteredController {
schema: schemas("top_entities"),
handler: handle_top_entities,
},
RegisteredController {
schema: schemas("chunk_score"),
handler: handle_chunk_score,
},
RegisteredController {
schema: schemas("delete_chunk"),
handler: handle_delete_chunk,
},
RegisteredController {
schema: schemas("get_llm"),
handler: handle_get_llm,
},
RegisteredController {
schema: schemas("set_llm"),
handler: handle_set_llm,
},
]
}
@@ -129,26 +183,26 @@ pub fn schemas(function: &str) -> ControllerSchema {
namespace: NAMESPACE,
function: "list_chunks",
description:
"List stored chunks, newest first, optionally filtered by source / owner / time.",
"Paginated list of chunks with optional filters by source kind / source id / \
entity ids / time window / keyword. Returns chunks plus total match count for \
pagination.",
inputs: vec![
FieldSchema {
name: "source_kind",
ty: TypeSchema::Option(Box::new(TypeSchema::Enum {
variants: vec!["chat", "email", "document"],
})),
comment: "Restrict to a single source kind.",
name: "source_kinds",
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::String)))),
comment: "Restrict to one or more source kinds (chat / email / document).",
required: false,
},
FieldSchema {
name: "source_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Restrict to a single logical source (channel/thread/doc id).",
name: "source_ids",
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::String)))),
comment: "Restrict to one or more logical source ids.",
required: false,
},
FieldSchema {
name: "owner",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Restrict to a single owner/account.",
name: "entity_ids",
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::String)))),
comment: "Restrict to chunks indexed against any of these canonical entity ids.",
required: false,
},
FieldSchema {
@@ -163,19 +217,39 @@ pub fn schemas(function: &str) -> ControllerSchema {
comment: "Inclusive upper bound on chunk timestamp (ms since epoch).",
required: false,
},
FieldSchema {
name: "query",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Substring keyword filter over chunk preview content.",
required: false,
},
FieldSchema {
name: "limit",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Maximum rows to return (defaults to 100).",
comment: "Maximum rows per page (defaults to 50, capped at 1000).",
required: false,
},
FieldSchema {
name: "offset",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Pagination offset (defaults to 0).",
required: false,
},
],
outputs: vec![FieldSchema {
name: "chunks",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Chunk"))),
comment: "Matching chunks ordered by timestamp DESC.",
required: true,
}],
outputs: vec![
FieldSchema {
name: "chunks",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Chunk"))),
comment: "Page of matching chunks ordered by timestamp DESC.",
required: true,
},
FieldSchema {
name: "total",
ty: TypeSchema::U64,
comment: "Total number of chunks matching the filter (pre-pagination).",
required: true,
},
],
},
"get_chunk" => ControllerSchema {
namespace: NAMESPACE,
@@ -194,6 +268,268 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: false,
}],
},
"list_sources" => ControllerSchema {
namespace: NAMESPACE,
function: "list_sources",
description:
"Distinct (source_kind, source_id) pairs with chunk counts and most-recent timestamps. \
`display_name` is computed from the source_id (un-slug + strip user email when known).",
inputs: vec![FieldSchema {
name: "user_email_hint",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "When provided, source ids that contain this email get it stripped from \
their display name so the UI shows the other party of an email thread.",
required: false,
}],
outputs: vec![FieldSchema {
name: "sources",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Source"))),
comment: "All distinct ingest sources, newest activity first.",
required: true,
}],
},
"search" => ControllerSchema {
namespace: NAMESPACE,
function: "search",
description:
"Keyword LIKE-search over chunk bodies. Cheap, deterministic; useful as a \
fallback when semantic recall is unavailable.",
inputs: vec![
FieldSchema {
name: "query",
ty: TypeSchema::String,
comment: "Substring to match against chunk content.",
required: true,
},
FieldSchema {
name: "k",
ty: TypeSchema::U64,
comment: "Maximum chunks to return.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "chunks",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Chunk"))),
comment: "Matching chunks ordered by recency.",
required: true,
}],
},
"recall" => ControllerSchema {
namespace: NAMESPACE,
function: "recall",
description:
"Semantic recall — runs the Phase 4 cosine rerank against the query embedding \
and returns leaf chunks (not summaries) for UI display.",
inputs: vec![
FieldSchema {
name: "query",
ty: TypeSchema::String,
comment: "Free-text query — embedded once and reranked against summary embeddings.",
required: true,
},
FieldSchema {
name: "k",
ty: TypeSchema::U64,
comment: "Maximum chunks to return.",
required: true,
},
],
outputs: vec![
FieldSchema {
name: "chunks",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Chunk"))),
comment: "Recalled chunks, sorted in the same order as the rerank.",
required: true,
},
FieldSchema {
name: "scores",
ty: TypeSchema::Array(Box::new(TypeSchema::Json)),
comment: "Parallel array of similarity scores (one per chunk).",
required: true,
},
],
},
"entity_index_for" => ControllerSchema {
namespace: NAMESPACE,
function: "entity_index_for",
description: "Return all canonical entities indexed against a chunk (or summary node) id.",
inputs: vec![FieldSchema {
name: "chunk_id",
ty: TypeSchema::String,
comment: "Chunk id (32 hex chars).",
required: true,
}],
outputs: vec![FieldSchema {
name: "entities",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("EntityRef"))),
comment: "Entities attached to the node, ordered by mention count DESC.",
required: true,
}],
},
"chunks_for_entity" => ControllerSchema {
namespace: NAMESPACE,
function: "chunks_for_entity",
description:
"Return chunk IDs that reference an entity_id (inverse of entity_index_for). \
Used by the Memory tab's People/Topics lenses to filter the chunk list.",
inputs: vec![FieldSchema {
name: "entity_id",
ty: TypeSchema::String,
comment:
"Canonical entity id (e.g. `person:Steven Enamakel`, \
`email:alice@example.com`).",
required: true,
}],
outputs: vec![FieldSchema {
name: "chunk_ids",
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment: "Chunk ids that mention the entity, ordered by recency DESC.",
required: true,
}],
},
"top_entities" => ControllerSchema {
namespace: NAMESPACE,
function: "top_entities",
description:
"Most-frequent canonical entities across the workspace, optionally narrowed by kind.",
inputs: vec![
FieldSchema {
name: "kind",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Restrict to a single entity_kind (`person`, `email`, `topic`, …).",
required: false,
},
FieldSchema {
name: "limit",
ty: TypeSchema::U64,
comment: "Maximum rows to return.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "entities",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("EntityRef"))),
comment: "Top entities, ordered by mention count DESC.",
required: true,
}],
},
"chunk_score" => ControllerSchema {
namespace: NAMESPACE,
function: "chunk_score",
description:
"Score breakdown stored in `mem_tree_score` for one chunk — used by the Memory \
tab's 'why was this kept / dropped' panel.",
inputs: vec![FieldSchema {
name: "chunk_id",
ty: TypeSchema::String,
comment: "Chunk id (32 hex chars).",
required: true,
}],
outputs: vec![FieldSchema {
name: "breakdown",
ty: TypeSchema::Option(Box::new(TypeSchema::Ref("ScoreBreakdown"))),
comment: "Per-signal weight + value array, total, threshold, kept flag, llm_consulted flag.",
required: false,
}],
},
"delete_chunk" => ControllerSchema {
namespace: NAMESPACE,
function: "delete_chunk",
description:
"Purge one chunk plus its score row, entity-index rows, and on-disk .md file. \
Idempotent — missing chunk returns deleted=false. Does NOT cascade through \
sealed summaries; UIs warn the user.",
inputs: vec![FieldSchema {
name: "chunk_id",
ty: TypeSchema::String,
comment: "Chunk id to remove.",
required: true,
}],
outputs: vec![
FieldSchema {
name: "deleted",
ty: TypeSchema::Bool,
comment: "True when the chunk row was found and removed.",
required: true,
},
FieldSchema {
name: "score_rows_removed",
ty: TypeSchema::U64,
comment: "Count of rows removed from `mem_tree_score`.",
required: true,
},
FieldSchema {
name: "entity_index_rows_removed",
ty: TypeSchema::U64,
comment: "Count of rows removed from `mem_tree_entity_index`.",
required: true,
},
],
},
"get_llm" => ControllerSchema {
namespace: NAMESPACE,
function: "get_llm",
description: "Read the currently configured LLM backend (`cloud` or `local`).",
inputs: vec![],
outputs: vec![FieldSchema {
name: "current",
ty: TypeSchema::Enum {
variants: vec!["cloud", "local"],
},
comment: "Active backend string.",
required: true,
}],
},
"set_llm" => ControllerSchema {
namespace: NAMESPACE,
function: "set_llm",
description:
"Update the LLM backend selector and (optionally) per-role model choices \
(`cloud_model`, `extract_model`, `summariser_model`) and persist the \
result to config.toml in a single atomic write. Absent model fields \
leave the corresponding config key unchanged so a caller flipping just \
the backend doesn't have to re-supply every model id.",
inputs: vec![
FieldSchema {
name: "backend",
ty: TypeSchema::Enum {
variants: vec!["cloud", "local"],
},
comment: "New backend value.",
required: true,
},
FieldSchema {
name: "cloud_model",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Cloud model id (used when backend=cloud). \
Absent → leave existing memory_tree.cloud_llm_model unchanged.",
required: false,
},
FieldSchema {
name: "extract_model",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Ollama model id for the entity extractor (used when backend=local). \
Absent → leave existing memory_tree.llm_extractor_model unchanged.",
required: false,
},
FieldSchema {
name: "summariser_model",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Ollama model id for the summariser (used when backend=local). \
Absent → leave existing memory_tree.llm_summariser_model unchanged.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "current",
ty: TypeSchema::Enum {
variants: vec!["cloud", "local"],
},
comment: "The effective backend after the call.",
required: true,
}],
},
"trigger_digest" => ControllerSchema {
namespace: NAMESPACE,
function: "trigger_digest",
@@ -260,14 +596,6 @@ fn handle_ingest(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_list_chunks(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<tree_rpc::ListChunksRequest>(Value::Object(params))?;
to_json(tree_rpc::list_chunks_rpc(&config, req).await?)
})
}
fn handle_get_chunk(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
@@ -284,6 +612,132 @@ fn handle_trigger_digest(params: Map<String, Value>) -> ControllerFuture {
})
}
// ── New read RPCs (Memory-tab UI) ────────────────────────────────────────
fn handle_list_chunks(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let filter = parse_value::<read_rpc::ChunkFilter>(Value::Object(params))?;
to_json(read_rpc::list_chunks_rpc(&config, filter).await?)
})
}
fn handle_list_sources(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
#[derive(serde::Deserialize, Default)]
struct Req {
#[serde(default)]
user_email_hint: Option<String>,
}
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<Req>(Value::Object(params)).unwrap_or_default();
to_json(read_rpc::list_sources_rpc(&config, req.user_email_hint).await?)
})
}
fn handle_search(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
#[derive(serde::Deserialize)]
struct Req {
query: String,
k: u32,
}
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<Req>(Value::Object(params))?;
to_json(read_rpc::search_rpc(&config, req.query, req.k).await?)
})
}
fn handle_recall(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
#[derive(serde::Deserialize)]
struct Req {
query: String,
k: u32,
}
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<Req>(Value::Object(params))?;
to_json(read_rpc::recall_rpc(&config, req.query, req.k).await?)
})
}
fn handle_entity_index_for(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
#[derive(serde::Deserialize)]
struct Req {
chunk_id: String,
}
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<Req>(Value::Object(params))?;
to_json(read_rpc::entity_index_for_rpc(&config, req.chunk_id).await?)
})
}
fn handle_chunks_for_entity(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
#[derive(serde::Deserialize)]
struct Req {
entity_id: String,
}
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<Req>(Value::Object(params))?;
to_json(read_rpc::chunks_for_entity_rpc(&config, req.entity_id).await?)
})
}
fn handle_top_entities(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
#[derive(serde::Deserialize)]
struct Req {
#[serde(default)]
kind: Option<String>,
limit: u32,
}
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<Req>(Value::Object(params))?;
to_json(read_rpc::top_entities_rpc(&config, req.kind, req.limit).await?)
})
}
fn handle_chunk_score(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
#[derive(serde::Deserialize)]
struct Req {
chunk_id: String,
}
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<Req>(Value::Object(params))?;
to_json(read_rpc::chunk_score_rpc(&config, req.chunk_id).await?)
})
}
fn handle_delete_chunk(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
#[derive(serde::Deserialize)]
struct Req {
chunk_id: String,
}
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<Req>(Value::Object(params))?;
to_json(read_rpc::delete_chunk_rpc(&config, req.chunk_id).await?)
})
}
fn handle_get_llm(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(read_rpc::get_llm_rpc(&config).await?)
})
}
fn handle_set_llm(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let mut config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<read_rpc::SetLlmRequest>(Value::Object(params))?;
to_json(read_rpc::set_llm_rpc(&mut config, req).await?)
})
}
fn parse_value<T: DeserializeOwned>(v: Value) -> Result<T, String> {
serde_json::from_value(v).map_err(|e| format!("invalid params: {e}"))
}
+59 -134
View File
@@ -1,20 +1,18 @@
//! LLM-based entity + importance extractor.
//!
//! Talks to an Ollama-compatible chat-completions HTTP endpoint, asks for
//! NER + an importance rating in one structured-JSON response, and parses
//! the result into [`ExtractedEntities`].
//! Builds a (system, user) prompt asking for NER + an importance rating
//! in one structured-JSON response, hands the prompt to a
//! [`ChatProvider`], and parses the result into [`ExtractedEntities`].
//!
//! ## Why this lives here
//!
//! Phase 2 ships a regex extractor only. Semantic NER (Person/Org/Loc/…)
//! requires a model. We use a small local LLM (Ollama default:
//! `qwen2.5:0.5b`) for two reasons:
//!
//! 1. **Reuse** — openhuman already runs Ollama for embeddings; no new
//! deps, no native ONNX runtime to ship.
//! 2. **Free importance signal** — extending the NER prompt with one extra
//! JSON field (`importance`) gives us an LLM-rated quality score per
//! chunk for the cost of one prompt instead of two LLM calls.
//! requires a model. Originally we used a small local LLM (Ollama default:
//! `qwen2.5:0.5b`) because openhuman already ran Ollama for embeddings.
//! After the cloud-default refactor, the same prompt now routes through
//! whichever backend the workspace selected — typically the OpenHuman
//! backend's `summarization-v1`. The extractor itself is unchanged below the
//! HTTP layer; only the transport moved.
//!
//! ## Span recovery
//!
@@ -25,38 +23,33 @@
//!
//! ## Soft fallback
//!
//! If the HTTP call fails (Ollama not running, model not pulled, timeout),
//! we log a warn and return [`ExtractedEntities::default()`]. The
//! If the chat call fails (provider unavailable, malformed JSON, …), we
//! log a warn and return [`ExtractedEntities::default()`]. The
//! [`super::CompositeExtractor`] already tolerates errors from individual
//! extractors; ingestion never blocks on LLM availability.
use std::time::Duration;
use std::sync::Arc;
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use super::types::{EntityKind, ExtractedEntities, ExtractedEntity, ExtractedTopic};
use super::EntityExtractor;
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
// ── Configuration ────────────────────────────────────────────────────────
/// Configuration for [`LlmEntityExtractor`].
#[derive(Clone, Debug)]
pub struct LlmExtractorConfig {
/// Base URL of the Ollama-compatible endpoint (e.g. `http://localhost:11434`).
/// Do NOT include `/api/chat` — the extractor appends it.
pub endpoint: String,
/// Model identifier as known to the endpoint (e.g. `qwen2.5:0.5b`).
/// Model identifier the chat provider should target. For cloud this
/// is e.g. `summarization-v1`; for local Ollama it's the Ollama tag
/// (`qwen2.5:0.5b`). Threaded through to [`ChatPrompt`] so the
/// provider can route to the right model.
///
/// Stored on the extractor for diagnostic logging only — the actual
/// model selection happens inside the [`ChatProvider`].
pub model: String,
/// TCP connect timeout. Used only to fail fast when Ollama is
/// down; no body-read timeout is applied (see `Self::new` for
/// rationale — local Ollama on slow CPU inference can take
/// minutes per call, and cancelling mid-stream triggered
/// cancellation-path retry storms). The default is generous
/// because the first request after a model swap may need to load
/// weights.
pub timeout: Duration,
/// Which entity kinds the LLM is allowed to emit. Anything outside this
/// set is mapped to [`EntityKind::Misc`] or dropped depending on
/// `strict_kinds`.
@@ -78,9 +71,7 @@ pub struct LlmExtractorConfig {
impl Default for LlmExtractorConfig {
fn default() -> Self {
Self {
endpoint: "http://localhost:11434".to_string(),
model: "qwen2.5:0.5b".to_string(),
timeout: Duration::from_secs(15),
allowed_kinds: vec![
EntityKind::Person,
EntityKind::Organization,
@@ -101,49 +92,32 @@ impl Default for LlmExtractorConfig {
// ── Extractor ────────────────────────────────────────────────────────────
/// LLM-backed entity + importance extractor.
///
/// Holds an `Arc<dyn ChatProvider>` rather than a per-instance HTTP
/// client. The provider abstraction lets a single workspace choose
/// cloud vs local at runtime (see
/// [`crate::openhuman::memory::tree::chat::build_chat_provider`]). Tests
/// can mock the provider to assert the prompt / parse behaviour without
/// a real Ollama or backend.
pub struct LlmEntityExtractor {
cfg: LlmExtractorConfig,
http: Client,
provider: Arc<dyn ChatProvider>,
}
impl LlmEntityExtractor {
/// Build the extractor and its inner HTTP client. Fails only when
/// `reqwest` rejects the timeout configuration.
pub fn new(cfg: LlmExtractorConfig) -> anyhow::Result<Self> {
// No body-read timeout. Ollama is a local process — slow
// responses mean the model is genuinely processing under CPU
// load (e.g. gemma3:1b on CPU-only inference can take minutes
// per call), not that the network broke. A body-read timeout
// here would cancel mid-flight generation and force the
// existing 3× retry-with-backoff to re-run the same prompt
// against the same slow model, which empirically caused retry
// storms during high-load ingest. `cfg.timeout` is now the
// TCP connect timeout — short enough to fail fast when
// Ollama is actually unreachable.
let http = Client::builder()
.connect_timeout(cfg.timeout)
.build()
.map_err(anyhow::Error::from)?;
Ok(Self { cfg, http })
/// Build the extractor with the supplied chat provider. Infallible —
/// the caller is responsible for provider construction.
pub fn new(cfg: LlmExtractorConfig, provider: Arc<dyn ChatProvider>) -> Self {
Self { cfg, provider }
}
/// Build the Ollama `/api/chat` request body.
fn build_request(&self, text: &str) -> OllamaChatRequest {
OllamaChatRequest {
model: self.cfg.model.clone(),
messages: vec![
OllamaMessage {
role: "system".to_string(),
content: build_system_prompt(self.cfg.emit_topics),
},
OllamaMessage {
role: "user".to_string(),
content: format!("Text:\n{text}\n\nReturn JSON only."),
},
],
format: "json".to_string(),
stream: false,
options: OllamaOptions { temperature: 0.0 },
/// Build the chat prompt sent to the provider for `text`.
fn build_prompt(&self, text: &str) -> ChatPrompt {
ChatPrompt {
system: build_system_prompt(self.cfg.emit_topics),
user: format!("Text:\n{text}\n\nReturn JSON only."),
temperature: 0.0,
kind: "memory_tree::extract",
}
}
}
@@ -198,64 +172,47 @@ impl EntityExtractor for LlmEntityExtractor {
}
impl LlmEntityExtractor {
/// Internal: one attempt at calling Ollama.
/// Internal: one attempt at calling the chat provider.
///
/// Returns:
/// - `Some(extracted)` — call completed (HTTP returned). Includes the
/// "HTTP non-success" and "malformed JSON" cases, which return
/// `Some(empty)` because retrying the same input won't help.
/// - `None` — transport-level failure (DNS, connect refused, timeout
/// before any HTTP response). Caller may retry.
/// - `Some(extracted)` — call completed (provider returned content).
/// Includes the "malformed JSON" case which returns `Some(empty)`
/// because retrying the same input won't help.
/// - `None` — transport-level / provider-level failure where retrying
/// might help (e.g. unreachable backend, transient HTTP 5xx). Caller
/// may retry.
async fn try_extract(&self, text: &str) -> Option<ExtractedEntities> {
let url = format!("{}/api/chat", self.cfg.endpoint.trim_end_matches('/'));
let body = self.build_request(text);
let prompt = self.build_prompt(text);
log::debug!(
"[memory_tree::extract::llm] POST {url} model={} text_chars={}",
"[memory_tree::extract::llm] chat provider={} model={} text_chars={}",
self.provider.name(),
self.cfg.model,
text.chars().count()
);
let resp = match self.http.post(&url).json(&body).send().await {
Ok(r) => r,
Err(e) => {
log::warn!("[memory_tree::extract::llm] transport failure to {url}: {e}");
return None;
}
};
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
log::warn!(
"[memory_tree::extract::llm] ollama non-success status {status}: {} — \
returning empty extraction",
truncate_for_log(&body, 200)
);
return Some(ExtractedEntities::default());
}
let envelope: OllamaChatResponse = match resp.json().await {
let raw = match self.provider.chat_for_json(&prompt).await {
Ok(v) => v,
Err(e) => {
log::warn!(
"[memory_tree::extract::llm] response body not Ollama-shaped JSON: {e} — \
returning empty extraction"
"[memory_tree::extract::llm] chat provider={} failed: {e:#}",
self.provider.name()
);
return Some(ExtractedEntities::default());
return None;
}
};
log::debug!(
"[memory_tree::extract::llm] response chars={}",
envelope.message.content.len()
"[memory_tree::extract::llm] response chars={} provider={}",
raw.len(),
self.provider.name()
);
let parsed: LlmExtractionOutput = match serde_json::from_str(&envelope.message.content) {
let parsed: LlmExtractionOutput = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
log::warn!(
"[memory_tree::extract::llm] LLM returned non-JSON or wrong-shape \
response: {e}; content was: {} — returning empty extraction",
truncate_for_log(&envelope.message.content, 400)
truncate_for_log(&raw, 400)
);
return Some(ExtractedEntities::default());
}
@@ -349,38 +306,6 @@ Importance guide:
)
}
// ── Wire types (Ollama API) ──────────────────────────────────────────────
#[derive(Debug, Serialize)]
struct OllamaChatRequest {
model: String,
messages: Vec<OllamaMessage>,
format: String,
stream: bool,
options: OllamaOptions,
}
#[derive(Debug, Serialize)]
struct OllamaMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct OllamaOptions {
temperature: f32,
}
#[derive(Debug, Deserialize)]
struct OllamaChatResponse {
message: OllamaResponseMessage,
}
#[derive(Debug, Deserialize)]
struct OllamaResponseMessage {
content: String,
}
// ── LLM JSON output ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
@@ -181,22 +181,104 @@ fn into_extracted_entities_drops_extra_duplicate_when_source_only_has_one() {
}
#[tokio::test]
async fn extract_soft_fallback_on_unreachable_endpoint() {
// Point at an unreachable port so the transport fails. extract()
// must NOT return Err — it must return an empty ExtractedEntities
// with a warn log.
let cfg = LlmExtractorConfig {
endpoint: "http://127.0.0.1:1".to_string(),
timeout: std::time::Duration::from_millis(100),
..LlmExtractorConfig::default()
};
let ex = LlmEntityExtractor::new(cfg).unwrap();
async fn extract_soft_fallback_on_provider_failure() {
// Provider always errors. extract() must NOT return Err — it must
// return an empty ExtractedEntities with a warn log after retry
// exhaustion.
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
use async_trait::async_trait;
use std::sync::Arc;
struct FailingProvider;
#[async_trait]
impl ChatProvider for FailingProvider {
fn name(&self) -> &str {
"test:failing"
}
async fn chat_for_json(&self, _p: &ChatPrompt) -> anyhow::Result<String> {
Err(anyhow::anyhow!("simulated transport failure"))
}
}
let ex = LlmEntityExtractor::new(LlmExtractorConfig::default(), Arc::new(FailingProvider));
let out = ex.extract("some text").await.unwrap();
assert!(out.entities.is_empty());
assert!(out.topics.is_empty());
assert!(out.llm_importance.is_none());
}
#[tokio::test]
async fn extract_routes_through_chat_provider_and_parses_response() {
// Mock provider returns canned NER+importance JSON. Verify the
// extractor parses it, recovers spans by string search, and emits the
// expected entities + importance signal.
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct MockProvider {
calls: AtomicUsize,
}
#[async_trait]
impl ChatProvider for MockProvider {
fn name(&self) -> &str {
"test:mock"
}
async fn chat_for_json(&self, _p: &ChatPrompt) -> anyhow::Result<String> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(r#"{
"entities": [
{"kind":"person","text":"Alice"},
{"kind":"organization","text":"Anthropic"}
],
"importance": 0.8,
"importance_reason": "factual"
}"#
.to_string())
}
}
let mock = Arc::new(MockProvider {
calls: AtomicUsize::new(0),
});
let ex = LlmEntityExtractor::new(LlmExtractorConfig::default(), mock.clone());
let out = ex.extract("Alice met Anthropic today.").await.unwrap();
assert_eq!(mock.calls.load(Ordering::SeqCst), 1);
assert_eq!(out.entities.len(), 2);
assert_eq!(out.entities[0].text, "Alice");
assert_eq!(out.entities[0].kind, EntityKind::Person);
assert_eq!(out.entities[1].text, "Anthropic");
assert_eq!(out.llm_importance, Some(0.8));
assert_eq!(out.llm_importance_reason.as_deref(), Some("factual"));
}
#[tokio::test]
async fn extract_returns_empty_on_malformed_provider_response() {
// Provider returns garbage. Caller must NOT see an Err — the parse
// failure path returns empty entities (retrying the same input would
// yield the same garbage, so we don't burn retries).
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
use async_trait::async_trait;
use std::sync::Arc;
struct GarbageProvider;
#[async_trait]
impl ChatProvider for GarbageProvider {
fn name(&self) -> &str {
"test:garbage"
}
async fn chat_for_json(&self, _p: &ChatPrompt) -> anyhow::Result<String> {
Ok("not json at all".to_string())
}
}
let ex = LlmEntityExtractor::new(LlmExtractorConfig::default(), Arc::new(GarbageProvider));
let out = ex.extract("text").await.unwrap();
assert!(out.entities.is_empty());
assert!(out.llm_importance.is_none());
}
#[test]
fn into_extracted_entities_drops_hallucinations() {
let out = LlmExtractionOutput {
@@ -295,21 +377,35 @@ fn into_extracted_entities_disallowed_known_kind_falls_back_to_misc() {
}
#[test]
fn build_request_uses_configured_model() {
fn build_prompt_carries_user_text_and_kind_tag() {
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
use async_trait::async_trait;
use std::sync::Arc;
struct NoopProvider;
#[async_trait]
impl ChatProvider for NoopProvider {
fn name(&self) -> &str {
"test:noop"
}
async fn chat_for_json(&self, _p: &ChatPrompt) -> anyhow::Result<String> {
Ok("{}".into())
}
}
let cfg = LlmExtractorConfig {
model: "test-model".into(),
..LlmExtractorConfig::default()
};
let ex = LlmEntityExtractor::new(cfg).unwrap();
let req = ex.build_request("hello");
assert_eq!(req.model, "test-model");
assert_eq!(req.format, "json");
assert!(!req.stream);
assert_eq!(req.options.temperature, 0.0);
assert_eq!(req.messages.len(), 2);
assert_eq!(req.messages[0].role, "system");
assert_eq!(req.messages[1].role, "user");
assert!(req.messages[1].content.contains("hello"));
let ex = LlmEntityExtractor::new(cfg, Arc::new(NoopProvider));
let prompt = ex.build_prompt("hello");
assert!(prompt.user.contains("hello"));
assert!(prompt.user.contains("Return JSON only"));
assert_eq!(prompt.temperature, 0.0);
assert_eq!(prompt.kind, "memory_tree::extract");
// System prompt should describe the JSON schema.
assert!(prompt.system.contains("\"entities\""));
assert!(prompt.system.contains("\"importance\""));
}
#[test]
+66 -46
View File
@@ -12,8 +12,8 @@ pub mod types;
use std::sync::Arc;
use crate::openhuman::config::Config;
use crate::openhuman::memory::tree::util::redact::redact_endpoint;
use crate::openhuman::config::{Config, LlmBackend, DEFAULT_CLOUD_LLM_MODEL};
use crate::openhuman::memory::tree::chat::{build_chat_provider, ChatConsumer};
pub use extractor::{CompositeExtractor, EntityExtractor, RegexEntityExtractor};
pub use llm::{LlmEntityExtractor, LlmExtractorConfig};
@@ -23,8 +23,10 @@ pub use types::{EntityKind, ExtractedEntities, ExtractedEntity, ExtractedTopic};
///
/// Composition:
/// - regex extractor — always on, mechanical, near-zero cost
/// - LLM extractor with `emit_topics: true` — added when
/// `memory_tree.llm_extractor_endpoint` and `..._model` are both set
/// - LLM extractor with `emit_topics: true` — added when the LLM backend
/// is reachable. For `llm_backend = "cloud"` (default) that's always. For
/// `llm_backend = "local"` we still require `llm_extractor_endpoint` +
/// `_model` to be set (otherwise the legacy regex-only path stays).
///
/// Differs from [`super::ScoringConfig::from_config`] (the chunk-admission
/// builder) in two ways: returns *just* an extractor (no thresholds /
@@ -32,61 +34,79 @@ pub use types::{EntityKind, ExtractedEntities, ExtractedEntity, ExtractedTopic};
/// `emit_topics` on so summaries surface thematic labels alongside
/// entities. Leaf-side scoring is unchanged.
pub fn build_summary_extractor(config: &Config) -> Arc<dyn EntityExtractor> {
let endpoint = config
.memory_tree
.llm_extractor_endpoint
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let model = config
.memory_tree
.llm_extractor_model
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let (Some(endpoint), Some(model)) = (endpoint, model) else {
let model = resolve_extractor_model(config);
let Some(model) = model else {
log::debug!(
"[memory_tree::extract] summary extractor: LLM not configured — using regex-only"
"[memory_tree::extract] summary extractor: LLM model not resolvable for \
llm_backend={} — using regex-only",
config.memory_tree.llm_backend.as_str()
);
return Arc::new(CompositeExtractor::regex_only());
};
let timeout_ms = config
.memory_tree
.llm_extractor_timeout_ms
.unwrap_or(15_000);
let cfg = LlmExtractorConfig {
endpoint: endpoint.to_string(),
model: model.to_string(),
timeout: std::time::Duration::from_millis(timeout_ms),
model: model.clone(),
emit_topics: true,
..LlmExtractorConfig::default()
};
match LlmEntityExtractor::new(cfg) {
Ok(llm) => {
// Drop to debug (diagnostic, not always-on) and redact the endpoint
// so embedded credentials (e.g. api keys in URL) don't leak.
log::debug!(
"[memory_tree::extract] summary extractor: regex + LLM endpoint={} model={} \
timeout_ms={} emit_topics=true",
redact_endpoint(endpoint),
model,
timeout_ms
);
Arc::new(CompositeExtractor::new(vec![
Box::new(RegexEntityExtractor),
Box::new(llm),
]))
}
let provider = match build_chat_provider(config, ChatConsumer::Extract) {
Ok(p) => p,
Err(err) => {
log::warn!(
"[memory_tree::extract] summary extractor: LlmEntityExtractor construction \
failed: {err:#} — falling back to regex-only"
"[memory_tree::extract] summary extractor: build_chat_provider failed: \
{err:#} — falling back to regex-only"
);
Arc::new(CompositeExtractor::regex_only())
return Arc::new(CompositeExtractor::regex_only());
}
};
log::debug!(
"[memory_tree::extract] summary extractor: regex + LLM provider={} model={} \
emit_topics=true",
provider.name(),
model
);
Arc::new(CompositeExtractor::new(vec![
Box::new(RegexEntityExtractor),
Box::new(LlmEntityExtractor::new(cfg, provider)),
]))
}
/// Resolve the model identifier the extractor's [`ChatProvider`] should
/// target, returning `None` when the configured backend can't be served:
///
/// - `Cloud`: always returns the configured `cloud_llm_model` or its
/// `summarization-v1` default.
/// - `Local`: returns `Some(model)` only when both
/// `llm_extractor_endpoint` AND `llm_extractor_model` are set —
/// otherwise the legacy regex-only path engages.
pub(super) fn resolve_extractor_model(config: &Config) -> Option<String> {
match config.memory_tree.llm_backend {
LlmBackend::Cloud => Some(
config
.memory_tree
.cloud_llm_model
.clone()
.unwrap_or_else(|| DEFAULT_CLOUD_LLM_MODEL.to_string()),
),
LlmBackend::Local => {
let endpoint = config
.memory_tree
.llm_extractor_endpoint
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let model = config
.memory_tree
.llm_extractor_model
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
match (endpoint, model) {
(Some(_), Some(m)) => Some(m.to_string()),
_ => None,
}
}
}
}
+33 -39
View File
@@ -105,57 +105,51 @@ impl ScoringConfig {
}
}
/// Build a [`ScoringConfig`] from the workspace [`Config`]. When
/// `memory_tree.llm_extractor_endpoint` and `llm_extractor_model`
/// are both set, wires [`extract::LlmEntityExtractor`] as the
/// second-pass extractor. Otherwise falls back to
/// [`Self::default_regex_only`]. Construction errors in the LLM
/// extractor (rare — only client-builder failures) also fall back
/// to regex-only with a warn log; scoring never blocks on LLM
/// availability.
/// Build a [`ScoringConfig`] from the workspace [`Config`]. The
/// resolution rules match `build_summary_extractor`:
///
/// - `llm_backend = "cloud"` (default): always wires the LLM extractor
/// against the cloud provider, using the configured
/// `cloud_llm_model` (defaulting to `summarization-v1`).
/// - `llm_backend = "local"`: wires the LLM extractor only when both
/// `llm_extractor_endpoint` and `llm_extractor_model` are set;
/// otherwise falls back to [`Self::default_regex_only`].
///
/// Construction errors in the chat provider (rare — only client-builder
/// failures) fall back to regex-only with a warn log; scoring never
/// blocks on LLM availability.
pub fn from_config(config: &crate::openhuman::config::Config) -> Self {
let endpoint = config
.memory_tree
.llm_extractor_endpoint
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let model = config
.memory_tree
.llm_extractor_model
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
use crate::openhuman::memory::tree::chat::{build_chat_provider, ChatConsumer};
let (Some(endpoint), Some(model)) = (endpoint, model) else {
log::debug!("[memory_tree::score] llm_extractor not configured — using regex-only");
return Self::default_regex_only();
let model = match extract::resolve_extractor_model(config) {
Some(m) => m,
None => {
log::debug!(
"[memory_tree::score] llm_extractor not resolvable for llm_backend={} \
— using regex-only",
config.memory_tree.llm_backend.as_str()
);
return Self::default_regex_only();
}
};
let timeout_ms = config
.memory_tree
.llm_extractor_timeout_ms
.unwrap_or(15_000);
let cfg = extract::LlmExtractorConfig {
endpoint: endpoint.to_string(),
model: model.to_string(),
timeout: std::time::Duration::from_millis(timeout_ms),
model: model.clone(),
..extract::LlmExtractorConfig::default()
};
match extract::LlmEntityExtractor::new(cfg) {
Ok(llm) => {
match build_chat_provider(config, ChatConsumer::Extract) {
Ok(provider) => {
log::info!(
"[memory_tree::score] using LlmEntityExtractor endpoint={} model={} timeout_ms={}",
endpoint,
model,
timeout_ms
"[memory_tree::score] using LlmEntityExtractor provider={} model={}",
provider.name(),
model
);
Self::with_llm_extractor(Arc::new(llm))
Self::with_llm_extractor(Arc::new(extract::LlmEntityExtractor::new(cfg, provider)))
}
Err(err) => {
log::warn!(
"[memory_tree::score] LlmEntityExtractor construction failed: {err:#} — \
"[memory_tree::score] build_chat_provider failed: {err:#} — \
falling back to regex-only"
);
Self::default_regex_only()
@@ -1,4 +1,4 @@
//! LLM-backed summariser — Ollama `/api/chat` peer of
//! LLM-backed summariser — peer of
//! [`crate::openhuman::memory::tree::score::extract::llm::LlmEntityExtractor`].
//!
//! ## Responsibility
@@ -25,19 +25,25 @@
//! ## Prompt shape
//!
//! The system prompt commits the model to returning JSON with the shape
//! `{ summary }`. We use Ollama's `format: "json"` +
//! `temperature: 0.0` to maximise determinism — same knobs the entity
//! extractor already uses with success.
use std::time::Duration;
//! `{ summary }`. We pass `temperature: 0.0` for maximum determinism —
//! same knob the entity extractor already uses with success.
//!
//! ## Backend transparency
//!
//! Originally this summariser owned its own `reqwest::Client` and talked
//! directly to Ollama. After the cloud-default refactor, it accepts an
//! `Arc<dyn ChatProvider>` instead — letting a single workspace pick
//! cloud (default) or local (opt-in) at runtime without changing this
//! file's prompt or parse logic.
use anyhow::Result;
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use std::sync::Arc;
use super::inert::InertSummariser;
use super::{Summariser, SummaryContext, SummaryInput, SummaryOutput};
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
use crate::openhuman::memory::tree::types::approx_token_count;
/// Hard cap on summariser output length (in approximate tokens).
@@ -59,10 +65,13 @@ use crate::openhuman::memory::tree::types::approx_token_count;
/// this regardless of what the model produces.
const MAX_SUMMARY_OUTPUT_TOKENS: u32 = 3_500;
/// Context window we ask Ollama for. Must match the value below in
/// [`OllamaOptions::num_ctx`] so the per-input clamp computed in
/// [`LlmSummariser::summarise`] sizes inputs against the same window
/// the model actually sees.
/// Context window assumed for the model. Used as the divisor in the
/// per-input clamp so the joined prompt body stays under this even at
/// upper-level seals where SUMMARY_FANOUT children each near
/// MAX_SUMMARY_OUTPUT_TOKENS would otherwise overflow. Conservative —
/// real cloud models have larger contexts; smaller local models may
/// truncate, but the post-generation `clamp_to_budget` ensures output
/// fits the embedder regardless.
const NUM_CTX_TOKENS: u32 = 16_384;
/// Tokens reserved for the system prompt, JSON wrapper, and tokenizer
@@ -71,34 +80,20 @@ const NUM_CTX_TOKENS: u32 = 16_384;
/// prompt body + output budget never exceeds `num_ctx`.
const OVERHEAD_RESERVE_TOKENS: u32 = 512;
/// Configuration for [`LlmSummariser`]. Endpoint + model defaults match
/// [`crate::openhuman::memory::tree::score::extract::llm::LlmExtractorConfig`]
/// so a workspace configured for one LLM path also satisfies the other
/// by default.
/// Configuration for [`LlmSummariser`]. Threaded down to the chat
/// provider for diagnostic logging — model selection at the wire level
/// happens inside the [`ChatProvider`].
#[derive(Clone, Debug)]
pub struct LlmSummariserConfig {
/// Base URL of the Ollama-compatible endpoint (e.g.
/// `http://localhost:11434`). Do NOT include `/api/chat` — the
/// summariser appends it.
pub endpoint: String,
/// Model identifier (e.g. `qwen2.5:0.5b` or `llama3.1:8b`).
/// Model identifier (e.g. `summarization-v1` for cloud, `qwen2.5:0.5b`
/// or `llama3.1:8b` for local Ollama). Diagnostic / log only.
pub model: String,
/// Per-request timeout. Generous default because first-call weight
/// loading can be slow.
pub timeout: Duration,
}
impl Default for LlmSummariserConfig {
fn default() -> Self {
Self {
endpoint: "http://localhost:11434".to_string(),
model: "qwen2.5:0.5b".to_string(),
// 120s — generous enough for small/medium models (1B-8B
// params) summarising the seal cascade's full token
// budget on first invocation, when Ollama may also be
// loading model weights into VRAM. Large models on CPU
// can still time out; bump via config for those.
timeout: Duration::from_secs(120),
}
}
}
@@ -107,59 +102,28 @@ impl Default for LlmSummariserConfig {
/// failure so seal cascades never fail.
pub struct LlmSummariser {
cfg: LlmSummariserConfig,
http: Client,
provider: Arc<dyn ChatProvider>,
fallback: InertSummariser,
}
impl LlmSummariser {
/// Build a summariser around `cfg`. Returns an error only if the HTTP
/// client fails to construct (timeout / TLS init).
pub fn new(cfg: LlmSummariserConfig) -> Result<Self> {
// No body-read timeout. Ollama is local — slow responses mean
// the model is genuinely processing, not that the network
// broke. A body-read timeout here would cancel mid-stream and
// force retries against the same slow model. `cfg.timeout` is
// repurposed as the TCP connect timeout (fast-fail when
// Ollama is actually down).
let http = Client::builder()
.connect_timeout(cfg.timeout)
.build()
.map_err(anyhow::Error::from)?;
Ok(Self {
/// Build a summariser with the supplied chat provider. Infallible —
/// the caller is responsible for provider construction.
pub fn new(cfg: LlmSummariserConfig, provider: Arc<dyn ChatProvider>) -> Self {
Self {
cfg,
http,
provider,
fallback: InertSummariser::new(),
})
}
}
fn build_request(&self, prompt_body: &str, budget: u32) -> OllamaChatRequest {
OllamaChatRequest {
model: self.cfg.model.clone(),
messages: vec![
OllamaMessage {
role: "system".to_string(),
content: system_prompt(budget),
},
OllamaMessage {
role: "user".to_string(),
content: prompt_body.to_string(),
},
],
format: "json".to_string(),
stream: false,
options: OllamaOptions {
temperature: 0.0,
// 16k context window. Sized so that the per-input
// clamp in `summarise` (NUM_CTX - output_budget -
// overhead, divided by `inputs.len()`) keeps the
// joined prompt body inside this window even at
// upper-level seals where SUMMARY_FANOUT children
// each near MAX_SUMMARY_OUTPUT_TOKENS would otherwise
// overflow. Keeping `num_ctx` modest also keeps the
// kv-cache small enough to fit on consumer GPUs
// alongside the model weights.
num_ctx: NUM_CTX_TOKENS,
},
/// Build the chat prompt sent to the provider for a given seal.
fn build_prompt(&self, prompt_body: &str, budget: u32) -> ChatPrompt {
ChatPrompt {
system: system_prompt(budget),
user: prompt_body.to_string(),
temperature: 0.0,
kind: "memory_tree::summarise",
}
}
}
@@ -212,12 +176,12 @@ impl Summariser for LlmSummariser {
});
}
let url = format!("{}/api/chat", self.cfg.endpoint.trim_end_matches('/'));
let req = self.build_request(&body, effective_budget);
let prompt = self.build_prompt(&body, effective_budget);
log::debug!(
"[tree_source::summariser::llm] POST {url} model={} tree_id={} level={} \
"[tree_source::summariser::llm] chat provider={} model={} tree_id={} level={} \
inputs={} budget={}",
self.provider.name(),
self.cfg.model,
ctx.tree_id,
ctx.target_level,
@@ -225,38 +189,13 @@ impl Summariser for LlmSummariser {
ctx.token_budget
);
let resp = match self.http.post(&url).json(&req).send().await {
Ok(r) => r,
Err(e) => {
log::warn!(
"[tree_source::summariser::llm] transport failure to {url}: {e} — \
falling back to inert summariser for tree_id={} level={}",
ctx.tree_id,
ctx.target_level
);
return self.fallback.summarise(inputs, ctx).await;
}
};
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
log::warn!(
"[tree_source::summariser::llm] ollama non-success status {status} \
tree_id={} level={}: {} — falling back to inert",
ctx.tree_id,
ctx.target_level,
truncate_for_log(&body, 200)
);
return self.fallback.summarise(inputs, ctx).await;
}
let envelope: OllamaChatResponse = match resp.json().await {
let raw = match self.provider.chat_for_json(&prompt).await {
Ok(v) => v,
Err(e) => {
log::warn!(
"[tree_source::summariser::llm] response not Ollama-shaped JSON: {e} — \
falling back to inert for tree_id={} level={}",
"[tree_source::summariser::llm] chat provider={} failed: {e:#} — \
falling back to inert summariser for tree_id={} level={}",
self.provider.name(),
ctx.tree_id,
ctx.target_level
);
@@ -264,13 +203,13 @@ impl Summariser for LlmSummariser {
}
};
let parsed: LlmSummaryOutput = match serde_json::from_str(&envelope.message.content) {
let parsed: LlmSummaryOutput = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
log::warn!(
"[tree_source::summariser::llm] model returned non-JSON or wrong-shape \
body: {e}; content was: {} — falling back to inert",
truncate_for_log(&envelope.message.content, 400)
truncate_for_log(&raw, 400)
);
return self.fallback.summarise(inputs, ctx).await;
}
@@ -356,44 +295,6 @@ fn truncate_for_log(s: &str, max_chars: usize) -> String {
format!("{truncated}")
}
// ── Wire types (Ollama API) ──────────────────────────────────────────────
#[derive(Debug, Serialize)]
struct OllamaChatRequest {
model: String,
messages: Vec<OllamaMessage>,
format: String,
stream: bool,
options: OllamaOptions,
}
#[derive(Debug, Serialize)]
struct OllamaMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct OllamaOptions {
temperature: f32,
/// Override Ollama's default 32k context window — large local
/// models inflate kv-cache to >15 GiB at 32k which won't fit on
/// consumer GPUs. 16k is plenty for one bucket summary (input is
/// bounded by the source-tree's ~10k-token bucket budget) while
/// keeping kv-cache reasonable.
num_ctx: u32,
}
#[derive(Debug, Deserialize)]
struct OllamaChatResponse {
message: OllamaResponseMessage,
}
#[derive(Debug, Deserialize)]
struct OllamaResponseMessage {
content: String,
}
// ── LLM JSON output ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
@@ -524,59 +425,115 @@ mod tests {
assert!(out.ends_with('…'));
}
/// Mock chat provider that lets us assert prompt shape and stub responses
/// in summariser unit tests without hitting the network.
struct StubProvider {
response: anyhow::Result<String>,
calls: std::sync::atomic::AtomicUsize,
}
impl StubProvider {
fn ok(text: impl Into<String>) -> Self {
Self {
response: Ok(text.into()),
calls: std::sync::atomic::AtomicUsize::new(0),
}
}
fn err(msg: &'static str) -> Self {
Self {
response: Err(anyhow::anyhow!(msg)),
calls: std::sync::atomic::AtomicUsize::new(0),
}
}
}
#[async_trait]
impl ChatProvider for StubProvider {
fn name(&self) -> &str {
"test:stub"
}
async fn chat_for_json(&self, _p: &ChatPrompt) -> anyhow::Result<String> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.response
.as_ref()
.map(|s| s.clone())
.map_err(|e| anyhow::anyhow!("{e}"))
}
}
#[tokio::test]
async fn empty_inputs_yield_empty_summary_without_network_call() {
async fn empty_inputs_yield_empty_summary_without_provider_call() {
// All inputs are blank → prompt body is empty → the summariser
// short-circuits and returns an empty output. Importantly, this
// path must work even if Ollama is unreachable.
let cfg = LlmSummariserConfig {
endpoint: "http://127.0.0.1:1".to_string(),
timeout: Duration::from_millis(50),
..LlmSummariserConfig::default()
};
let s = LlmSummariser::new(cfg).unwrap();
// short-circuits and returns an empty output without invoking the
// chat provider.
let provider = std::sync::Arc::new(StubProvider::ok("never returned"));
let s = LlmSummariser::new(LlmSummariserConfig::default(), provider.clone());
let inputs = vec![sample_input("a", " "), sample_input("b", "")];
let out = s.summarise(&inputs, &test_ctx()).await.unwrap();
assert!(out.content.is_empty());
assert_eq!(out.token_count, 0);
assert_eq!(
provider.calls.load(std::sync::atomic::Ordering::SeqCst),
0,
"blank inputs must not call the chat provider"
);
}
#[tokio::test]
async fn transport_failure_falls_back_to_inert() {
// Unreachable endpoint → transport error → must NOT return Err;
// must fall through to InertSummariser's concatenate+truncate
// behaviour (content present, entities empty).
let cfg = LlmSummariserConfig {
endpoint: "http://127.0.0.1:1".to_string(),
timeout: Duration::from_millis(100),
..LlmSummariserConfig::default()
};
let s = LlmSummariser::new(cfg).unwrap();
async fn provider_failure_falls_back_to_inert() {
// Provider errors → must NOT return Err; must fall through to
// InertSummariser's concatenate+truncate behaviour (content
// present, entities empty).
let provider = std::sync::Arc::new(StubProvider::err("simulated"));
let s = LlmSummariser::new(LlmSummariserConfig::default(), provider);
let inputs = vec![sample_input("a", "alice decided to ship friday")];
let out = s.summarise(&inputs, &test_ctx()).await.unwrap();
assert!(out.content.contains("alice decided to ship"));
// Inert branch emits empty entities/topics — this is how callers
// can distinguish fallback from a real LLM success with no entities.
assert!(out.entities.is_empty());
assert!(out.topics.is_empty());
}
#[tokio::test]
async fn malformed_response_falls_back_to_inert() {
// Provider returns garbage → parse fails → fallback to inert.
let provider = std::sync::Arc::new(StubProvider::ok("not json"));
let s = LlmSummariser::new(LlmSummariserConfig::default(), provider);
let inputs = vec![sample_input("a", "alice ships friday")];
let out = s.summarise(&inputs, &test_ctx()).await.unwrap();
// Inert fallback content includes the original input.
assert!(out.content.contains("alice"));
}
#[tokio::test]
async fn provider_summary_response_is_used_and_clamped() {
// Provider returns valid JSON; summariser parses it and clamps to
// the budget.
let provider = std::sync::Arc::new(StubProvider::ok(
r#"{"summary":"alice decided to ship friday"}"#,
));
let s = LlmSummariser::new(LlmSummariserConfig::default(), provider.clone());
let inputs = vec![sample_input("a", "alice ships friday")];
let out = s.summarise(&inputs, &test_ctx()).await.unwrap();
assert!(out.content.contains("alice decided to ship"));
assert!(out.token_count > 0);
assert_eq!(provider.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn build_request_uses_configured_model_and_json_format() {
let cfg = LlmSummariserConfig {
model: "llama3.1:8b".into(),
..LlmSummariserConfig::default()
};
let s = LlmSummariser::new(cfg).unwrap();
let req = s.build_request("body", 2048);
assert_eq!(req.model, "llama3.1:8b");
assert_eq!(req.format, "json");
assert!(!req.stream);
assert_eq!(req.options.temperature, 0.0);
assert_eq!(req.messages[0].role, "system");
assert!(req.messages[0].content.contains("\"summary\""));
assert_eq!(req.messages[1].role, "user");
assert_eq!(req.messages[1].content, "body");
fn build_prompt_carries_body_and_kind_tag() {
let provider = std::sync::Arc::new(StubProvider::ok("{}"));
let s = LlmSummariser::new(
LlmSummariserConfig {
model: "llama3.1:8b".into(),
},
provider,
);
let prompt = s.build_prompt("body", 2048);
assert!(prompt.system.contains("\"summary\""));
assert!(!prompt.system.contains("\"entities\""));
assert_eq!(prompt.user, "body");
assert_eq!(prompt.temperature, 0.0);
assert_eq!(prompt.kind, "memory_tree::summarise");
}
#[test]
@@ -66,64 +66,83 @@ pub trait Summariser: Send + Sync {
}
/// Build the summariser implementation driven by the workspace's
/// [`Config`]. When `memory_tree.llm_summariser_endpoint` and
/// `llm_summariser_model` are both set, return the Ollama-backed
/// [`llm::LlmSummariser`] (which itself soft-falls-back to inert on
/// transport failure). Otherwise return [`inert::InertSummariser`].
/// [`Config`]. The cloud-default refactor changed the resolution rules:
///
/// - `llm_backend = "cloud"` (default): always returns the LLM summariser
/// routed through the OpenHuman backend's `cloud_llm_model`
/// (defaulting to `summarization-v1`).
/// - `llm_backend = "local"`: returns the LLM summariser only when both
/// `llm_summariser_endpoint` AND `llm_summariser_model` are set;
/// otherwise returns the [`inert::InertSummariser`] fallback.
///
/// In all cases the LLM summariser itself soft-falls-back to inert per
/// seal on transport failure, so seal cascades never abort.
///
/// Returned as `Arc<dyn Summariser>` so the ingest pipeline can pass it
/// by reference to `append_leaf` and `route_leaf_to_topic_trees`
/// without threading a generic type parameter through every caller.
pub fn build_summariser(config: &Config) -> Arc<dyn Summariser> {
let endpoint = config
.memory_tree
.llm_summariser_endpoint
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let model = config
.memory_tree
.llm_summariser_model
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
use crate::openhuman::config::{LlmBackend, DEFAULT_CLOUD_LLM_MODEL};
use crate::openhuman::memory::tree::chat::{build_chat_provider, ChatConsumer};
let (Some(endpoint), Some(model)) = (endpoint, model) else {
// Resolve the model identifier to log alongside the provider name.
// Returns None (→ inert fallback) only when llm_backend=local and the legacy
// llm_summariser_endpoint/_model fields are not both set.
let model: Option<String> = match config.memory_tree.llm_backend {
LlmBackend::Cloud => Some(
config
.memory_tree
.cloud_llm_model
.clone()
.unwrap_or_else(|| DEFAULT_CLOUD_LLM_MODEL.to_string()),
),
LlmBackend::Local => {
let endpoint = config
.memory_tree
.llm_summariser_endpoint
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let m = config
.memory_tree
.llm_summariser_model
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
match (endpoint, m) {
(Some(_), Some(m)) => Some(m.to_string()),
_ => None,
}
}
};
let Some(model) = model else {
log::debug!(
"[tree_source::summariser] llm_summariser not configured — using InertSummariser"
"[tree_source::summariser] llm_summariser not configured for llm_backend={} \
— using InertSummariser",
config.memory_tree.llm_backend.as_str()
);
return Arc::new(inert::InertSummariser::new());
};
// 120s default — matches `LlmSummariserConfig::default()`. Lets a
// small/medium local model finish the seal-budget summary on a
// cold-loaded weight cache without spurious timeouts.
let timeout_ms = config
.memory_tree
.llm_summariser_timeout_ms
.unwrap_or(120_000);
let cfg = llm::LlmSummariserConfig {
endpoint: endpoint.to_string(),
model: model.to_string(),
timeout: std::time::Duration::from_millis(timeout_ms),
};
match llm::LlmSummariser::new(cfg) {
Ok(s) => {
log::info!(
"[tree_source::summariser] using LlmSummariser endpoint={} model={} timeout_ms={}",
endpoint,
model,
timeout_ms
);
Arc::new(s)
}
let provider = match build_chat_provider(config, ChatConsumer::Summarise) {
Ok(p) => p,
Err(err) => {
log::warn!(
"[tree_source::summariser] LlmSummariser construction failed: {err:#} — \
"[tree_source::summariser] build_chat_provider failed: {err:#} — \
falling back to InertSummariser"
);
Arc::new(inert::InertSummariser::new())
return Arc::new(inert::InertSummariser::new());
}
}
};
log::info!(
"[tree_source::summariser] using LlmSummariser provider={} model={}",
provider.name(),
model
);
Arc::new(llm::LlmSummariser::new(
llm::LlmSummariserConfig { model },
provider,
))
}
+30 -6
View File
@@ -1167,13 +1167,24 @@ async fn json_rpc_memory_tree_end_to_end() {
write_min_config(&openhuman_home, &mock_origin);
let controllers = all_memory_tree_registered_controllers();
// Sampled methods this test exercises end-to-end. Don't pin
// controllers.len() — the registry has grown organically
// (list_sources, search, recall, entity_index_for, top_entities,
// chunk_score, delete_chunk, get_llm, set_llm, chunks_for_entity, …)
// and adding a new RPC shouldn't break this smoke test. We just
// assert the four sampled methods exercised below are registered.
let expected_methods = vec![
"openhuman.memory_tree_ingest".to_string(),
"openhuman.memory_tree_list_chunks".to_string(),
"openhuman.memory_tree_get_chunk".to_string(),
"openhuman.memory_tree_trigger_digest".to_string(),
];
assert_eq!(controllers.len(), expected_methods.len());
assert!(
controllers.len() >= expected_methods.len(),
"expected at least {} memory_tree controllers, found {}",
expected_methods.len(),
controllers.len()
);
for method in &expected_methods {
assert!(
controllers
@@ -1226,9 +1237,8 @@ async fn json_rpc_memory_tree_end_to_end() {
201,
&expected_methods[1],
json!({
"source_kind": "document",
"source_id": "notion:launch-plan",
"owner": "alice@example.com",
"source_kinds": ["document"],
"source_ids": ["notion:launch-plan"],
"limit": 0
}),
)
@@ -1240,10 +1250,16 @@ async fn json_rpc_memory_tree_end_to_end() {
.and_then(Value::as_array)
.expect("chunks array");
assert_eq!(chunks.len(), 1);
// `list_chunks` returns the flat `ChunkRow` projection (id, source_kind,
// source_id, source_ref as a flat string, owner, timestamp_ms, …), not
// the full `Chunk { metadata: Metadata { source_ref: Option<SourceRef>,
// … }, seq_in_source, … }` that `get_chunk` returns. Assert against
// the row shape here.
let chunk = &chunks[0];
assert_eq!(chunk.get("seq_in_source"), Some(&json!(0)));
assert_eq!(chunk.get("source_kind"), Some(&json!("document")));
assert_eq!(chunk.get("source_id"), Some(&json!("notion:launch-plan")));
assert_eq!(
chunk.pointer("/metadata/source_ref/value"),
chunk.get("source_ref"),
Some(&json!("notion://page/launch-plan"))
);
@@ -1259,6 +1275,14 @@ async fn json_rpc_memory_tree_end_to_end() {
let get_outer = assert_no_jsonrpc_error(&get_chunk, "memory_tree_get_chunk");
let get_result = get_outer.get("result").unwrap_or(get_outer);
assert_eq!(get_result.pointer("/chunk/id"), Some(&chunk_ids[0]));
// Full-Chunk-shape assertions live here because `get_chunk` returns the
// canonical `Chunk` (with nested `metadata` + `seq_in_source`), unlike
// `list_chunks`'s `ChunkRow` projection above.
assert_eq!(get_result.pointer("/chunk/seq_in_source"), Some(&json!(0)));
assert_eq!(
get_result.pointer("/chunk/metadata/source_ref/value"),
Some(&json!("notion://page/launch-plan"))
);
let invalid_ingest = post_json_rpc(
&rpc_base,