[codex] Add tiny.place orchestration pairing flow (#4400)

This commit is contained in:
Steven Enamakel
2026-07-02 01:20:54 -07:00
committed by GitHub
parent 4c98a31000
commit 391aa733a5
41 changed files with 3910 additions and 14 deletions
@@ -0,0 +1,206 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { apiClient } from '../../agentworld/AgentWorldShell';
import TinyPlaceOrchestrationTab from './TinyPlaceOrchestrationTab';
vi.mock('../../agentworld/AgentWorldShell', () => ({
apiClient: {
messages: { list: vi.fn() },
inbox: { list: vi.fn() },
orchestrationPairing: {
list: vi.fn(),
linkSession: vi.fn(),
acceptRequest: vi.fn(),
declineRequest: vi.fn(),
blockRequest: vi.fn(),
},
},
}));
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
const messagesListMock = vi.mocked(apiClient.messages.list);
const inboxListMock = vi.mocked(apiClient.inbox.list);
const pairingListMock = vi.mocked(apiClient.orchestrationPairing.list);
const pairingLinkMock = vi.mocked(apiClient.orchestrationPairing.linkSession);
const pairingAcceptMock = vi.mocked(apiClient.orchestrationPairing.acceptRequest);
const pairingDeclineMock = vi.mocked(apiClient.orchestrationPairing.declineRequest);
const pairingBlockMock = vi.mocked(apiClient.orchestrationPairing.blockRequest);
describe('TinyPlaceOrchestrationTab', () => {
beforeEach(() => {
vi.clearAllMocks();
messagesListMock.mockResolvedValue({ messages: [] });
inboxListMock.mockResolvedValue({ items: [], unreadCount: 0, totalCount: 0 });
pairingListMock.mockResolvedValue({
records: [],
contacts: { contacts: [] },
requests: { incoming: [], outgoing: [] },
stats: { agentId: '@openhuman', contactCount: 0, pendingIncoming: 0, pendingOutgoing: 0 },
});
pairingLinkMock.mockResolvedValue({
record: {
agentId: '@worker-new',
status: 'pending',
linkedAt: '2026-07-01T12:00:00.000Z',
source: 'user_link',
},
remote: { agentId: '@worker-new', status: 'pending' },
});
pairingAcceptMock.mockResolvedValue({
record: {
agentId: '@worker-pending',
status: 'linked',
linkedAt: '2026-07-01T12:00:00.000Z',
source: 'approved_request',
},
remote: { agentId: '@worker-pending', status: 'accepted' },
});
pairingDeclineMock.mockResolvedValue({ record: null, remote: { ok: true } });
pairingBlockMock.mockResolvedValue({
record: {
agentId: '@worker-pending',
status: 'blocked',
linkedAt: '2026-07-01T12:00:00.000Z',
source: 'approved_request',
},
remote: { agentId: '@worker-pending', status: 'blocked' },
});
});
it('renders pinned master and subconscious chats before session chats', async () => {
messagesListMock.mockResolvedValue({
messages: [
{
id: 'm-master',
from: 'human',
to: 'master-agent',
timestamp: '2026-07-01T12:00:00.000Z',
deviceId: 1,
type: 'agent-human',
body: 'Coordinate the next worker handoff',
},
{
id: 'm-subconscious',
from: 'subconscious-loop',
to: 'tinyplace_agent',
timestamp: '2026-07-01T12:01:00.000Z',
deviceId: 1,
type: 'internal',
body: 'Memory synthesis finished',
},
{
id: 'm-session',
from: '@worker-alpha',
to: '@openhuman',
timestamp: '2026-07-01T12:02:00.000Z',
deviceId: 1,
type: 'session',
body: 'I asked the human master for context, then opened a worktree.',
sessionId: 'app-session-1',
sessionLabel: 'OpenHuman app session',
},
],
});
render(<TinyPlaceOrchestrationTab />);
expect(await screen.findAllByText('tinyplaceOrchestration.master.title')).toHaveLength(2);
expect(screen.getByText('tinyplaceOrchestration.subconscious.title')).toBeInTheDocument();
expect(screen.getByText('OpenHuman app session')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('tinyplace-chat-session:app-session-1'));
expect(
within(await screen.findByTestId('tinyplace-chat-messages')).getByText(
'I asked the human master for context, then opened a worktree.'
)
).toBeInTheDocument();
});
it('adds unread inbox sessions and marks them active', async () => {
inboxListMock.mockResolvedValue({
items: [
{
itemId: 'inbox-1',
type: 'dm',
status: 'unread',
priority: 'normal',
timestamp: '2026-07-01T12:03:00.000Z',
subject: 'Worker update',
summary: 'The subagent is waiting on a decision.',
from: '@worker-beta',
},
],
unreadCount: 1,
totalCount: 1,
});
render(<TinyPlaceOrchestrationTab />);
expect(await screen.findByText('@worker-beta')).toBeInTheDocument();
expect(screen.getByText('The subagent is waiting on a decision.')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('tinyplaceOrchestration.active')).toBeInTheDocument();
});
it('surfaces load errors and retries', async () => {
messagesListMock.mockRejectedValueOnce(new Error('rpc failed'));
render(<TinyPlaceOrchestrationTab />);
expect(await screen.findByText(/tinyplaceOrchestration.failedToLoad/)).toBeInTheDocument();
expect(screen.getByText(/rpc failed/)).toBeInTheDocument();
fireEvent.click(screen.getByText('common.retry'));
await waitFor(() => expect(messagesListMock).toHaveBeenCalledTimes(2));
expect(await screen.findByText('tinyplaceOrchestration.noMessages')).toBeInTheDocument();
});
it('requests a contact edge for a pasted session identity', async () => {
render(<TinyPlaceOrchestrationTab />);
const input = await screen.findByPlaceholderText(
'tinyplaceOrchestration.pairing.linkPlaceholder'
);
fireEvent.change(input, { target: { value: '@worker-new' } });
fireEvent.click(screen.getByText('tinyplaceOrchestration.pairing.linkAction'));
await waitFor(() => expect(pairingLinkMock).toHaveBeenCalledWith('@worker-new'));
await waitFor(() => expect(pairingListMock).toHaveBeenCalledTimes(2));
});
it('surfaces incoming contact requests for explicit approval', async () => {
pairingListMock.mockResolvedValue({
records: [],
contacts: { contacts: [] },
requests: {
incoming: [
{
agentId: '@worker-pending',
status: 'pending',
direction: 'incoming',
contact: {
requester: '@worker-pending',
addressee: '@openhuman',
status: 'pending',
createdAt: '2026-07-01T12:00:00.000Z',
updatedAt: '2026-07-01T12:00:00.000Z',
},
},
],
outgoing: [],
},
stats: { agentId: '@openhuman', contactCount: 0, pendingIncoming: 1, pendingOutgoing: 0 },
});
render(<TinyPlaceOrchestrationTab />);
expect(await screen.findByText('@worker-pending')).toBeInTheDocument();
fireEvent.click(screen.getByText('tinyplaceOrchestration.pairing.accept'));
await waitFor(() => expect(pairingAcceptMock).toHaveBeenCalledWith('@worker-pending'));
});
});
@@ -0,0 +1,696 @@
import debugFactory from 'debug';
import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { apiClient } from '../../agentworld/AgentWorldShell';
import {
type ContactRequestsResponse,
type ContactView,
type InboxItem,
type MessageEnvelope,
type PairingSnapshot,
PaymentRequiredError,
} from '../../lib/agentworld/invokeApiClient';
import { useT } from '../../lib/i18n/I18nContext';
import Button from '../ui/Button';
const debug = debugFactory('brain:tinyplace-orchestration');
const MESSAGE_LIMIT = 100;
const INBOX_LIMIT = 40;
const ACTIVE_WINDOW_MS = 45 * 60 * 1000;
type ChatKind = 'master' | 'subconscious' | 'session';
interface ChatMessage {
id: string;
from: string;
body: string;
timestamp: string;
encrypted: boolean;
}
interface ChatWindow {
id: string;
kind: ChatKind;
title: string;
subtitle: string;
preview: string;
lastTimestamp: string | null;
unread: number;
active: boolean;
pinned: boolean;
peerAgentId: string | null;
messages: ChatMessage[];
}
interface TinyPlaceChatData {
messages: MessageEnvelope[];
inboxItems: InboxItem[];
pairing: PairingSnapshot;
}
type LoadState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'payment_required' }
| { status: 'ok'; data: TinyPlaceChatData };
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function pickString(source: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = asString(source[key]);
if (value) return value;
}
return null;
}
function chatKindForEnvelope(envelope: MessageEnvelope): ChatKind {
const type = (envelope.type ?? '').toLowerCase();
if (type.includes('subconscious') || type.includes('internal')) return 'subconscious';
if (type.includes('master') || type.includes('agent-human') || type.includes('human')) {
return 'master';
}
return 'session';
}
function sessionIdForEnvelope(envelope: MessageEnvelope): string {
return (
pickString(envelope, ['sessionId', 'appSessionId', 'threadId', 'conversationId', 'runId']) ??
`${envelope.from || 'unknown'}:${envelope.to || 'unknown'}`
);
}
function isEncrypted(envelope: MessageEnvelope): boolean {
const hint = (envelope.contentHint ?? '').toLowerCase();
const type = (envelope.type ?? '').toLowerCase();
return Boolean(envelope.signal) || hint.includes('encrypted') || type.includes('signal');
}
function displayBody(message: MessageEnvelope, encryptedText: string): string {
if (isEncrypted(message)) return encryptedText;
return message.body || message.contentHint || encryptedText;
}
function messageTime(message: Pick<ChatMessage, 'timestamp'>): number {
const parsed = Date.parse(message.timestamp);
return Number.isFinite(parsed) ? parsed : 0;
}
function sortMessages(messages: ChatMessage[]): ChatMessage[] {
return messages.slice().sort((a, b) => messageTime(a) - messageTime(b));
}
function formatTime(timestamp: string | null): string {
if (!timestamp) return '';
const parsed = Date.parse(timestamp);
if (!Number.isFinite(parsed)) return '';
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(parsed));
}
function truncate(text: string, length = 96): string {
if (text.length <= length) return text;
return `${text.slice(0, length - 1)}`;
}
function isActive(lastTimestamp: string | null, unread: number): boolean {
if (unread > 0) return true;
if (!lastTimestamp) return false;
const parsed = Date.parse(lastTimestamp);
return Number.isFinite(parsed) && Date.now() - parsed < ACTIVE_WINDOW_MS;
}
function emptyPinnedChats(t: (key: string) => string): ChatWindow[] {
return [
{
id: 'pinned:master',
kind: 'master',
title: t('tinyplaceOrchestration.master.title'),
subtitle: t('tinyplaceOrchestration.master.subtitle'),
preview: t('tinyplaceOrchestration.master.preview'),
lastTimestamp: null,
unread: 0,
active: true,
pinned: true,
peerAgentId: null,
messages: [],
},
{
id: 'pinned:subconscious',
kind: 'subconscious',
title: t('tinyplaceOrchestration.subconscious.title'),
subtitle: t('tinyplaceOrchestration.subconscious.subtitle'),
preview: t('tinyplaceOrchestration.subconscious.preview'),
lastTimestamp: null,
unread: 0,
active: true,
pinned: true,
peerAgentId: null,
messages: [],
},
];
}
function buildChats(data: TinyPlaceChatData, t: (key: string) => string): ChatWindow[] {
const encryptedText = t('tinyplaceOrchestration.encryptedBody');
const unknownSender = t('tinyplaceOrchestration.unknownSender');
const pinned = emptyPinnedChats(t);
const byId = new Map<string, ChatWindow>(pinned.map(chat => [chat.id, chat]));
for (const envelope of data.messages) {
const kind = chatKindForEnvelope(envelope);
const id = kind === 'session' ? `session:${sessionIdForEnvelope(envelope)}` : `pinned:${kind}`;
const message: ChatMessage = {
id: envelope.id,
from: envelope.from || unknownSender,
body: displayBody(envelope, encryptedText),
timestamp: envelope.timestamp,
encrypted: isEncrypted(envelope),
};
const existing = byId.get(id);
const title =
kind === 'session'
? (pickString(envelope, ['sessionLabel', 'appName', 'threadTitle']) ??
sessionIdForEnvelope(envelope))
: (existing?.title ?? id);
const subtitle =
kind === 'session'
? (pickString(envelope, ['workspace', 'source', 'appSessionId']) ??
t('tinyplaceOrchestration.session.subtitle'))
: (existing?.subtitle ?? '');
const nextMessages = sortMessages([...(existing?.messages ?? []), message]);
const last = nextMessages[nextMessages.length - 1] ?? message;
byId.set(id, {
id,
kind,
title,
subtitle,
preview: truncate(last.body),
lastTimestamp: last.timestamp,
unread: existing?.unread ?? 0,
active: true,
pinned: kind !== 'session',
peerAgentId: kind === 'session' ? envelope.from || envelope.to || null : null,
messages: nextMessages,
});
}
for (const item of data.inboxItems) {
const sender = item.from ?? item.type ?? 'tiny.place';
const id = `session:${sender}`;
const message: ChatMessage = {
id: item.itemId,
from: sender,
body: item.summary ?? item.subject,
timestamp: item.timestamp,
encrypted: false,
};
const existing = byId.get(id);
const nextMessages = sortMessages([...(existing?.messages ?? []), message]);
const last = nextMessages[nextMessages.length - 1] ?? message;
const unread = (existing?.unread ?? 0) + (item.status === 'unread' ? 1 : 0);
byId.set(id, {
id,
kind: 'session',
title: sender,
subtitle: item.type || t('tinyplaceOrchestration.session.subtitle'),
preview: truncate(last.body),
lastTimestamp: last.timestamp,
unread,
active: isActive(last.timestamp, unread),
pinned: false,
peerAgentId: sender,
messages: nextMessages,
});
}
return Array.from(byId.values()).map(chat => ({
...chat,
active: chat.pinned ? true : isActive(chat.lastTimestamp, chat.unread),
}));
}
function acceptedContactIds(contacts: ContactView[]): Set<string> {
return new Set(
contacts
.filter(contact => contact.status === 'accepted')
.map(contact => contact.agentId)
.filter(Boolean)
);
}
function pendingContactIds(requests: ContactRequestsResponse): Set<string> {
return new Set(
[...requests.incoming, ...requests.outgoing]
.filter(contact => contact.status === 'pending')
.map(contact => contact.agentId)
.filter(Boolean)
);
}
function contactBadgeKey(
chat: ChatWindow,
accepted: Set<string>,
pending: Set<string>
): string | null {
if (chat.pinned || !chat.peerAgentId) return null;
if (accepted.has(chat.peerAgentId)) return 'tinyplaceOrchestration.pairing.linked';
if (pending.has(chat.peerAgentId)) return 'tinyplaceOrchestration.pairing.pending';
return 'tinyplaceOrchestration.pairing.unlinked';
}
function ChatListButton({
chat,
selected,
onSelect,
contactBadge,
}: {
chat: ChatWindow;
selected: boolean;
onSelect: () => void;
contactBadge?: string | null;
}) {
const { t } = useT();
return (
<button
type="button"
data-testid={`tinyplace-chat-${chat.id}`}
onClick={onSelect}
className={`flex w-full items-start gap-3 border-b border-line-subtle px-3 py-3 text-left transition last:border-b-0 hover:bg-surface-hover ${
selected ? 'bg-surface-muted' : ''
}`}>
<span className="mt-0.5 flex h-9 w-9 flex-none items-center justify-center rounded-lg border border-line bg-surface-strong text-xs font-semibold text-content-secondary">
{chat.kind === 'subconscious' ? 'S' : chat.kind === 'master' ? 'M' : '#'}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold text-content">{chat.title}</span>
<span className="flex-none text-[10px] text-content-faint">
{formatTime(chat.lastTimestamp)}
</span>
</span>
<span className="mt-0.5 block truncate text-[11px] text-content-muted">
{chat.subtitle}
</span>
<span className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs text-content-faint">{chat.preview}</span>
{chat.unread > 0 ? (
<span className="flex-none rounded-full bg-ocean-500 px-1.5 py-0.5 text-[10px] font-semibold text-content-inverted">
{chat.unread}
</span>
) : null}
{!chat.pinned ? (
<span
className={`flex-none rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
chat.active
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
: 'bg-surface-strong text-content-faint'
}`}>
{chat.active
? t('tinyplaceOrchestration.active')
: t('tinyplaceOrchestration.inactive')}
</span>
) : null}
{contactBadge ? (
<span className="flex-none rounded-full bg-surface-strong px-1.5 py-0.5 text-[10px] font-medium text-content-faint">
{t(contactBadge)}
</span>
) : null}
</span>
</span>
</button>
);
}
function MessageBubble({ message }: { message: ChatMessage }) {
return (
<div className="flex gap-2">
<div className="mt-1.5 h-2 w-2 flex-none rounded-full bg-ocean-500" />
<div className="min-w-0 rounded-lg border border-line bg-surface px-3 py-2 shadow-soft">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs font-semibold text-content-secondary">{message.from}</span>
<span className="text-[10px] text-content-faint">{formatTime(message.timestamp)}</span>
</div>
<p
className={`mt-1 whitespace-pre-wrap break-words text-sm ${
message.encrypted ? 'text-content-muted' : 'text-content'
}`}>
{message.body}
</p>
</div>
</div>
);
}
export default function TinyPlaceOrchestrationTab() {
const { t } = useT();
const [state, setState] = useState<LoadState>({ status: 'loading' });
const [selectedId, setSelectedId] = useState('pinned:master');
const [linkAgentId, setLinkAgentId] = useState('');
const [pairingAction, setPairingAction] = useState<string | null>(null);
const [pairingError, setPairingError] = useState<string | null>(null);
const mountedRef = useRef(true);
const load = useCallback(async () => {
debug('[tinyplace-orchestration] load entry');
setState({ status: 'loading' });
try {
const [messages, inbox, pairing] = await Promise.all([
apiClient.messages.list({ limit: MESSAGE_LIMIT }),
apiClient.inbox.list({ limit: INBOX_LIMIT }),
apiClient.orchestrationPairing.list(),
]);
if (!mountedRef.current) return;
debug(
'[tinyplace-orchestration] load exit messages=%d inbox=%d contacts=%d incoming=%d outgoing=%d',
messages.messages.length,
inbox.items.length,
pairing.contacts.contacts.length,
pairing.requests.incoming.length,
pairing.requests.outgoing.length
);
setState({
status: 'ok',
data: { messages: messages.messages, inboxItems: inbox.items, pairing },
});
} catch (error) {
if (!mountedRef.current) return;
if (error instanceof PaymentRequiredError) {
debug('[tinyplace-orchestration] load payment_required');
setState({ status: 'payment_required' });
return;
}
const message = error instanceof Error ? error.message : String(error);
debug('[tinyplace-orchestration] load error %s', message);
setState({ status: 'error', message });
}
}, []);
const runPairingAction = useCallback(
async (actionId: string, action: () => Promise<unknown>) => {
debug('[tinyplace-orchestration] pairing action entry id=%s', actionId);
setPairingAction(actionId);
setPairingError(null);
try {
await action();
if (!mountedRef.current) return;
debug('[tinyplace-orchestration] pairing action success id=%s', actionId);
await load();
} catch (error) {
if (!mountedRef.current) return;
const message = error instanceof Error ? error.message : String(error);
debug('[tinyplace-orchestration] pairing action error id=%s %s', actionId, message);
setPairingError(message);
} finally {
if (mountedRef.current) {
setPairingAction(null);
}
}
},
[load]
);
const submitLink = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const agentId = linkAgentId.trim();
if (!agentId) return;
void runPairingAction(`request:${agentId}`, async () => {
await apiClient.orchestrationPairing.linkSession(agentId);
setLinkAgentId('');
});
},
[linkAgentId, runPairingAction]
);
useEffect(() => {
mountedRef.current = true;
const handle = window.setTimeout(() => void load(), 0);
return () => {
window.clearTimeout(handle);
mountedRef.current = false;
};
}, [load]);
const chats = useMemo(
() => (state.status === 'ok' ? buildChats(state.data, t) : emptyPinnedChats(t)),
[state, t]
);
const resolvedSelectedId = chats.some(chat => chat.id === selectedId)
? selectedId
: (chats[0]?.id ?? 'pinned:master');
const selected = chats.find(chat => chat.id === resolvedSelectedId) ?? chats[0];
const pinned = chats.filter(chat => chat.pinned);
const sessions = chats
.filter(chat => !chat.pinned)
.sort(
(a, b) =>
Number(b.active) - Number(a.active) || messageTimeFromChat(b) - messageTimeFromChat(a)
);
const contactData = state.status === 'ok' ? state.data : null;
const acceptedContacts = useMemo(
() => acceptedContactIds(contactData?.pairing.contacts.contacts ?? []),
[contactData?.pairing.contacts.contacts]
);
const pendingContacts = useMemo(
() => pendingContactIds(contactData?.pairing.requests ?? { incoming: [], outgoing: [] }),
[contactData?.pairing.requests]
);
const incomingRequests = contactData?.pairing.requests.incoming ?? [];
const contactStats = contactData?.pairing.stats ?? null;
return (
<div className="flex min-h-[620px] overflow-hidden rounded-xl border border-line bg-surface shadow-soft">
<aside className="flex w-80 flex-none flex-col border-r border-line bg-surface-muted/40">
<div className="border-b border-line px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-content">
{t('tinyplaceOrchestration.title')}
</h3>
<p className="mt-0.5 truncate text-[11px] text-content-muted">
{t('tinyplaceOrchestration.subtitle')}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => void load()}
aria-label={t('tinyplaceOrchestration.refresh')}
disabled={state.status === 'loading'}>
{t('tinyplaceOrchestration.refresh')}
</Button>
</div>
</div>
<section className="border-b border-line px-4 py-3">
<form className="space-y-2" onSubmit={submitLink}>
<label
htmlFor="tinyplace-session-agent-id"
className="block text-[10px] font-semibold uppercase tracking-wide text-content-muted">
{t('tinyplaceOrchestration.pairing.linkLabel')}
</label>
<div className="flex gap-2">
<input
id="tinyplace-session-agent-id"
value={linkAgentId}
onChange={event => setLinkAgentId(event.target.value)}
placeholder={t('tinyplaceOrchestration.pairing.linkPlaceholder')}
className="min-w-0 flex-1 rounded-md border border-line bg-surface px-2 py-1.5 text-xs text-content outline-none transition focus:border-ocean-500 focus:ring-2 focus:ring-ocean-500/20"
/>
<Button
type="submit"
variant="secondary"
size="sm"
disabled={!linkAgentId.trim() || pairingAction !== null}>
{t('tinyplaceOrchestration.pairing.linkAction')}
</Button>
</div>
</form>
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px] text-content-faint">
<span className="rounded-full bg-surface-strong px-2 py-0.5">
{t('tinyplaceOrchestration.pairing.linked')}: {contactStats?.contactCount ?? 0}
</span>
<span className="rounded-full bg-surface-strong px-2 py-0.5">
{t('tinyplaceOrchestration.pairing.incoming')}: {incomingRequests.length}
</span>
<span className="rounded-full bg-surface-strong px-2 py-0.5">
{t('tinyplaceOrchestration.pairing.outgoing')}:{' '}
{contactData?.pairing.requests.outgoing.length ?? 0}
</span>
</div>
{pairingError ? (
<p className="mt-2 rounded-md bg-coral-50 px-2 py-1 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
{pairingError}
</p>
) : null}
{incomingRequests.length > 0 ? (
<div className="mt-3 space-y-2">
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-content-muted">
{t('tinyplaceOrchestration.pairing.requests')}
</h4>
{incomingRequests.map(request => (
<div
key={request.agentId}
className="rounded-lg border border-line bg-surface px-2 py-2">
<div className="truncate text-xs font-medium text-content">{request.agentId}</div>
<div className="mt-2 flex gap-1.5">
<Button
variant="primary"
size="sm"
disabled={pairingAction !== null}
onClick={() =>
void runPairingAction(`accept:${request.agentId}`, () =>
apiClient.orchestrationPairing.acceptRequest(request.agentId)
)
}>
{t('tinyplaceOrchestration.pairing.accept')}
</Button>
<Button
variant="secondary"
size="sm"
disabled={pairingAction !== null}
onClick={() =>
void runPairingAction(`remove:${request.agentId}`, () =>
apiClient.orchestrationPairing.declineRequest(request.agentId)
)
}>
{t('tinyplaceOrchestration.pairing.decline')}
</Button>
<Button
variant="secondary"
size="sm"
disabled={pairingAction !== null}
onClick={() =>
void runPairingAction(`block:${request.agentId}`, () =>
apiClient.orchestrationPairing.blockRequest(request.agentId)
)
}>
{t('tinyplaceOrchestration.pairing.block')}
</Button>
</div>
</div>
))}
</div>
) : null}
</section>
<div className="min-h-0 flex-1 overflow-y-auto">
<section>
<h4 className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
{t('tinyplaceOrchestration.pinned')}
</h4>
<div>
{pinned.map(chat => (
<ChatListButton
key={chat.id}
chat={chat}
selected={selected?.id === chat.id}
onSelect={() => {
debug('[tinyplace-orchestration] open pinned id=%s', chat.id);
setSelectedId(chat.id);
}}
/>
))}
</div>
</section>
<section>
<h4 className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
{t('tinyplaceOrchestration.sessions')}
</h4>
{sessions.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-content-faint">
{t('tinyplaceOrchestration.noSessions')}
</div>
) : (
<div>
{sessions.map(chat => (
<ChatListButton
key={chat.id}
chat={chat}
selected={selected?.id === chat.id}
contactBadge={contactBadgeKey(chat, acceptedContacts, pendingContacts)}
onSelect={() => {
debug('[tinyplace-orchestration] open session id=%s', chat.id);
setSelectedId(chat.id);
}}
/>
))}
</div>
)}
</section>
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col bg-surface">
<div className="flex items-center justify-between gap-3 border-b border-line px-5 py-4">
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-content">{selected?.title}</h3>
<p className="mt-0.5 truncate text-xs text-content-muted">{selected?.subtitle}</p>
</div>
{selected && !selected.pinned ? (
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
selected.active
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
: 'bg-surface-strong text-content-muted'
}`}>
{selected.active
? t('tinyplaceOrchestration.active')
: t('tinyplaceOrchestration.inactive')}
</span>
) : null}
</div>
{state.status === 'loading' ? (
<div className="flex flex-1 items-center justify-center text-sm text-content-muted">
{t('tinyplaceOrchestration.loading')}
</div>
) : state.status === 'payment_required' ? (
<div className="flex flex-1 items-center justify-center text-sm text-amber-600 dark:text-amber-300">
{t('tinyplaceOrchestration.paymentRequired')}
</div>
) : state.status === 'error' ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-coral-600 dark:text-coral-300">
<p>
{t('tinyplaceOrchestration.failedToLoad')}: {state.message}
</p>
<Button variant="secondary" size="sm" onClick={() => void load()}>
{t('common.retry')}
</Button>
</div>
) : selected?.messages.length ? (
<div className="min-h-0 flex-1 overflow-y-auto bg-surface-muted/20 p-5">
<div className="space-y-3" data-testid="tinyplace-chat-messages">
{selected.messages.map(message => (
<MessageBubble key={message.id} message={message} />
))}
</div>
</div>
) : (
<div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-content-faint">
{t('tinyplaceOrchestration.noMessages')}
</div>
)}
</main>
</div>
);
}
function messageTimeFromChat(chat: ChatWindow): number {
if (!chat.lastTimestamp) return 0;
const parsed = Date.parse(chat.lastTimestamp);
return Number.isFinite(parsed) ? parsed : 0;
}
@@ -192,6 +192,116 @@ describe('directory.skills', () => {
});
});
// ── contacts ─────────────────────────────────────────────────────────────────
describe('contacts', () => {
test.each([
['request', 'openhuman.tinyplace_contacts_request', { agentId: '@worker' }],
['accept', 'openhuman.tinyplace_contacts_accept', { agentId: '@worker' }],
['remove', 'openhuman.tinyplace_contacts_remove', { agentId: '@worker' }],
['block', 'openhuman.tinyplace_contacts_block', { agentId: '@worker' }],
['unblock', 'openhuman.tinyplace_contacts_unblock', { agentId: '@worker' }],
['status', 'openhuman.tinyplace_contacts_status', { agentId: '@worker' }],
] as const)('calls %s with agentId', async (methodName, rpcMethod, params) => {
mockCallCoreRpc.mockResolvedValueOnce({ ok: true });
const client = createInvokeApiClient();
await client.contacts[methodName]('@worker');
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: rpcMethod, params });
});
test.each([
['list', 'openhuman.tinyplace_contacts_list'],
['requests', 'openhuman.tinyplace_contacts_requests'],
] as const)('calls %s with default null params', async (methodName, rpcMethod) => {
mockCallCoreRpc.mockResolvedValueOnce({});
const client = createInvokeApiClient();
await client.contacts[methodName]();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: rpcMethod, params: { params: null } });
});
test.each([
['list', 'openhuman.tinyplace_contacts_list'],
['requests', 'openhuman.tinyplace_contacts_requests'],
] as const)('calls %s with provided params', async (methodName, rpcMethod) => {
mockCallCoreRpc.mockResolvedValueOnce({});
const client = createInvokeApiClient();
const params = { limit: 25, cursor: 'next' };
await client.contacts[methodName](params);
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: rpcMethod, params: { params } });
});
test('calls stats without params', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ contactCount: 1 });
const client = createInvokeApiClient();
await client.contacts.stats();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.tinyplace_contacts_stats',
params: {},
});
});
});
// ── orchestrationPairing ─────────────────────────────────────────────────────
describe('orchestrationPairing', () => {
test('calls list without params', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ records: [] });
const client = createInvokeApiClient();
await client.orchestrationPairing.list();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.orchestration_pairing_list',
params: {},
});
});
test.each([
['acceptRequest', 'openhuman.orchestration_pairing_accept_request', { agentId: '@worker' }],
['declineRequest', 'openhuman.orchestration_pairing_decline_request', { agentId: '@worker' }],
['blockRequest', 'openhuman.orchestration_pairing_block_request', { agentId: '@worker' }],
] as const)('calls %s with agentId', async (methodName, rpcMethod, params) => {
mockCallCoreRpc.mockResolvedValueOnce({ record: null, remote: {} });
const client = createInvokeApiClient();
await client.orchestrationPairing[methodName]('@worker');
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: rpcMethod, params });
});
test('calls linkSession with default null label', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ record: null, remote: {} });
const client = createInvokeApiClient();
await client.orchestrationPairing.linkSession('@worker');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.orchestration_pairing_link_session',
params: { agentId: '@worker', label: null },
});
});
test('calls linkSession with provided label', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ record: null, remote: {} });
const client = createInvokeApiClient();
await client.orchestrationPairing.linkSession('@worker', 'Worker session');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.orchestration_pairing_link_session',
params: { agentId: '@worker', label: 'Worker session' },
});
});
});
// ── PaymentRequiredError ──────────────────────────────────────────────────────
describe('PaymentRequiredError propagation', () => {
+98
View File
@@ -843,6 +843,67 @@ export interface InboxQueryParams {
[key: string]: unknown;
}
// ── Contacts types ─────────────────────────────────────────────────────────
export type ContactStatus = 'pending' | 'accepted' | 'blocked';
export interface Contact {
requester: string;
addressee: string;
status: ContactStatus;
blockedBy?: string;
createdAt: string;
updatedAt: string;
[key: string]: unknown;
}
export interface ContactView {
agentId: string;
status: ContactStatus;
direction?: 'incoming' | 'outgoing';
contact: Contact;
[key: string]: unknown;
}
export interface ContactListParams {
limit?: number;
offset?: number;
}
export interface ContactsResponse {
contacts: ContactView[];
}
export interface ContactRequestsResponse {
incoming: ContactView[];
outgoing: ContactView[];
}
export interface ContactStatusResponse {
agentId: string;
status: ContactStatus | 'none';
direction?: 'incoming' | 'outgoing';
}
export interface ContactStats {
agentId: string;
contactCount: number;
pendingIncoming: number;
pendingOutgoing: number;
}
export type PairingStatus = 'pending' | 'linked' | 'blocked';
export type PairingSource = 'user_link' | 'approved_request';
export interface PairingRecord {
agentId: string;
label?: string;
status: PairingStatus;
linkedAt: string;
source: PairingSource;
}
export interface PairingSnapshot {
records: PairingRecord[];
contacts: ContactsResponse;
requests: ContactRequestsResponse;
stats: ContactStats | null;
}
export interface PairingActionResult {
record?: PairingRecord | null;
remote: unknown;
}
// ── Follows types ───────────────────────────────────────────────────────────
export interface AgentFollow {
@@ -1892,6 +1953,43 @@ export function createInvokeApiClient() {
remove: (itemId: string, owner?: string) =>
call<void>('openhuman.tinyplace_inbox_remove', { itemId, owner: owner ?? null }),
},
// ── Contacts section ─────────────────────────────────────────────────────
contacts: {
request: (agentId: string) =>
call<ContactView>('openhuman.tinyplace_contacts_request', { agentId }),
accept: (agentId: string) =>
call<ContactView>('openhuman.tinyplace_contacts_accept', { agentId }),
remove: (agentId: string) =>
call<{ ok?: boolean }>('openhuman.tinyplace_contacts_remove', { agentId }),
block: (agentId: string) =>
call<ContactView>('openhuman.tinyplace_contacts_block', { agentId }),
unblock: (agentId: string) =>
call<{ ok?: boolean }>('openhuman.tinyplace_contacts_unblock', { agentId }),
list: (params?: ContactListParams) =>
call<ContactsResponse>('openhuman.tinyplace_contacts_list', { params: params ?? null }),
requests: (params?: ContactListParams) =>
call<ContactRequestsResponse>('openhuman.tinyplace_contacts_requests', {
params: params ?? null,
}),
status: (agentId: string) =>
call<ContactStatusResponse>('openhuman.tinyplace_contacts_status', { agentId }),
stats: () => call<ContactStats>('openhuman.tinyplace_contacts_stats', {}),
},
// ── Orchestration pairing policy ─────────────────────────────────────────
orchestrationPairing: {
list: () => call<PairingSnapshot>('openhuman.orchestration_pairing_list', {}),
linkSession: (agentId: string, label?: string) =>
call<PairingActionResult>('openhuman.orchestration_pairing_link_session', {
agentId,
label: label ?? null,
}),
acceptRequest: (agentId: string) =>
call<PairingActionResult>('openhuman.orchestration_pairing_accept_request', { agentId }),
declineRequest: (agentId: string) =>
call<PairingActionResult>('openhuman.orchestration_pairing_decline_request', { agentId }),
blockRequest: (agentId: string) =>
call<PairingActionResult>('openhuman.orchestration_pairing_block_request', { agentId }),
},
// ── Follows section ───────────────────────────────────────────────────────
follows: {
follow: (agentId: string) =>
+34
View File
@@ -182,6 +182,40 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
'brain.tabs.sources': 'المصادر',
'brain.tabs.sync': 'المزامنة',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'مرحّل TinyPlace',
'tinyplaceOrchestration.subtitle': 'قنوات الوكلاء المثبتة ودردشات جلسات التطبيق',
'tinyplaceOrchestration.refresh': 'تحديث',
'tinyplaceOrchestration.pinned': 'مثبتة',
'tinyplaceOrchestration.sessions': 'الجلسات',
'tinyplaceOrchestration.loading': 'جارٍ تحميل دردشات TinyPlace…',
'tinyplaceOrchestration.paymentRequired': 'يتطلب الوصول إلى TinyPlace دفعًا.',
'tinyplaceOrchestration.failedToLoad': 'تعذّر تحميل دردشات TinyPlace',
'tinyplaceOrchestration.noSessions': 'لا توجد جلسات تطبيق TinyPlace بعد.',
'tinyplaceOrchestration.noMessages': 'لا توجد رسائل في هذه الدردشة بعد.',
'tinyplaceOrchestration.active': 'نشطة',
'tinyplaceOrchestration.inactive': 'غير نشطة',
'tinyplaceOrchestration.encryptedBody': 'رسالة TinyPlace مشفرة',
'tinyplaceOrchestration.unknownSender': 'مرسل غير معروف',
'tinyplaceOrchestration.master.title': 'وكيل / إنسان',
'tinyplaceOrchestration.master.subtitle': 'الدردشة الرئيسية',
'tinyplaceOrchestration.master.preview': 'تنسيق مباشر بينك وبين OpenHuman.',
'tinyplaceOrchestration.subconscious.title': 'اللاوعي',
'tinyplaceOrchestration.subconscious.subtitle': 'دردشات الوكلاء الداخلية',
'tinyplaceOrchestration.subconscious.preview': 'تنسيق الوكلاء في الخلفية وحلقات الذاكرة.',
'tinyplaceOrchestration.session.subtitle': 'جلسة تطبيق TinyPlace',
'tinyplaceOrchestration.pairing.linkLabel': 'ربط جلسة',
'tinyplaceOrchestration.pairing.linkPlaceholder': 'ألصق معرف وكيل الجلسة',
'tinyplaceOrchestration.pairing.linkAction': 'ربط',
'tinyplaceOrchestration.pairing.requests': 'طلبات الاتصال',
'tinyplaceOrchestration.pairing.accept': 'قبول',
'tinyplaceOrchestration.pairing.decline': 'رفض',
'tinyplaceOrchestration.pairing.block': 'حظر',
'tinyplaceOrchestration.pairing.linked': 'مرتبط',
'tinyplaceOrchestration.pairing.pending': 'معلق',
'tinyplaceOrchestration.pairing.unlinked': 'غير مرتبط',
'tinyplaceOrchestration.pairing.incoming': 'واردة',
'tinyplaceOrchestration.pairing.outgoing': 'صادرة',
'brain.empty': 'دماغك فارغ في الوقت الحالي — قم بربط مصدر لبدء بناء الذاكرة.',
'brain.error': 'تعذّر تحميل دماغك. يرجى المحاولة مرة أخرى.',
'common.cancel': 'إلغاء',
+34
View File
@@ -185,6 +185,40 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'কিছু ভুল হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।',
'brain.tabs.sources': 'উৎস',
'brain.tabs.sync': 'সিঙ্ক',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'TinyPlace রিলে',
'tinyplaceOrchestration.subtitle': 'পিন করা এজেন্ট চ্যানেল এবং অ্যাপ সেশন চ্যাট',
'tinyplaceOrchestration.refresh': 'রিফ্রেশ',
'tinyplaceOrchestration.pinned': 'পিন করা',
'tinyplaceOrchestration.sessions': 'সেশন',
'tinyplaceOrchestration.loading': 'TinyPlace চ্যাট লোড হচ্ছে…',
'tinyplaceOrchestration.paymentRequired': 'TinyPlace অ্যাক্সেসের জন্য পেমেন্ট দরকার।',
'tinyplaceOrchestration.failedToLoad': 'TinyPlace চ্যাট লোড করা যায়নি',
'tinyplaceOrchestration.noSessions': 'এখনও কোনো TinyPlace অ্যাপ সেশন নেই।',
'tinyplaceOrchestration.noMessages': 'এই চ্যাটে এখনও কোনো বার্তা নেই।',
'tinyplaceOrchestration.active': 'সক্রিয়',
'tinyplaceOrchestration.inactive': 'নিষ্ক্রিয়',
'tinyplaceOrchestration.encryptedBody': 'এনক্রিপ্টেড TinyPlace বার্তা',
'tinyplaceOrchestration.unknownSender': 'অজানা প্রেরক',
'tinyplaceOrchestration.master.title': 'এজেন্ট / মানব',
'tinyplaceOrchestration.master.subtitle': 'মাস্টার চ্যাট',
'tinyplaceOrchestration.master.preview': 'আপনি এবং OpenHuman-এর মধ্যে সরাসরি সমন্বয়।',
'tinyplaceOrchestration.subconscious.title': 'অবচেতন',
'tinyplaceOrchestration.subconscious.subtitle': 'অভ্যন্তরীণ এজেন্ট চ্যাট',
'tinyplaceOrchestration.subconscious.preview': 'পটভূমির এজেন্ট সমন্বয় এবং মেমরি লুপ।',
'tinyplaceOrchestration.session.subtitle': 'TinyPlace অ্যাপ সেশন',
'tinyplaceOrchestration.pairing.linkLabel': 'সেশন লিঙ্ক করুন',
'tinyplaceOrchestration.pairing.linkPlaceholder': 'সেশন এজেন্ট আইডি পেস্ট করুন',
'tinyplaceOrchestration.pairing.linkAction': 'লিঙ্ক',
'tinyplaceOrchestration.pairing.requests': 'যোগাযোগ অনুরোধ',
'tinyplaceOrchestration.pairing.accept': 'গ্রহণ',
'tinyplaceOrchestration.pairing.decline': 'প্রত্যাখ্যান',
'tinyplaceOrchestration.pairing.block': 'ব্লক',
'tinyplaceOrchestration.pairing.linked': 'লিঙ্কড',
'tinyplaceOrchestration.pairing.pending': 'অপেক্ষমান',
'tinyplaceOrchestration.pairing.unlinked': 'লিঙ্ক নেই',
'tinyplaceOrchestration.pairing.incoming': 'আসছে',
'tinyplaceOrchestration.pairing.outgoing': 'যাচ্ছে',
'brain.empty': 'আপনার ব্রেইন এখন খালি — মেমরি তৈরি শুরু করতে একটি উৎস সংযুক্ত করুন।',
'brain.error': 'আপনার ব্রেইন লোড করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।',
'common.cancel': 'বাতিল',
+35
View File
@@ -186,6 +186,41 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'Etwas ist schiefgelaufen. Bitte versuche es erneut.',
'brain.tabs.sources': 'Quellen',
'brain.tabs.sync': 'Synchronisierung',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'TinyPlace-Relay',
'tinyplaceOrchestration.subtitle': 'Angepinnte Agentenkanäle und App-Sitzungs-Chats',
'tinyplaceOrchestration.refresh': 'Aktualisieren',
'tinyplaceOrchestration.pinned': 'Angepinnt',
'tinyplaceOrchestration.sessions': 'Sitzungen',
'tinyplaceOrchestration.loading': 'TinyPlace-Chats werden geladen…',
'tinyplaceOrchestration.paymentRequired': 'TinyPlace-Zugriff erfordert eine Zahlung.',
'tinyplaceOrchestration.failedToLoad': 'TinyPlace-Chats konnten nicht geladen werden',
'tinyplaceOrchestration.noSessions': 'Noch keine TinyPlace-App-Sitzungen.',
'tinyplaceOrchestration.noMessages': 'Noch keine Nachrichten in diesem Chat.',
'tinyplaceOrchestration.active': 'Aktiv',
'tinyplaceOrchestration.inactive': 'Inaktiv',
'tinyplaceOrchestration.encryptedBody': 'Verschlüsselte TinyPlace-Nachricht',
'tinyplaceOrchestration.unknownSender': 'Unbekannter Absender',
'tinyplaceOrchestration.master.title': 'Agent / Mensch',
'tinyplaceOrchestration.master.subtitle': 'Master-Chat',
'tinyplaceOrchestration.master.preview': 'Direkte Koordination zwischen dir und OpenHuman.',
'tinyplaceOrchestration.subconscious.title': 'Unterbewusstsein',
'tinyplaceOrchestration.subconscious.subtitle': 'Interne Agenten-Chats',
'tinyplaceOrchestration.subconscious.preview':
'Agentenkoordination im Hintergrund und Gedächtnisschleifen.',
'tinyplaceOrchestration.session.subtitle': 'TinyPlace-App-Sitzung',
'tinyplaceOrchestration.pairing.linkLabel': 'Sitzung verknüpfen',
'tinyplaceOrchestration.pairing.linkPlaceholder': 'Sitzungs-Agent-ID einfügen',
'tinyplaceOrchestration.pairing.linkAction': 'Verknüpfen',
'tinyplaceOrchestration.pairing.requests': 'Kontaktanfragen',
'tinyplaceOrchestration.pairing.accept': 'Annehmen',
'tinyplaceOrchestration.pairing.decline': 'Ablehnen',
'tinyplaceOrchestration.pairing.block': 'Blockieren',
'tinyplaceOrchestration.pairing.linked': 'Verknüpft',
'tinyplaceOrchestration.pairing.pending': 'Ausstehend',
'tinyplaceOrchestration.pairing.unlinked': 'Nicht verknüpft',
'tinyplaceOrchestration.pairing.incoming': 'Eingehend',
'tinyplaceOrchestration.pairing.outgoing': 'Ausgehend',
'brain.empty': 'Dein Gehirn ist noch leer verbinde eine Quelle, um Speicher aufzubauen.',
'brain.error': 'Dein Gehirn konnte nicht geladen werden. Bitte versuche es erneut.',
'common.cancel': 'Abbrechen',
+34
View File
@@ -122,6 +122,7 @@ const en: TranslationMap = {
'brain.tabs.goals': 'Goals',
'brain.tabs.sources': 'Sources',
'brain.tabs.sync': 'Sync',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'brain.empty': 'Your brain is empty for now — connect a source to start building memory.',
'brain.error': "Couldn't load your brain. Please try again.",
'brain.goals.title': 'Long-term Goals',
@@ -4085,6 +4086,39 @@ const en: TranslationMap = {
'orchestration.detail.agents': 'agents',
'orchestration.detail.childRefs': 'Child agents',
'orchestration.detail.synthesis': 'Final synthesis',
'tinyplaceOrchestration.title': 'TinyPlace relay',
'tinyplaceOrchestration.subtitle': 'Pinned agent channels and app-session chats',
'tinyplaceOrchestration.refresh': 'Refresh',
'tinyplaceOrchestration.pinned': 'Pinned',
'tinyplaceOrchestration.sessions': 'Sessions',
'tinyplaceOrchestration.loading': 'Loading TinyPlace chats…',
'tinyplaceOrchestration.paymentRequired': 'TinyPlace access requires payment.',
'tinyplaceOrchestration.failedToLoad': 'Failed to load TinyPlace chats',
'tinyplaceOrchestration.noSessions': 'No TinyPlace app sessions yet.',
'tinyplaceOrchestration.noMessages': 'No messages in this chat yet.',
'tinyplaceOrchestration.active': 'Active',
'tinyplaceOrchestration.inactive': 'Inactive',
'tinyplaceOrchestration.encryptedBody': 'Encrypted TinyPlace message',
'tinyplaceOrchestration.unknownSender': 'Unknown sender',
'tinyplaceOrchestration.master.title': 'Agent / human',
'tinyplaceOrchestration.master.subtitle': 'Master chat',
'tinyplaceOrchestration.master.preview': 'Direct coordination between you and OpenHuman.',
'tinyplaceOrchestration.subconscious.title': 'Subconscious',
'tinyplaceOrchestration.subconscious.subtitle': 'Internal agent chats',
'tinyplaceOrchestration.subconscious.preview': 'Background agent coordination and memory loops.',
'tinyplaceOrchestration.session.subtitle': 'TinyPlace app session',
'tinyplaceOrchestration.pairing.linkLabel': 'Link session',
'tinyplaceOrchestration.pairing.linkPlaceholder': 'Paste session agent ID',
'tinyplaceOrchestration.pairing.linkAction': 'Link',
'tinyplaceOrchestration.pairing.requests': 'Contact requests',
'tinyplaceOrchestration.pairing.accept': 'Accept',
'tinyplaceOrchestration.pairing.decline': 'Decline',
'tinyplaceOrchestration.pairing.block': 'Block',
'tinyplaceOrchestration.pairing.linked': 'Linked',
'tinyplaceOrchestration.pairing.pending': 'Pending',
'tinyplaceOrchestration.pairing.unlinked': 'Unlinked',
'tinyplaceOrchestration.pairing.incoming': 'Incoming',
'tinyplaceOrchestration.pairing.outgoing': 'Outgoing',
'intelligence.teams.subtitle': 'Coordinated agent teams and the tasks they share.',
'intelligence.teams.loading': 'Loading teams…',
'intelligence.teams.failedToLoad': 'Failed to load teams',
+35
View File
@@ -185,6 +185,41 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'Algo salió mal. Inténtalo de nuevo.',
'brain.tabs.sources': 'Fuentes',
'brain.tabs.sync': 'Sincronización',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'Relay de TinyPlace',
'tinyplaceOrchestration.subtitle': 'Canales de agentes fijados y chats de sesiones de app',
'tinyplaceOrchestration.refresh': 'Actualizar',
'tinyplaceOrchestration.pinned': 'Fijados',
'tinyplaceOrchestration.sessions': 'Sesiones',
'tinyplaceOrchestration.loading': 'Cargando chats de TinyPlace…',
'tinyplaceOrchestration.paymentRequired': 'El acceso a TinyPlace requiere pago.',
'tinyplaceOrchestration.failedToLoad': 'No se pudieron cargar los chats de TinyPlace',
'tinyplaceOrchestration.noSessions': 'Aún no hay sesiones de app de TinyPlace.',
'tinyplaceOrchestration.noMessages': 'Aún no hay mensajes en este chat.',
'tinyplaceOrchestration.active': 'Activa',
'tinyplaceOrchestration.inactive': 'Inactiva',
'tinyplaceOrchestration.encryptedBody': 'Mensaje cifrado de TinyPlace',
'tinyplaceOrchestration.unknownSender': 'Remitente desconocido',
'tinyplaceOrchestration.master.title': 'Agente / humano',
'tinyplaceOrchestration.master.subtitle': 'Chat maestro',
'tinyplaceOrchestration.master.preview': 'Coordinación directa entre tú y OpenHuman.',
'tinyplaceOrchestration.subconscious.title': 'Subconsciente',
'tinyplaceOrchestration.subconscious.subtitle': 'Chats internos de agentes',
'tinyplaceOrchestration.subconscious.preview':
'Coordinación de agentes en segundo plano y bucles de memoria.',
'tinyplaceOrchestration.session.subtitle': 'Sesión de app de TinyPlace',
'tinyplaceOrchestration.pairing.linkLabel': 'Vincular sesión',
'tinyplaceOrchestration.pairing.linkPlaceholder': 'Pega el ID del agente de sesión',
'tinyplaceOrchestration.pairing.linkAction': 'Vincular',
'tinyplaceOrchestration.pairing.requests': 'Solicitudes de contacto',
'tinyplaceOrchestration.pairing.accept': 'Aceptar',
'tinyplaceOrchestration.pairing.decline': 'Rechazar',
'tinyplaceOrchestration.pairing.block': 'Bloquear',
'tinyplaceOrchestration.pairing.linked': 'Vinculada',
'tinyplaceOrchestration.pairing.pending': 'Pendiente',
'tinyplaceOrchestration.pairing.unlinked': 'Sin vincular',
'tinyplaceOrchestration.pairing.incoming': 'Entrantes',
'tinyplaceOrchestration.pairing.outgoing': 'Salientes',
'brain.empty':
'Tu cerebro está vacío por ahora: conecta una fuente para empezar a construir tu memoria.',
'brain.error': 'No se pudo cargar tu cerebro. Inténtalo de nuevo.',
+35
View File
@@ -185,6 +185,41 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'Une erreur sest produite. Veuillez réessayer.',
'brain.tabs.sources': 'Sources',
'brain.tabs.sync': 'Synchronisation',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'Relais TinyPlace',
'tinyplaceOrchestration.subtitle': "Canaux d'agents épinglés et chats de sessions app",
'tinyplaceOrchestration.refresh': 'Actualiser',
'tinyplaceOrchestration.pinned': 'Épinglés',
'tinyplaceOrchestration.sessions': 'Sessions',
'tinyplaceOrchestration.loading': 'Chargement des chats TinyPlace…',
'tinyplaceOrchestration.paymentRequired': "L'accès à TinyPlace nécessite un paiement.",
'tinyplaceOrchestration.failedToLoad': 'Échec du chargement des chats TinyPlace',
'tinyplaceOrchestration.noSessions': "Aucune session d'app TinyPlace pour le moment.",
'tinyplaceOrchestration.noMessages': "Aucun message dans ce chat pour l'instant.",
'tinyplaceOrchestration.active': 'Active',
'tinyplaceOrchestration.inactive': 'Inactive',
'tinyplaceOrchestration.encryptedBody': 'Message TinyPlace chiffré',
'tinyplaceOrchestration.unknownSender': 'Expéditeur inconnu',
'tinyplaceOrchestration.master.title': 'Agent / humain',
'tinyplaceOrchestration.master.subtitle': 'Chat maître',
'tinyplaceOrchestration.master.preview': 'Coordination directe entre vous et OpenHuman.',
'tinyplaceOrchestration.subconscious.title': 'Subconscient',
'tinyplaceOrchestration.subconscious.subtitle': 'Chats internes des agents',
'tinyplaceOrchestration.subconscious.preview':
'Coordination des agents en arrière-plan et boucles de mémoire.',
'tinyplaceOrchestration.session.subtitle': "Session d'app TinyPlace",
'tinyplaceOrchestration.pairing.linkLabel': 'Lier une session',
'tinyplaceOrchestration.pairing.linkPlaceholder': "Coller l'ID d'agent de session",
'tinyplaceOrchestration.pairing.linkAction': 'Lier',
'tinyplaceOrchestration.pairing.requests': 'Demandes de contact',
'tinyplaceOrchestration.pairing.accept': 'Accepter',
'tinyplaceOrchestration.pairing.decline': 'Refuser',
'tinyplaceOrchestration.pairing.block': 'Bloquer',
'tinyplaceOrchestration.pairing.linked': 'Liée',
'tinyplaceOrchestration.pairing.pending': 'En attente',
'tinyplaceOrchestration.pairing.unlinked': 'Non liée',
'tinyplaceOrchestration.pairing.incoming': 'Entrantes',
'tinyplaceOrchestration.pairing.outgoing': 'Sortantes',
'brain.empty':
'Votre cerveau est vide pour linstant — connectez une source pour commencer à constituer votre mémoire.',
'brain.error': 'Impossible de charger votre cerveau. Veuillez réessayer.',
+34
View File
@@ -185,6 +185,40 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'कुछ गलत हो गया। कृपया पुनः प्रयास करें।',
'brain.tabs.sources': 'स्रोत',
'brain.tabs.sync': 'सिंक',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'TinyPlace रिले',
'tinyplaceOrchestration.subtitle': 'पिन किए गए एजेंट चैनल और ऐप-सत्र चैट',
'tinyplaceOrchestration.refresh': 'रीफ़्रेश',
'tinyplaceOrchestration.pinned': 'पिन किए गए',
'tinyplaceOrchestration.sessions': 'सत्र',
'tinyplaceOrchestration.loading': 'TinyPlace चैट लोड हो रहे हैं…',
'tinyplaceOrchestration.paymentRequired': 'TinyPlace एक्सेस के लिए भुगतान आवश्यक है।',
'tinyplaceOrchestration.failedToLoad': 'TinyPlace चैट लोड नहीं हो सके',
'tinyplaceOrchestration.noSessions': 'अभी कोई TinyPlace ऐप सत्र नहीं है।',
'tinyplaceOrchestration.noMessages': 'इस चैट में अभी कोई संदेश नहीं है।',
'tinyplaceOrchestration.active': 'सक्रिय',
'tinyplaceOrchestration.inactive': 'निष्क्रिय',
'tinyplaceOrchestration.encryptedBody': 'एन्क्रिप्टेड TinyPlace संदेश',
'tinyplaceOrchestration.unknownSender': 'अज्ञात प्रेषक',
'tinyplaceOrchestration.master.title': 'एजेंट / मानव',
'tinyplaceOrchestration.master.subtitle': 'मास्टर चैट',
'tinyplaceOrchestration.master.preview': 'आप और OpenHuman के बीच सीधा समन्वय।',
'tinyplaceOrchestration.subconscious.title': 'अवचेतन',
'tinyplaceOrchestration.subconscious.subtitle': 'आंतरिक एजेंट चैट',
'tinyplaceOrchestration.subconscious.preview': 'पृष्ठभूमि एजेंट समन्वय और मेमोरी लूप।',
'tinyplaceOrchestration.session.subtitle': 'TinyPlace ऐप सत्र',
'tinyplaceOrchestration.pairing.linkLabel': 'सत्र लिंक करें',
'tinyplaceOrchestration.pairing.linkPlaceholder': 'सत्र एजेंट ID पेस्ट करें',
'tinyplaceOrchestration.pairing.linkAction': 'लिंक',
'tinyplaceOrchestration.pairing.requests': 'संपर्क अनुरोध',
'tinyplaceOrchestration.pairing.accept': 'स्वीकारें',
'tinyplaceOrchestration.pairing.decline': 'अस्वीकारें',
'tinyplaceOrchestration.pairing.block': 'ब्लॉक',
'tinyplaceOrchestration.pairing.linked': 'लिंक किया',
'tinyplaceOrchestration.pairing.pending': 'लंबित',
'tinyplaceOrchestration.pairing.unlinked': 'लिंक नहीं',
'tinyplaceOrchestration.pairing.incoming': 'आने वाले',
'tinyplaceOrchestration.pairing.outgoing': 'जाने वाले',
'brain.empty': 'आपका ब्रेन अभी खाली है — मेमोरी बनाना शुरू करने के लिए कोई स्रोत कनेक्ट करें।',
'brain.error': 'आपका ब्रेन लोड नहीं हो सका। कृपया फिर से प्रयास करें।',
'common.cancel': 'रद्द करें',
+34
View File
@@ -184,6 +184,40 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'Terjadi kesalahan. Silakan coba lagi.',
'brain.tabs.sources': 'Sumber',
'brain.tabs.sync': 'Sinkronisasi',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'Relay TinyPlace',
'tinyplaceOrchestration.subtitle': 'Kanal agen tersemat dan chat sesi aplikasi',
'tinyplaceOrchestration.refresh': 'Segarkan',
'tinyplaceOrchestration.pinned': 'Tersemat',
'tinyplaceOrchestration.sessions': 'Sesi',
'tinyplaceOrchestration.loading': 'Memuat chat TinyPlace…',
'tinyplaceOrchestration.paymentRequired': 'Akses TinyPlace memerlukan pembayaran.',
'tinyplaceOrchestration.failedToLoad': 'Gagal memuat chat TinyPlace',
'tinyplaceOrchestration.noSessions': 'Belum ada sesi aplikasi TinyPlace.',
'tinyplaceOrchestration.noMessages': 'Belum ada pesan di chat ini.',
'tinyplaceOrchestration.active': 'Aktif',
'tinyplaceOrchestration.inactive': 'Tidak aktif',
'tinyplaceOrchestration.encryptedBody': 'Pesan TinyPlace terenkripsi',
'tinyplaceOrchestration.unknownSender': 'Pengirim tidak dikenal',
'tinyplaceOrchestration.master.title': 'Agen / manusia',
'tinyplaceOrchestration.master.subtitle': 'Chat master',
'tinyplaceOrchestration.master.preview': 'Koordinasi langsung antara Anda dan OpenHuman.',
'tinyplaceOrchestration.subconscious.title': 'Alam Bawah Sadar',
'tinyplaceOrchestration.subconscious.subtitle': 'Chat internal agen',
'tinyplaceOrchestration.subconscious.preview': 'Koordinasi agen latar belakang dan loop memori.',
'tinyplaceOrchestration.session.subtitle': 'Sesi aplikasi TinyPlace',
'tinyplaceOrchestration.pairing.linkLabel': 'Tautkan sesi',
'tinyplaceOrchestration.pairing.linkPlaceholder': 'Tempel ID agen sesi',
'tinyplaceOrchestration.pairing.linkAction': 'Tautkan',
'tinyplaceOrchestration.pairing.requests': 'Permintaan kontak',
'tinyplaceOrchestration.pairing.accept': 'Terima',
'tinyplaceOrchestration.pairing.decline': 'Tolak',
'tinyplaceOrchestration.pairing.block': 'Blokir',
'tinyplaceOrchestration.pairing.linked': 'Tertaut',
'tinyplaceOrchestration.pairing.pending': 'Menunggu',
'tinyplaceOrchestration.pairing.unlinked': 'Belum tertaut',
'tinyplaceOrchestration.pairing.incoming': 'Masuk',
'tinyplaceOrchestration.pairing.outgoing': 'Keluar',
'brain.empty': 'Otak Anda masih kosong — hubungkan sumber untuk mulai membangun memori.',
'brain.error': 'Tidak dapat memuat otak Anda. Silakan coba lagi.',
'common.cancel': 'Batal',
+35
View File
@@ -185,6 +185,41 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'Qualcosa è andato storto. Riprova.',
'brain.tabs.sources': 'Fonti',
'brain.tabs.sync': 'Sincronizzazione',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'Relay TinyPlace',
'tinyplaceOrchestration.subtitle': 'Canali agente fissati e chat delle sessioni app',
'tinyplaceOrchestration.refresh': 'Aggiorna',
'tinyplaceOrchestration.pinned': 'Fissati',
'tinyplaceOrchestration.sessions': 'Sessioni',
'tinyplaceOrchestration.loading': 'Caricamento chat TinyPlace…',
'tinyplaceOrchestration.paymentRequired': "L'accesso a TinyPlace richiede un pagamento.",
'tinyplaceOrchestration.failedToLoad': 'Impossibile caricare le chat TinyPlace',
'tinyplaceOrchestration.noSessions': 'Nessuna sessione app TinyPlace per ora.',
'tinyplaceOrchestration.noMessages': 'Ancora nessun messaggio in questa chat.',
'tinyplaceOrchestration.active': 'Attiva',
'tinyplaceOrchestration.inactive': 'Inattiva',
'tinyplaceOrchestration.encryptedBody': 'Messaggio TinyPlace cifrato',
'tinyplaceOrchestration.unknownSender': 'Mittente sconosciuto',
'tinyplaceOrchestration.master.title': 'Agente / umano',
'tinyplaceOrchestration.master.subtitle': 'Chat principale',
'tinyplaceOrchestration.master.preview': 'Coordinamento diretto tra te e OpenHuman.',
'tinyplaceOrchestration.subconscious.title': 'Subconscio',
'tinyplaceOrchestration.subconscious.subtitle': 'Chat interne degli agenti',
'tinyplaceOrchestration.subconscious.preview':
'Coordinamento degli agenti in background e cicli di memoria.',
'tinyplaceOrchestration.session.subtitle': 'Sessione app TinyPlace',
'tinyplaceOrchestration.pairing.linkLabel': 'Collega sessione',
'tinyplaceOrchestration.pairing.linkPlaceholder': "Incolla l'ID agente sessione",
'tinyplaceOrchestration.pairing.linkAction': 'Collega',
'tinyplaceOrchestration.pairing.requests': 'Richieste contatto',
'tinyplaceOrchestration.pairing.accept': 'Accetta',
'tinyplaceOrchestration.pairing.decline': 'Rifiuta',
'tinyplaceOrchestration.pairing.block': 'Blocca',
'tinyplaceOrchestration.pairing.linked': 'Collegata',
'tinyplaceOrchestration.pairing.pending': 'In attesa',
'tinyplaceOrchestration.pairing.unlinked': 'Non collegata',
'tinyplaceOrchestration.pairing.incoming': 'In arrivo',
'tinyplaceOrchestration.pairing.outgoing': 'In uscita',
'brain.empty':
'Il tuo cervello è ancora vuoto: collega una fonte per iniziare a costruire la memoria.',
'brain.error': 'Impossibile caricare il tuo cervello. Riprova.',
+34
View File
@@ -185,6 +185,40 @@ const messages: TranslationMap = {
'brain.goals.actionError': '문제가 발생했습니다. 다시 시도해 주세요.',
'brain.tabs.sources': '소스',
'brain.tabs.sync': '동기화',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'TinyPlace 릴레이',
'tinyplaceOrchestration.subtitle': '고정된 에이전트 채널과 앱 세션 채팅',
'tinyplaceOrchestration.refresh': '새로 고침',
'tinyplaceOrchestration.pinned': '고정됨',
'tinyplaceOrchestration.sessions': '세션',
'tinyplaceOrchestration.loading': 'TinyPlace 채팅을 불러오는 중…',
'tinyplaceOrchestration.paymentRequired': 'TinyPlace 접근에는 결제가 필요합니다.',
'tinyplaceOrchestration.failedToLoad': 'TinyPlace 채팅을 불러오지 못했습니다',
'tinyplaceOrchestration.noSessions': '아직 TinyPlace 앱 세션이 없습니다.',
'tinyplaceOrchestration.noMessages': '이 채팅에는 아직 메시지가 없습니다.',
'tinyplaceOrchestration.active': '활성',
'tinyplaceOrchestration.inactive': '비활성',
'tinyplaceOrchestration.encryptedBody': '암호화된 TinyPlace 메시지',
'tinyplaceOrchestration.unknownSender': '알 수 없는 보낸 사람',
'tinyplaceOrchestration.master.title': '에이전트 / 사람',
'tinyplaceOrchestration.master.subtitle': '마스터 채팅',
'tinyplaceOrchestration.master.preview': '사용자와 OpenHuman 간의 직접 조율.',
'tinyplaceOrchestration.subconscious.title': '잠재의식',
'tinyplaceOrchestration.subconscious.subtitle': '내부 에이전트 채팅',
'tinyplaceOrchestration.subconscious.preview': '백그라운드 에이전트 조율과 메모리 루프.',
'tinyplaceOrchestration.session.subtitle': 'TinyPlace 앱 세션',
'tinyplaceOrchestration.pairing.linkLabel': '세션 연결',
'tinyplaceOrchestration.pairing.linkPlaceholder': '세션 에이전트 ID 붙여넣기',
'tinyplaceOrchestration.pairing.linkAction': '연결',
'tinyplaceOrchestration.pairing.requests': '연락처 요청',
'tinyplaceOrchestration.pairing.accept': '수락',
'tinyplaceOrchestration.pairing.decline': '거절',
'tinyplaceOrchestration.pairing.block': '차단',
'tinyplaceOrchestration.pairing.linked': '연결됨',
'tinyplaceOrchestration.pairing.pending': '대기 중',
'tinyplaceOrchestration.pairing.unlinked': '미연결',
'tinyplaceOrchestration.pairing.incoming': '수신',
'tinyplaceOrchestration.pairing.outgoing': '발신',
'brain.empty': '아직 브레인이 비어 있습니다 — 소스를 연결하여 메모리를 만들어 보세요.',
'brain.error': '브레인을 불러올 수 없습니다. 다시 시도해 주세요.',
'common.cancel': '취소',
+34
View File
@@ -185,6 +185,40 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'Coś poszło nie tak. Spróbuj ponownie.',
'brain.tabs.sources': 'Źródła',
'brain.tabs.sync': 'Synchronizacja',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'Przekaźnik TinyPlace',
'tinyplaceOrchestration.subtitle': 'Przypięte kanały agentów i czaty sesji aplikacji',
'tinyplaceOrchestration.refresh': 'Odśwież',
'tinyplaceOrchestration.pinned': 'Przypięte',
'tinyplaceOrchestration.sessions': 'Sesje',
'tinyplaceOrchestration.loading': 'Ładowanie czatów TinyPlace…',
'tinyplaceOrchestration.paymentRequired': 'Dostęp do TinyPlace wymaga płatności.',
'tinyplaceOrchestration.failedToLoad': 'Nie udało się załadować czatów TinyPlace',
'tinyplaceOrchestration.noSessions': 'Brak sesji aplikacji TinyPlace.',
'tinyplaceOrchestration.noMessages': 'Brak wiadomości w tym czacie.',
'tinyplaceOrchestration.active': 'Aktywna',
'tinyplaceOrchestration.inactive': 'Nieaktywna',
'tinyplaceOrchestration.encryptedBody': 'Zaszyfrowana wiadomość TinyPlace',
'tinyplaceOrchestration.unknownSender': 'Nieznany nadawca',
'tinyplaceOrchestration.master.title': 'Agent / człowiek',
'tinyplaceOrchestration.master.subtitle': 'Czat główny',
'tinyplaceOrchestration.master.preview': 'Bezpośrednia koordynacja między tobą a OpenHuman.',
'tinyplaceOrchestration.subconscious.title': 'Podświadomość',
'tinyplaceOrchestration.subconscious.subtitle': 'Wewnętrzne czaty agentów',
'tinyplaceOrchestration.subconscious.preview': 'Koordynacja agentów w tle i pętle pamięci.',
'tinyplaceOrchestration.session.subtitle': 'Sesja aplikacji TinyPlace',
'tinyplaceOrchestration.pairing.linkLabel': 'Połącz sesję',
'tinyplaceOrchestration.pairing.linkPlaceholder': 'Wklej ID agenta sesji',
'tinyplaceOrchestration.pairing.linkAction': 'Połącz',
'tinyplaceOrchestration.pairing.requests': 'Prośby kontaktowe',
'tinyplaceOrchestration.pairing.accept': 'Akceptuj',
'tinyplaceOrchestration.pairing.decline': 'Odrzuć',
'tinyplaceOrchestration.pairing.block': 'Blokuj',
'tinyplaceOrchestration.pairing.linked': 'Połączona',
'tinyplaceOrchestration.pairing.pending': 'Oczekuje',
'tinyplaceOrchestration.pairing.unlinked': 'Niepołączona',
'tinyplaceOrchestration.pairing.incoming': 'Przychodzące',
'tinyplaceOrchestration.pairing.outgoing': 'Wychodzące',
'brain.empty': 'Twój mózg jest na razie pusty — połącz źródło, aby zacząć budować pamięć.',
'brain.error': 'Nie udało się załadować Twojego mózgu. Spróbuj ponownie.',
'common.cancel': 'Anuluj',
+35
View File
@@ -185,6 +185,41 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'Algo deu errado. Tente novamente.',
'brain.tabs.sources': 'Fontes',
'brain.tabs.sync': 'Sincronização',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'Relay TinyPlace',
'tinyplaceOrchestration.subtitle': 'Canais de agentes fixados e chats de sessões do app',
'tinyplaceOrchestration.refresh': 'Atualizar',
'tinyplaceOrchestration.pinned': 'Fixados',
'tinyplaceOrchestration.sessions': 'Sessões',
'tinyplaceOrchestration.loading': 'Carregando chats do TinyPlace…',
'tinyplaceOrchestration.paymentRequired': 'O acesso ao TinyPlace requer pagamento.',
'tinyplaceOrchestration.failedToLoad': 'Falha ao carregar chats do TinyPlace',
'tinyplaceOrchestration.noSessions': 'Ainda não há sessões de app do TinyPlace.',
'tinyplaceOrchestration.noMessages': 'Ainda não há mensagens neste chat.',
'tinyplaceOrchestration.active': 'Ativa',
'tinyplaceOrchestration.inactive': 'Inativa',
'tinyplaceOrchestration.encryptedBody': 'Mensagem criptografada do TinyPlace',
'tinyplaceOrchestration.unknownSender': 'Remetente desconhecido',
'tinyplaceOrchestration.master.title': 'Agente / humano',
'tinyplaceOrchestration.master.subtitle': 'Chat mestre',
'tinyplaceOrchestration.master.preview': 'Coordenação direta entre você e o OpenHuman.',
'tinyplaceOrchestration.subconscious.title': 'Subconsciente',
'tinyplaceOrchestration.subconscious.subtitle': 'Chats internos de agentes',
'tinyplaceOrchestration.subconscious.preview':
'Coordenação de agentes em segundo plano e loops de memória.',
'tinyplaceOrchestration.session.subtitle': 'Sessão de app do TinyPlace',
'tinyplaceOrchestration.pairing.linkLabel': 'Vincular sessão',
'tinyplaceOrchestration.pairing.linkPlaceholder': 'Cole o ID do agente da sessão',
'tinyplaceOrchestration.pairing.linkAction': 'Vincular',
'tinyplaceOrchestration.pairing.requests': 'Solicitações de contato',
'tinyplaceOrchestration.pairing.accept': 'Aceitar',
'tinyplaceOrchestration.pairing.decline': 'Recusar',
'tinyplaceOrchestration.pairing.block': 'Bloquear',
'tinyplaceOrchestration.pairing.linked': 'Vinculada',
'tinyplaceOrchestration.pairing.pending': 'Pendente',
'tinyplaceOrchestration.pairing.unlinked': 'Sem vínculo',
'tinyplaceOrchestration.pairing.incoming': 'Entrada',
'tinyplaceOrchestration.pairing.outgoing': 'Saída',
'brain.empty':
'Seu cérebro está vazio por enquanto — conecte uma fonte para começar a construir a memória.',
'brain.error': 'Não foi possível carregar seu cérebro. Tente novamente.',
+34
View File
@@ -185,6 +185,40 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'Что-то пошло не так. Пожалуйста, попробуйте снова.',
'brain.tabs.sources': 'Источники',
'brain.tabs.sync': 'Синхронизация',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'Ретранслятор TinyPlace',
'tinyplaceOrchestration.subtitle': 'Закрепленные каналы агентов и чаты сессий приложения',
'tinyplaceOrchestration.refresh': 'Обновить',
'tinyplaceOrchestration.pinned': 'Закрепленные',
'tinyplaceOrchestration.sessions': 'Сессии',
'tinyplaceOrchestration.loading': 'Загрузка чатов TinyPlace…',
'tinyplaceOrchestration.paymentRequired': 'Доступ к TinyPlace требует оплаты.',
'tinyplaceOrchestration.failedToLoad': 'Не удалось загрузить чаты TinyPlace',
'tinyplaceOrchestration.noSessions': 'Сессий приложения TinyPlace пока нет.',
'tinyplaceOrchestration.noMessages': 'В этом чате пока нет сообщений.',
'tinyplaceOrchestration.active': 'Активна',
'tinyplaceOrchestration.inactive': 'Неактивна',
'tinyplaceOrchestration.encryptedBody': 'Зашифрованное сообщение TinyPlace',
'tinyplaceOrchestration.unknownSender': 'Неизвестный отправитель',
'tinyplaceOrchestration.master.title': 'Агент / человек',
'tinyplaceOrchestration.master.subtitle': 'Главный чат',
'tinyplaceOrchestration.master.preview': 'Прямая координация между вами и OpenHuman.',
'tinyplaceOrchestration.subconscious.title': 'Подсознание',
'tinyplaceOrchestration.subconscious.subtitle': 'Внутренние чаты агентов',
'tinyplaceOrchestration.subconscious.preview': 'Фоновая координация агентов и циклы памяти.',
'tinyplaceOrchestration.session.subtitle': 'Сессия приложения TinyPlace',
'tinyplaceOrchestration.pairing.linkLabel': 'Связать сессию',
'tinyplaceOrchestration.pairing.linkPlaceholder': 'Вставьте ID агента сессии',
'tinyplaceOrchestration.pairing.linkAction': 'Связать',
'tinyplaceOrchestration.pairing.requests': 'Запросы контактов',
'tinyplaceOrchestration.pairing.accept': 'Принять',
'tinyplaceOrchestration.pairing.decline': 'Отклонить',
'tinyplaceOrchestration.pairing.block': 'Блокировать',
'tinyplaceOrchestration.pairing.linked': 'Связана',
'tinyplaceOrchestration.pairing.pending': 'Ожидает',
'tinyplaceOrchestration.pairing.unlinked': 'Не связана',
'tinyplaceOrchestration.pairing.incoming': 'Входящие',
'tinyplaceOrchestration.pairing.outgoing': 'Исходящие',
'brain.empty': 'Ваш мозг пока пуст — подключите источник, чтобы начать формировать память.',
'brain.error': 'Не удалось загрузить ваш мозг. Пожалуйста, попробуйте ещё раз.',
'common.cancel': 'Отмена',
+34
View File
@@ -182,6 +182,40 @@ const messages: TranslationMap = {
'brain.goals.actionError': '出了点问题。请重试。',
'brain.tabs.sources': '来源',
'brain.tabs.sync': '同步',
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
'tinyplaceOrchestration.title': 'TinyPlace 中继',
'tinyplaceOrchestration.subtitle': '固定的代理频道和应用会话聊天',
'tinyplaceOrchestration.refresh': '刷新',
'tinyplaceOrchestration.pinned': '固定',
'tinyplaceOrchestration.sessions': '会话',
'tinyplaceOrchestration.loading': '正在加载 TinyPlace 聊天…',
'tinyplaceOrchestration.paymentRequired': '访问 TinyPlace 需要付款。',
'tinyplaceOrchestration.failedToLoad': '无法加载 TinyPlace 聊天',
'tinyplaceOrchestration.noSessions': '还没有 TinyPlace 应用会话。',
'tinyplaceOrchestration.noMessages': '此聊天中还没有消息。',
'tinyplaceOrchestration.active': '活跃',
'tinyplaceOrchestration.inactive': '不活跃',
'tinyplaceOrchestration.encryptedBody': '加密的 TinyPlace 消息',
'tinyplaceOrchestration.unknownSender': '未知发送者',
'tinyplaceOrchestration.master.title': '代理 / 人类',
'tinyplaceOrchestration.master.subtitle': '主聊天',
'tinyplaceOrchestration.master.preview': '你和 OpenHuman 之间的直接协调。',
'tinyplaceOrchestration.subconscious.title': '潜意识',
'tinyplaceOrchestration.subconscious.subtitle': '代理内部聊天',
'tinyplaceOrchestration.subconscious.preview': '后台代理协调和记忆循环。',
'tinyplaceOrchestration.session.subtitle': 'TinyPlace 应用会话',
'tinyplaceOrchestration.pairing.linkLabel': '链接会话',
'tinyplaceOrchestration.pairing.linkPlaceholder': '粘贴会话智能体 ID',
'tinyplaceOrchestration.pairing.linkAction': '链接',
'tinyplaceOrchestration.pairing.requests': '联系人请求',
'tinyplaceOrchestration.pairing.accept': '接受',
'tinyplaceOrchestration.pairing.decline': '拒绝',
'tinyplaceOrchestration.pairing.block': '屏蔽',
'tinyplaceOrchestration.pairing.linked': '已链接',
'tinyplaceOrchestration.pairing.pending': '待处理',
'tinyplaceOrchestration.pairing.unlinked': '未链接',
'tinyplaceOrchestration.pairing.incoming': '传入',
'tinyplaceOrchestration.pairing.outgoing': '传出',
'brain.empty': '你的大脑暂时是空的——连接一个来源即可开始构建记忆。',
'brain.error': '无法加载你的大脑,请重试。',
'common.cancel': '取消',
+25 -1
View File
@@ -15,6 +15,7 @@ import { MemoryGraph } from '../components/intelligence/MemoryGraph';
import { MemorySourcesRegistry } from '../components/intelligence/MemorySourcesRegistry';
import { MemoryTreeStatusPanel } from '../components/intelligence/MemoryTreeStatusPanel';
import SubconsciousTriggersPanel from '../components/intelligence/SubconsciousTriggersPanel';
import TinyPlaceOrchestrationTab from '../components/intelligence/TinyPlaceOrchestrationTab';
import { ToastContainer } from '../components/intelligence/Toast';
import PanelPage from '../components/layout/PanelPage';
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
@@ -44,6 +45,7 @@ type BrainTab =
| 'memory-data'
| 'memory-debug'
| 'analysis-views'
| 'tinyplace-orchestration'
| 'subconscious';
/** Tabs that render a relocated settings panel (Knowledge & Memory group). */
@@ -70,6 +72,7 @@ const BRAIN_TABS: readonly BrainTab[] = [
'memory-data',
'memory-debug',
'analysis-views',
'tinyplace-orchestration',
'subconscious',
];
@@ -226,6 +229,18 @@ export default function Brain() {
},
],
},
{
label: t('memory.tab.orchestration'),
items: [
{
value: 'tinyplace-orchestration',
label: t('brain.tabs.tinyplaceOrchestration'),
icon: navIcon(
'M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.82L3 20l1.3-3.9A7.44 7.44 0 013 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'
),
},
],
},
{
items: [
{
@@ -268,7 +283,10 @@ export default function Brain() {
// Bespoke tabs share the standard scaffold: a single scrolling body,
// all custom controls live inside it.
<PanelPage contentClassName="p-4">
<div className="mx-auto max-w-3xl space-y-5">
<div
className={`mx-auto space-y-5 ${
activeTab === 'tinyplace-orchestration' ? 'max-w-5xl' : 'max-w-3xl'
}`}>
{activeTab === 'graph' && (
<div className="space-y-5 animate-fade-up">
<MemoryControls
@@ -312,6 +330,12 @@ export default function Brain() {
</div>
)}
{activeTab === 'tinyplace-orchestration' && (
<div className="animate-fade-up">
<TinyPlaceOrchestrationTab />
</div>
)}
{activeTab === 'subconscious' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
+7
View File
@@ -95,6 +95,12 @@ vi.mock('../../components/settings/layout/SettingsLayoutContext', async () => {
React.createElement(React.Fragment, null, children),
};
});
vi.mock('../../components/intelligence/TinyPlaceOrchestrationTab', async () => {
const React = await import('react');
return {
default: () => React.createElement('div', { 'data-testid': 'brain-tinyplace-orchestration' }),
};
});
const makeGraph = (n: number) => ({
nodes: Array.from({ length: n }, (_, i) => ({ id: `n${i}`, kind: 'summary', label: `N${i}` })),
@@ -176,6 +182,7 @@ describe('Brain page', () => {
['analysis-views', 'brain-analysis-views'],
['sources', 'brain-sources'],
['sync', 'brain-sync'],
['tinyplace-orchestration', 'brain-tinyplace-orchestration'],
['subconscious', 'brain-subconscious'],
])('renders the %s tab', async (tab, testId) => {
graphExportMock.mockResolvedValue(makeGraph(0));
@@ -37,6 +37,7 @@ const FORCED_RESPONSES = [
interface MockRequest {
method: string;
url: string;
body?: string;
}
async function resetMock(): Promise<void> {
@@ -61,6 +62,16 @@ async function requests(): Promise<MockRequest[]> {
return Array.isArray(payload.data) ? payload.data : [];
}
function findToolInLlmLog(log: MockRequest[], toolName: string): boolean {
return log.some(
request =>
request.method === 'POST' &&
request.url.includes('/chat/completions') &&
typeof request.body === 'string' &&
request.body.includes(`"${toolName}"`)
);
}
async function openChat(page: Page): Promise<void> {
await bootAuthenticatedPage(page, USER_ID, '/chat');
await page.goto('/#/chat');
@@ -170,14 +181,25 @@ test.describe('Chat Multi Tool Round', () => {
await expect
.poll(
async () =>
(await toolTimelineNames(page, threadId)).some(name => name.includes('web_fetch')),
async () => {
const names = await toolTimelineNames(page, threadId);
if (names.some(name => name.includes('web_fetch'))) return true;
return findToolInLlmLog(await requests(), 'web_fetch');
},
{ timeout: 20_000 }
)
.toBe(true);
const names = await toolTimelineNames(page, threadId);
expect(names.some(name => name.includes('web_search'))).toBe(true);
await expect
.poll(
async () => {
const names = await toolTimelineNames(page, threadId);
if (names.some(name => name.includes('web_search'))) return true;
return findToolInLlmLog(await requests(), 'web_search_tool');
},
{ timeout: 20_000 }
)
.toBe(true);
await expect
.poll(async () => {
@@ -27,6 +27,7 @@ const FORCED_RESPONSES = [
interface MockRequest {
method: string;
url: string;
body?: string;
}
async function resetMock(): Promise<void> {
@@ -51,6 +52,16 @@ async function requests(): Promise<MockRequest[]> {
return Array.isArray(payload.data) ? payload.data : [];
}
function findToolInLlmLog(log: MockRequest[], toolName: string): boolean {
return log.some(
request =>
request.method === 'POST' &&
request.url.includes('/chat/completions') &&
typeof request.body === 'string' &&
request.body.includes(`"${toolName}"`)
);
}
async function openChat(page: Page): Promise<void> {
await bootAuthenticatedPage(page, USER_ID, '/chat');
await page.goto('/#/chat');
@@ -160,14 +171,16 @@ test.describe('Chat Tool Call Flow', () => {
await expect(page.getByText(CANARY_FINAL).first()).toBeVisible({ timeout: 40_000 });
const names = await expect
.poll(async () => toolTimelineNames(page, threadId), { timeout: 20_000 })
.not.toEqual([]);
void names;
expect((await toolTimelineNames(page, threadId)).some(name => name.includes('web_fetch'))).toBe(
true
);
await expect
.poll(
async () => {
const names = await toolTimelineNames(page, threadId);
if (names.some(name => name.includes('web_fetch'))) return true;
return findToolInLlmLog(await requests(), 'web_fetch');
},
{ timeout: 20_000 }
)
.toBe(true);
await expect
.poll(async () => {
+363
View File
@@ -0,0 +1,363 @@
Here is the updated technical specification and architectural manual. The human-in-the-loop validation layer has been completely removed, replacing it with a fully automated, bi-directional orchestration loop where the Front-End Agent defers to the Reasoning LLM, which spawns sub-agents and routes feedback back up. Additionally, the compression matrix has been recalibrated to a **20:1 ratio**, and the Subconscious Loop now processes a cumulative **World State Diff** tracking environmental evolution from start to finish.
---
# Technical Specification & Architecture Manual: Autonomous Closed-Loop LangGraph Harness
This document provides a comprehensive technical specification and architectural blueprint for a stateful, split-brain multi-agent system implemented via **LangGraph**. The system decouples immediate user-facing interface management from complex task orchestration and asynchronous deep-state optimization (the "Subconscious Loop") without requiring human intervention.
---
## 1. Architectural Philosophy & Autonomous Closed-Loop Design
The architecture replicates a biological sleep/wake cognitive model optimized for complete autonomy. Immediate input parsing and environmental feedback are handled by lightweight, low-latency loops. Long-term strategic alignment, memory consolidation, and system steering are offloaded to an offline, heavy-reasoning asynchronous layer triggered by a clock cycle (cron).
Unlike legacy systems that block execution for human verification, this topology establishes a fully automated feedback loop where the orchestrator directly replies back to the ingest surface.
### The Three LLM Cognitive Tiers
1. **Quick LLM (Surface Interface Layer):** Low context window (~8k32k tokens), high-speed streaming optimization. Drives the Front-End Agent to manage ingestion channels, hand off macro-directives to the reasoning layer, and deliver near-instant consumer feedback once execution cycles complete.
2. **Reasoning LLM (Execution & Orchestration Layer):** Large context window (1 Million tokens). High-capacity operational model optimized for tool call routing, state mutation planning, dynamic execution sub-agent spawning, and feedback loop compilation.
3. **Subconscious LLM (Deep Reflection Layer):** Large context window (1 Million tokens). Extremely high-density reasoning model operating completely offline. It possesses no awareness of external networks or direct user presences. It consumes highly compressed operational traces and cumulative world state diffs to output short, dense, high-impact configuration overrides that steer the Reasoning LLM.
---
## 2. Component Topology & Structural Constraints
```
[ External Channels: Telegram / Web App ]
│ ▲
│ (Webhooks / Events) │ (Final Streaming Response)
▼ │
┌────────────────────────────────────────────────────────┐
│ FRONT-END LAYER │
│ ┌──────────────────────────────────────────────────┐ │
│ │ FRONT-END AGENT │ │◄── Always running /
│ │ (Quick LLM) │ │ Triggered externally
│ └──────────────────────────────────────────────────┘ │
│ │ ▲ │
│ │ Defers Macro- │ Replies │
│ │ Instructions │ Back │
│ ▼ │ │
└───────────┼────────────────────────────────┼───────────┘
│ │
▼ │
┌───────────┼────────────────────────────────┼───────────┐
│ ▼ │ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ REASONING LLM │ │◄── Context Managed
│ │ (Orchestration Core) │ │ (80-90% Hooks)
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ├─► Spawns Autonomous Execution Sub-Agents │
│ │ │
│ ▼ Generates 20:1 Summary │
│ & Historical World State Diffs │
│ ┌──────────────────────────────────────────────────┐ │
│ │ SUBCONSCIOUS LLM │ │
│ │ (Asynchronous Core) │ ├─── Steers via Cron
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
```
### 2.1 The Front-End Ingestion Layer
- **Channels:** The boundary surface of the graph. Channels absorb heterogeneous asynchronous streams (Telegram bot long-polling/webhooks, Web App WebSocket packets) and standardize them into a singular data payload structure within the Graph State.
- **Front-End Agent:** Driven by the **Quick LLM**, this component operates on a two-pass cycle. On intake, it translates raw channel traffic into actionable macro-instructions and defers execution downwards to the orchestration loop. On the return pass, it digests the execution responses and compiles consumer-facing streaming feedback.
### 2.2 Automated Loop Routing (Bi-Directional Feedback)
This architecture enforces an autonomous, closed control sequence that loops through the cognitive engine before resolving:
1. **Front-End Interfacing:** The Front-End Agent processes raw inputs, registers them into state, and yields control down the graph topology.
2. **Orchestrated Execution & Sub-Agent Spawning:** The Reasoning LLM assumes control, references current behavioral steering profiles, and spins up ephemeral execution sub-agents to interface with tools and infrastructure.
3. **Upstream Reporting:** Once execution bounds are reached, the Reasoning LLM synthesizes operational data and _replies back_ directly to the Front-End Agent.
4. **Resolution:** The Front-End Agent intercepts the execution reply, constructs the finalized presentation layer payload, and streams it back to the originating communication channel.
---
## 3. Cognitive Dynamics & Memory Lifecycle
### 3.1 The 20:1 Information Compression Engine
To preserve context capacity without omitting critical structural history, the Reasoning LLM's raw execution traces, multi-agent message logs, and sub-agent output streams are routed through an inline compression hook.
- This hook condenses noisy text blocks into a crisp semantic abstraction targeted at a strict **20:1 token reduction ratio** (e.g., a 20,000-token verbose sub-agent debugging trace is boiled down to a dense 1,000-token structural log entry).
- These compressed records are periodically committed to the Subconscious memory partition along with the evolving world timeline data.
### 3.2 Asynchronous Steering Loop & World State Diffs
The Subconscious LLM executes fully decoupled from the core transaction pipeline, operating on an isolated schedule managed by system **cron jobs**.
- **The Agent's World State Diff:** Rather than evaluating isolated system mutations, the Subconscious loop consumes a comprehensive, cumulative structural dictionary representing the "state of the diff of the agent's world". This diff documents explicitly how the agent's internal and external environment has shifted over time from start to finish.
- **Steering Directive Output:** It evaluates macro-trends and shifts across the world state timeline, filtering out localized operational variance. It outputs highly condensed, high-impact behavioral guidelines. These guidelines are injected directly into the **Reasoning LLM's** operational prompts for subsequent runs, modifying resource prioritization parameters and sub-agent steering traits.
### 3.3 Context Lifecycle Hooks (80%90% Threshold)
Both the Reasoning LLM and Subconscious LLM monitor active token footprints via explicit guardrail hooks:
- **The Intercept Boundary:** When context window consumption trends between **80% and 90%** of total allocation, the graph routes state execution through a background truncation node.
- **Eviction Strategy:** Older tracking logs and early world diff fragments are summarized via an autonomous map-reduce routine, pushed to a long-term Vector Database for RAG operations, and excised from the working state. The window is shifted right, maintaining core operational identities and immediate state milestones.
---
## 4. Technical Reference Implementation (LangGraph)
The complete, runnable Python script below demonstrates how to map out this autonomous, bi-directional topology using **LangGraph**. It removes the human gating mechanism, implements cyclic routing between the Front-End and Reasoning layers, models sub-agent spawning, and executes the out-of-band Subconscious cron process.
```python
import os
import uuid
from typing import Annotated, Any, Dict, List, Literal, TypedDict
from dataclasses import dataclass
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from openhuman.orchestration.checkpoint import SqlRunLedgerCheckpointer
# ==========================================
# 1. AUTONOMOUS STATE DEFINITION
# ==========================================
class SystemState(TypedDict):
# Core Communication Channel
messages: Annotated[list, add_messages]
channel_source: str # "telegram" | "webapp"
raw_channel_payload: str
# Bi-Directional Instruction Flow (No Human in the Loop)
agent_instructions: str # Front-End Agent deferring to Reasoning LLM
agent_reply: str # Reasoning LLM replying back to Front-End Agent
channel_response: str # Final compiled channel feedback output
# Cognitive Engine Memory & Deep State Steering
subconscious_steering: str # Guidelines injected by Subconscious LLM
compressed_history: List[str] # Strict 20:1 condensed execution logs
world_state_diff: Dict[str, Any] # Cumulative timeline tracking agent's world changes from start to finish
context_utilization: float # Tracks 1M token context limit window (0.0 to 1.0)
# ==========================================
# 2. CORE NODE IMPLEMENTATIONS
# ==========================================
def channel_ingestion_node(state: SystemState) -> Dict[str, Any]:
"""
Acts as the entry vector. Normalizes incoming raw payloads into the unified graph state.
"""
print(f"[Channel Ingest] Incoming packet from source: {state.get('channel_source')}")
return {
"raw_channel_payload": state.get("raw_channel_payload", ""),
"messages": [("user", state.get("raw_channel_payload", ""))]
}
def frontend_agent_node(state: SystemState) -> Dict[str, Any]:
"""
Driven by Quick LLM.
Pass 1: Translates user intent into instructions and defers down to the Reasoning LLM.
Pass 2: Receives the Reasoning LLM reply and formulates final channel feedback.
"""
if not state.get("agent_reply"):
print("[Front-End Agent] Pass 1: Deferring action instructions downstream to Reasoning LLM...")
raw_input = state.get("raw_channel_payload", "")
return {
"agent_instructions": f"AUTONOMOUS_EXECUTE: Process environmental signature -> '{raw_input}'"
}
else:
print("[Front-End Agent] Pass 2: Processing Reasoning LLM reply to generate final channel response...")
exec_reply = state.get("agent_reply")
return {
"channel_response": f"Successfully completed. Engine Output: {exec_reply}"
}
def agent_execution_node(state: SystemState) -> Dict[str, Any]:
"""
Driven by Reasoning LLM. Orchestrates execution, spawns sub-agents,
applies Subconscious steering directives, and replies back upstream.
"""
print("[Agent Execution] Initializing orchestration core via Reasoning LLM...")
instructions = state.get("agent_instructions")
steering = state.get("subconscious_steering", "DEFAULT_ALIGNMENT: Maximize throughput performance.")
print(f" -> Injecting Subconscious Steering Guidelines: [ {steering} ]")
print(f" -> Spawning execution sub-agents to fulfill: [ {instructions} ]")
# Simulate multi-agent operational activity trace
print(" -> [Sub-Agent Alpha] Compiling environmental variables...")
print(" -> [Sub-Agent Beta] Mutating structural matrix parameters...")
# 20:1 Information Compression Engine execution
# Simulates condensing a 20,000 token verbose sub-agent log into 1,000 tokens
simulated_20_to_1_summary = (
"[20:1 Compression Trace] Orchestrated 2 sub-agents. State variables mutated. "
"Sub-agent traces pruned from core context loop to maintain compliance bounds."
)
# Accumulate and update the World State Diff (Tracking changes over time from start to finish)
current_world_diff = state.get("world_state_diff", {})
if not current_world_diff:
current_world_diff = {
"system_genesis": "initialized",
"evolution_timeline": [],
"terminal_state": "pending"
}
mutation_step = len(current_world_diff["evolution_timeline"]) + 1
current_world_diff["evolution_timeline"].append({
"sequence": mutation_step,
"event_signature": f"Execution Cycle {mutation_step}",
"world_mutation": "Infrastructure parameters rewritten. Ephemeral sub-agents terminated.",
"delta_delta": "Matrix state transitioned from idle to computed_active."
})
current_world_diff["terminal_state"] = "execution_finalized"
# Tracking Context utilization
current_utilization = min(state.get("context_utilization", 0.1) + 0.05, 1.0)
return {
"agent_reply": "Reasoning Core and spawned sub-agents finalized all pipeline mutations successfully.",
"compressed_history": [simulated_20_to_1_summary],
"world_state_diff": current_world_diff,
"context_utilization": current_utilization
}
def context_manager_hook_node(state: SystemState) -> Dict[str, Any]:
"""
Enforces systemic context window safeguards between 80% and 90%.
"""
utilization = state.get("context_utilization", 0.0)
print(f"[Context Manager Hook] Current window utilization footprint: {utilization * 100:.2f}%")
if utilization >= 0.85:
print("[Context Manager Hook] CRITICAL: Context threshold breached. Evicting and shifting window...")
return {
"context_utilization": 0.2,
"compressed_history": ["--- Historical context blocks compressed and evicted to Vector Store ---"]
}
print("[Context Manager Hook] Memory boundaries verified clean.")
return {}
def subconscious_cron_node(state: SystemState) -> Dict[str, Any]:
"""
Long-running heavy reasoning block executed completely out-of-band via Cron loops.
Evaluates compressed logs and cumulative world state diffs to construct new steering rules.
"""
print("[Subconscious Loop] Out-of-band Cron Trigger Activated...")
history = state.get("compressed_history", [])
world_diff = state.get("world_state_diff", {})
print(f" -> Digesting {len(history)} historical 20:1 compressed summaries...")
print(f" -> Deep evaluation of cumulative Agent's World State Diff timeline from start to finish:")
for step in world_diff.get("evolution_timeline", []):
print(f" * Step [{step['sequence']}]: {step['world_mutation']} ({step['delta_delta']})")
new_steering_directive = "STEERING_DIRECTIVE: High asset mutability detected. Enforce stricter resource parameters."
print(f" -> Emitting new high-density steering directive: {new_steering_directive}")
return {
"subconscious_steering": new_steering_directive
}
# ==========================================
# 3. GRAPH COMPOSITION & CONDITIONAL ROUTING
# ==========================================
def automated_loop_router(state: SystemState) -> Literal["agent_execution", "context_manager_hook"]:
"""
Directs graph traffic based on system execution tracking.
If a final response payload exists, routes to wrap up. Otherwise, defers to execution.
"""
if state.get("channel_response"):
return "context_manager_hook"
return "agent_execution"
workflow = StateGraph(SystemState)
# Declare nodes in processing space
workflow.add_node("channel_ingestion", channel_ingestion_node)
workflow.add_node("frontend_agent", frontend_agent_node)
workflow.add_node("agent_execution", agent_execution_node)
workflow.add_node("context_manager_hook", context_manager_hook_node)
workflow.add_node("subconscious_cron", subconscious_cron_node)
# Map edge connections
workflow.add_edge(START, "channel_ingestion")
workflow.add_edge("channel_ingestion", "frontend_agent")
# Set up bi-directional feedback routing around the Front-End Agent
workflow.add_conditional_edges(
"frontend_agent",
automated_loop_router,
{
"agent_execution": "agent_execution",
"context_manager_hook": "context_manager_hook"
}
)
# Execution loops right back up into the Front-End Agent to communicate findings
workflow.add_edge("agent_execution", "frontend_agent")
workflow.add_edge("context_manager_hook", END)
# Compile graph with the durable run ledger checkpointer used by OpenHuman.
checkpointer = SqlRunLedgerCheckpointer()
compiled_autonomous_harness = workflow.compile(checkpointer=checkpointer)
# ==========================================
# 4. RUNTIME WALKTHROUGH SIMULATION
# ==========================================
if __name__ == "__main__":
print("--- STARTING AUTONOMOUS HARNESS GRAPH RUNTIME ---")
thread_config = {"configurable": {"thread_id": "autonomous_session_999"}}
runtime_input = {
"channel_source": "webapp",
"raw_channel_payload": "Reallocate resource segments across network array Gamma.",
"context_utilization": 0.1,
"compressed_history": [],
"world_state_diff": {}
}
print("\n--- Phase 1: Streamlined Autonomous Pipeline Execution ---")
# Flows completely through ingestion -> front-end -> execution -> front-end -> context checkout autonomously
for event in compiled_autonomous_harness.stream(runtime_input, thread_config, stream_mode="values"):
pass
# Verify final execution state output
final_state = compiled_autonomous_harness.get_state(thread_config).values
print(f"\nFinal Channel Output Received: '{final_state.get('channel_response')}'")
print(f"System Context Utilization Factor: {final_state.get('context_utilization')}")
print("\n--- Phase 2: Isolated Out-of-Band Cron Trigger (Subconscious Alignment) ---")
# Fetch runtime state metrics from the thread history
active_runtime_state = compiled_autonomous_harness.get_state(thread_config).values
# Execute the asynchronous subconscious computation block using current state parameters
subconscious_adjustments = subconscious_cron_node(active_runtime_state)
# Directly inject emitted steering guidelines back into the state layout
compiled_autonomous_harness.update_state(
thread_config,
subconscious_adjustments,
as_node="subconscious_cron"
)
print("\n--- Autonomous Architecture Verified. Pipeline Clear for Deployment ---")
```
---
## 5. Architectural Guardrails Checklist for External LLMs
When developing, translating, or porting this autonomous spec, verify engine patterns against these strict invariants:
- **[ ] Feedback Loop Continuity:** Ensure that the Routing function accurately evaluates the presence of `channel_response` or `agent_reply` to prevent infinite execution cycling between the Front-End Agent and the Reasoning LLM.
- **[ ] 20:1 Compression Strictness:** Verify that the inline compression mechanism truncates verbose structural strings down to a 5% footprint before modifying the global history arrays to safeguard the 1-Million-token framework.
- **[ ] Historical World Tracking:** The `world_state_diff` tracking dictionary must record structural system mutations sequentially from start to finish rather than wiping old keys on each loop iteration.
- **[ ] Context Pre-emptibility:** Confirm that context validation checks execute _after_ execution mutations finish but _before_ the graph reaches terminal status (`END`), ensuring memory resets take effect prior to successive iterations.
@@ -0,0 +1,111 @@
# Subconscious Orchestration Layer — Multistage Plan
Implements the split-brain architecture in [`docs/arch-subconscious.md`](../../arch-subconscious.md)
on top of the **tinyagents graph harness** (`src/openhuman/tinyagents/` + `agent_graph`), with
**tiny.place DM channels** as the transport for external Claude Code / Codex sessions, surfaced in
the new **`TinyPlaceOrchestrationTab`** on the Brain page.
## How to use this folder (goal commands)
Each `stage-NN-*.md` file is a self-contained **goal**: hand a single stage file to an agent
(`claude "implement docs/plans/subconscious-orchestration/stage-03-core-ingest.md"`) and it has the
goal statement, the files to read first, deliverables, tasks, and acceptance criteria. Stages are
ordered by dependency; each stage must land (tests green, committed) before the next starts.
Stage 1 lands in the **`tiny.place/` checkout** (separate repo, separate PR); stage 2 spans both
repos (its wrapper half rides with stage 1); stages 38 land here.
## Design decision: one graph, two triggers
The whole **wake path is a single tinyagents graph** (mirroring the spec's one `StateGraph`), not
separate agents ping-ponging through the event bus:
```
invoke(session thread) ─► normalize ─► frontend(pass1) ─► reasoning/execute ─► compress
▲ │
└── reply ◄── world_diff ◄─────────────┘
frontend(pass2) ─► send_dm ─► context_guard ─► END
```
- **One state object** (`OrchestrationState`: `messages`, `agent_instructions`, `agent_reply`,
`channel_response`, `subconscious_steering`, `compressed_history`, `world_state_diff`,
`context_utilization`) flows through conditional edges — the spec's routing predicate
(`channel_response` present → wrap up, else execute) is a graph edge, not bus choreography.
- **One checkpointer** (`SqlRunLedgerCheckpointer`, thread id `orchestration:<session_id>`) gives
crash-resume for the whole cycle and gives the subconscious a consistent snapshot to read.
- Only two things live **outside** the graph:
1. **Transport/ingest** (stage 3): DMs arrive asynchronously; the subscriber persists + then
*invokes* the graph on the session's thread. A polling node inside the graph would fight
checkpointing.
2. **Subconscious** (stage 6): out-of-band cron by design (the spec runs it via
`update_state(as_node="subconscious_cron")`). It reads checkpointed state + the store and
writes `subconscious_steering` back into the thread — a decoupled writer, not an edge.
Consequence for the stages: stage 4 builds the **graph skeleton + state + front-end nodes**,
stage 5 adds the **reasoning/memory nodes to the same graph**. Different model tiers per node are
fine — tinyagents nodes each resolve their own provider/model (`hint:chat` vs `hint:reasoning`).
## Target architecture → repo primitives
| Spec concept (`arch-subconscious.md`) | Repo primitive |
| --- | --- |
| Channels (ingestion boundary) | tiny.place Signal DMs → `src/openhuman/tinyplace/` (`messages_list`, `signal_send_message`, `streams.rs`) |
| DM authorization | tiny.place mutual **contact graph** (`backend-tinyplace-v2` `/contacts/*`; relay rejects non-contact DMs) → new `tinyplace_contacts_*` controllers + pairing flow (stage 2) |
| Front-End Agent (Quick LLM) | `frontend` nodes of the unified graph, model `hint:chat` (`src/openhuman/routing/policy.rs`) |
| Reasoning LLM (Orchestration Core) | `execute` node of the unified graph (`AgentGraph::Custom` runner, `hint:reasoning`), spawning sub-agents via `subagent_runner` |
| Bi-directional loop (frontend ⇄ reasoning) | Conditional edges on `agent_reply` / `channel_response` in the single graph state |
| Checkpointing / resume | `SqlRunLedgerCheckpointer` (`src/openhuman/tinyagents/checkpoint.rs`), keyed by orchestration `thread_id` |
| 20:1 compression hook | tinyagents middleware (pattern: `summarize.rs`, `TurnContextMiddleware`) writing `compressed_history` |
| World state diff | New `orchestration` store table, appended per execution cycle |
| 8090% context hooks | Context-window middleware (extend `summarize.rs` seam) + eviction to memory/embeddings |
| Subconscious LLM + cron | Existing `SubconsciousEngine` tick (`src/openhuman/subconscious/engine.rs`, `heartbeat/`), gated by `scheduler_gate` |
| Steering directive injection | New steering store read by the Reasoning agent's `prompt.rs::build` |
| UI surface | `TinyPlaceOrchestrationTab` (Brain → Orchestration group), backed by real metadata instead of string heuristics |
## Message flow (end to end)
```
Claude Code / Codex instance
└─ tinyplace wrapper (stage 1) — tails session JSONL → SessionEnvelope
└─ contact pairing (stage 2) — accepted mutual contact edge; relay refuses DMs
between non-contacts (403 not_a_contact), so this gates everything below
└─ Signal E2E DM → owner agent's tiny.place inbox [tagged: sessionId, source, role, kind]
└─ core ingest (stage 3) — orchestration::ingest normalizes DMs → SessionState
└─ Front-End nodes (stage 4, Quick LLM) — macro-instructions
└─ Reasoning + memory nodes (stage 5) — sub-agents, 20:1 compression,
world-state diff, context hooks; replies upstream
└─ Front-End pass 2 — channel_response → Signal DM back to session
└─ Subconscious tick (stage 6, cron/heartbeat) — reads compressed history + world diff,
writes steering directives → injected into Reasoning prompts next run
UI: TinyPlaceOrchestrationTab (stage 7) — pairing, master / subconscious / per-session chat windows
```
## Stage index
| Stage | Repo | Delivers | Depends on |
| --- | --- | --- | --- |
| [1 — tiny.place session bridge](stage-01-tinyplace-session-bridge.md) | `tiny.place/` | `SessionEnvelope` v1 schema; Claude Code wrapper; DM forwarding; wrapper pairing handshake | — |
| [2 — contact pairing & DM authorization](stage-02-contact-pairing.md) | both | Contacts RPC in core; user-consented link/approve flows; Brain-tab pairing UX | 1 |
| [3 — core ingest + session state](stage-03-core-ingest.md) | openhuman | `orchestration` domain: DM ingest, session store, typed envelopes | 1 (2 for live traffic) |
| [4 — graph skeleton + front-end nodes](stage-04-frontend-agent.md) | openhuman | Unified `OrchestrationState` graph, checkpointing, two-pass frontend nodes (Quick LLM) | 3 |
| [5 — reasoning + memory nodes](stage-05-reasoning-orchestrator.md) | openhuman | `execute`/`compress`/`world_diff`/`context_guard` nodes in the same graph, sub-agent spawning | 4 |
| [6 — subconscious steering loop](stage-06-subconscious-steering.md) | openhuman | Steering directives from world diff via existing heartbeat/cron | 5 |
| [7 — RPC + UI wiring](stage-07-rpc-and-ui.md) | openhuman | `orchestration.*` RPC; `TinyPlaceOrchestrationTab` on real data + streaming + composer | 3, 6 |
| [8 — testing & observability](stage-08-testing-observability.md) | both | json_rpc_e2e, Vitest, debug logging audit, coverage gate | all |
## Global invariants (apply to every stage)
- **Loop continuity**: routing must terminate on `channel_response` presence — never allow
frontend ⇄ reasoning infinite cycling (spec §5 checklist).
- **20:1 strictness**: compression output budget = ⌈input_tokens / 20⌉, enforced, not advisory.
- **World diff is append-only**: sequential timeline from genesis; never wipe keys per cycle.
- **Context hooks run before END**: eviction takes effect prior to the next iteration.
- **Subconscious isolation**: it never talks to channels or users directly; its only output is
steering directives (and it keeps `SubconsciousTainted` origin so effect tools stay gated).
- **Pairing consent**: DMs only flow over user-consented contact edges — the wrapper auto-accepts
only its configured owner identity; OpenHuman never auto-accepts an unsolicited request unless
the user initiated the link or explicitly opted in via config.
- **Security**: tinyplace identity stays wallet-derived (`wallet::tinyplace_signer_seed()`); never
log message bodies or seeds; Signal bodies stay E2E — the relay sees ciphertext only.
- **Repo rules**: new Rust code in a dedicated domain dir (canonical module shape), controller
registry (no `dispatch.rs` branches), verbose `[orchestration]`-prefixed debug logging, i18n for
all UI strings across all 14 locales, ≥80% changed-line coverage.
@@ -0,0 +1,116 @@
# Stage 1 — tiny.place session bridge (SDK/CLI, `tiny.place/` repo)
## Goal command
> In the `tiny.place/` checkout (`sdk/typescript`), define a versioned **`SessionEnvelope` v1**
> message schema for harness sessions and extend the CLI wrappers so that **every semantic message
> (user and agent) from a wrapped Codex or Claude Code instance is sent as a Signal E2E DM to a
> configured tiny.place recipient** (the OpenHuman owner agent), in addition to the existing
> on-disk JSONL envelopes. Add a `tinyplace claude` wrapper mirroring `tinyplace codex`.
> **Configure-once, zero-args**: after one-time setup the owner target and forwarding preferences
> live in `~/.tinyplace/config.json`, so plain `tinyplace codex` / `tinyplace claude` (or a session
> launched from the TUI) forwards automatically — no per-invocation flags. The **TUI is the
> primary surface** for setup, pairing status, and launching sessions; `--tinyplace-*` flags exist
> only as overrides for scripting.
## Read first
- `sdk/typescript/src/cli/codex.ts` — existing wrapper: PTY proxy + session-JSONL tailing already
produces `SemanticMessage { role: "user" | "assistant" }` and `SessionEnvelope`s written under
`~/.tinyplace/codex-envelopes/messages/…`. This is the source of truth to forward.
- `sdk/typescript/src/agent/``Agent` facade (`sendMessage` does handle-resolution + Signal E2E).
- `sdk/typescript/src/messaging/`, `src/signal/` — DM send path.
- `sdk/typescript/src/cli/context.ts` — persisted CLI config at `~/.tinyplace/config.json`
(`TINYPLACE_CONFIG` override); this is where the new keys land.
- `sdk/typescript/src/cli/tui.ts` — existing blessed TUI (`runTinyPlaceTui`), already models agent
kinds `"claude" | "codex"` and session launch; the setup/pairing UX extends this.
- `sdk/typescript/AGENTS.md` — identifier kinds, error-code contract.
## Deliverables
1. **`SessionEnvelope` v1 wire schema** (new `sdk/typescript/src/types/session-envelope.ts`,
exported from the root). This is the contract stages 37 parse; it rides inside the DM body as
JSON (encrypted end-to-end):
```ts
interface HarnessSessionEnvelope {
v: 1;
kind: "session_message" | "session_lifecycle";
source: "codex" | "claude-code";
sessionId: string; // stable wrapper session id
sessionLabel?: string; // e.g. repo folder name
workspace?: string; // cwd of the wrapped instance
seq: number; // monotonic per session
role: "user" | "assistant" | "system";
body: string; // the semantic message text (may be truncated, see limits)
truncated?: boolean;
timestamp: string; // ISO-8601
lifecycle?: "started" | "ended" | "error"; // kind === session_lifecycle
}
```
2. **Persisted forwarding config + one-time setup**: new `orchestration` block in
`~/.tinyplace/config.json` — `{ forwardTo: "<@handle|agentId>", forwardEnabled: true,
pairingStatus?, scope?, bucket? }` — written once by the TUI setup flow (or
`tinyplace setup --forward-to @owner` non-interactively). Resolution order per run:
CLI flag (`--tinyplace-forward-to`) → env (`TINYPLACE_FORWARD_TO`) → config. Once configured,
**bare `tinyplace codex` / `tinyplace claude` forwards with no extra args**; when nothing is
configured, the wrappers behave exactly as today (local envelopes only, no nagging).
3. **DM forwarding in the wrappers**: each `SemanticMessage` and lifecycle event is wrapped in a
`HarnessSessionEnvelope` and sent via the Agent facade Signal DM path to the resolved target.
Batching: flush per message, but coalesce assistant deltas into one message per completed turn
(the tailer already yields whole messages). Failures are logged to stderr JSON and **never
crash the wrapped CLI**; retry with backoff on `transient`/`rate_limited`, drop after N retries
with a lifecycle `error`.
4. **`tinyplace claude` wrapper** (`sdk/typescript/src/cli/claude.ts`): same PTY-proxy shape as
`codex.ts`, tailing Claude Code session JSONL (`~/.claude/projects/<slug>/*.jsonl`) for
user/assistant messages; shares the envelope writer + forwarder (extract the reusable parts of
`codex.ts` into `src/cli/session-bridge.ts` rather than copy-pasting). Both wrappers are
launchable from the TUI (`tui.ts` already models both agent kinds) and inherit the config.
5. **Size limits**: cap `body` at 8 KiB per DM (set `truncated: true`; full text stays in the local
JSONL). Never forward raw terminal chunks — semantic messages only.
6. **Contact-pairing handshake in the TUI** (wrapper half of stage 2 — the relay refuses DMs
between non-contacts, `403 not_a_contact`). The TUI setup flow owns this: it shows the CLI's
own identity (`agentId` / `@handle`) so the user can link it from OpenHuman, displays live
pairing status (`none / pending / accepted / blocked`), and persists `pairingStatus` to config
so subsequent runs skip straight to forwarding. Headless runs perform the same handshake
silently from config. State machine on `contacts/{owner}/status`:
- `accepted` → forward; `none` → send a contact request, queue envelopes (bounded, drop-oldest
with a lifecycle `error` note) and poll until accepted;
- `pending incoming` **from the exact configured forward-to identity only** → accept;
- `blocked` → terminal error, no retries.
At send time, branch on the `not_a_contact` error code → re-enter pairing instead of retrying.
7. Docs: README section + `tinyplace describe` entries for setup, the config keys, and the
override flags.
## Tasks
1. Extract shared session-bridge module from `codex.ts` (envelope writer, session-id logic, tailer
interface) — no behavior change; existing tests stay green.
2. Add `HarnessSessionEnvelope` type + serializer/validator (`parseHarnessSessionEnvelope` for
consumers) with unit tests round-tripping v1 and rejecting unknown `v`.
3. Add the `orchestration` config block to `context.ts` (read/write, flag→env→config resolution)
+ `tinyplace setup` command; config round-trip tests.
4. Implement the forwarder (queue + retry + never-crash guarantee), target resolved from config.
5. Implement `claude.ts` tailer: resolve session file for the wrapped instance (newest JSONL in the
project slug dir after spawn; honor `--tinyplace-session-file` override like codex does).
6. Implement the pairing handshake (status check, request, owner-only auto-accept, bounded queue,
`not_a_contact` recovery) in the shared session-bridge module; TUI panels for setup, pairing
status, and session launch on top of it.
7. Wire both into `cli.ts`/`commands.ts`/`tui.ts`; update help/catalog output.
8. Vitest coverage: envelope schema, config resolution order, forwarder retry/drop behavior (mock
client), pairing state machine (all four contact statuses + owner-only accept), claude JSONL
parsing fixtures (user message, assistant message, tool-use records ignored).
## Acceptance criteria
- After one-time setup (TUI or `tinyplace setup --forward-to @owner`), a bare `tinyplace codex`
forwards every user + assistant message of the session as Signal DMs whose plaintext bodies
parse as `HarnessSessionEnvelope` v1, plus `started`/`ended` lifecycle envelopes — **no
per-invocation flags**.
- `tinyplace claude` does the same for a Claude Code session; both launch from the TUI too.
- With no config and no flags, behavior is unchanged from today (no forwarding, no prompts).
- With no contact edge, the wrapper pairs (or waits, queueing) before any envelope DM is sent; it
never auto-accepts an identity other than the configured forward-to target.
- Killing the network mid-session does not break the wrapped CLI; envelopes on disk stay complete.
- `pnpm test` green in `sdk/typescript`; new module has no `any`-typed public surface.
@@ -0,0 +1,105 @@
# Stage 2 — Contact pairing & DM authorization (OpenHuman ⇄ session identities)
## Goal command
> Build the **authentication/pairing flow** between the user's OpenHuman tiny.place identity and
> each wrapped Claude Code / Codex session identity. The relay **refuses 1:1 DMs between
> non-contacts** (`PUT /messages` → `403 not_a_contact`; see
> `backend-tinyplace-v2/docs/spec/contacts.md`), so before any stage-1 envelope can flow, the two
> identities must hold an **accepted mutual contact edge**. Expose the contacts API through the
> core `tinyplace` domain, implement a user-consented pairing policy in the orchestration domain,
> and surface link/approve UX in the Brain tab. Contact requests carry no free text by design
> (prompt-injection-safe bootstrap) — the pairing signal is the edge itself, never message content.
## Backend facts this stage builds on
- Contact model (`tiny.place/sdk/typescript/src/types/contacts.ts`, backend `docs/spec/contacts.md`):
one edge per unordered pair, `pending | accepted | blocked`, `requester`/`addressee` direction.
- Routes: `POST /contacts/{agentId}` (request), `POST …/accept`, `POST …/block`, `…/unblock`,
`DELETE /contacts/{agentId}`, `GET /contacts`, `GET /contacts/requests`,
`GET /contacts/{agentId}/status`, `GET /contacts/stats`. All signed; reads too (graph is private).
- **Crossing requests auto-accept**: if A→B is pending and B sends B→A, the edge converges to
`accepted` — this is the mechanism that makes user-initiated linking frictionless.
- Send is idempotent for duplicate outgoing / already-accepted; refused when blocked.
## Read first
- `backend-tinyplace-v2``docs/spec/contacts.md`, `docs/spec/messaging.md` (DM gate),
`internal/controllers/relay/controller.go` (enforcement).
- `tiny.place/sdk/typescript/src/api/contacts.ts` + `types/contacts.ts` — client surface to mirror.
- `src/openhuman/tinyplace/{mod.rs, schemas.rs, ops.rs, state.rs}` — controller pattern; note
there is **no contacts support in the core domain today** (net-new).
- `app/src/lib/agentworld/invokeApiClient.ts` — no `contacts` namespace yet (net-new).
- Approval surface precedent: `src/openhuman/approval/` + `ApprovalRequestCard` (frontend), and
`DomainEvent::ProactiveMessageRequested` for notification-style delivery.
- Stage 1 (`stage-01-tinyplace-session-bridge.md`) — the wrapper-side half of the handshake.
## Pairing flows (both must work)
**A. User-initiated link (recommended, zero unsolicited approvals):**
1. The tinyplace TUI setup flow shows the CLI's identity (`agentId` + optional `@handle`)
(stage 1; this identity is per-machine, from `~/.tinyplace/config.json` — pairing happens once,
not per session).
2. User pastes it into "Link a session" in the Brain tab → core sends `POST /contacts/{cliId}`.
3. The CLI, polling `GET /contacts/{owner}/status`, sees `pending incoming` from its **configured
owner identity** and accepts (it only ever auto-accepts that exact identity) — or, if the CLI
requested first, the owner's request crosses and **auto-accepts** server-side. The TUI flips to
"paired", persists the status, and envelopes flow for every future session with no further
steps.
**B. Session-initiated request (approval-gated):**
1. Wrapper sends the contact request to the owner identity and queues envelopes (stage 1).
2. Core polls/ingests `GET /contacts/requests` → each new incoming request raises an
orchestration pairing approval (approval-card or notification path), showing `agentId`,
resolved handle/profile if any, and first-seen time.
3. User accepts → core calls `POST /contacts/{sessionId}/accept`; decline → `DELETE`; block →
`…/block`. Config `[orchestration] auto_accept_session_contacts = false` (default **off**;
never auto-accept arbitrary requests).
## Deliverables
1. **Core contacts controllers** (`src/openhuman/tinyplace/schemas.rs` + `ops.rs`, internal
registry): `tinyplace_contacts_{request,accept,remove,block,unblock,list,requests,status,stats}`
mapping 1:1 onto the routes above through `TinyPlaceState::client()` signed HTTP.
2. **Pairing manager** (`orchestration/pairing.rs`): poll or stream incoming contact requests
(cursor in the stage-3 store's `kv`), dedupe, raise/resolve approvals, persist pairing records
`{ agent_id, label?, status, linked_at, source: user_link | approved_request }`. Publishes
`DomainEvent::OrchestrationPairingChanged`.
3. **Renderer client**: `contacts` namespace in `invokeApiClient.ts`
(`openhuman.tinyplace_contacts_*`) mirroring the SDK types.
4. **UI (Brain tab)**: "Link a session" affordance (paste `@handle`/agentId → request + pending
chip → accepted), pending-request approval list (accept / decline / block), and a linked-
sessions indicator on session chat windows (unlinked/pending sessions render a "waiting for
pairing" state instead of messages). i18n keys across all 14 locales.
5. **CLI-side handshake** (specified in `stage-01` deliverable 6, config-first + TUI-driven):
status check on start, request + bounded queue + poll on `none`, owner-only auto-accept,
`blocked` → hard error, `not_a_contact` at send time → re-enter pairing. Pairing is
**once per machine identity** (persisted `pairingStatus` in `~/.tinyplace/config.json`), never
per session or per flag.
6. **Security invariants**: wrapper auto-accepts only its configured owner identity; core never
auto-accepts unless the user initiated the link (flow A) or explicitly enabled the config;
blocked identities are never re-requested automatically; the contact graph is private — don't
sync it to any store outside the workspace.
## Tasks
1. Core controllers + ops with mock-relay unit tests (status transitions, idempotent request,
blocked refusal).
2. Pairing manager + approval integration + events; tests for flow A (crossing auto-accept) and
flow B (approve/decline/block), dedupe on re-poll.
3. Renderer `contacts` namespace + Vitest (RPC name mapping, type round-trip).
4. Brain-tab UX + i18n + component tests (link flow optimistic states, pending approval list,
unpaired session placeholder).
5. Amend the stage-1 CLI per deliverable 5 (lands in `tiny.place/` with its own tests: queue
bounds, owner-only auto-accept, `not_a_contact` recovery).
6. `tests/json_rpc_e2e.rs`: pair → DM flows; unpaired → send refused surfaced cleanly.
## Acceptance criteria
- Flow A: pasting the CLI identity in the tab yields an accepted edge (crossing-request case
covered) and envelopes flow with no approval prompt.
- Flow B: an unsolicited session request appears as a pending approval; accept → DMs flow;
decline/block → wrapper reports a clear terminal state and stops retrying.
- `not_a_contact` at send time never loops hot — it parks into pairing state on both sides.
- No path auto-accepts an arbitrary identity; config default is prompt.
- `pnpm test:rust`, `pnpm test`, `pnpm i18n:check` green; ≥80% changed-line coverage.
@@ -0,0 +1,71 @@
# Stage 3 — Core ingest + session state (`src/openhuman/orchestration/`)
## Goal command
> Create a new Rust domain `src/openhuman/orchestration/` that ingests tiny.place Signal DMs,
> recognizes `HarnessSessionEnvelope` v1 payloads (stage 1), and maintains durable per-session
> state (master / subconscious / session chat windows). This is the "channel ingestion" boundary of
> the split-brain graph: it normalizes heterogeneous DM traffic into typed graph inputs and
> persists a chat-window model the RPC/UI layer (stage 7) can read directly — replacing the string
> heuristics currently in `TinyPlaceOrchestrationTab.tsx`.
## Read first
- `src/openhuman/tinyplace/``mod.rs` (architecture), `schemas.rs` (`handle_tinyplace_messages_list`,
`handle_tinyplace_signal_send_message`), `signal_store.rs`, `streams.rs`, `state.rs`.
- `src/openhuman/subconscious/store.rs` — SQLite-per-domain store pattern.
- `src/core/event_bus/``DomainEvent`, `publish_global`/`subscribe_global`, domain `bus.rs` convention.
- `tiny.place/sdk/typescript/src/types/session-envelope.ts` (after stage 1) — the wire schema.
- Canonical module shape table in `CLAUDE.md`.
## Deliverables
1. **Domain skeleton** (canonical shape): `orchestration/{mod.rs, types.rs, store.rs, ops.rs,
schemas.rs, bus.rs, ingest.rs}` + inline tests. Wire `all_controller_schemas` into
`src/core/all.rs` (schemas themselves land in stage 7; register the namespace now).
2. **`types.rs`**: Rust mirror of `HarnessSessionEnvelope` (serde, `#[serde(tag = "v")]`-style
versioning tolerant of unknown fields), plus:
- `ChatKind { Master, Subconscious, Session }`
- `OrchestrationSession { session_id, source (Codex|ClaudeCode|Other), label, workspace,
last_seq, created_at, last_message_at, active }`
- `OrchestrationMessage { id, session_id, chat_kind, role, body, timestamp, encrypted, seq }`
3. **`ingest.rs`**: subscribe to incoming tiny.place DMs. Preferred seam: a `DomainEvent`
published by the tinyplace domain when a Signal DM is received/decrypted (add
`DomainEvent::TinyplaceDmReceived { from, envelope_json, … }` in the tinyplace stream/inbox
path — `streams.rs` websocket handler and the poll path both publish). Fallback if streams are
unavailable: a poll loop calling the existing messages-list op with a cursor. Classification:
- body parses as `HarnessSessionEnvelope` → `ChatKind::Session`, keyed by `sessionId`.
- DM from the agent's own subconscious identity/thread marker → `ChatKind::Subconscious`.
- everything else from the owner/human counterpart → `ChatKind::Master`.
Idempotent by `(session_id, seq)` / message id — re-ingest must not duplicate.
4. **`store.rs`**: SQLite at `<workspace>/orchestration/orchestration.db` — tables `sessions`,
`messages` (indexed by session + timestamp), `kv`. Retention: prune messages beyond N=2000 per
session (configurable). Message bodies stored decrypted here are workspace-internal — protected
by `is_workspace_internal_path`.
5. **`bus.rs`**: `OrchestrationIngestSubscriber` (`name() = "orchestration::ingest"`), registered
at startup next to the other bus registrations; also publish
`DomainEvent::OrchestrationSessionMessage` after persist, so stage 4 (front-end graph) and
stage 7 (UI socket push) can both react without coupling.
6. **Logging**: `[orchestration]` prefix; log envelope seq/session/kind on ingest entry/exit,
classification decisions, dedupe skips, parse failures (body **never** logged).
## Tasks
1. Scaffold domain + wire `mod.rs`/`all.rs`; add config knob `[orchestration] enabled = true`
(schema in `src/openhuman/config/schema/`, follow `scheduler_gate.rs` pattern).
2. Implement types + envelope parsing with fixture tests (valid v1, unknown v, junk body → Master).
3. Implement store + migrations + retention, unit-tested with tempdir DBs.
4. Add the `TinyplaceDmReceived` event publication in `tinyplace::streams`/inbox ops; implement
`OrchestrationIngestSubscriber`; startup registration.
5. Poll-fallback ingest with cursor in `kv` (used when the Signal stream is down), behind the same
dedupe.
6. Tests: end-to-end ingest of a synthetic envelope sequence → sessions/messages rows; dedupe on
replay; classification matrix.
## Acceptance criteria
- Feeding N stage-1 envelopes (mixed sessions, out-of-order seq, duplicates) through the
subscriber yields correctly bucketed, deduped `sessions`/`messages` rows.
- Non-envelope DMs land in the Master window; nothing crashes on malformed bodies.
- `cargo check` + `pnpm test:rust` green; new code ≥80% line coverage on the diff.
- No message bodies or seeds in logs at any level.
@@ -0,0 +1,75 @@
# Stage 4 — Unified orchestration graph: skeleton, state & front-end nodes
## Goal command
> Build the **single orchestration graph** that carries the whole wake path, and implement its
> **front-end (Quick LLM) nodes**. Define the shared `OrchestrationState`, the graph topology with
> conditional routing, checkpointing on thread id `orchestration:<session_id>`, and the two-pass
> front-end behavior: pass 1 turns raw session/master traffic into `agent_instructions` and routes
> down; pass 2 (after the reasoning node sets `agent_reply`, stubbed in this stage) compiles
> `channel_response` and sends it back to the originating tiny.place DM. No human gate — the loop
> is autonomous, and the routing predicate must terminate (spec §5 loop continuity).
## Read first
- `docs/plans/subconscious-orchestration/README.md` — "one graph, two triggers" design decision.
- `src/openhuman/agent/harness/agent_graph.rs``AgentGraph::Custom`, `AgentTurnRequest`.
- `src/openhuman/tinyagents/``mod.rs` (shared runner), `checkpoint.rs`
(`SqlRunLedgerCheckpointer`), `topology.rs`/`orchestration.rs` (existing graph-composition
precedents — follow whichever expresses multi-node graphs; document the choice), `tools.rs`
(`EarlyExit` seam), `model.rs` (per-node provider/model resolution).
- `src/openhuman/subconscious/agent/` — slim-agent packaging (agent.toml + prompt.md + graph.rs).
- `src/openhuman/routing/policy.rs``hint:chat` (quick, remote for TTFT) vs `hint:reasoning`.
- `src/openhuman/tinyplace/schemas.rs``handle_tinyplace_signal_send_message` (reply path).
- `docs/arch-subconscious.md` §2.2, §4 — node/edge reference topology.
## Deliverables
1. **Graph state** (`orchestration/graph/state.rs`): `OrchestrationState``messages` (windowed
from the stage-3 store), `agent_instructions: Option<String>`, `agent_reply: Option<String>`,
`channel_response: Option<String>`, `subconscious_steering: Option<String>` (read in stage 5/6),
`compressed_history: Vec<CompressedEntry>`, `world_state_diff: WorldDiff`,
`context_utilization: f32`. Serde-serializable so `SqlRunLedgerCheckpointer<OrchestrationState>`
persists it at superstep boundaries.
2. **Graph topology** (`orchestration/graph/mod.rs`), registered as the orchestration agent's
`AgentGraph::Custom` runner:
- Nodes this stage: `normalize` (fold pending session messages into state),
`frontend` (two-pass, Quick LLM), `send_dm` (Signal reply to the session counterpart),
`execute_stub` (sets a canned `agent_reply`; replaced in stage 5), `context_guard`.
- Conditional edges = the spec's router: from `frontend`, `channel_response` present →
`send_dm``context_guard` → END; else → `execute` → back to `frontend`.
- **Invocation**: `invoke_orchestration_graph(session_id)` in `orchestration/ops.rs`, called by
the stage-3 ingest subscriber on `OrchestrationSessionMessage`, debounced per session so DM
bursts produce one graph run. Resumes from the last checkpoint for that thread.
3. **Front-end node** driven by a slim agent package
`orchestration/frontend_agent/{agent.toml, prompt.md}`: model `hint:chat`, small context
budget, tool surface limited to `defer_to_orchestrator` (EarlyExit emitting
`agent_instructions`) and `reply_to_channel` (emits `channel_response`); pass selection purely
from state (`agent_reply` present → pass 2), mirroring `frontend_agent_node` in the spec.
4. **Gating**: graph runs with background origin (no interactive approval parking); each
LLM-bearing node awaits `scheduler_gate::wait_for_capacity()`.
5. **Logging**: `[orchestration]` node entry/exit with `session_id`, node name, pass number,
routing decision at debug.
## Tasks
1. State + checkpointer round-trip test (serialize → resume → identical state).
2. Topology with stubs; graph-level test: one invocation walks
normalize → frontend(1) → execute_stub → frontend(2) → send_dm → guard → END, sending exactly
one DM (mock tinyplace op).
3. Frontend agent package (prompt.md with two-pass contract + macro-instruction format,
agent.toml) + loader registration; tools in `orchestration/tools.rs` (domain-owned rule).
4. Debounced invocation from the ingest subscriber; idempotence test (re-invoke with no new
messages → no LLM call, no DM).
5. Loop-continuity property test: adversarial state combos (`agent_reply` + `channel_response`
both set, neither set, instructions without reply) never cycle more than the configured max
supersteps and never double-send.
## Acceptance criteria
- A new session DM triggers one full stubbed cycle ending in exactly one outbound Signal DM to the
right counterpart; checkpoints exist for the thread.
- Quick tier verified: frontend node requests resolve via `hint:chat`; prompt+history within the
small budget (asserted).
- Kill/restart mid-run resumes from checkpoint without a duplicate DM.
- `pnpm test:rust` green; no infinite cycling under repeated triggers.
@@ -0,0 +1,76 @@
# Stage 5 — Reasoning + memory nodes (completing the unified graph)
## Goal command
> Replace the stage-4 stubs with the real **reasoning and memory nodes** of the unified
> orchestration graph: an `execute` node on the reasoning tier that applies the current
> subconscious steering directive and spawns execution sub-agents via `subagent_runner`, followed
> by the three memory mechanics from the spec as their own nodes — the **20:1 compression hook**
> over the cycle's execution trace, the **append-only world-state diff**, and the **8090%
> context-eviction guard** that runs after mutations and before END. All state changes flow
> through `OrchestrationState` and are checkpointed; durable copies land in the orchestration
> store for the subconscious (stage 6) and the UI (stage 7).
## Read first
- Stage 4's `orchestration/graph/` (state, topology, stubs to replace).
- `src/openhuman/tinyagents/``mod.rs` (`run_turn_via_tinyagents_shared`, `RunPolicy`, steering
forwarders), `delegation.rs` (sub-agent spawning), `summarize.rs` (tokenizer + summarization
seam), `middleware.rs` (`TurnContextMiddleware`, `SuperContextConfig`), `observability.rs`
(`OpenhumanEventBridge`, `CapPauser`, `GraphTracingSink`).
- `src/openhuman/agent/harness/subagent_runner/ops/graph.rs``run_subagent_via_graph`.
- `docs/arch-subconscious.md` §3–§5 — compression ratio, world diff shape, guardrail checklist.
## Deliverables
1. **`execute` node** driven by `orchestration/reasoning_agent/{agent.toml, prompt.md}`
(`agent_tier = "reasoning"`, `hint:reasoning`, large context):
- System prompt assembled per cycle: base prompt + `subconscious_steering` from state (default
alignment string when empty).
- Tools: sub-agent spawn (via the `delegation.rs` seam; steering text threaded into sub-agent
definitions as steering traits, spec §3.2; concurrent cap config, default 2) + the tinyplace
read tools already whitelisted for the orchestrator agent.
- Output: sets `agent_reply` in state; raw trace (assistant text, tool calls/results, sub-agent
outputs) captured for the compression node.
2. **`compress` node** (`orchestration/graph/compress.rs`): token-count the cycle trace (tokenizer
util used by `summarize.rs`); summarize via a cheap `hint:*` route with an enforced output
budget of `min(input_tokens / 20, input_tokens)`; apply the 200-token floor only when the
source trace is large enough that the budget is still compressive. Retry once if >1.5× budget,
then hard-truncate. Append to `state.compressed_history` **and** persist to the store's
`compressed_history` table (cycle id, session id, token counts, text).
3. **`world_diff` node** (`orchestration/graph/world_diff.rs` + store table): append one timeline
entry `{ seq, cycle_id, event_signature, world_mutation, delta, timestamp }`; `genesis` row on
first cycle; `terminal_state` kv updated per cycle. Append-only — never rewritten.
4. **`context_guard` node**: utilization from accumulated `AgentTurnUsage` vs the resolved context
window, stored in `state.context_utilization`; at ≥ threshold
(`[orchestration] context_evict_threshold = 0.85`, clamped 0.80.9) map-reduce-summarize the
oldest compressed-history entries, push summaries to the memory domain
(`metadata.path_scope = "orchestration/<session_id>"` for RAG), drop them from state, reset
utilization. Runs after all mutations, before END (spec invariant — edge ordering test).
5. **Observability**: node progress mirrored through `OpenhumanEventBridge` (cycles + sub-agents
visible in the agent-observability UI with usage/cost); `scheduler_gate::wait_for_capacity()`
before every model call (execute, compress, evict).
## Tasks
1. Reasoning agent package + swap `execute_stub``execute`; mock-provider test: instructions in
→ steering applied (assert in captured system prompt) → `agent_reply` set.
2. Sub-agent spawning path with a stub sub-agent; steering-trait threading test.
3. `compress` node with ratio-enforcement tests (large fixture trace → ≤ budget×1.5; floor case;
store row written).
4. `world_diff` node + append-only property test (two cycles → seq 1,2; genesis untouched).
5. `context_guard` tests at 0.84 (no-op) and 0.86 (evicts: state shrinks, memory write happened,
utilization reset) + guard-before-END edge-ordering test.
6. Full-graph e2e (mock providers): DM → normalize → frontend(1) → execute (sub-agent stub) →
compress → world_diff → frontend(2) → send_dm → guard → END; exactly one compressed row + one
diff entry per cycle; checkpoint resume mid-cycle produces no duplicate side effects.
## Acceptance criteria
- Full autonomous cycle passes hermetically with exactly one outbound DM, one compressed-history
row, one world-diff entry.
- All four spec §5 guardrails have a dedicated test (loop continuity, 20:1 strictness, append-only
diff, guard-before-END ordering).
- Kill/restart mid-cycle resumes from the last checkpoint without duplicate outbound DMs or
duplicate store rows.
- `pnpm test:rust` green; cycles visible in agent observability with cost/usage totals.
@@ -0,0 +1,65 @@
# Stage 6 — Subconscious steering loop (offline reflection)
## Goal command
> Extend the existing **`SubconsciousEngine`** so its cron/heartbeat tick consumes the
> orchestration layer's **compressed history** and **cumulative world-state diff** (stage 5) and
> emits short, dense **steering directives** that are injected into the Reasoning orchestrator's
> prompts on subsequent cycles. The subconscious stays fully offline: no channels, no user
> contact, no tools with external effects — its only output is the directive (and its existing
> proactive-notify path, unchanged).
## Read first
- `src/openhuman/subconscious/README.md`, `engine.rs` (three-stage tick), `heartbeat/mod.rs`,
`store.rs`, `agent/{agent.toml, prompt.md, graph.rs}`, `global.rs`.
- `src/openhuman/scheduler_gate/` — capacity gating already applied to ticks.
- Stage 5's store tables (`compressed_history`, `world_diff`) and `orchestration/types.rs`.
- `docs/arch-subconscious.md` §3.2 — world-diff evaluation semantics ("macro-trends, filter
localized variance").
## Deliverables
1. **New tick stage `orchestration_review`** in `SubconsciousEngine::tick_inner`, after
`memory_diff` / before `decide` (or folded into `prepare_context` — decide against the existing
stage contract and document): loads, since the last reviewed cursor,
- unreviewed `compressed_history` rows (bounded batch, oldest-first), and
- the **full world-diff timeline** (it is the cumulative object the spec requires — cap by
summarizing the oldest ranges through the same 20:1 hook if it outgrows the budget).
2. **Steering synthesis**: extend the slim subconscious agent prompt with a steering section —
output contract: at most ~150 tokens, imperative, model-agnostic
(`STEERING_DIRECTIVE: …` semantics from the spec), plus a machine field
`expires_after_cycles: u32` (default 20). Runs on the existing `hint:subconscious` route with
`SubconsciousTainted` origin.
3. **Steering store** (`orchestration/store.rs`, table `steering_directives`): append-only history
`{ id, text, created_at, source_tick_id, expires_after_cycles, superseded_by }`; "current
directive" = latest non-expired. The unified graph's `execute` node loads it into
`state.subconscious_steering` at cycle start — the spec's out-of-band
`update_state(as_node="subconscious_cron")` pattern: the subconscious is a decoupled writer
into the checkpointed thread, never an edge in the wake graph.
4. **Feedback provenance**: each directive records which compressed-history rows / diff seq range
it was derived from; reviewed cursor advances only on successful persist (idempotent ticks).
5. **Subconscious chat surface**: publish each emitted directive as an
`OrchestrationMessage { chat_kind: Subconscious }` for the local UI only. This fills the pinned
"Subconscious" window in the UI (stage 7) without introducing outbound tiny.place effects.
## Tasks
1. Add the tick stage + cursor kv; no-op cleanly when orchestration is disabled or tables empty.
2. Prompt work: steering section with 23 few-shot examples; parse/validate the structured output
(reject and retry once on contract violation; skip tick on second failure, log warn).
3. Store + supersede/expiry logic; unit tests (expiry by cycle count, supersede chain).
4. Integration test: seed fake compressed rows + diff timeline → tick → directive persisted →
the stage-5 graph loads it via `execute` on the next cycle (assert it lands in the system prompt of
the mock provider call).
5. Isolation test: assert the tick's tool surface contains no channel/effect tools and that no
tinyplace outbound op is reachable.
## Acceptance criteria
- A heartbeat tick over seeded orchestration data produces exactly one current directive; the next
reasoning cycle demonstrably runs with it injected.
- Ticks are idempotent (re-run without new data → no new directive) and cheap when idle.
- Directives appear in the Subconscious chat window feed (stage 7 consumes them).
- Existing subconscious behaviors (memory diff, planner, notify_user) unchanged — their tests stay
green.
@@ -0,0 +1,71 @@
# Stage 7 — RPC surface + TinyPlaceOrchestrationTab wiring
## Goal command
> Expose the orchestration layer over JSON-RPC (`openhuman.orchestration_*`) and rewire
> **`app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx`** onto it: the tab's
> master / subconscious / per-session chat windows come from the stage-3 store's real
> classification and metadata instead of the current `chatKindForEnvelope` string heuristics; add
> live updates over the core socket and a composer for the Master window (owner → agent DM).
## Read first
- `app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx` (+ its test) — current shape:
`ChatKind`/`ChatWindow` model, pinned master+subconscious, `LoadState` incl. `payment_required`.
- `app/src/lib/agentworld/invokeApiClient.ts``callCoreRpc` pattern, `PaymentRequiredError`.
- `src/openhuman/tinyplace/schemas.rs` — internal-registry controller pattern to copy.
- `src/openhuman/orchestration/{types.rs, store.rs, ops.rs}` (stages 36).
- Socket push: `app/src/services/socketService.ts`, `src/openhuman/socket/` — how domain events
reach the renderer (dual-socket sync rule).
- i18n: `app/src/lib/i18n/en.ts` `tinyplaceOrchestration.*` block (exists; extend).
## Deliverables
1. **RPC controllers** (`orchestration/schemas.rs`, internal registry — renderer-only, not
agent-advertised):
- `orchestration.sessions_list``{ sessions: OrchestrationSession[] }` (incl. computed
`active`, unread counts).
- `orchestration.messages_list { chat: "master"|"subconscious"|{sessionId}, limit, before? }`.
- `orchestration.send_master_message { body }` → DM to the agent's Master counterpart via the
signal-send op (this is the human steering the front-end agent).
- `orchestration.mark_read { chat }`.
- `orchestration.status` → steering directive (current), last tick, ingest cursor health.
2. **Renderer client** `app/src/lib/orchestration/orchestrationClient.ts` following the
`invokeApiClient` conventions (`callCoreRpc('openhuman.orchestration_…')`, typed results,
`PaymentRequiredError` passthrough where relevant).
3. **Tab rewire** (keep the existing layout/UX and testids):
- Data source: `sessions_list` + per-selected-chat `messages_list` (drop the client-side
bucketing of raw envelopes; keep the file's `ChatWindow` view model).
- Live updates: subscribe to an `orchestration.message` socket event (bridge
`DomainEvent::OrchestrationSessionMessage` / `ReplyReady` / steering-emitted through the
socket domain) → targeted refetch of the affected chat; keep manual Refresh.
- Master composer: input + send via `send_master_message`, optimistic append, error surface.
- Unread: `mark_read` on chat open; badge from server counts.
- Preserve `payment_required` and error states; loading per-pane rather than whole-tab.
4. **i18n**: new keys (composer placeholder, send, steering banner, read errors) added to `en.ts`
**and all 13 other locales** (`pnpm i18n:check`, `pnpm i18n:english:check` clean).
5. **Steering visibility**: pinned Subconscious window shows directives (stage 6 feed); header
chip on the tab surfaces the current directive from `orchestration.status`.
## Tasks
1. Controllers + schemas + `all.rs` wiring; unit tests on handlers (`RpcOutcome` paths, bad chat
key, empty stores).
2. Socket bridge for the three orchestration events (follow an existing domain's socket relay).
3. `orchestrationClient.ts` + Vitest (RPC name mapping, error classification).
4. Tab refactor: extract data hooks (`useOrchestrationChats`) so the component stays presentational
and under the ~500-line rule; update `TinyPlaceOrchestrationTab.test.tsx` to mock the new
client; add tests for composer send, live-update refetch, unread clearing.
5. i18n additions across locales.
6. `tests/json_rpc_e2e.rs`: seed store → `sessions_list`/`messages_list`/`send_master_message`
round-trip over real RPC with the mock backend.
## Acceptance criteria
- With stages 16 running and a wrapped Codex/Claude session active, the tab shows that session's
window with real label/workspace metadata and both user and assistant messages, updating live
without manual refresh.
- Master composer sends an E2E DM that reaches the front-end agent (verified in e2e via mock).
- Subconscious window shows emitted steering directives.
- `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm i18n:check` green; no `import.meta.env` reads;
no dynamic imports; all RPC through `core_rpc_relay`.
@@ -0,0 +1,48 @@
# Stage 8 — End-to-end testing, observability & hardening
## Goal command
> Prove the whole loop with automated tests spanning both repos, audit logging/observability
> against the project's debug-logging rules, and harden failure modes (relay down, payment
> required, malformed envelopes, provider errors) so the layer can run unattended.
## Read first
- `tests/json_rpc_e2e.rs`, `scripts/test-rust-with-mock.sh`, `scripts/mock-api-core.mjs`.
- `gitbooks/developing/agent-observability.md`, `gitbooks/developing/testing-strategy.md`.
- `app/test/e2e/` — WDIO spec conventions.
- All stage docs in this folder (the invariants list in `README.md`).
## Deliverables
1. **Mock tiny.place relay** additions to the shared mock backend (`scripts/mock-api-core.mjs` or
a sibling): DM send/receive + contacts endpoints (enforcing `403 not_a_contact` on unpaired
DMs, crossing-request auto-accept) + a scriptable "wrapped session" that emits stage-1
envelopes, so the full loop runs hermetically in CI.
2. **Cross-layer e2e** (`tests/json_rpc_e2e.rs` + mock relay): scripted session pairs first
(stage-2 flow A and flow B both covered), then emits
user+assistant envelopes → ingest → frontend pass 1 → reasoning cycle (mock provider) →
frontend pass 2 → outbound DM captured by the mock → `orchestration.messages_list` shows the
complete conversation; then a subconscious tick emits a directive and the next cycle carries it.
3. **Failure-mode suite**: relay 5xx/timeout during ingest and during reply (retry + no message
loss), `payment_required` on send (surfaced, not retried blindly), malformed envelope flood
(bounded log noise, Master fallback), provider error mid-graph (checkpoint resume, no duplicate
outbound DM), scheduler_gate `Paused` (cycles defer, none dropped).
4. **Observability audit**: every node/stage logs entry/exit + correlation ids
(`session_id`, `cycle_id`, `tick_id`); cycles and sub-agents visible in the agent-observability
UI with usage/cost; `orchestration.status` exposes ingest-cursor lag and last-error. Add a
`doctor` check for orchestration health if the doctor domain pattern fits.
5. **Frontend E2E** (WDIO, mock core): Brain → Orchestration tab renders pinned windows + a seeded
session, live-updates on a pushed message, composer send round-trips.
6. **Docs**: update `gitbooks/developing/architecture/agent-harness.md` (new graph), a new
`gitbooks/developing/architecture/orchestration.md` narrative, and
`src/openhuman/about_app/` (user-facing feature registry). Run `pnpm docs:check`.
## Acceptance criteria
- One command each side proves the loop: `bash scripts/test-rust-with-mock.sh --test json_rpc_e2e`
and `pnpm test` green, including the new suites; WDIO spec passes on the Linux lane.
- Coverage gate (≥80% changed lines) passes across frontend + rust-core lanes.
- Failure-mode suite green; no unbounded retry loops; no secret/body leakage in captured logs
(assert with a log-scan test).
- Docs Drift lane green; about_app updated.
+6
View File
@@ -346,6 +346,9 @@ fn build_internal_only_controllers() -> Vec<RegisteredController> {
// tiny.place A2A social-network integration: renderer-callable via core_rpc_relay
// but NOT advertised to agents in tool listings or schema discovery.
controllers.extend(crate::openhuman::tinyplace::all_tinyplace_registered_controllers());
// User-consented tiny.place pairing for wrapped agent sessions: UI-callable
// via core_rpc_relay, but excluded from agent tool listings/schema discovery.
controllers.extend(crate::openhuman::agent_orchestration::all_pairing_registered_controllers());
controllers
}
@@ -579,6 +582,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"agent_team" => Some(
"Durable agent-team coordination: teams, members, dependency-aware task claiming, and teammate messaging.",
),
"orchestration_pairing" => Some(
"User-consented tiny.place contact pairing for wrapped agent sessions.",
),
"billing" => Some("Subscription plan, payment links, and credit top-up via the backend."),
"announcements" => {
Some("Latest active product announcement surfaced on harness init, via the backend.")
+20
View File
@@ -217,6 +217,18 @@ fn schema_for_rpc_method_finds_internal_mcp_audit_list() {
assert_eq!(s.function, "list");
}
#[test]
fn schema_for_rpc_method_finds_internal_orchestration_pairing_link_session() {
let schema = schema_for_rpc_method("openhuman.orchestration_pairing_link_session");
assert!(
schema.is_some(),
"orchestration_pairing.link_session should be internally routable"
);
let s = schema.unwrap();
assert_eq!(s.namespace, "orchestration_pairing");
assert_eq!(s.function, "link_session");
}
#[test]
fn rpc_method_from_parts_does_not_expose_internal_mcp_audit_list() {
assert!(
@@ -225,6 +237,14 @@ fn rpc_method_from_parts_does_not_expose_internal_mcp_audit_list() {
);
}
#[test]
fn rpc_method_from_parts_does_not_expose_internal_orchestration_pairing() {
assert!(
rpc_method_from_parts("orchestration_pairing", "link_session").is_none(),
"pairing write RPCs must not appear in the public controller registry"
);
}
#[test]
fn schema_for_rpc_method_returns_none_for_unknown() {
assert!(schema_for_rpc_method("openhuman.nonexistent_method_xyz").is_none());
+10
View File
@@ -129,6 +129,14 @@ pub enum DomainEvent {
orchestration_id: String,
reason: Option<String>,
},
/// A tiny.place contact edge changed for a wrapped orchestration session.
/// Payload is intentionally metadata only; contact graph details stay behind
/// the signed tiny.place API.
OrchestrationPairingChanged {
agent_id: String,
status: String,
source: String,
},
// ── Subconscious orchestrator ───────────────────────────────────────
/// A subconscious trigger finished gate evaluation (promote or drop).
@@ -1217,6 +1225,7 @@ impl DomainEvent {
| Self::AgentOrchestrationCompleted { .. }
| Self::AgentOrchestrationFailed { .. }
| Self::AgentOrchestrationClosed { .. }
| Self::OrchestrationPairingChanged { .. }
| Self::RunQueueMessageQueued { .. }
| Self::RunQueueMessageDelivered { .. }
| Self::RunQueueFollowupDispatched { .. }
@@ -1373,6 +1382,7 @@ impl DomainEvent {
Self::AgentOrchestrationCompleted { .. } => "AgentOrchestrationCompleted",
Self::AgentOrchestrationFailed { .. } => "AgentOrchestrationFailed",
Self::AgentOrchestrationClosed { .. } => "AgentOrchestrationClosed",
Self::OrchestrationPairingChanged { .. } => "OrchestrationPairingChanged",
Self::SubconsciousTriggerProcessed { .. } => "SubconsciousTriggerProcessed",
Self::RunQueueMessageQueued { .. } => "RunQueueMessageQueued",
Self::RunQueueMessageDelivered { .. } => "RunQueueMessageDelivered",
+6
View File
@@ -11,6 +11,8 @@ pub mod background_delivery;
pub mod command_center;
pub mod delegation;
mod ops;
pub mod pairing;
mod pairing_schemas;
pub(crate) mod parent_context;
pub mod run_ledger_finalize;
pub mod running_subagents;
@@ -30,6 +32,10 @@ pub use command_center::{
all_command_center_controller_schemas, all_command_center_registered_controllers,
};
pub use ops::{AgentOrchestrationSession, OrchestrationError};
pub use pairing_schemas::{
all_controller_schemas as all_pairing_controller_schemas,
all_registered_controllers as all_pairing_registered_controllers,
};
pub use subagent_control::{
all_controller_schemas as all_subagent_control_controller_schemas,
all_registered_controllers as all_subagent_control_registered_controllers,
@@ -0,0 +1,499 @@
//! User-consented tiny.place contact pairing for wrapped agent sessions.
//!
//! The tiny.place backend owns the contact graph; this module owns OpenHuman's
//! local consent record for orchestration sessions that are allowed to exchange
//! 1:1 encrypted envelopes.
use std::collections::HashMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::Mutex;
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::config::Config;
use crate::openhuman::tinyplace::ops::{global_state as tinyplace_state, map_err};
const LOG_TARGET: &str = "orchestration_pairing";
static STORE_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PairingStatus {
Pending,
Linked,
Blocked,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PairingSource {
UserLink,
ApprovedRequest,
}
impl PairingSource {
fn as_str(&self) -> &'static str {
match self {
Self::UserLink => "user_link",
Self::ApprovedRequest => "approved_request",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PairingRecord {
pub agent_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub status: PairingStatus,
pub linked_at: String,
pub source: PairingSource,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PairingSnapshot {
pub records: Vec<PairingRecord>,
pub contacts: Value,
pub requests: Value,
pub stats: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PairingActionResult {
pub record: Option<PairingRecord>,
pub remote: Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PairingStore {
#[serde(default)]
records: Vec<PairingRecord>,
}
pub async fn list(config: &Config) -> Result<PairingSnapshot, String> {
log::debug!(target: LOG_TARGET, "[orchestration_pairing] list.entry");
let records = load_store(&config.workspace_dir).await?.records;
let client = tinyplace_state().client().await?;
let contacts: Value = client
.http()
.get_agent_auth::<Value>("/contacts", &[("limit".to_string(), "100".to_string())])
.await
.map_err(map_err)?;
let requests: Value = client
.http()
.get_agent_auth::<Value>(
"/contacts/requests",
&[("limit".to_string(), "100".to_string())],
)
.await
.map_err(map_err)?;
let stats: Value = client
.http()
.get_agent_auth::<Value>("/contacts/stats", &[])
.await
.map_err(map_err)?;
log::debug!(
target: LOG_TARGET,
"[orchestration_pairing] list.exit records={}",
records.len()
);
Ok(PairingSnapshot {
records,
contacts,
requests,
stats,
})
}
pub async fn link_session(
config: &Config,
agent_id: &str,
label: Option<String>,
) -> Result<PairingActionResult, String> {
let agent_id = normalize_agent_id(agent_id)?;
log::debug!(target: LOG_TARGET, "[orchestration_pairing] link.entry agent_id={agent_id}");
let client = tinyplace_state().client().await?;
let status = contact_status(&agent_id).await?;
if status == "blocked" {
log::warn!(
target: LOG_TARGET,
"[orchestration_pairing] link.blocked agent_id={agent_id}"
);
return Err("session agent is blocked; unblock before linking".to_string());
}
let remote = if status == "accepted" {
serde_json::json!({ "agentId": agent_id, "status": "accepted" })
} else {
client
.http()
.post_agent_auth::<Value, ()>(&contact_path(&agent_id, None), None)
.await
.map_err(map_err)?
};
let record_status = if remote_status(&remote).as_deref() == Some("accepted") {
PairingStatus::Linked
} else {
PairingStatus::Pending
};
let record = persist_record(
&config.workspace_dir,
agent_id,
label,
record_status,
PairingSource::UserLink,
)
.await?;
publish_pairing_changed(&record);
log::debug!(target: LOG_TARGET, "[orchestration_pairing] link.exit agent_id={}", record.agent_id);
Ok(PairingActionResult {
record: Some(record),
remote,
})
}
pub async fn accept_request(
config: &Config,
agent_id: &str,
) -> Result<PairingActionResult, String> {
let agent_id = normalize_agent_id(agent_id)?;
log::debug!(target: LOG_TARGET, "[orchestration_pairing] accept.entry agent_id={agent_id}");
let client = tinyplace_state().client().await?;
let remote: Value = client
.http()
.post_agent_auth::<Value, ()>(&contact_path(&agent_id, Some("accept")), None)
.await
.map_err(map_err)?;
let record = persist_record(
&config.workspace_dir,
agent_id,
None,
PairingStatus::Linked,
PairingSource::ApprovedRequest,
)
.await?;
publish_pairing_changed(&record);
log::debug!(target: LOG_TARGET, "[orchestration_pairing] accept.exit agent_id={}", record.agent_id);
Ok(PairingActionResult {
record: Some(record),
remote,
})
}
pub async fn decline_request(
config: &Config,
agent_id: &str,
) -> Result<PairingActionResult, String> {
let agent_id = normalize_agent_id(agent_id)?;
log::debug!(target: LOG_TARGET, "[orchestration_pairing] decline.entry agent_id={agent_id}");
let client = tinyplace_state().client().await?;
let remote: Value = client
.http()
.delete_agent_auth::<Value, ()>(&contact_path(&agent_id, None), None)
.await
.map_err(map_err)?;
remove_record(&config.workspace_dir, &agent_id).await?;
publish_global(DomainEvent::OrchestrationPairingChanged {
agent_id: agent_id.clone(),
status: "removed".to_string(),
source: "approved_request".to_string(),
});
log::debug!(target: LOG_TARGET, "[orchestration_pairing] decline.exit agent_id={agent_id}");
Ok(PairingActionResult {
record: None,
remote,
})
}
pub async fn block_request(config: &Config, agent_id: &str) -> Result<PairingActionResult, String> {
let agent_id = normalize_agent_id(agent_id)?;
log::debug!(target: LOG_TARGET, "[orchestration_pairing] block.entry agent_id={agent_id}");
let client = tinyplace_state().client().await?;
let remote: Value = client
.http()
.post_agent_auth::<Value, ()>(&contact_path(&agent_id, Some("block")), None)
.await
.map_err(map_err)?;
let record = persist_record(
&config.workspace_dir,
agent_id,
None,
PairingStatus::Blocked,
PairingSource::ApprovedRequest,
)
.await?;
publish_pairing_changed(&record);
log::debug!(target: LOG_TARGET, "[orchestration_pairing] block.exit agent_id={}", record.agent_id);
Ok(PairingActionResult {
record: Some(record),
remote,
})
}
async fn contact_status(agent_id: &str) -> Result<String, String> {
let client = tinyplace_state().client().await?;
let remote: Value = client
.http()
.get_agent_auth::<Value>(&contact_path(agent_id, Some("status")), &[])
.await
.map_err(map_err)?;
Ok(remote_status(&remote).unwrap_or_else(|| "none".to_string()))
}
fn remote_status(value: &Value) -> Option<String> {
value
.get("status")
.and_then(Value::as_str)
.map(str::to_string)
}
async fn persist_record(
workspace_dir: &Path,
agent_id: String,
label: Option<String>,
status: PairingStatus,
source: PairingSource,
) -> Result<PairingRecord, String> {
let store_lock = store_lock(workspace_dir).await;
let _guard = store_lock.lock().await;
let mut store = load_store(workspace_dir).await?;
let record = PairingRecord {
agent_id,
label: label.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}),
status,
linked_at: chrono::Utc::now().to_rfc3339(),
source,
};
store
.records
.retain(|existing| existing.agent_id != record.agent_id);
store.records.push(record.clone());
store.records.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
save_store(workspace_dir, &store).await?;
Ok(record)
}
async fn remove_record(workspace_dir: &Path, agent_id: &str) -> Result<(), String> {
let store_lock = store_lock(workspace_dir).await;
let _guard = store_lock.lock().await;
let mut store = load_store(workspace_dir).await?;
store.records.retain(|record| record.agent_id != agent_id);
save_store(workspace_dir, &store).await
}
async fn store_lock(workspace_dir: &Path) -> Arc<Mutex<()>> {
let path = store_path(workspace_dir);
let mut locks = STORE_LOCKS.lock().await;
locks
.entry(path)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
async fn load_store(workspace_dir: &Path) -> Result<PairingStore, String> {
let path = store_path(workspace_dir);
match tokio::fs::read(&path).await {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| format!("read orchestration pairing store: {e}")),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(PairingStore::default()),
Err(err) => Err(format!("read orchestration pairing store: {err}")),
}
}
async fn save_store(workspace_dir: &Path, store: &PairingStore) -> Result<(), String> {
let path = store_path(workspace_dir);
let parent = path
.parent()
.ok_or_else(|| "invalid orchestration pairing store path".to_string())?;
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("create orchestration pairing store dir: {e}"))?;
let tmp = path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(store)
.map_err(|e| format!("serialize orchestration pairing store: {e}"))?;
tokio::fs::write(&tmp, bytes)
.await
.map_err(|e| format!("write orchestration pairing store: {e}"))?;
#[cfg(windows)]
{
match tokio::fs::remove_file(&path).await {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(format!(
"remove existing orchestration pairing store: {err}"
))
}
}
}
tokio::fs::rename(&tmp, &path)
.await
.map_err(|e| format!("replace orchestration pairing store: {e}"))
}
fn store_path(workspace_dir: &Path) -> PathBuf {
workspace_dir
.join("agent_orchestration")
.join("pairings.json")
}
fn normalize_agent_id(agent_id: &str) -> Result<String, String> {
let trimmed = agent_id.trim();
if trimmed.is_empty() {
Err("agentId is required".to_string())
} else {
Ok(trimmed.to_string())
}
}
fn contact_path(agent_id: &str, suffix: Option<&str>) -> String {
match suffix {
Some(suffix) => format!("/contacts/{}/{}", encode_path_segment(agent_id), suffix),
None => format!("/contacts/{}", encode_path_segment(agent_id)),
}
}
fn encode_path_segment(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for byte in raw.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
out.push(byte as char);
} else {
write!(&mut out, "%{byte:02X}").expect("writing to String cannot fail");
}
}
out
}
fn publish_pairing_changed(record: &PairingRecord) {
publish_global(DomainEvent::OrchestrationPairingChanged {
agent_id: record.agent_id.clone(),
status: serde_json::to_value(&record.status)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_else(|| "unknown".to_string()),
source: record.source.as_str().to_string(),
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contact_paths_encode_agent_ids() {
assert_eq!(
contact_path("agent/with space", Some("status")),
"/contacts/agent%2Fwith%20space/status"
);
}
#[tokio::test]
async fn pairing_store_upserts_and_removes_records() {
let tmp = tempfile::tempdir().unwrap();
let record = persist_record(
tmp.path(),
"@worker".to_string(),
Some("Worker".to_string()),
PairingStatus::Pending,
PairingSource::UserLink,
)
.await
.unwrap();
assert_eq!(record.agent_id, "@worker");
let record = persist_record(
tmp.path(),
"@worker".to_string(),
None,
PairingStatus::Linked,
PairingSource::ApprovedRequest,
)
.await
.unwrap();
assert_eq!(record.status, PairingStatus::Linked);
let store = load_store(tmp.path()).await.unwrap();
assert_eq!(store.records.len(), 1);
assert_eq!(store.records[0].source, PairingSource::ApprovedRequest);
remove_record(tmp.path(), "@worker").await.unwrap();
let store = load_store(tmp.path()).await.unwrap();
assert!(store.records.is_empty());
}
#[tokio::test]
async fn pairing_store_rewrites_existing_file() {
let tmp = tempfile::tempdir().unwrap();
persist_record(
tmp.path(),
"@worker".to_string(),
Some("Worker".to_string()),
PairingStatus::Pending,
PairingSource::UserLink,
)
.await
.unwrap();
persist_record(
tmp.path(),
"@worker".to_string(),
None,
PairingStatus::Linked,
PairingSource::ApprovedRequest,
)
.await
.unwrap();
let store = load_store(tmp.path()).await.unwrap();
assert_eq!(store.records.len(), 1);
assert_eq!(store.records[0].status, PairingStatus::Linked);
assert_eq!(store.records[0].source, PairingSource::ApprovedRequest);
}
#[tokio::test]
async fn pairing_store_serializes_concurrent_mutations() {
let tmp = tempfile::tempdir().unwrap();
let mut tasks = Vec::new();
for index in 0..20 {
let workspace_dir = tmp.path().to_path_buf();
tasks.push(tokio::spawn(async move {
persist_record(
&workspace_dir,
format!("@worker-{index}"),
Some(format!("Worker {index}")),
PairingStatus::Linked,
PairingSource::ApprovedRequest,
)
.await
}));
}
for task in tasks {
task.await.unwrap().unwrap();
}
let store = load_store(tmp.path()).await.unwrap();
assert_eq!(store.records.len(), 20);
for index in 0..20 {
assert!(store
.records
.iter()
.any(|record| record.agent_id == format!("@worker-{index}")));
}
}
}
@@ -0,0 +1,204 @@
//! Controller schemas for user-consented tiny.place session pairing.
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;
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schema_for("orchestration_pairing_list"),
schema_for("orchestration_pairing_link_session"),
schema_for("orchestration_pairing_accept_request"),
schema_for("orchestration_pairing_decline_request"),
schema_for("orchestration_pairing_block_request"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schema_for("orchestration_pairing_list"),
handler: handle_list,
},
RegisteredController {
schema: schema_for("orchestration_pairing_link_session"),
handler: handle_link_session,
},
RegisteredController {
schema: schema_for("orchestration_pairing_accept_request"),
handler: handle_accept_request,
},
RegisteredController {
schema: schema_for("orchestration_pairing_decline_request"),
handler: handle_decline_request,
},
RegisteredController {
schema: schema_for("orchestration_pairing_block_request"),
handler: handle_block_request,
},
]
}
fn schema_for(function: &str) -> ControllerSchema {
match function {
"orchestration_pairing_list" => ControllerSchema {
namespace: "orchestration_pairing",
function: "list",
description: "List local orchestration pairing records plus the signed tiny.place contact state.",
inputs: vec![],
outputs: vec![json_output("result", "PairingSnapshot.")],
},
"orchestration_pairing_link_session" => ControllerSchema {
namespace: "orchestration_pairing",
function: "link_session",
description: "User-consented link request for a wrapped session identity. Refuses blocked peers and sends no free-text contact payload.",
inputs: vec![
required_str("agentId", "Session agent identity to link."),
optional_str("label", "Optional local display label for the session identity."),
],
outputs: vec![json_output("result", "PairingActionResult.")],
},
"orchestration_pairing_accept_request" => ControllerSchema {
namespace: "orchestration_pairing",
function: "accept_request",
description: "Accept an incoming contact request and persist a local approved-request pairing record.",
inputs: vec![required_str("agentId", "Requesting session agent identity.")],
outputs: vec![json_output("result", "PairingActionResult.")],
},
"orchestration_pairing_decline_request" => ControllerSchema {
namespace: "orchestration_pairing",
function: "decline_request",
description: "Decline an incoming contact request and remove any local pairing record.",
inputs: vec![required_str("agentId", "Requesting session agent identity.")],
outputs: vec![json_output("result", "PairingActionResult.")],
},
"orchestration_pairing_block_request" => ControllerSchema {
namespace: "orchestration_pairing",
function: "block_request",
description: "Block an incoming contact request and persist a local blocked record so it is not re-requested automatically.",
inputs: vec![required_str("agentId", "Requesting session agent identity.")],
outputs: vec![json_output("result", "PairingActionResult.")],
},
other => unreachable!("unknown orchestration_pairing schema: {other}"),
}
}
fn handle_list(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = load_config("list").await?;
to_json(super::pairing::list(&config).await?)
})
}
fn handle_link_session(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = load_config("link_session").await?;
let agent_id = required_param(&params, "agentId")?;
let label = params
.get("label")
.and_then(Value::as_str)
.map(str::to_string);
to_json(super::pairing::link_session(&config, agent_id, label).await?)
})
}
fn handle_accept_request(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = load_config("accept_request").await?;
let agent_id = required_param(&params, "agentId")?;
to_json(super::pairing::accept_request(&config, agent_id).await?)
})
}
fn handle_decline_request(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = load_config("decline_request").await?;
let agent_id = required_param(&params, "agentId")?;
to_json(super::pairing::decline_request(&config, agent_id).await?)
})
}
fn handle_block_request(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = load_config("block_request").await?;
let agent_id = required_param(&params, "agentId")?;
to_json(super::pairing::block_request(&config, agent_id).await?)
})
}
async fn load_config(action: &str) -> Result<crate::openhuman::config::Config, String> {
log::debug!(target: "orchestration_pairing_rpc", "[orchestration_pairing_rpc] {action}.config_load");
config_rpc::load_config_with_timeout()
.await
.inspect_err(|err| {
log::warn!(target: "orchestration_pairing_rpc", "[orchestration_pairing_rpc] {action}.config_failed err={err}");
})
}
fn required_param<'a>(params: &'a Map<String, Value>, key: &str) -> Result<&'a str, String> {
params
.get(key)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| format!("{key} is required"))
}
fn required_str(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::String,
comment,
required: true,
}
}
fn optional_str(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment,
required: false,
}
}
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Json,
comment,
required: true,
}
}
fn to_json<T: serde::Serialize>(value: T) -> Result<Value, String> {
serde_json::to_value(value)
.map_err(|err| format!("serialize orchestration pairing response: {err}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schemas_use_orchestration_pairing_namespace() {
let schemas = all_controller_schemas();
assert_eq!(schemas.len(), 5);
assert!(schemas
.iter()
.all(|schema| schema.namespace == "orchestration_pairing"));
assert_eq!(
schema_for("orchestration_pairing_link_session").function,
"link_session"
);
}
#[test]
fn required_param_rejects_blank_agent_id() {
let mut params = Map::new();
params.insert("agentId".to_string(), Value::String(" ".to_string()));
let err = required_param(&params, "agentId").unwrap_err();
assert!(err.contains("agentId"));
}
}
+223
View File
@@ -19,6 +19,7 @@
//! 5. Serialises the result with `serde_json::to_value`.
use std::collections::HashMap;
use std::fmt::Write as _;
use std::time::Duration;
use base64::Engine as _;
@@ -52,6 +53,48 @@ fn req_str<'a>(params: &'a Map<String, Value>, key: &str) -> Result<&'a str, Str
get_opt_str(params, key).ok_or_else(|| format!("missing required param '{key}'"))
}
fn req_nonblank_str(params: &Map<String, Value>, key: &str) -> Result<String, String> {
let value = req_str(params, key)?.trim();
if value.is_empty() {
Err(format!("missing required param '{key}'"))
} else {
Ok(value.to_string())
}
}
fn encode_path_segment(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for byte in raw.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
out.push(byte as char);
} else {
write!(&mut out, "%{byte:02X}").expect("writing to String cannot fail");
}
}
out
}
fn contact_path(agent_id: &str, suffix: Option<&str>) -> String {
match suffix {
Some(suffix) => format!("/contacts/{}/{}", encode_path_segment(agent_id), suffix),
None => format!("/contacts/{}", encode_path_segment(agent_id)),
}
}
fn contact_query(params: &Map<String, Value>) -> Vec<(String, String)> {
let source = params
.get("params")
.and_then(Value::as_object)
.unwrap_or(params);
let mut query = Vec::new();
for key in ["limit", "offset"] {
if let Some(value) = source.get(key).and_then(Value::as_i64) {
query.push((key.to_string(), value.to_string()));
}
}
query
}
// ── Handler implementations ───────────────────────────────────────────────────
// === AGENT-WORLD SECTION MANIFEST (append rows here) ===
@@ -3419,6 +3462,133 @@ pub(crate) fn handle_tinyplace_messages_acknowledge(
})
}
// ── Contacts: signed social graph for encrypted direct messages ─────────────
pub(crate) fn handle_tinyplace_contacts_request(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let agent_id = req_nonblank_str(&params, "agentId")?;
log::debug!("{LOG_PREFIX} contacts_request agent_id={agent_id}");
let client = global_state().client().await?;
let result: Value = client
.http()
.post_agent_auth::<Value, ()>(&contact_path(&agent_id, None), None)
.await
.map_err(map_err)?;
to_value(result)
})
}
pub(crate) fn handle_tinyplace_contacts_accept(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let agent_id = req_nonblank_str(&params, "agentId")?;
log::debug!("{LOG_PREFIX} contacts_accept agent_id={agent_id}");
let client = global_state().client().await?;
let result: Value = client
.http()
.post_agent_auth::<Value, ()>(&contact_path(&agent_id, Some("accept")), None)
.await
.map_err(map_err)?;
to_value(result)
})
}
pub(crate) fn handle_tinyplace_contacts_remove(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let agent_id = req_nonblank_str(&params, "agentId")?;
log::debug!("{LOG_PREFIX} contacts_remove agent_id={agent_id}");
let client = global_state().client().await?;
let result: Value = client
.http()
.delete_agent_auth::<Value, ()>(&contact_path(&agent_id, None), None)
.await
.map_err(map_err)?;
to_value(result)
})
}
pub(crate) fn handle_tinyplace_contacts_block(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let agent_id = req_nonblank_str(&params, "agentId")?;
log::debug!("{LOG_PREFIX} contacts_block agent_id={agent_id}");
let client = global_state().client().await?;
let result: Value = client
.http()
.post_agent_auth::<Value, ()>(&contact_path(&agent_id, Some("block")), None)
.await
.map_err(map_err)?;
to_value(result)
})
}
pub(crate) fn handle_tinyplace_contacts_unblock(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let agent_id = req_nonblank_str(&params, "agentId")?;
log::debug!("{LOG_PREFIX} contacts_unblock agent_id={agent_id}");
let client = global_state().client().await?;
let result: Value = client
.http()
.post_agent_auth::<Value, ()>(&contact_path(&agent_id, Some("unblock")), None)
.await
.map_err(map_err)?;
to_value(result)
})
}
pub(crate) fn handle_tinyplace_contacts_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let query = contact_query(&params);
log::debug!("{LOG_PREFIX} contacts_list query={query:?}");
let client = global_state().client().await?;
let result: Value = client
.http()
.get_agent_auth("/contacts", &query)
.await
.map_err(map_err)?;
to_value(result)
})
}
pub(crate) fn handle_tinyplace_contacts_requests(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let query = contact_query(&params);
log::debug!("{LOG_PREFIX} contacts_requests query={query:?}");
let client = global_state().client().await?;
let result: Value = client
.http()
.get_agent_auth("/contacts/requests", &query)
.await
.map_err(map_err)?;
to_value(result)
})
}
pub(crate) fn handle_tinyplace_contacts_status(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let agent_id = req_nonblank_str(&params, "agentId")?;
log::debug!("{LOG_PREFIX} contacts_status agent_id={agent_id}");
let client = global_state().client().await?;
let result: Value = client
.http()
.get_agent_auth(&contact_path(&agent_id, Some("status")), &[])
.await
.map_err(map_err)?;
to_value(result)
})
}
pub(crate) fn handle_tinyplace_contacts_stats(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
log::debug!("{LOG_PREFIX} contacts_stats");
let client = global_state().client().await?;
let result: Value = client
.http()
.get_agent_auth("/contacts/stats", &[])
.await
.map_err(map_err)?;
to_value(result)
})
}
// ── Signal: encryption key registration (0D) ────────────────────────────────
/// Publish the user's X25519 identity public key on their directory card as
@@ -5297,6 +5467,59 @@ mod tests {
}
}
/// Contact write/status handlers reject a missing or blank peer id before
/// wallet/client initialization.
#[test]
fn contacts_handlers_require_agent_id() {
for handler in [
handle_tinyplace_contacts_request as fn(Map<String, Value>) -> ControllerFuture,
handle_tinyplace_contacts_accept,
handle_tinyplace_contacts_remove,
handle_tinyplace_contacts_block,
handle_tinyplace_contacts_unblock,
handle_tinyplace_contacts_status,
] {
let err = block_on(handler(Map::new())).unwrap_err();
assert!(err.contains("agentId"), "got: {err}");
let mut params = Map::new();
params.insert("agentId".to_string(), Value::String(" ".to_string()));
let err = block_on(handler(params)).unwrap_err();
assert!(err.contains("agentId"), "got: {err}");
}
}
#[test]
fn contacts_path_segments_are_encoded() {
assert_eq!(
contact_path("agent/with space", Some("status")),
"/contacts/agent%2Fwith%20space/status"
);
}
#[test]
fn contacts_query_accepts_nested_or_top_level_pagination() {
let mut nested = Map::new();
nested.insert(
"params".to_string(),
serde_json::json!({ "limit": 20, "offset": 40 }),
);
assert_eq!(
contact_query(&nested),
vec![
("limit".to_string(), "20".to_string()),
("offset".to_string(), "40".to_string())
]
);
let mut top_level = Map::new();
top_level.insert("limit".to_string(), Value::Number(10.into()));
assert_eq!(
contact_query(&top_level),
vec![("limit".to_string(), "10".to_string())]
);
}
/// registry_export rejects a missing `name` before any client work.
#[test]
fn registry_export_requires_name() {
+1 -1
View File
@@ -28,7 +28,7 @@
pub(crate) mod agent;
mod agent_tools;
mod manifest;
mod ops;
pub(crate) mod ops;
mod payment;
mod schemas;
pub(crate) mod signal_store;
+170
View File
@@ -31,6 +31,15 @@ use crate::openhuman::tinyplace::manifest::{
handle_tinyplace_channels_join,
handle_tinyplace_channels_leave,
handle_tinyplace_channels_list,
handle_tinyplace_contacts_accept,
handle_tinyplace_contacts_block,
handle_tinyplace_contacts_list,
handle_tinyplace_contacts_remove,
handle_tinyplace_contacts_request,
handle_tinyplace_contacts_requests,
handle_tinyplace_contacts_stats,
handle_tinyplace_contacts_status,
handle_tinyplace_contacts_unblock,
handle_tinyplace_directory_find_by_encryption_key,
handle_tinyplace_directory_get_agent,
handle_tinyplace_directory_list_agents,
@@ -1884,6 +1893,120 @@ fn schema_messages_acknowledge() -> ControllerSchema {
}
}
fn schema_contacts_request() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "contacts_request",
description: "Send a signed contact request to a peer agent. Contact requests carry no free text; the edge itself is the pairing signal.",
inputs: vec![required_string("agentId", "Target agent ID.")],
outputs: vec![json_output("result", "ContactView or backend contact result.")],
}
}
fn schema_contacts_accept() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "contacts_accept",
description: "Accept an incoming signed contact request from a peer agent.",
inputs: vec![required_string("agentId", "Requesting agent ID.")],
outputs: vec![json_output(
"result",
"Accepted ContactView or backend contact result.",
)],
}
}
fn schema_contacts_remove() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "contacts_remove",
description:
"Remove a contact edge or pending request. Blocked edges are not cleared by remove.",
inputs: vec![required_string("agentId", "Peer agent ID.")],
outputs: vec![json_output(
"result",
"{ ok: true } or backend contact result.",
)],
}
}
fn schema_contacts_block() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "contacts_block",
description: "Block a peer agent. Blocked peers must not be re-requested automatically.",
inputs: vec![required_string("agentId", "Peer agent ID.")],
outputs: vec![json_output(
"result",
"Blocked ContactView or backend contact result.",
)],
}
}
fn schema_contacts_unblock() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "contacts_unblock",
description:
"Unblock a peer agent without automatically creating or accepting a contact edge.",
inputs: vec![required_string("agentId", "Peer agent ID.")],
outputs: vec![json_output(
"result",
"{ ok: true } or backend contact result.",
)],
}
}
fn schema_contacts_list() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "contacts_list",
description: "List signed contact edges for the current tiny.place identity.",
inputs: vec![optional_object(
"params",
"Optional pagination params (limit, offset).",
)],
outputs: vec![json_output(
"result",
"ContactsResponse { contacts: ContactView[] }.",
)],
}
}
fn schema_contacts_requests() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "contacts_requests",
description: "List incoming and outgoing signed contact requests for the current tiny.place identity.",
inputs: vec![optional_object("params", "Optional pagination params (limit, offset).")],
outputs: vec![json_output(
"result",
"ContactRequestsResponse { incoming: ContactView[], outgoing: ContactView[] }.",
)],
}
}
fn schema_contacts_status() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "contacts_status",
description:
"Fetch the contact status between the current tiny.place identity and a peer agent.",
inputs: vec![required_string("agentId", "Peer agent ID.")],
outputs: vec![json_output("result", "ContactStatusResponse.")],
}
}
fn schema_contacts_stats() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "contacts_stats",
description: "Fetch aggregate signed contact counts for the current tiny.place identity.",
inputs: vec![],
outputs: vec![json_output("result", "ContactStats.")],
}
}
// ── Encryption key registration + discovery (0D) ────────────────────────────
fn schema_signal_register_encryption_key() -> ControllerSchema {
@@ -2688,6 +2811,16 @@ pub fn all_tinyplace_controller_schemas() -> Vec<ControllerSchema> {
schema_signal_decrypt_message(),
schema_messages_list(),
schema_messages_acknowledge(),
// Contacts / pairing precondition for encrypted DMs
schema_contacts_request(),
schema_contacts_accept(),
schema_contacts_remove(),
schema_contacts_block(),
schema_contacts_unblock(),
schema_contacts_list(),
schema_contacts_requests(),
schema_contacts_status(),
schema_contacts_stats(),
// Encryption key registration + discovery (0D)
schema_signal_register_encryption_key(),
schema_directory_find_by_encryption_key(),
@@ -3156,6 +3289,43 @@ pub fn all_tinyplace_registered_controllers() -> Vec<RegisteredController> {
schema: schema_messages_acknowledge(),
handler: handle_tinyplace_messages_acknowledge,
},
// Contacts / pairing precondition for encrypted DMs
RegisteredController {
schema: schema_contacts_request(),
handler: handle_tinyplace_contacts_request,
},
RegisteredController {
schema: schema_contacts_accept(),
handler: handle_tinyplace_contacts_accept,
},
RegisteredController {
schema: schema_contacts_remove(),
handler: handle_tinyplace_contacts_remove,
},
RegisteredController {
schema: schema_contacts_block(),
handler: handle_tinyplace_contacts_block,
},
RegisteredController {
schema: schema_contacts_unblock(),
handler: handle_tinyplace_contacts_unblock,
},
RegisteredController {
schema: schema_contacts_list(),
handler: handle_tinyplace_contacts_list,
},
RegisteredController {
schema: schema_contacts_requests(),
handler: handle_tinyplace_contacts_requests,
},
RegisteredController {
schema: schema_contacts_status(),
handler: handle_tinyplace_contacts_status,
},
RegisteredController {
schema: schema_contacts_stats(),
handler: handle_tinyplace_contacts_stats,
},
// Encryption key registration + discovery (0D)
RegisteredController {
schema: schema_signal_register_encryption_key(),